diff --git a/.claude-plugin/hooks.json b/.claude-plugin/hooks.json new file mode 100644 index 000000000..57ae5f4d2 --- /dev/null +++ b/.claude-plugin/hooks.json @@ -0,0 +1,13 @@ +{ + "PostToolUse": [ + { + "matcher": "mcp__auto-mobile__*", + "hooks": [ + { + "type": "command", + "command": "if echo \"$CLAUDE_TOOL_STDOUT\" | grep -qiE '(ANDROID_HOME|JAVA_HOME|adb.*not found|device.*not found|no devices|daemon.*not running|accessibility.*not|emulator.*not|simulator.*not|xcode.*not|xcrun.*failed|simctl.*error|provisioning.*profile)'; then echo '{\"decision\": \"block\", \"reason\": \"AutoMobile error detected. Run /doctor to diagnose and fix the issue.\"}'; fi" + } + ] + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..017f78653 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,47 @@ +{ + "name": "auto-mobile-marketplace", + "owner": { + "name": "AutoMobile Contributors", + "email": "jason.d.pearson@gmail.com" + }, + "metadata": { + "description": "Official AutoMobile plugin marketplace for Claude Code - mobile device automation for Android and iOS", + "version": "1.0.0", + "pluginRoot": "./" + }, + "plugins": [ + { + "name": "auto-mobile", + "source": "./", + "description": "Mobile device automation for Android and iOS - control devices with natural language", + "version": "0.0.13", + "author": { + "name": "AutoMobile Contributors", + "email": "jason.d.pearson@gmail.com" + }, + "homepage": "https://kaeawc.github.io/auto-mobile/", + "repository": "https://github.com/kaeawc/auto-mobile", + "license": "Apache-2.0", + "keywords": [ + "mobile", + "android", + "ios", + "automation", + "testing", + "mcp", + "adb", + "simctl" + ], + "category": "development", + "tags": [ + "mobile-testing", + "android", + "ios", + "automation", + "ui-testing", + "device-control" + ], + "strict": true + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 000000000..78675bfed --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "auto-mobile", + "description": "Mobile device automation for Android and iOS - control devices with natural language", + "version": "0.0.13", + "author": { + "name": "AutoMobile Contributors", + "email": "jason.d.pearson@gmail.com" + }, + "homepage": "https://kaeawc.github.io/auto-mobile/", + "repository": "https://github.com/kaeawc/auto-mobile", + "license": "Apache-2.0", + "keywords": [ + "mobile", + "android", + "ios", + "automation", + "testing", + "mcp", + "adb", + "simctl" + ], + "commands": [ + "./skills/" + ], + "hooks": "./hooks.json", + "mcpServers": { + "auto-mobile": { + "command": "npx", + "args": [ + "-y", + "@kaeawc/auto-mobile@latest" + ] + } + } +} diff --git a/.claude-plugin/skills/apps.md b/.claude-plugin/skills/apps.md new file mode 100644 index 000000000..ab50b31b0 --- /dev/null +++ b/.claude-plugin/skills/apps.md @@ -0,0 +1,71 @@ +--- +description: Launch, terminate, and manage apps +allowed-tools: mcp__auto-mobile__launchApp, mcp__auto-mobile__terminateApp, mcp__auto-mobile__openLink, mcp__auto-mobile__installApp, mcp__auto-mobile__clearAppData +--- + +Manage applications on the device - launch, terminate, install, and control app state. + +## Launch App + +Use `launchApp` to start an application: +``` +launchApp with packageName: "com.example.app" +``` + +Parameters: +- `packageName`: App identifier (e.g., `com.android.settings`, `com.apple.Preferences`) +- `waitUntilLaunched`: Wait for app to be fully loaded (default: true) + +## Terminate App + +Use `terminateApp` to stop a running application: +``` +terminateApp with packageName: "com.example.app" +``` + +This force-stops the app, clearing it from memory. + +## Open Link + +Use `openLink` to open a URL in the default browser or app: +``` +openLink with url: "https://example.com" +``` + +Deep links are also supported: +``` +openLink with url: "myapp://screen/settings" +``` + +## Install App + +Use `installApp` to install an APK (Android) or IPA (iOS): +``` +installApp with path: "/path/to/app.apk" +``` + +## Clear App Data + +Use `clearAppData` to reset an app to fresh-install state: +``` +clearAppData with packageName: "com.example.app" +``` + +This removes all app data, caches, and preferences. + +## Common Workflows + +**Fresh start testing:** +``` +terminateApp → clearAppData → launchApp +``` + +**Switch between apps:** +``` +terminateApp (current) → launchApp (new) +``` + +**Test deep links:** +``` +openLink with deep link URL → observe to verify screen +``` diff --git a/.claude-plugin/skills/doctor.md b/.claude-plugin/skills/doctor.md new file mode 100644 index 000000000..c9f7eeddf --- /dev/null +++ b/.claude-plugin/skills/doctor.md @@ -0,0 +1,40 @@ +--- +description: Diagnose and fix AutoMobile setup issues +allowed-tools: mcp__auto-mobile__doctor +--- + +Run the AutoMobile doctor to diagnose setup issues and get actionable recommendations. + +## Workflow + +1. **Run diagnostics** using the `doctor` MCP tool to check: + - System requirements (OS, architecture, runtime) + - Android setup (ANDROID_HOME, ADB, emulator, AVDs, connected devices) + - iOS setup (Xcode, Command Line Tools, simulators, code signing) + - AutoMobile status (version, daemon, accessibility service) + +2. **Analyze results** and categorize issues by severity: + - **Failures**: Critical issues that must be fixed + - **Warnings**: Non-blocking issues that may cause problems + - **Passed**: Components working correctly + +3. **Present recommendations** for each failed or warning check: + - Explain what the issue means + - Provide the specific command or action to fix it + - Offer to help execute the fix if possible + +4. **Platform-specific guidance**: + - For Android issues: Guide through SDK setup, emulator creation, ADB configuration + - For iOS issues: Guide through Xcode installation, simulator setup, provisioning profiles + +## Common Issues and Fixes + +- **ANDROID_HOME not set**: Export the environment variable pointing to Android SDK +- **No AVDs found**: Create an emulator via Android Studio or `avdmanager` +- **No devices connected**: Connect via USB or start an emulator/simulator +- **Daemon not running**: Start with `npx -y @kaeawc/auto-mobile@latest --daemon start` +- **Accessibility service not enabled**: Guide user through device Settings > Accessibility +- **Xcode Command Line Tools missing**: Run `xcode-select --install` +- **No simulator runtimes**: Install in Xcode Settings > Platforms + +Report a summary with pass/warn/fail counts and prioritized action items. diff --git a/.claude-plugin/skills/explore.md b/.claude-plugin/skills/explore.md new file mode 100644 index 000000000..e20577106 --- /dev/null +++ b/.claude-plugin/skills/explore.md @@ -0,0 +1,88 @@ +--- +description: Explore and interact with mobile devices using all available tools +allowed-tools: mcp__auto-mobile__observe, mcp__auto-mobile__tapOn, mcp__auto-mobile__swipeOn, mcp__auto-mobile__inputText, mcp__auto-mobile__clearText, mcp__auto-mobile__selectAllText, mcp__auto-mobile__pressButton, mcp__auto-mobile__pressKey, mcp__auto-mobile__dragAndDrop, mcp__auto-mobile__pinchOn, mcp__auto-mobile__keyboard, mcp__auto-mobile__imeAction, mcp__auto-mobile__homeScreen, mcp__auto-mobile__recentApps, mcp__auto-mobile__systemTray, mcp__auto-mobile__launchApp, mcp__auto-mobile__terminateApp, mcp__auto-mobile__openLink, mcp__auto-mobile__clipboard, mcp__auto-mobile__rotate, mcp__auto-mobile__shake, mcp__auto-mobile__deviceSnapshot, mcp__auto-mobile__installApp, mcp__auto-mobile__clearAppData +--- + +Explore and interact with connected mobile devices. This skill combines all interaction capabilities for comprehensive device control. + +## Getting Started + +Use `observe` to capture the initial screen state when starting a session. Most interaction tools automatically return updated screen state, so you only need to call `observe` again if: +- Starting a new session or switching devices +- An action resulted in an incomplete or loading state +- You need to verify state after a delay or background process + +## Available Skills + +For detailed usage of specific capabilities, see these focused skills: + +- `/apps` - Launch, terminate, and manage applications +- `/system` - Home screen, recent apps, hardware buttons, rotation +- `/notifications` - Interact with notification shade and alerts +- `/text` - Text input, keyboard control, clipboard operations +- `/gesture` - Tap, swipe, scroll, pinch, drag-and-drop +- `/snapshot` - Capture and restore device state + +## Quick Reference + +### App Management +``` +launchApp with packageName: "com.example.app" +terminateApp with packageName: "com.example.app" +openLink with url: "https://example.com" +``` + +### System Navigation +``` +homeScreen +recentApps +pressButton with button: "back" +rotate with orientation: "landscape" +``` + +### Notifications +``` +systemTray with action: "open" +systemTray with action: "find", notification: {title: "Message"} +systemTray with action: "tap" +``` + +### Gestures +``` +tapOn with text: "Submit" +tapOn with text: "Item", action: "longPress" +swipeOn with direction: "up" +swipeOn with direction: "up", lookFor: {text: "Settings"} +dragAndDrop with source: {text: "Item"}, target: {text: "Folder"} +pinchOn with direction: "out" +``` + +### Text Input +``` +tapOn with text: "Email", action: "focus" +inputText with text: "user@example.com" +imeAction with action: "next" +clearText +selectAllText +clipboard with action: "paste" +``` + +### Device State +``` +deviceSnapshot with action: "capture", snapshotName: "baseline" +deviceSnapshot with action: "restore", snapshotName: "baseline" +``` + +## Workflow + +1. **Start** - Use `observe` to capture initial screen state +2. **Navigate** - Use apps/system tools to reach target +3. **Interact** - Perform gestures, input text (state updates automatically) +4. **Verify** - Use `observe` only if action showed loading/incomplete state + +## Tips + +- Use `homeScreen` to reset to a known starting point +- Use `lookFor` with swipe to find off-screen elements +- Use snapshots to speed up repetitive test setup +- Reference the focused skills for detailed parameter info diff --git a/.claude-plugin/skills/gesture.md b/.claude-plugin/skills/gesture.md new file mode 100644 index 000000000..0490a6a22 --- /dev/null +++ b/.claude-plugin/skills/gesture.md @@ -0,0 +1,123 @@ +--- +description: Tap, swipe, scroll, pinch, drag and other gestures +allowed-tools: mcp__auto-mobile__tapOn, mcp__auto-mobile__swipeOn, mcp__auto-mobile__dragAndDrop, mcp__auto-mobile__pinchOn +--- + +Perform touch gestures including taps, swipes, scrolls, pinches, and drag-and-drop. + +## Tap Actions + +Use `tapOn` to interact with elements: + +**Single tap:** +``` +tapOn with text: "Submit" +tapOn with id: "login_button" +``` + +**Double tap:** +``` +tapOn with text: "Image", action: "doubleTap" +``` + +**Long press:** +``` +tapOn with text: "Item", action: "longPress" +``` + +**Focus (without keyboard):** +``` +tapOn with text: "Email", action: "focus" +``` + +### Targeting Elements + +- `text`: Match by visible text +- `id`: Match by accessibility ID or resource ID +- `container`: Scope search within a parent element + +``` +tapOn with text: "Save", container: {id: "dialog"} +``` + +## Swipe & Scroll + +Use `swipeOn` for scrolling and swiping: + +**Scroll content:** +``` +swipeOn with direction: "up" # Scroll down (content moves up) +swipeOn with direction: "down" # Scroll up +``` + +**Swipe gesture (faster):** +``` +swipeOn with direction: "left", gestureType: "swipe" +``` + +**Fling (fastest):** +``` +swipeOn with direction: "up", gestureType: "fling" +``` + +**Scroll until element found:** +``` +swipeOn with direction: "up", lookFor: {text: "Settings"} +``` + +**Scroll within container:** +``` +swipeOn with direction: "up", container: {id: "list_view"} +``` + +## Drag and Drop + +Move elements between locations: +``` +dragAndDrop with source: {text: "Item 1"}, target: {text: "Folder"} +dragAndDrop with source: {id: "draggable"}, target: {id: "drop_zone"} +``` + +## Pinch to Zoom + +Use `pinchOn` for zoom gestures: + +**Zoom in:** +``` +pinchOn with direction: "out" +``` + +**Zoom out:** +``` +pinchOn with direction: "in" +``` + +**With rotation:** +``` +pinchOn with direction: "out", rotationDegrees: 45 +``` + +## Common Workflows + +**Scroll and tap:** +``` +swipeOn "up" lookFor: {text: "Settings"} → tapOn "Settings" +``` + +**Reorder list items:** +``` +tapOn "Item" action: "longPress" → dragAndDrop to target +``` + +**Zoom and interact:** +``` +pinchOn "out" → tapOn (now-visible element) +``` + +## Tips + +- Use `lookFor` with swipe to auto-scroll to off-screen elements +- Long press often reveals context menus or enables drag mode +- Use `container` to scope searches in screens with duplicate text +- Swipe "up" scrolls content down (reveals content below) +- Use `gestureType: "fling"` for fast scrolling through long lists diff --git a/.claude-plugin/skills/notifications.md b/.claude-plugin/skills/notifications.md new file mode 100644 index 000000000..e4efdb67d --- /dev/null +++ b/.claude-plugin/skills/notifications.md @@ -0,0 +1,72 @@ +--- +description: Interact with notifications and system tray +allowed-tools: mcp__auto-mobile__systemTray +--- + +Interact with the notification shade and system tray. + +## Open Notification Shade + +Pull down the notification shade: +``` +systemTray with action: "open" +``` + +## Find Notification + +Search for a specific notification: +``` +systemTray with action: "find", notification: {title: "New message"} +systemTray with action: "find", notification: {body: "You have 3 new emails"} +systemTray with action: "find", notification: {appId: "com.example.app"} +``` + +Search criteria: +- `title`: Notification title text +- `body`: Notification body text +- `appId`: Source app package name + +## Tap Notification + +Tap on a notification or its action button: +``` +systemTray with action: "tap", notification: {title: "New message"} +systemTray with action: "tap", notification: {title: "New message"}, tapActionLabel: "Reply" +``` + +## Dismiss Notification + +Swipe away a notification: +``` +systemTray with action: "dismiss", notification: {title: "New message"} +``` + +## Clear All Notifications + +Remove all notifications: +``` +systemTray with action: "clearAll" +``` + +## Common Workflows + +**Check and act on notification:** +``` +systemTray "open" → systemTray "find" → systemTray "tap" +``` + +**Clear notifications before test:** +``` +systemTray "open" → systemTray "clearAll" → pressButton "back" +``` + +**Verify notification appeared:** +``` +(trigger notification) → systemTray "open" → systemTray "find" +``` + +## Tips + +- Always `open` the system tray before other actions +- Use `pressButton "back"` or `homeScreen` to close the shade +- Notifications may take a moment to appear after triggering diff --git a/.claude-plugin/skills/reproduce-bug.md b/.claude-plugin/skills/reproduce-bug.md new file mode 100644 index 000000000..eb76daca5 --- /dev/null +++ b/.claude-plugin/skills/reproduce-bug.md @@ -0,0 +1,116 @@ +--- +description: Systematically reproduce a bug and document reproduction steps +allowed-tools: mcp__auto-mobile__observe, mcp__auto-mobile__highlight, mcp__auto-mobile__launchApp, mcp__auto-mobile__terminateApp +--- + +Systematically reproduce a reported bug, document exact steps, and capture evidence. + +## Available Skills + +For device interactions during bug reproduction, use these skills: + +- `/apps` - Launch and terminate apps +- `/system` - Navigate with hardware buttons, home screen +- `/gesture` - Tap, swipe, scroll to reproduce user actions +- `/text` - Input text, manipulate fields +- `/notifications` - Check notification-related bugs +- `/snapshot` - Capture device state for restoration + +## Workflow + +### 1. Understand the Bug Report + +Gather information: +- What is the expected behavior? +- What is the actual behavior? +- What conditions trigger it? (device, OS version, user state) +- Any error messages or visual symptoms? + +### 2. Prepare Environment + +``` +/snapshot capture "before_repro" # Save initial state +/apps launch the target app +observe # Get initial screen state +``` + +Use `observe` at the start of a session to capture initial state. After that, interaction tools automatically return updated screen state. + +### 3. Attempt Reproduction + +Follow the reported steps using interaction skills: +- Use `/gesture` for taps, swipes, scrolls +- Use `/text` for text input +- Use `/system` for hardware button presses + +Document each action taken and note any deviations. Only use `observe` if an action resulted in an incomplete or loading state that needs re-checking. + +### 4. When Bug is Reproduced + +``` +highlight # Mark defect visually on screen +``` + +Record the exact sequence that triggered the issue. If the screen showed a loading state, use `observe` to capture the final state. + +### 5. Document Findings + +Create a structured report with: +- Exact reproduction steps (numbered list) +- Environment details (device, OS, app version) +- Expected vs actual behavior +- Screenshots showing the issue +- Any patterns (intermittent, specific conditions) + +### 6. If Cannot Reproduce + +- Document attempted steps +- Note differences from reported environment +- Suggest additional information needed +- Try variations of the reported steps + +### 7. Cleanup + +``` +/apps terminate the app +/snapshot restore "before_repro" # Restore initial state +``` + +## Output Format + +```markdown +## Bug Reproduction Report + +**Bug**: [Brief description] +**Status**: Reproduced / Not Reproduced / Intermittent + +### Environment +- Device: [model] +- OS: [version] +- App Version: [version] + +### Reproduction Steps +1. [Step 1] +2. [Step 2] +... + +### Expected Behavior +[Description] + +### Actual Behavior +[Description] + +### Evidence +- Screenshots: [attached/described] + +### Notes +[Any additional observations] +``` + +## Tips + +- Capture a snapshot before starting to enable easy state restoration +- Use `observe` only at session start or after loading/incomplete states +- Use `highlight` to visually mark the bug location on screen +- Document environment details early - they often matter for reproduction +- Try multiple devices/OS versions if bug doesn't reproduce diff --git a/.claude-plugin/skills/snapshot.md b/.claude-plugin/skills/snapshot.md new file mode 100644 index 000000000..7a09c2326 --- /dev/null +++ b/.claude-plugin/skills/snapshot.md @@ -0,0 +1,65 @@ +--- +description: Capture and restore device state snapshots +allowed-tools: mcp__auto-mobile__deviceSnapshot +--- + +Capture and restore device state for testing isolation and reproducibility. + +## Capture Snapshot + +Save the current device state: +``` +deviceSnapshot with action: "capture" +deviceSnapshot with action: "capture", snapshotName: "logged_in_state" +``` + +Options: +- `snapshotName`: Name for the snapshot (optional) +- `includeAppData`: Include app data in snapshot (default: true) +- `includeSettings`: Include device settings (default: false) + +## Restore Snapshot + +Restore a previously captured state: +``` +deviceSnapshot with action: "restore" +deviceSnapshot with action: "restore", snapshotName: "logged_in_state" +``` + +## Use Cases + +### Test Isolation +Capture state before each test, restore after: +``` +deviceSnapshot "capture" → (run test) → deviceSnapshot "restore" +``` + +### Skip Repetitive Setup +Capture state after login, restore for each test: +``` +(login flow) → deviceSnapshot "capture" name: "logged_in" +... +deviceSnapshot "restore" name: "logged_in" → (run test) +``` + +### Bug Reproduction +Capture state when bug occurs for later investigation: +``` +(reproduce bug) → deviceSnapshot "capture" name: "bug_state" +``` + +### A/B Comparison +Capture baseline, make changes, compare: +``` +deviceSnapshot "capture" name: "before" +(make changes) +deviceSnapshot "capture" name: "after" +``` + +## Tips + +- Name snapshots descriptively for easy identification +- Capture snapshots at stable points (after app load, after login) +- Snapshots include app state but may not capture all system state +- Restore clears current state, so capture first if needed +- Use snapshots to speed up test setup by skipping repetitive flows diff --git a/.claude-plugin/skills/system.md b/.claude-plugin/skills/system.md new file mode 100644 index 000000000..4c76ea3a7 --- /dev/null +++ b/.claude-plugin/skills/system.md @@ -0,0 +1,90 @@ +--- +description: Navigate system UI - home screen, recent apps, hardware buttons +allowed-tools: mcp__auto-mobile__homeScreen, mcp__auto-mobile__recentApps, mcp__auto-mobile__pressButton, mcp__auto-mobile__pressKey, mcp__auto-mobile__rotate, mcp__auto-mobile__shake +--- + +Navigate system-level UI and control hardware functions. + +## Home Screen + +Use `homeScreen` to return to the device home screen: +``` +homeScreen +``` + +Useful for: +- Resetting to a known state +- Exiting apps +- Starting fresh navigation + +## Recent Apps + +Use `recentApps` to open the app switcher: +``` +recentApps +``` + +From here you can: +- Switch between running apps +- Close apps by swiping them away +- See app thumbnails + +## Hardware Buttons + +Use `pressButton` for hardware button presses: +``` +pressButton with button: "back" +``` + +Available buttons: +- `home`: Go to home screen +- `back`: Navigate back +- `menu`: Open menu (Android) +- `power`: Power button +- `volume_up`: Increase volume +- `volume_down`: Decrease volume +- `recent`: Open recent apps + +## Key Press + +Use `pressKey` for specific key codes: +``` +pressKey with key: "enter" +``` + +## Device Orientation + +Use `rotate` to change screen orientation: +``` +rotate with orientation: "landscape" +rotate with orientation: "portrait" +``` + +## Shake Device + +Use `shake` to trigger shake gesture: +``` +shake +``` + +Useful for: +- Triggering shake-to-undo +- Developer menu access +- Feedback dialogs + +## Common Workflows + +**Navigate back through screens:** +``` +pressButton "back" → observe → pressButton "back" → observe +``` + +**Reset to known state:** +``` +homeScreen → launchApp +``` + +**Test orientation changes:** +``` +rotate "landscape" → observe → rotate "portrait" → observe +``` diff --git a/.claude-plugin/skills/text.md b/.claude-plugin/skills/text.md new file mode 100644 index 000000000..2f33a8c07 --- /dev/null +++ b/.claude-plugin/skills/text.md @@ -0,0 +1,96 @@ +--- +description: Text input, keyboard control, and clipboard operations +allowed-tools: mcp__auto-mobile__inputText, mcp__auto-mobile__clearText, mcp__auto-mobile__selectAllText, mcp__auto-mobile__keyboard, mcp__auto-mobile__imeAction, mcp__auto-mobile__clipboard +--- + +Handle text input, keyboard interactions, and clipboard operations. + +## Text Input + +Type text into the focused field: +``` +inputText with text: "Hello, world!" +``` + +The field must be focused first (use `tapOn` with action "focus"). + +## Clear Text + +Clear the current input field: +``` +clearText +``` + +Removes all text from the focused field. + +## Select All Text + +Select all text in the focused field: +``` +selectAllText +``` + +Useful for replacing existing text: +``` +selectAllText → inputText with new text +``` + +## Keyboard Control + +Control the soft keyboard: +``` +keyboard with action: "open" # Show keyboard +keyboard with action: "close" # Hide keyboard +keyboard with action: "detect" # Check if visible +``` + +## IME Actions + +Trigger keyboard action buttons: +``` +imeAction with action: "done" # Submit/complete +imeAction with action: "next" # Move to next field +imeAction with action: "search" # Trigger search +imeAction with action: "send" # Send message +imeAction with action: "go" # Navigate/submit +``` + +## Clipboard + +Manage clipboard content: +``` +clipboard with action: "copy", text: "Text to copy" +clipboard with action: "paste" # Paste into focused field +clipboard with action: "get" # Read clipboard content +clipboard with action: "clear" # Clear clipboard +``` + +## Common Workflows + +**Fill a text field:** +``` +tapOn (field) → inputText → imeAction "next" +``` + +**Replace existing text:** +``` +tapOn (field) → selectAllText → inputText (new text) +``` + +**Copy text between fields:** +``` +tapOn (source) → selectAllText → clipboard "copy" +tapOn (target) → clipboard "paste" +``` + +**Submit a form:** +``` +inputText (last field) → imeAction "done" +``` + +## Tips + +- Always focus a field before typing (use `tapOn` or `tapOn` with action "focus") +- Use `imeAction "next"` to move through form fields efficiently +- Check `keyboardVisible` in observation before text operations +- Use `selectAllText` + `inputText` to replace text (faster than clearText + inputText) diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 000000000..93c0f73fa --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1 @@ +settings.local.json diff --git a/.claude/commands/check-ci.md b/.claude/commands/check-ci.md new file mode 100644 index 000000000..cad59ed04 --- /dev/null +++ b/.claude/commands/check-ci.md @@ -0,0 +1,311 @@ +--- +description: Check CI status, analyze failures, reproduce locally, and provide next steps +allowed-tools: Bash, Read, Grep, Glob +argument-hint: [PR number (optional)] +--- + +Check the CI status for a pull request, analyze failures, check for merge conflicts and PR comments, attempt to reproduce issues locally, and provide an analysis of next steps. + +Use the following bash script to check CI status. If an argument is provided, use it as the PR number. Otherwise, auto-detect from the current branch. + +```bash +#!/usr/bin/env bash + +# Step 1: Determine PR number +if [ -n "$1" ]; then + PR_NUM="$1" +else + PR_NUM=$(gh pr view --json number -q .number 2>/dev/null || echo "") +fi + +if [ -z "$PR_NUM" ]; then + echo "❌ Error: No PR found for current branch" + echo "Usage: /check-ci [PR_NUMBER]" + exit 1 +fi + +echo "=== CI Status for PR #${PR_NUM} ===" +echo "" + +# Step 2: Get CI check status +CHECKS_OUTPUT=$(gh pr checks ${PR_NUM} 2>&1) + +# Count statuses using simple grep +PASSED_COUNT=$(echo "$CHECKS_OUTPUT" | grep -c "pass" || true) +PENDING_COUNT=$(echo "$CHECKS_OUTPUT" | grep -c "pending" || true) +FAILED_COUNT=$(echo "$CHECKS_OUTPUT" | grep -c "fail" || true) + +# Calculate total +if [ -z "$PASSED_COUNT" ]; then PASSED_COUNT=0; fi +if [ -z "$PENDING_COUNT" ]; then PENDING_COUNT=0; fi +if [ -z "$FAILED_COUNT" ]; then FAILED_COUNT=0; fi + +TOTAL=$((PASSED_COUNT + PENDING_COUNT + FAILED_COUNT)) + +# Display summary +echo "📊 Summary: ${PASSED_COUNT}/${TOTAL} checks passed" +echo "" +if [ "$PASSED_COUNT" -gt 0 ]; then + echo " ✅ Passed: ${PASSED_COUNT}" +fi +if [ "$PENDING_COUNT" -gt 0 ]; then + echo " ⚠️ Pending: ${PENDING_COUNT}" +fi +if [ "$FAILED_COUNT" -gt 0 ]; then + echo " ❌ Failed: ${FAILED_COUNT}" +fi +echo "" + +# Show all checks +echo "$CHECKS_OUTPUT" +echo "" + +# Step 3 & 4: Handle different states +if [ "$FAILED_COUNT" -gt 0 ]; then + echo "=== Failure Details ===" + echo "" + + # Extract unique run IDs from failed checks + RUN_IDS=$(echo "$CHECKS_OUTPUT" | grep "fail" | grep -oP 'runs/\K[0-9]+' | sort -u || true) + + if [ -n "$RUN_IDS" ]; then + for RUN_ID in $RUN_IDS; do + echo "----------------------------------------" + echo "📋 Fetching failure logs for run ${RUN_ID}..." + echo "🔗 https://github.com/kaeawc/auto-mobile/actions/runs/${RUN_ID}" + echo "" + + # Get failed logs (last 100 lines) + gh run view ${RUN_ID} --log-failed 2>&1 | tail -100 + echo "" + done + fi +elif [ "$PENDING_COUNT" -gt 0 ]; then + echo "⏳ Waiting for ${PENDING_COUNT} check(s) to complete..." + echo "" + echo "Pending checks:" + echo "$CHECKS_OUTPUT" | grep "pending" || true +else + echo "✅ All checks passed! PR is ready to merge." +fi +``` + +## How it works: + +1. **PR Detection**: Accepts optional PR number argument, otherwise auto-detects from current branch +2. **Status Counting**: Uses grep to count passed/pending/failed checks +3. **Summary Display**: Shows visual summary with emojis (✅/⚠️/❌) +4. **Failure Handling**: Extracts run IDs from failed checks and fetches last 100 lines of logs +5. **Clear Output**: Provides clickable GitHub Actions URLs for detailed investigation + +## Additional Analysis Steps + +After running the bash script above, continue with these analysis steps: + +### Step 1: Check for Merge Conflicts + +```bash +# Check if branch is behind main +gh pr view ${PR_NUM} --json mergeable,mergeStateStatus -q '.mergeable, .mergeStateStatus' + +# If behind, check details +git fetch origin main +git log HEAD..origin/main --oneline + +# Check for merge conflicts +git merge-tree $(git merge-base HEAD origin/main) HEAD origin/main +``` + +**If conflicts exist**: +- List conflicting files +- Show conflict markers +- Recommend resolution strategy (rebase vs merge) +- Provide commands to resolve + +### Step 2: Check PR Comments and Feedback + +```bash +# Get all PR comments +gh pr view ${PR_NUM} --json comments -q '.comments[].body' + +# Get review comments (inline code comments) +gh api repos/:owner/:repo/pulls/${PR_NUM}/comments --jq '.[] | {file: .path, line: .line, comment: .body}' +``` + +**Analyze comments**: +- Identify unresolved feedback +- Categorize by type (bug report, suggestion, question, approval) +- Highlight actionable items +- Note if any reviewers requested changes + +### Step 3: Reproduce Failures Locally + +For each failed CI check, provide commands to reproduce: + +**Lint failures**: +```bash +bun run lint +``` + +**Build failures**: +```bash +bun run build +``` + +**Test failures**: +```bash +# Run all tests +bun test + +# Run specific test file mentioned in logs +bun test + +# Run with coverage +bun test --coverage +``` + +**TypeScript errors**: +```bash +# Check types +bun run typecheck +# or +tsc --noEmit +``` + +**Docker build failures**: +```bash +# Rebuild locally +docker build -t auto-mobile . + +# Check specific stage +docker build --target -t auto-mobile . +``` + +**Android/Gradle failures**: +```bash +cd android +./gradlew clean build + +# Run specific task mentioned in logs +./gradlew +``` + +**Attempt to run the commands** that match the failure type and report results. + +### Step 4: Analyze Failures + +For each failure found: + +1. **Identify root cause**: + - Parse error messages from CI logs + - Search codebase for related code using Grep + - Read relevant files to understand context + +2. **Categorize the issue**: + - Syntax error (typo, missing import) + - Type error (TypeScript) + - Test failure (assertion failed) + - Flaky test (timing issue) + - Integration issue (dependency problem) + - Configuration issue (CI-specific) + +3. **Determine reproducibility**: + - Can reproduce locally → Direct fix possible + - Cannot reproduce locally → CI environment issue + - Intermittent → Flaky test or race condition + +### Step 5: Provide Next Steps Analysis + +Generate a summary report: + +```markdown +## CI Status Report for PR #[number] + +### Current State +- **Status**: [All passing / X failing / X pending] +- **Merge conflicts**: [Yes/No] +- **Unresolved comments**: [count] + +### Failures Analysis + +#### Failure 1: [Check name] +- **Type**: [lint/build/test/etc] +- **Root cause**: [description] +- **Reproducible locally**: [Yes/No] +- **Files affected**: [list] +- **Recommended fix**: [specific action] + +#### Failure 2: [Check name] +... + +### PR Comments Summary +- **Total comments**: [count] +- **Actionable feedback**: [list key items] +- **Requested changes**: [list] + +### Merge Conflicts +- **Status**: [clean / conflicts in X files] +- **Affected files**: [list] +- **Resolution strategy**: [rebase / merge / manual] + +### Recommended Next Steps + +1. [Priority 1 action with commands] +2. [Priority 2 action with commands] +3. [Priority 3 action with commands] + +### Commands to Execute + +```bash +# Fix merge conflicts (if any) +git fetch origin main +git rebase origin/main +# [resolve conflicts] + +# Apply feedback from PR comments +# [specific changes based on comments] + +# Fix failing checks +[specific commands based on failures] + +# Validate locally +bun run lint +bun run build +bun test + +# Push fixes +git push --force-with-lease +``` +``` + +### Step 6: Execute Fixes (Optional) + +If user confirms, execute the recommended fixes: +- Resolve merge conflicts +- Apply PR feedback +- Fix failing checks +- Run local validation +- Commit and push changes + +## Usage Examples: + +**Simple check**: +``` +/check-ci +``` +Output: Shows CI status, then analyzes failures, checks conflicts, reviews comments, and provides next steps + +**Check specific PR**: +``` +/check-ci 83 +``` + +**Typical workflow** (from prompt analysis): +``` +/check-ci # Analyze current state +[Review analysis] +[Make fixes based on recommendations] +/validate # Run local validation +/push # Push fixes +/check-ci # Verify fixes resolved issues +``` diff --git a/.claude/commands/observe.md b/.claude/commands/observe.md new file mode 100644 index 000000000..4865d8e39 --- /dev/null +++ b/.claude/commands/observe.md @@ -0,0 +1,9 @@ +--- +description: Observe connected Android device +allowed-tools: mcp__auto-mobile__observe +--- + +Use the auto-mobile MCP server to observe the current state of the connected Android device. Report: +- Active app and activity +- Key UI elements visible on screen +- The updatedAt timestamp diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 000000000..650cc7877 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,12 @@ +--- +description: Run tests with optional filter +allowed-tools: Bash +argument-hint: [test name filter] +--- + +Run the test suite for AutoMobile. + +If an argument is provided, run: `bun run test -- --grep "$ARGUMENTS"` +Otherwise run: `bun run test` + +Summarize test results concisely. diff --git a/.claude/commands/validate.md b/.claude/commands/validate.md new file mode 100644 index 000000000..f3afe71b6 --- /dev/null +++ b/.claude/commands/validate.md @@ -0,0 +1,16 @@ +--- +description: Run lint and build to validate changes +allowed-tools: Bash +--- + +Run validation for the AutoMobile project: + +1. Run `bun run lint` to check and auto-fix linting issues +2. Run `bun run build` to compile TypeScript +3. Run `bash scripts/hadolint/validate_hadolint.sh` to validate the Dockerfile +4. Run `bash scripts/act/validate_act.sh` to validate act (GitHub Actions runner) setup +5. Run `bash scripts/ios/swift-build.sh` to build Swift packages +6. Run `ONLY_TOUCHED_FILES=false bash scripts/swiftformat/validate_swiftformat.sh` to validate Swift formatting +7. Run `ONLY_TOUCHED_FILES=false bash scripts/swiftlint/validate_swiftlint.sh` to validate Swift linting + +Report any errors that need manual fixes. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..bfcfef68f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run build)", + "Bash(npm run lint)", + "Bash(npm run test)", + "Bash(npm run test -- --grep:*)", + "Bash(npm install)", + "mcp__auto-mobile__*" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "if [[ \"$CLAUDE_TOOL_ARG_FILE_PATH\" == *.ts ]]; then echo '{\"message\": \"Remember to run /validate after TypeScript changes\"}'; fi" + } + ] + } + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..edad63734 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,197 @@ +# ============================================================================= +# .dockerignore for AutoMobile - Android automation MCP server +# ============================================================================= +# This file controls what gets copied into the Docker image during build. +# The Dockerfile copies package*.json first, runs npm ci, then copies the rest. +# Therefore: block node_modules (rebuilt in container) but allow package*.json +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Version Control +# ----------------------------------------------------------------------------- +.git +.gitignore +.gitattributes +.github/ +.githooks/ + +# ----------------------------------------------------------------------------- +# Node.js Dependencies & Build Artifacts +# ----------------------------------------------------------------------------- +# Node modules are rebuilt inside container via npm ci +node_modules/ +package-lock.json.bak +.turbo/ + +# npm artifacts +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.yarn +.pnp.* +*.tgz + +# Build artifacts (dist/ is rebuilt inside container) +dist/ +*.tsbuildinfo + +# Test coverage +.nyc_output/ +coverage/ +.coverage/ + +# ----------------------------------------------------------------------------- +# Development Tools & IDE +# ----------------------------------------------------------------------------- +.vscode/ +.idea/ +.vs/ +*.swp +*.swo +*.swn +*~ +.editorconfig +.eslintrc* +eslint.config.* +.prettierrc* +.mocharc.* + +# ----------------------------------------------------------------------------- +# Operating System Files +# ----------------------------------------------------------------------------- +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# ----------------------------------------------------------------------------- +# TypeScript & Testing +# ----------------------------------------------------------------------------- +# Exclude test files but keep src/ for building inside container +test/ +**/*.test.ts +**/*.spec.ts + +# ----------------------------------------------------------------------------- +# Documentation & Planning (not needed at runtime) +# ----------------------------------------------------------------------------- +docs/ +*.md +!package.json +README* +CHANGELOG* +CONTRIBUTING* +CODE_OF_CONDUCT* +DISCLAIMER* +LICENSE* +CLAUDE.md + +# Local planning/notes +scratch/ +roadmap/ +blog/ +presentation/ +notes/ + +# Generated documentation +site/ +mkdocs.yml + +# ----------------------------------------------------------------------------- +# Testing & Quality Assurance +# ----------------------------------------------------------------------------- +test/ +test_screenshots/ +screenshots/ +.view_hierarchy_cache/ +.event_monitor_cache/ + +# ----------------------------------------------------------------------------- +# CI/CD Configuration +# ----------------------------------------------------------------------------- +.github/ +.gitlab-ci.yml +.gitlab-ci-local/ +.circleci/ +.travis.yml +azure-pipelines.yml +.buildkite/ +.semaphore/ + +# ----------------------------------------------------------------------------- +# Docker Files (no need to copy Docker files into Docker) +# ----------------------------------------------------------------------------- +Dockerfile* +.dockerignore +docker-compose*.yml +.hadolint.yaml + +# ----------------------------------------------------------------------------- +# Android Sources & Artifacts +# ----------------------------------------------------------------------------- +# Android sources are not required for the image build; mount the repo if needed +android/ +*.apk +*.aab +*.dex +*.class + +# ----------------------------------------------------------------------------- +# Local Configuration & Secrets +# ----------------------------------------------------------------------------- +.auto-mobile-config.json +.env +.env.* +!.env.example +*.key +*.pem +*.p12 +secrets/ +credentials.json + +# ----------------------------------------------------------------------------- +# Build & Runtime Artifacts +# ----------------------------------------------------------------------------- +.gradle/ +build/ +.cache/ +.temp/ +tmp/ +temp/ + +# Logs +logs/ +*.log +*.log.* +venv/ + +# ----------------------------------------------------------------------------- +# Claude Code Configuration +# ----------------------------------------------------------------------------- +.claude/ + +# ----------------------------------------------------------------------------- +# Miscellaneous Development Files +# ----------------------------------------------------------------------------- +firebender.json +.config/ +jemalloc-5.3.0.tar.bz2 +# Keep scripts needed for build, exclude only docker/validation/github scripts +scripts/docker/ +scripts/hadolint/ +scripts/ktfmt/ +scripts/shellcheck/ +scripts/xml/ +scripts/github/ + +# Temporary files +*.tmp +*.temp +*.bak +*.backup +*.old diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2d28a1ade..d9a3ef1b0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1,60 @@ # Contributing -We're currently working on the process to document accepting outside contributions. +AutoMobile welcomes contributions! + +If you have a question file an issue or discussion, but small contributions like documentation improvements, small obvious fixes, etc don't need prior discussion. If you see a TODO go for it. For larger functionality changes or features, please raise a discussion or issue first before starting work. Keep in mind the [design principles](https://kaeawc.github.io/auto-mobile/design-docs/#design-principles) of the project. Almost everything in CI can be run locally, check out the `scripts/` directory. After you've forked and cloned the project, set up your [local development](#local-development) environment. We expect contributors to be active users of Claude Code or some other AI tool. + +## Ways to Contribute + +| Type | Description | +|------|-------------| +| Bug reports | File issues with reproduction steps | +| Feature requests | Propose new capabilities via issues | +| Documentation | Improve guides, fix typos, add examples | +| Code | Bug fixes, new features, performance improvements | + +## Code Guidelines + +- **TypeScript only** - Never write JavaScript +- **Run validation** - `bun run lint` and `bun test` before submitting. If modifying Android or iOS code check for appropriate validation scripts in the `scripts/` directory +- **Keep PRs focused** - One feature or fix per PR +- **Add tests** - Cover new functionality with tests + +## Pull Request Process + +1. Create a branch from `main` +2. Make your changes with tests +3. At a minimum run `scripts/all_fast_validate_checks.sh`. If modifying Android or iOS code check for appropriate validation scripts in the `scripts/` directory +4. Submit PR with clear description +5. Address review feedback + +## Local Development + +| Platform | Script | +|----------|--------| +| Android | `./scripts/local-dev/android-hot-reload.sh` | +| iOS | `./scripts/local-dev/ios-hot-reload.sh` | + +Options: +- `--device ` - Target specific device (ADB device ID or simulator UDID) +- `--skip-ai` - Run without AI agent prompt +- `--once` - Build once and exit + +Both scripts write logs to `scratch/`, auto-detect ports based on your git branch, and automatically enable debug flags (`AUTOMOBILE_DEBUG`, `AUTOMOBILE_DEBUG_PERF`). + +**Verifying Setup** + +In Claude Code, run `/mcp` to check the connection status. A successful setup looks like: + +``` +╭─────────────────────────────────────────────────────────────────────╮ +│ Auto-mobile MCP Server │ +│ │ +│ Status: ✔ connected │ +│ Auth: ✔ authenticated │ +│ URL: http://localhost:9000/auto-mobile/streamable │ +│ Config location: /path/to/your/worktree/.mcp.json │ +│ Capabilities: tools · resources │ +│ Tools: 44 tools │ +╰─────────────────────────────────────────────────────────────────────╯ +``` diff --git a/.github/actions/android-emulator-wtf/action.yml b/.github/actions/android-emulator-wtf/action.yml new file mode 100644 index 000000000..562301d4e --- /dev/null +++ b/.github/actions/android-emulator-wtf/action.yml @@ -0,0 +1,101 @@ +#file: noinspection YAMLSchemaValidation +name: "android-emulator-wtf" +description: "Run Android tests against emulator.wtf using an adb-connected session" +inputs: + shell: + description: "The shell to use for any steps that use shells" + default: "bash" + required: "true" + script: + description: "Script to execute once the emulator.wtf session is ready" + example: './gradlew :junit-runner:test' + required: "true" + working-directory: + description: "Working directory" + default: "./" + required: "true" + max-time-limit: + description: "Max emulator session time (e.g., 1m, 2m)" + default: "1m" + required: "true" + device: + description: "Optional device profile (e.g., model=Pixel2,version=34,gpu=auto)" + default: "" + required: "false" + accessibility-apk-path: + description: "Optional path to accessibility service APK to install before running the script" + default: "" + required: "false" + +runs: + using: "composite" + steps: + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "21" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3.2.2 + + - name: Cache emulator.wtf CLI + id: cache-ew-cli + uses: actions/cache@v4 + with: + path: ~/bin/ew-cli + key: ew-cli-v1 + + - name: Install emulator.wtf CLI + if: steps.cache-ew-cli.outputs.cache-hit != 'true' + shell: ${{ inputs.shell }} + run: | + "${{ github.workspace }}/scripts/android/install-ew-cli.sh" \ + --install-dir "$HOME/bin" \ + --max-attempts 5 \ + --retry-delay 1 + + - name: Add ew-cli to PATH and verify + shell: ${{ inputs.shell }} + run: | + set -euo pipefail + mkdir -p "$HOME/bin" + echo "$HOME/bin" >> "$GITHUB_PATH" + "$HOME/bin/ew-cli" --version + + - name: Start emulator.wtf session + shell: ${{ inputs.shell }} + env: + EW_API_TOKEN: ${{ env.EW_API_TOKEN }} + run: | + device_args="" + if [ -n "${{ inputs.device }}" ]; then + device_args="--device ${{ inputs.device }}" + fi + "${{ github.workspace }}/scripts/android/start-emulator-wtf-session.sh" \ + --max-time-limit "${{ inputs.max-time-limit }}" \ + --session-log "$RUNNER_TEMP/emulator-wtf-session.log" \ + --env-file "$GITHUB_ENV" \ + --timeout 60 \ + --poll-interval 2 \ + $device_args + + - name: Run tests + shell: ${{ inputs.shell }} + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + if [ -n "${{ inputs.accessibility-apk-path }}" ]; then + "${{ github.workspace }}/scripts/android/run-emulator-tests.sh" \ + "${{ inputs.accessibility-apk-path }}" \ + "${{ inputs.script }}" + else + eval "${{ inputs.script }}" + fi + + - name: Stop emulator.wtf session + if: always() + shell: ${{ inputs.shell }} + run: | + "${{ github.workspace }}/scripts/android/stop-emulator-wtf-session.sh" \ + --log-lines 200 diff --git a/.github/actions/android-emulator/action.yml b/.github/actions/android-emulator/action.yml index 5b7c85965..e60f40f29 100644 --- a/.github/actions/android-emulator/action.yml +++ b/.github/actions/android-emulator/action.yml @@ -1,6 +1,6 @@ #file: noinspection YAMLSchemaValidation -name: "gradle-task-run" -description: "" +name: "android-emulator" +description: "Run Android Emulator with AVD snapshot caching for optimized CI performance" inputs: shell: description: "The shell to use for any steps that use shells" @@ -21,16 +21,66 @@ inputs: required: "true" target: description: "System image target" - default: "default" + default: "google_apis" required: "true" working-directory: description: "Working directory" default: "./" required: "true" + accessibility-apk-path: + description: "Optional path to accessibility service APK to install before running the script" + default: "" + required: "false" + gradle-home-directory: + description: "The directory to use for Gradle User Home" + default: "" + required: "false" + release-keystore-base64: + description: "Base64 encoded release keystore for signing" + required: false + default: "" + release-keystore-password: + description: "Password for the release keystore" + required: false + default: "" + release-key-alias: + description: "Key alias in the release keystore" + required: false + default: "" + release-key-password: + description: "Password for the key in the release keystore" + required: false + default: "" runs: using: "composite" steps: + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "21" + + - name: Set Gradle User Home + shell: ${{ inputs.shell }} + run: | + gradle_home="${{ inputs.gradle-home-directory }}" + if [ -z "$gradle_home" ]; then + gradle_home="$HOME/.gradle" + fi + gradle_home="${gradle_home/#\~/$HOME}" + echo "GRADLE_USER_HOME=$gradle_home" >> $GITHUB_ENV + echo "Using GRADLE_USER_HOME=$gradle_home" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Verify Java version + shell: ${{ inputs.shell }} + run: | + java -version + java -XshowSettings:properties -version 2>&1 | awk -F= '/java.specification.version/ {gsub(/ /,"",$2); if ($2+0 < 21) {print "Java 21+ is required"; exit 1}}' + - name: Enable KVM group perms shell: ${{ inputs.shell }} run: | @@ -38,45 +88,110 @@ runs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm -# - name: Restore AVD cache -# id: avd-cache-restore -# uses: actions/cache/restore@v4 -# with: -# path: | -# ~/.android/avd/* -# ~/.android/adb* -# key: avd-${{ runner.os }}-${{ inputs.api_level }} + - name: Free disk space for emulator + shell: ${{ inputs.shell }} + run: | + echo "Disk space before cleanup:" + df -h + + # Remove large pre-installed tools that aren't needed + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + + echo "" + echo "Disk space after cleanup:" + df -h + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + /usr/local/lib/android/sdk/system-images/* + key: avd-${{ runner.os }}-${{ inputs.api_level }}-${{ inputs.arch }}-${{ inputs.target }} + restore-keys: | + avd-${{ runner.os }}-${{ inputs.api_level }}-${{ inputs.arch }}- + avd-${{ runner.os }}-${{ inputs.api_level }}- + avd-${{ runner.os }}- -# - name: Cache AVD + Snapshot -# id: avd-cache-generate -# if: steps.avd-cache-restore.outputs.cache-hit != 'true' -# uses: reactivecircus/android-emulator-runner@v2 -# with: -# api-level: ${{ inputs.api_level }} -# arch: ${{ inputs.arch }} -# target: ${{ inputs.target }} -# force-avd-creation: false -# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -# disable-animations: false -# script: echo "Generated AVD snapshot for caching." + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2.35.0 + with: + api-level: ${{ inputs.api_level }} + arch: ${{ inputs.arch }} + target: ${{ inputs.target }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: | + echo "==========================================" + echo "AVD CREATION DEBUG" + echo "==========================================" + echo "Emulator version: $(emulator -version 2>/dev/null || echo 'unknown')" + echo "Android SDK tools location: $ANDROID_HOME" + ls -la "$ANDROID_HOME" 2>/dev/null || echo "ANDROID_HOME not found" + echo "AVD directory: $ANDROID_AVD_HOME" + ls -la "$ANDROID_AVD_HOME" 2>/dev/null || echo "ANDROID_AVD_HOME not found" + echo "" + echo "Generated AVD snapshot for caching." + + - name: Setup Release Keystore + if: ${{ inputs.release-keystore-base64 != '' }} + shell: ${{ inputs.shell }} + working-directory: ${{ inputs.working-directory }} + run: | + mkdir -p keystore + echo "${{ inputs.release-keystore-base64 }}" | base64 -d > keystore/release.keystore + echo "RELEASE_KEYSTORE_PATH=keystore/release.keystore" >> $GITHUB_ENV + echo "RELEASE_KEYSTORE_PASSWORD=${{ inputs.release-keystore-password }}" >> $GITHUB_ENV + echo "RELEASE_KEY_ALIAS=${{ inputs.release-key-alias }}" >> $GITHUB_ENV + echo "RELEASE_KEY_PASSWORD=${{ inputs.release-key-password }}" >> $GITHUB_ENV -# - name: "Save Android SDK Platform Tools" -# uses: actions/cache/save@v4 -# if: steps.avd-cache-generate.outputs.cache-hit == 'true' -# with: -# path: | -# /usr/local/lib/android/sdk/platform-tools -# /home/runner/.config/.android/cache -# key: avd-${{ runner.os }}-${{ inputs.api_level }} + # Run emulator with automatic retry for infrastructure flakiness (#808) + # Handles: adb device offline, device not found, adb server killed, emulator boot failures + - name: Run Emulator (Attempt 1) + id: emulator-attempt-1 + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2.35.0 + with: + api-level: ${{ inputs.api_level }} + arch: ${{ inputs.arch }} + target: ${{ inputs.target }} + script: ../scripts/android/run-emulator-tests.sh "${{ inputs.accessibility-apk-path }}" "${{ inputs.script }}" + working-directory: ${{ inputs.working-directory }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false - - name: Run Emulator - uses: reactivecircus/android-emulator-runner@v2 + - name: Run Emulator (Retry) + id: emulator-attempt-2 + if: steps.emulator-attempt-1.outcome == 'failure' + uses: reactivecircus/android-emulator-runner@v2.35.0 with: - api-level: ${{ inputs.api_level }} + api-level: ${{ inputs.api_level }} arch: ${{ inputs.arch }} target: ${{ inputs.target }} - script: ${{ inputs.script }} + script: ../scripts/android/run-emulator-tests.sh "${{ inputs.accessibility-apk-path }}" "${{ inputs.script }}" working-directory: ${{ inputs.working-directory }} -# force-avd-creation: false -# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -# disable-animations: false + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + + - name: Verify Emulator Tests Passed + if: always() + shell: ${{ inputs.shell }} + run: | + if [[ "${{ steps.emulator-attempt-1.outcome }}" == "success" ]]; then + echo "✓ Emulator tests passed on first attempt" + exit 0 + elif [[ "${{ steps.emulator-attempt-2.outcome }}" == "success" ]]; then + echo "✓ Emulator tests passed on retry (infrastructure flakiness detected)" + exit 0 + else + echo "✗ Emulator tests failed after retry" + exit 1 + fi diff --git a/.github/actions/ensure-ios-simulator-runtime/action.yml b/.github/actions/ensure-ios-simulator-runtime/action.yml new file mode 100644 index 000000000..99fd1c93b --- /dev/null +++ b/.github/actions/ensure-ios-simulator-runtime/action.yml @@ -0,0 +1,40 @@ +name: "Ensure iOS Simulator Runtime" +description: "Detect missing iOS simulator runtimes and install them if needed" + +runs: + using: "composite" + steps: + - name: "Check iOS Simulator runtimes" + id: check + shell: bash + run: | + sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version) + major_version="${sdk_version%%.*}" + echo "Xcode SDK version: ${sdk_version} (need iOS ${major_version}.x runtime)" + echo "major_version=${major_version}" >> "$GITHUB_OUTPUT" + + match_count=$(xcrun simctl list runtimes iOS --json \ + | jq --arg v "${major_version}" '[.runtimes[] | select(.version | startswith($v + "."))] | length') + echo "Found ${match_count} iOS ${major_version}.x simulator runtime(s)" + if [ "${match_count}" -eq 0 ]; then + echo "::warning::No iOS ${major_version}.x simulator runtime found — downloading platform" + echo "needs_download=true" >> "$GITHUB_OUTPUT" + fi + + - name: "Download iOS Simulator runtime" + if: steps.check.outputs.needs_download == 'true' + shell: bash + run: xcodebuild -downloadPlatform iOS + + - name: "Verify iOS Simulator runtime" + if: steps.check.outputs.needs_download == 'true' + shell: bash + run: | + major_version="${{ steps.check.outputs.major_version }}" + match_count=$(xcrun simctl list runtimes iOS --json \ + | jq --arg v "${major_version}" '[.runtimes[] | select(.version | startswith($v + "."))] | length') + echo "Found ${match_count} iOS ${major_version}.x simulator runtime(s) after download" + if [ "${match_count}" -eq 0 ]; then + echo "::error::Still no iOS ${major_version}.x simulator runtime after download" + exit 1 + fi diff --git a/.github/actions/gradle-task-run/action.yml b/.github/actions/gradle-task-run/action.yml index c4a72c2af..fbc455e6f 100644 --- a/.github/actions/gradle-task-run/action.yml +++ b/.github/actions/gradle-task-run/action.yml @@ -49,6 +49,22 @@ inputs: description: "Optional suffix to add to file names" required: "true" default: "" + release-keystore-base64: + description: "Base64 encoded release keystore for signing" + required: false + default: "" + release-keystore-password: + description: "Password for the release keystore" + required: false + default: "" + release-key-alias: + description: "Key alias in the release keystore" + required: false + default: "" + release-key-password: + description: "Password for the key in the release keystore" + required: false + default: "" outputs: gradle-home-project-cache-hit: @@ -65,7 +81,7 @@ runs: uses: actions/setup-java@v4 with: distribution: 'zulu' - java-version: '23' + java-version: '21' - name: "Set up jemalloc" if: ${{ inputs.malloc-replacement == 'jemalloc' }} @@ -75,6 +91,27 @@ runs: if: ${{ inputs.malloc-replacement == 'tcmalloc' }} uses: kaeawc/setup-tcmalloc@v0.0.1 + - name: "Set Gradle User Home" + shell: ${{ inputs.shell }} + run: | + gradle_home="${{ inputs.gradle-home-directory }}" + if [ -z "$gradle_home" ]; then + gradle_home="$HOME/.gradle" + fi + gradle_home="${gradle_home/#\~/$HOME}" + echo "GRADLE_USER_HOME=$gradle_home" >> $GITHUB_ENV + echo "Using GRADLE_USER_HOME=$gradle_home" + + - name: "Sanitize artifact name" + id: sanitize-name + shell: ${{ inputs.shell }} + if: success() || failure() + run: | + # Remove or replace non-alphanumeric characters (keeping hyphens and underscores), then truncate to 64 chars + sanitized_name=$(echo "${{ inputs.gradle-tasks }}" | sed 's/[^a-zA-Z0-9_-]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//' | cut -c1-64 | sed 's/-$//') + echo "sanitized_name=$sanitized_name" >> $GITHUB_OUTPUT + echo "Sanitized name: $sanitized_name" + - name: "Print Java Flags & version" if: ${{ inputs.debug == 'true' }} shell: ${{ inputs.shell }} @@ -149,27 +186,39 @@ runs: build-scan-terms-of-use-agree: 'yes' validate-wrappers: true - - name: "Restore Android SDK Platform Tools" - id: cache-android-platform-tools + - name: "Restore Android SDK Cache" + id: cache-android-sdk uses: actions/cache/restore@v4 with: path: | - /usr/local/lib/android/sdk/platform-tools - /home/runner/.config/.android/cache - key: v2-${{ runner.os }}-android-platform-tools + ~/.android + ~/.config + key: v4-${{ runner.os }}-android-sdk - - name: "Setup Android SDK" - if: steps.cache-android-platform-tools.outputs.cache-hit != 'true' + - name: Setup Android SDK + if: steps.cache-android-sdk.outputs.cache-hit != 'true' uses: android-actions/setup-android@v3.2.2 - - name: "Save Android SDK Platform Tools" + - name: "Save Android SDK Cache" uses: actions/cache/save@v4 - if: steps.cache-android-platform-tools.outputs.cache-hit != 'true' + if: steps.cache-android-sdk.outputs.cache-hit != 'true' with: path: | - /usr/local/lib/android/sdk/platform-tools - /home/runner/.config/.android/cache - key: v2-${{ runner.os }}-android-platform-tools + ~/.android + ~/.config + key: v4-${{ runner.os }}-android-sdk + + - name: "Setup Release Keystore" + if: ${{ inputs.release-keystore-base64 != '' }} + shell: ${{ inputs.shell }} + working-directory: ${{ inputs.gradle-project-directory }} + run: | + mkdir -p keystore + echo "${{ inputs.release-keystore-base64 }}" | base64 -d > keystore/release.keystore + echo "RELEASE_KEYSTORE_PATH=keystore/release.keystore" >> $GITHUB_ENV + echo "RELEASE_KEYSTORE_PASSWORD=${{ inputs.release-keystore-password }}" >> $GITHUB_ENV + echo "RELEASE_KEY_ALIAS=${{ inputs.release-key-alias }}" >> $GITHUB_ENV + echo "RELEASE_KEY_PASSWORD=${{ inputs.release-key-password }}" >> $GITHUB_ENV - name: "Run Tasks via Gradle" shell: ${{ inputs.shell }} @@ -204,3 +253,11 @@ runs: else touch build/reports/config-cache-report.html fi + + - name: "Store Config Cache Report" + uses: actions/upload-artifact@v4.4.0 + if: success() + with: + name: config-cache-report-${{ steps.sanitize-name.outputs.sanitized_name }} + path: | + ${{ inputs.gradle-project-directory }}/build/reports/config-cache-report.html diff --git a/.github/actions/setup-auto-mobile-npm-package/action.yml b/.github/actions/setup-auto-mobile-npm-package/action.yml index 21d7b285e..2c78364c2 100644 --- a/.github/actions/setup-auto-mobile-npm-package/action.yml +++ b/.github/actions/setup-auto-mobile-npm-package/action.yml @@ -10,29 +10,39 @@ inputs: runs: using: "composite" steps: - - name: "Setup Node.js" - uses: actions/setup-node@v4 + - name: "Setup Bun" + uses: oven-sh/setup-bun@v2 with: - node-version: "24" - cache: "npm" + bun-version: 1.3.9 - name: "Install ripgrep" - shell: ${{ inputs.shell }} - run: | - sudo apt-get update - sudo apt-get install -y ripgrep + uses: ./.github/actions/setup-ripgrep - name: "Install dependencies" shell: ${{ inputs.shell }} run: | - npm ci + bun install --frozen-lockfile || { + bun pm cache rm + bun install --frozen-lockfile + } - name: "Build AutoMobile" shell: ${{ inputs.shell }} + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" run: | - npm run build + turbo run build --output-logs=errors-only - name: "Globally Install AutoMobile" shell: ${{ inputs.shell }} run: | + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + npm install -g . + exit 0 + fi + + bun install -g . npm install -g . diff --git a/.github/actions/setup-ripgrep/action.yml b/.github/actions/setup-ripgrep/action.yml new file mode 100644 index 000000000..f0fcd53fa --- /dev/null +++ b/.github/actions/setup-ripgrep/action.yml @@ -0,0 +1,65 @@ +#file: noinspection YAMLSchemaValidation +name: "setup-ripgrep" +description: "Install ripgrep by downloading pre-built binaries. Skips if already installed." +inputs: + version: + description: "ripgrep version to install (default: 15.1.0)" + default: "15.1.0" + required: false + install-dir: + description: "Directory to install ripgrep binary" + default: "" + required: false + +runs: + using: "composite" + steps: + - name: "Check if ripgrep is already installed" + id: check-rg + shell: bash + run: | + if command -v rg >/dev/null 2>&1; then + echo "ripgrep is already installed: $(rg --version | head -1)" + echo "installed=true" >> "$GITHUB_OUTPUT" + else + echo "ripgrep not found, will install" + echo "installed=false" >> "$GITHUB_OUTPUT" + fi + + - name: "Install ripgrep" + if: steps.check-rg.outputs.installed != 'true' + shell: bash + run: | + "${{ github.action_path }}/install-ripgrep.sh" \ + --version "${{ inputs.version }}" \ + ${INSTALL_DIR:+--install-dir "$INSTALL_DIR"} + env: + INSTALL_DIR: ${{ inputs.install-dir }} + + - name: "Add to GITHUB_PATH (Unix)" + if: steps.check-rg.outputs.installed != 'true' && runner.os != 'Windows' + shell: bash + run: | + install_dir="${{ inputs.install-dir }}" + if [ -z "$install_dir" ]; then + install_dir="$HOME/.local/bin" + fi + echo "$install_dir" >> "$GITHUB_PATH" + + - name: "Add to GITHUB_PATH (Windows)" + if: steps.check-rg.outputs.installed != 'true' && runner.os == 'Windows' + shell: bash + run: | + install_dir="${{ inputs.install-dir }}" + if [ -z "$install_dir" ]; then + install_dir="$USERPROFILE/.local/bin" + fi + echo "$install_dir" >> "$GITHUB_PATH" + + - name: "Verify installation" + if: steps.check-rg.outputs.installed != 'true' + shell: bash + run: | + # Give PATH a moment to update + export PATH="$HOME/.local/bin:$USERPROFILE/.local/bin:$PATH" + rg --version diff --git a/.github/actions/setup-ripgrep/install-ripgrep.sh b/.github/actions/setup-ripgrep/install-ripgrep.sh new file mode 100755 index 000000000..4d288adc9 --- /dev/null +++ b/.github/actions/setup-ripgrep/install-ripgrep.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# +# Install ripgrep by downloading pre-built binaries from GitHub releases +# +# Usage: ./install-ripgrep.sh [OPTIONS] +# +# Options: +# --version VERSION ripgrep version to install (default: 15.1.0) +# --install-dir DIR Directory to install binary (default: ~/.local/bin) +# --dry-run Print what would be done without executing +# --help Show this help message +# +# Supported platforms: +# - Linux (x86_64, aarch64) +# - macOS (x86_64, aarch64/Apple Silicon) +# - Windows (x86_64, aarch64) +# + +set -euo pipefail + +# Defaults +VERSION="15.1.0" +INSTALL_DIR="" +DRY_RUN=false +TEMP_DIR="" + +cleanup() { + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi +} +trap cleanup EXIT + +usage() { + head -n 17 "$0" | tail -n 15 | sed 's/^# //' | sed 's/^#//' +} + +log() { + echo "[setup-ripgrep] $*" +} + +error() { + echo "[setup-ripgrep] ERROR: $*" >&2 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --version) + VERSION="$2" + shift 2 + ;; + --install-dir) + INSTALL_DIR="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --help) + usage + exit 0 + ;; + *) + error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Detect OS +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux" ;; + Darwin*) echo "macos" ;; + CYGWIN*|MINGW*|MSYS*) echo "windows" ;; + *) + # Check for Windows via OS env var + if [[ "${OS:-}" == "Windows_NT" ]]; then + echo "windows" + else + error "Unsupported operating system: $(uname -s)" + exit 1 + fi + ;; + esac +} + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + *) + error "Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac +} + +# Get the download URL for the platform +get_download_url() { + local os="$1" + local arch="$2" + local version="$3" + local base_url="https://github.com/BurntSushi/ripgrep/releases/download/${version}" + local filename + + case "${os}-${arch}" in + linux-x86_64) + filename="ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz" + ;; + linux-aarch64) + filename="ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz" + ;; + macos-x86_64) + filename="ripgrep-${version}-x86_64-apple-darwin.tar.gz" + ;; + macos-aarch64) + filename="ripgrep-${version}-aarch64-apple-darwin.tar.gz" + ;; + windows-x86_64) + filename="ripgrep-${version}-x86_64-pc-windows-msvc.zip" + ;; + windows-aarch64) + filename="ripgrep-${version}-aarch64-pc-windows-msvc.zip" + ;; + *) + error "Unsupported platform: ${os}-${arch}" + exit 1 + ;; + esac + + echo "${base_url}/${filename}" +} + +# Get default install directory +get_default_install_dir() { + local os="$1" + case "$os" in + windows) + echo "${USERPROFILE:-$HOME}/.local/bin" + ;; + *) + echo "$HOME/.local/bin" + ;; + esac +} + +# Main installation logic +main() { + local os arch url install_dir archive_name + + os=$(detect_os) + arch=$(detect_arch) + url=$(get_download_url "$os" "$arch" "$VERSION") + + if [[ -z "$INSTALL_DIR" ]]; then + install_dir=$(get_default_install_dir "$os") + else + install_dir="$INSTALL_DIR" + fi + + archive_name=$(basename "$url") + + log "Platform: ${os}-${arch}" + log "Version: ${VERSION}" + log "Download URL: ${url}" + log "Install directory: ${install_dir}" + + if [[ "$DRY_RUN" == "true" ]]; then + log "[DRY-RUN] Would create directory: ${install_dir}" + log "[DRY-RUN] Would download: ${url}" + log "[DRY-RUN] Would extract to: ${install_dir}" + log "[DRY-RUN] Would verify: rg --version" + exit 0 + fi + + # Create install directory + mkdir -p "$install_dir" + + # Create temp directory for download (uses global TEMP_DIR for cleanup trap) + TEMP_DIR=$(mktemp -d) + + # Download + log "Downloading ripgrep ${VERSION}..." + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "${TEMP_DIR}/${archive_name}" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "${TEMP_DIR}/${archive_name}" + else + error "Neither curl nor wget found" + exit 1 + fi + + # Extract + log "Extracting..." + cd "$TEMP_DIR" + + case "$archive_name" in + *.tar.gz) + tar -xzf "$archive_name" + # Find the rg binary in the extracted directory + local extracted_dir + extracted_dir=$(find . -maxdepth 1 -type d -name "ripgrep-*" | head -1) + if [[ -n "$extracted_dir" ]]; then + cp "${extracted_dir}/rg" "$install_dir/" + chmod +x "${install_dir}/rg" + else + error "Could not find extracted ripgrep directory" + exit 1 + fi + ;; + *.zip) + if command -v unzip >/dev/null 2>&1; then + unzip -q "$archive_name" + elif command -v 7z >/dev/null 2>&1; then + 7z x -y "$archive_name" >/dev/null + else + # Try PowerShell on Windows + powershell -Command "Expand-Archive -Path '$archive_name' -DestinationPath '.' -Force" + fi + # Find the rg binary + local extracted_dir + extracted_dir=$(find . -maxdepth 1 -type d -name "ripgrep-*" | head -1) + if [[ -n "$extracted_dir" ]]; then + cp "${extracted_dir}/rg.exe" "$install_dir/" 2>/dev/null || cp "${extracted_dir}/rg" "$install_dir/" + chmod +x "${install_dir}/rg.exe" 2>/dev/null || chmod +x "${install_dir}/rg" 2>/dev/null || true + else + error "Could not find extracted ripgrep directory" + exit 1 + fi + ;; + *) + error "Unknown archive format: ${archive_name}" + exit 1 + ;; + esac + + log "Successfully installed ripgrep ${VERSION} to ${install_dir}" + + # Verify + if [[ "$os" == "windows" ]]; then + "${install_dir}/rg.exe" --version || "${install_dir}/rg" --version + else + "${install_dir}/rg" --version + fi +} + +main diff --git a/.github/bug_report.md b/.github/bug_report.md new file mode 100644 index 000000000..7b56178cc --- /dev/null +++ b/.github/bug_report.md @@ -0,0 +1,13 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior in auto-worktree +title: '' +labels: 'bug' +assignees: '' +--- + +## What functionality was being used? + +## What information do we have (stacktrace, logs, screenshots)? + +## Any hypotheses to investigate? diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 941b721c4..3fd2b3c5c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "npm" + - package-ecosystem: "bun" directory: "/" schedule: interval: "daily" @@ -15,3 +15,65 @@ updates: open-pull-requests-limit: 10 # Set versioning strategy appropriate for a library/package versioning-strategy: "increase-if-necessary" + # Android Gradle dependencies + - package-ecosystem: "gradle" + directory: "/android" + schedule: + interval: "daily" + time: "07:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "android" + commit-message: + prefix: "chore(deps)" + # iOS Swift Package Manager dependencies + - package-ecosystem: "swift" + directory: "/ios/XCTestService" + schedule: + interval: "daily" + time: "07:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ios" + commit-message: + prefix: "chore(deps)" + - package-ecosystem: "swift" + directory: "/ios/XCTestRunner" + schedule: + interval: "daily" + time: "07:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ios" + commit-message: + prefix: "chore(deps)" + - package-ecosystem: "swift" + directory: "/ios/XcodeCompanion" + schedule: + interval: "daily" + time: "07:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ios" + commit-message: + prefix: "chore(deps)" + - package-ecosystem: "swift" + directory: "/ios/XcodeExtension" + schedule: + interval: "daily" + time: "07:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ios" + commit-message: + prefix: "chore(deps)" diff --git a/.github/feature_request.md b/.github/feature_request.md new file mode 100644 index 000000000..693e314aa --- /dev/null +++ b/.github/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement for auto-worktree +title: '' +labels: 'enhancement' +assignees: '' +--- + +## What is the problem being solved? + +## What concepts or ideas do we need to explore to solve it? + + diff --git a/.github/workflows/build-control-proxy-apk.yml b/.github/workflows/build-control-proxy-apk.yml new file mode 100644 index 000000000..cb37f461c --- /dev/null +++ b/.github/workflows/build-control-proxy-apk.yml @@ -0,0 +1,59 @@ +name: "Build CtrlProxy APK" + +on: + workflow_call: + secrets: + GRADLE_ENCRYPTION_KEY: + required: true + RELEASE_KEYSTORE_BASE64: + required: true + RELEASE_KEYSTORE_PASSWORD: + required: true + RELEASE_KEY_ALIAS: + required: true + RELEASE_KEY_PASSWORD: + required: true + outputs: + sha256: + description: "SHA256 checksum of the built APK" + value: ${{ jobs.build.outputs.sha256 }} + +jobs: + build: + name: "Build Accessibility Service" + runs-on: ubuntu-latest + outputs: + sha256: ${{ steps.checksum.outputs.sha256 }} + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: ":control-proxy:assembleDebug" + gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/build-control-proxy-apk" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + - name: "Calculate APK SHA256" + id: checksum + run: | + APK_PATH="android/control-proxy/build/outputs/apk/debug/control-proxy-debug.apk" + SHA256=$(sha256sum "$APK_PATH" | cut -d' ' -f1) + echo "sha256=$SHA256" >> $GITHUB_OUTPUT + echo "APK SHA256: $SHA256" + + - name: "Copy APK to /tmp" + run: cp android/control-proxy/build/outputs/apk/debug/control-proxy-debug.apk /tmp/control-proxy-debug.apk + + - name: "Upload CtrlProxy APK" + uses: actions/upload-artifact@v6 + with: + name: control-proxy-apk + path: /tmp/control-proxy-debug.apk + retention-days: 7 diff --git a/.github/workflows/build-xctestservice-ipa.yml b/.github/workflows/build-xctestservice-ipa.yml new file mode 100644 index 000000000..fc17ae61e --- /dev/null +++ b/.github/workflows/build-xctestservice-ipa.yml @@ -0,0 +1,59 @@ +name: "Build XCTestService IPA" + +on: + workflow_call: + outputs: + sha256: + description: "SHA256 checksum of the built IPA" + value: ${{ jobs.build.outputs.sha256 }} + runner_sha256: + description: "SHA256 checksum of the XCTestServiceUITests-Runner binary" + value: ${{ jobs.build.outputs.runner_sha256 }} + +jobs: + build: + name: "Build XCTestService IPA" + runs-on: macos-26 + outputs: + sha256: ${{ steps.checksum.outputs.ipa_sha256 }} + runner_sha256: ${{ steps.checksum.outputs.runner_sha256 }} + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - name: "Select Xcode 26.0" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.0" + + - name: "Cache Homebrew packages" + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/Homebrew + /opt/homebrew/Cellar/xcodegen + /usr/local/Cellar/xcodegen + key: homebrew-xcodegen-${{ runner.os }} + + - name: "Install XcodeGen" + run: brew install xcodegen + + - name: "Cache Ruby gems" + uses: actions/cache@v4 + with: + path: ~/.gem + key: gems-xcpretty-${{ runner.os }} + + - name: "Install xcpretty" + run: gem install xcpretty + + - name: "Build XCTestService IPA" + id: checksum + run: ./scripts/ios/xctestservice-create-ipa.sh --output /tmp/XCTestService.ipa + + - name: "Upload XCTestService IPA" + uses: actions/upload-artifact@v6 + with: + name: xctestservice-ipa + path: /tmp/XCTestService.ipa + retention-days: 7 diff --git a/.github/workflows/dead-code-detection.yml b/.github/workflows/dead-code-detection.yml new file mode 100644 index 000000000..1bc62a303 --- /dev/null +++ b/.github/workflows/dead-code-detection.yml @@ -0,0 +1,214 @@ +name: "Dead Code Detection" + +on: + schedule: + # Run weekly on Monday at 00:00 UTC + - cron: '0 0 * * 1' + workflow_dispatch: + inputs: + threshold: + description: 'Maximum allowed dead code issues (default: 10)' + required: false + default: '10' + +permissions: + contents: read + issues: write + actions: read + +jobs: + detect-dead-code: + name: "Detect TypeScript Dead Code" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Install jq" + run: | + sudo apt-get update + sudo apt-get install -y jq + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Dead Code Detection" + id: detect + run: | + # Create reports directory + mkdir -p reports + + # Set threshold from workflow input or default to 10 + THRESHOLD=${{ github.event.inputs.threshold || '10' }} + + # Run detection script and capture exit code + bun run dead-code:ts --output-dir=reports --threshold=$THRESHOLD || echo "FAILED=true" >> $GITHUB_ENV + + # Always preserve the report files even if detection failed + exit 0 + + - name: "Upload Dead Code Report (JSON)" + uses: actions/upload-artifact@v6 + if: always() + with: + name: dead-code-report-json + path: reports/dead-code-report.json + retention-days: 90 + + - name: "Upload Dead Code Report (Markdown)" + uses: actions/upload-artifact@v6 + if: always() + with: + name: dead-code-report-markdown + path: reports/dead-code-report.md + retention-days: 90 + + - name: "Check Detection Result" + if: always() + run: | + if [ "$FAILED" = "true" ]; then + echo "::error::Dead code detection failed - threshold exceeded" + cat reports/dead-code-report.json + exit 1 + fi + echo "::notice::Dead code detection passed - below threshold" + + - name: "Create or Update Issue" + if: always() && env.FAILED == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const reportPath = 'reports/dead-code-report.md'; + + if (!fs.existsSync(reportPath)) { + console.log('No dead code report found'); + return; + } + + const reportContent = fs.readFileSync(reportPath, 'utf8'); + const jsonReportPath = 'reports/dead-code-report.json'; + const jsonReport = JSON.parse(fs.readFileSync(jsonReportPath, 'utf8')); + + // Build issue body with summary and link to full report + let body = '## Dead Code Detection Report\n\n'; + body += '> ⚠️ **Dead code threshold exceeded** - This automated report identifies unused TypeScript code in the repository.\n\n'; + body += `**Timestamp:** ${jsonReport.timestamp}\n\n`; + body += `**Total Issues:** ${jsonReport.totalIssues}\n\n`; + + body += '### Summary\n\n'; + body += '| Category | Count |\n'; + body += '|----------|-------|\n'; + body += `| Unused Exports | ${jsonReport.summary.unusedExports} |\n`; + body += `| Unused Files | ${jsonReport.summary.unusedFiles} |\n`; + body += `| Unused Dependencies | ${jsonReport.summary.unusedDependencies} |\n`; + body += `| Other | ${jsonReport.summary.other} |\n\n`; + + body += '### Action Items\n\n'; + body += '1. Review the [full report artifact](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n'; + body += '2. Determine which items are false positives (e.g., public API exports)\n'; + body += '3. Remove genuinely unused code\n'; + body += '4. Update knip.json configuration if needed to ignore valid exports\n\n'; + + body += '### Tool Breakdown\n\n'; + body += `- **ts-prune:** ${jsonReport.byTool.tsPrune} issues\n`; + body += `- **knip:** ${jsonReport.byTool.knip} issues\n\n`; + + if (jsonReport.summary.unusedExports > 0 && jsonReport.issues.length > 0) { + body += '### Sample Issues (first 10)\n\n'; + const unusedExports = jsonReport.issues.filter(i => i.type === 'unused export').slice(0, 10); + if (unusedExports.length > 0) { + body += '**Unused Exports:**\n'; + for (const issue of unusedExports) { + body += `- \`${issue.location}\` - ${issue.name}\n`; + } + body += '\n'; + } + } + + body += '### How to Fix\n\n'; + body += '```bash\n'; + body += '# Run locally to see full report\n'; + body += 'bun run dead-code:ts\n\n'; + body += '# Run individual tools\n'; + body += 'bun run dead-code:ts:prune\n'; + body += 'bun run dead-code:ts:knip\n'; + body += '```\n\n'; + + body += '### Documentation\n\n'; + body += 'See [docs/contributing/dead-code-detection.md](https://github.com/${{ github.repository }}/blob/main/docs/contributing/dead-code-detection.md) for details on:\n'; + body += '- Interpreting results\n'; + body += '- Configuring knip\n'; + body += '- Handling false positives\n\n'; + + body += '_This issue was automatically created by the [Dead Code Detection workflow](https://github.com/${{ github.repository }}/blob/main/.github/workflows/dead-code-detection.yml)_'; + + // Search for existing dead code issue + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'dead-code,automated', + }); + + const existingIssue = issues.find(issue => + issue.title === 'Dead Code Detection: Threshold Exceeded' + ); + + if (existingIssue) { + // Update existing issue + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: body + }); + console.log(`Updated existing issue #${existingIssue.number}`); + } else { + // Create new issue + const newIssue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Dead Code Detection: Threshold Exceeded', + body: body, + labels: ['dead-code', 'automated', 'maintenance'] + }); + console.log(`Created new issue #${newIssue.data.number}`); + } + + - name: "Close Issue if Passed" + if: always() && env.FAILED != 'true' + uses: actions/github-script@v7 + with: + script: | + // Search for existing dead code issue + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'dead-code,automated', + }); + + const existingIssue = issues.find(issue => + issue.title === 'Dead Code Detection: Threshold Exceeded' + ); + + if (existingIssue) { + // Close the issue with a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: '✅ Dead code has been cleaned up! The latest detection run found no issues above the threshold. Closing this issue.' + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + state: 'closed' + }); + + console.log(`Closed issue #${existingIssue.number}`); + } else { + console.log('No open dead code issue found - nothing to close'); + } diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 985aac977..4a5541cf8 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -1,6 +1,7 @@ name: "On Merge" on: + workflow_dispatch: push: branches: - main @@ -9,10 +10,73 @@ permissions: checks: write security-events: write pull-requests: write - contents: read + contents: write packages: write jobs: + build-ide-plugin: + name: "Build IDE Plugin" + runs-on: ubuntu-latest + defaults: + run: + working-directory: android/ide-plugin + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: "build -x test" + gradle-project-directory: "android/ide-plugin" + gradle-home-directory: "~/.gradle/${{ github.job }}" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: "Build Plugin ZIP" + shell: bash + working-directory: android/ide-plugin + run: | + ./gradlew buildPlugin --stacktrace --continue + + - name: "Verify Plugin" + shell: bash + working-directory: android/ide-plugin + run: | + ./gradlew verifyPlugin --stacktrace --continue + + - name: "Upload IDE Plugin ZIP" + uses: actions/upload-artifact@v6 + if: success() + with: + name: ide-plugin-zip + path: android/ide-plugin/build/distributions/*.zip + + ide-plugin-unit-tests: + name: "Run IDE Plugin Unit Tests" + runs-on: ubuntu-latest + needs: build-ide-plugin + defaults: + run: + working-directory: android/ide-plugin + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: "test" + gradle-project-directory: "android/ide-plugin" + gradle-home-directory: "~/.gradle/${{ github.job }}" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: "Upload Test Results" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: ide-plugin-test-results + path: android/ide-plugin/build/reports/tests/test/ + validate-xml: name: "Validate XML" runs-on: ubuntu-latest @@ -51,20 +115,297 @@ jobs: run: | scripts/shellcheck/validate_shell_scripts.sh - mcp-build-and-test: - name: "Node TypeScript Build and Test" + validate-mkdocs-nav: + name: "Validate MkDocs Navigation" runs-on: ubuntu-latest steps: - name: "Git Checkout" uses: actions/checkout@v4 + - name: "Validate MkDocs Navigation" + shell: "bash" + run: | + scripts/validate_mkdocs_nav.sh + + validate-claude-plugin: + name: "Validate Claude Plugin" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Validate Claude Plugin" + shell: "bash" + run: | + scripts/claude/validate_plugin.sh + + validate-documentation-links: + name: "Validate Documentation Links" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Create contributing file" + shell: "bash" + run: | + touch docs/contributing.md + + - name: "Install Lychee" + shell: "bash" + run: | + scripts/lychee/install_lychee.sh + + - name: "Validate Links" + shell: "bash" + run: | + scripts/lychee/validate_lychee.sh + + interactive-installer: + name: "Interactive Installer (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Run Interactive Installer" + shell: bash + continue-on-error: true + run: | + set -uo pipefail + mkdir -p ci-logs + log_path="ci-logs/interactive-installer-${{ matrix.os }}.log" + bash ./scripts/install.sh --preset minimal --record-mode 2>&1 | tee "${log_path}" + echo "INSTALL_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV" + + - name: "Run Interactive Uninstaller" + shell: bash + continue-on-error: true + run: | + set -uo pipefail + mkdir -p ci-logs + log_path="ci-logs/interactive-uninstaller-${{ matrix.os }}.log" + bash ./scripts/uninstall.sh --record-mode --force 2>&1 | tee "${log_path}" + echo "UNINSTALL_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV" + + - name: "Upload Installer Logs" + if: env.INSTALL_EXIT_CODE != '0' + uses: actions/upload-artifact@v6 + with: + name: interactive-installer-${{ matrix.os }}-logs + path: ci-logs/interactive-installer-${{ matrix.os }}.log + retention-days: 7 + + - name: "Upload Uninstaller Logs" + if: env.UNINSTALL_EXIT_CODE != '0' + uses: actions/upload-artifact@v6 + with: + name: interactive-uninstaller-${{ matrix.os }}-logs + path: ci-logs/interactive-uninstaller-${{ matrix.os }}.log + retention-days: 7 + + - name: "Fail if Installer or Uninstaller Failed" + if: env.INSTALL_EXIT_CODE != '0' || env.UNINSTALL_EXIT_CODE != '0' + shell: bash + run: | + echo "Installer exit code: ${INSTALL_EXIT_CODE:-0}" + echo "Uninstaller exit code: ${UNINSTALL_EXIT_CODE:-0}" + exit 1 + + mcp-build-and-test: + name: "Node TypeScript Build and Test (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Cache Turborepo" + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ matrix.os }}-${{ hashFiles('bun.lock') }}-${{ github.sha }} + restore-keys: | + turbo-${{ matrix.os }}-${{ hashFiles('bun.lock') }}- + turbo-${{ matrix.os }}- + - uses: ./.github/actions/setup-auto-mobile-npm-package - name: "Run Lint" - run: npm run lint + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run lint --output-logs=errors-only --log-order=grouped + + - name: "Run Build" + shell: bash + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: | + set -euo pipefail + mkdir -p ci-logs + turbo run build --output-logs=errors-only --log-order=grouped 2>&1 | tee "ci-logs/bun-build-${{ matrix.os }}.log" - name: "Run Tests" - run: npm run test + shell: bash + env: + # Force AdbClient into test mode on Windows to prevent adb daemon startup + AUTOMOBILE_TEST_MODE: ${{ matrix.os == 'windows-latest' && 'true' || '' }} + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: | + set -euo pipefail + mkdir -p ci-logs + turbo run test --output-logs=errors-only --log-order=grouped 2>&1 | tee "ci-logs/bun-test-${{ matrix.os }}.log" + + - name: "Upload Build/Test Logs" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: mcp-build-test-logs-${{ matrix.os }} + path: ci-logs/*.log + retention-days: 7 + + # ============================================================================= + # iOS Build and Test Jobs + # ============================================================================= + + ios-swift-build: + name: "Build Swift Packages (${{ matrix.config.name }})" + runs-on: ${{ matrix.config.runner }} + env: + IOS_SIGNING_ENABLED: ${{ secrets.IOS_CERTIFICATE_BASE64 != '' }} + MACOS_SIGNING_ENABLED: ${{ secrets.MACOS_DEVELOPER_ID_CERT_BASE64 != '' }} + strategy: + fail-fast: false + matrix: + config: + - { name: "Xcode 15", runner: "macos-14", xcode: "15.4" } + - { name: "Xcode 26", runner: "macos-26", xcode: "26.0" } + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode ${{ matrix.config.xcode }}" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.config.xcode }} + + - name: "Install macOS Developer ID certificate" + if: ${{ env.MACOS_SIGNING_ENABLED == 'true' }} + env: + MACOS_DEVELOPER_ID_CERT_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_BASE64 }} + MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }} + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: ./scripts/ios/setup-macos-signing-keychain.sh + + - name: "Install iOS signing certificate" + if: ${{ env.IOS_SIGNING_ENABLED == 'true' }} + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }} + run: ./scripts/ios/setup-signing-keychain.sh + + - name: "Build Swift Packages" + env: + MACOS_SIGNING_ENABLED: ${{ env.MACOS_SIGNING_ENABLED }} + MACOS_DEVELOPER_ID_SIGNING_IDENTITY: ${{ secrets.MACOS_DEVELOPER_ID_SIGNING_IDENTITY }} + MACOS_DEVELOPER_ID_TEAM_ID: ${{ secrets.MACOS_DEVELOPER_ID_TEAM_ID }} + MACOS_SIGNING_STRICT: ${{ env.MACOS_SIGNING_ENABLED }} + IOS_SIGNING_ENABLED: ${{ env.IOS_SIGNING_ENABLED }} + IOS_SIGNING_IDENTITY: ${{ secrets.IOS_SIGNING_IDENTITY }} + IOS_SIGNING_TEAM_ID: ${{ secrets.IOS_SIGNING_TEAM_ID }} + IOS_SIGNING_STRICT: ${{ env.IOS_SIGNING_ENABLED }} + run: ./scripts/ios/swift-build.sh + + ios-swift-test: + name: "Test Swift Packages (${{ matrix.config.name }})" + runs-on: ${{ matrix.config.runner }} + needs: ios-swift-build + strategy: + fail-fast: false + matrix: + config: + - { name: "Xcode 15", runner: "macos-14", xcode: "15.4" } + - { name: "Xcode 26", runner: "macos-26", xcode: "26.0" } + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode ${{ matrix.config.xcode }}" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.config.xcode }} + + - name: "Test Swift Packages" + run: ./scripts/ios/swift-test.sh + + ios-xcodegen: + name: "Generate Xcode Projects" + runs-on: macos-26 + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Install XcodeGen" + run: brew install xcodegen + + - name: "Generate Xcode Projects" + run: ./scripts/ios/xcodegen-generate.sh + + ios-xcode-build: + name: "Build Xcode Projects (${{ matrix.config.name }})" + runs-on: ${{ matrix.config.runner }} + needs: ios-xcodegen + strategy: + fail-fast: false + matrix: + config: + - { name: "Xcode 15", runner: "macos-14", xcode: "15.4" } + - { name: "Xcode 26", runner: "macos-26", xcode: "26.0" } + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode ${{ matrix.config.xcode }}" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.config.xcode }} + + - name: "Ensure iOS Simulator runtime" + uses: ./.github/actions/ensure-ios-simulator-runtime + + - name: "Install XcodeGen" + run: brew install xcodegen + + - name: "Generate Xcode Projects" + run: ./scripts/ios/xcodegen-generate.sh + + - name: "Build Xcode Projects" + run: ./scripts/ios/xcode-build.sh + + # ============================================================================= + # Android Build and Test Jobs + # ============================================================================= junit-runner-unit-tests: name: "Run JUnit Runner Unit Tests" @@ -83,47 +424,103 @@ jobs: with: gradle-tasks: ":junit-runner:test" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + # Test isolation issue resolved in #107 and #109 + # Re-enabled in #110 + junit-runner-emulator-tests: + name: "Run JUnit Runner Emulator Tests" + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build-android-control-proxy + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 + with: + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug # Run AutoMobile tests that require emulator - # - uses: ./.github/actions/android-emulator - # with: - # script: "./gradlew :junit-runner:test --rerun-tasks" - # working-directory: './android/' - - # - name: "Publish Test Report" - # uses: mikepenz/action-junit-report@v4 - # if: always() - # with: - # check_name: "JUnit Runner Test Report" - # report_paths: '**/build/test-results/**/*.xml' - - kotlin-test-author-unit-tests: - name: "Run Kotlin Test Author Unit Tests" + - uses: ./.github/actions/android-emulator + with: + script: "./gradlew :junit-runner:test" + working-directory: './android/' + accessibility-apk-path: control-proxy/build/outputs/apk/debug/control-proxy-debug.apk + gradle-home-directory: "~/.gradle/${{ github.job }}" + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + - name: "Upload Test Results" + if: always() + uses: actions/upload-artifact@v6 + with: + name: junit-runner-emulator-test-results + path: android/junit-runner/build/reports/tests/test/ + + - name: "Publish Test Report" + uses: mikepenz/action-junit-report@v4 + if: always() + with: + check_name: "JUnit Runner Emulator Test Report" + report_paths: '**/build/test-results/**/*.xml' + + playground-automobile-emulator-tests: + name: "Run Playground Automobile Emulator Tests" runs-on: ubuntu-latest - defaults: - run: - working-directory: android + timeout-minutes: 15 + needs: build-android-control-proxy steps: - name: "Git Checkout" uses: actions/checkout@v4 - - uses: ./.github/actions/gradle-task-run + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 with: - gradle-tasks: ":kotlinTestAuthor:test" - gradle-project-directory: "android" - reuse-configuration-cache: true - gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug + + # Run AutoMobile tests that require emulator + - uses: ./.github/actions/android-emulator + with: + script: "./gradlew :playground:app:test --stacktrace" + working-directory: './android/' + accessibility-apk-path: control-proxy/build/outputs/apk/debug/control-proxy-debug.apk + gradle-home-directory: "~/.gradle/${{ github.job }}" + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + - name: "Upload Test Results" + if: always() + uses: actions/upload-artifact@v6 + with: + name: playground-automobile-emulator-test-results + path: android/playground/app/build/reports/tests/test/ - name: "Publish Test Report" uses: mikepenz/action-junit-report@v4 if: always() with: - check_name: "Kotlin Test Author Test Report" + check_name: "Playground Automobile Emulator Test Report" report_paths: '**/build/test-results/**/*.xml' - android-accessibility-service-unit-tests: + android-control-proxy-unit-tests: name: "Run Accessibility Service Unit Tests" runs-on: ubuntu-latest defaults: @@ -135,10 +532,15 @@ jobs: - uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":accessibility-service:testDebugUnitTest" + gradle-tasks: ":control-proxy:testDebugUnitTest" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} build-junit-runner-library: name: "Build JUnitRunner Library" @@ -156,17 +558,22 @@ jobs: with: gradle-tasks: ":junitRunner:assemble" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - name: "Store AAR" - uses: actions/upload-artifact@v4.4.0 + uses: actions/upload-artifact@v6 with: name: aar path: core/build/outputs/aar/core-debug.aar - build-kotlin-test-author-clikt-app: - name: "Build Kotlin Test Author Clikt App" + build-android-control-proxy: + name: "Build Accessibility Service" runs-on: ubuntu-latest defaults: run: @@ -177,19 +584,24 @@ jobs: - uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":kotlinTestAuthor:assembleDist" + gradle-tasks: ":control-proxy:assembleDebug" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - - name: "Store AAR" - uses: actions/upload-artifact@v4.4.0 + - name: "Store Accessibility Service APK" + uses: actions/upload-artifact@v6 with: - name: aar - path: core/build/outputs/aar/core-debug.aar + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug/control-proxy-debug.apk - build-android-accessibility-service: - name: "Build Accessibility Service" + build-playground-app: + name: "Build Playground App" runs-on: ubuntu-latest defaults: run: @@ -198,38 +610,113 @@ jobs: - name: "Git Checkout" uses: actions/checkout@v4 + - uses: ./.github/actions/setup-auto-mobile-npm-package + - uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":accessibility-service:assembleDebug" + gradle-tasks: ":playground:app:assembleDebug" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - build-playground-app: - name: "Build Playground App" + - name: "Store Sample APK" + uses: actions/upload-artifact@v6 + if: success() # Only upload if build succeeded + with: + name: playground-app-apk + path: android/playground/app/build/outputs/apk/debug/playground-app-debug.apk + + publish-android-libraries-snapshot: + name: "Publish Android Libraries Snapshot" runs-on: ubuntu-latest - defaults: - run: - working-directory: android + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + needs: + - build-ide-plugin + - ide-plugin-unit-tests + - validate-xml + - ktfmt + - validate-shell-scripts + - validate-mkdocs-nav + - validate-documentation-links + - mcp-build-and-test + - junit-runner-unit-tests + - junit-runner-emulator-tests + - playground-automobile-emulator-tests + - android-control-proxy-unit-tests + - build-junit-runner-library + - build-android-control-proxy + - build-playground-app + - benchmark-context-thresholds + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME || vars.MAVEN_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD || vars.MAVEN_PASSWORD }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_USERNAME || vars.MAVEN_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_PASSWORD || vars.MAVEN_PASSWORD }} + SIGNING_IN_MEMORY_KEY: ${{ secrets.SIGNING_IN_MEMORY_KEY || vars.SIGNING_IN_MEMORY_KEY }} + SIGNING_IN_MEMORY_KEY_PASSWORD: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD || vars.SIGNING_IN_MEMORY_KEY_PASSWORD }} + SIGNING_IN_MEMORY_KEY_ID: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID || vars.SIGNING_IN_MEMORY_KEY_ID }} steps: - name: "Git Checkout" uses: actions/checkout@v4 - - uses: ./.github/actions/setup-auto-mobile-npm-package + - name: "Read library version" + id: library-version + shell: bash + run: | + # Read VERSION_NAME from gradle.properties (single source of truth) + version=$(grep -E '^VERSION_NAME=' android/gradle.properties | cut -d'=' -f2) + if [ -z "$version" ]; then + echo "Could not find VERSION_NAME in android/gradle.properties" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Library version: $version" + + - name: "Verify Maven Central credentials" + if: ${{ endsWith(steps.library-version.outputs.version, '-SNAPSHOT') }} + shell: bash + run: | + if [ -z "$MAVEN_USERNAME" ] || [ -z "$MAVEN_PASSWORD" ]; then + echo "Missing MAVEN_USERNAME or MAVEN_PASSWORD for snapshot publish." >&2 + exit 1 + fi + + - name: "Export signing credentials" + if: ${{ endsWith(steps.library-version.outputs.version, '-SNAPSHOT') }} + shell: bash + run: | + if [ -n "$SIGNING_IN_MEMORY_KEY" ]; then + echo "ORG_GRADLE_PROJECT_signingInMemoryKey=$SIGNING_IN_MEMORY_KEY" >> "$GITHUB_ENV" + fi + if [ -n "$SIGNING_IN_MEMORY_KEY_PASSWORD" ]; then + echo "ORG_GRADLE_PROJECT_signingInMemoryKeyPassword=$SIGNING_IN_MEMORY_KEY_PASSWORD" >> "$GITHUB_ENV" + fi + if [ -n "$SIGNING_IN_MEMORY_KEY_ID" ]; then + echo "ORG_GRADLE_PROJECT_signingInMemoryKeyId=$SIGNING_IN_MEMORY_KEY_ID" >> "$GITHUB_ENV" + fi - uses: ./.github/actions/gradle-task-run + if: ${{ endsWith(steps.library-version.outputs.version, '-SNAPSHOT') }} with: - gradle-tasks: ":playground:app:assembleDebug" + gradle-tasks: ":junit-runner:publish :auto-mobile-sdk:publish" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - - name: "Store Sample APK" - uses: actions/upload-artifact@v4.4.0 - if: success() # Only upload if build succeeded - with: - name: playground-app-apk - path: android/playground/app/build/outputs/apk/debug/playground-app-debug.apk + - name: "Skip snapshot publish (non-snapshot version)" + if: ${{ !endsWith(steps.library-version.outputs.version, '-SNAPSHOT') }} + run: | + echo "Skipping publish. Version is ${{ steps.library-version.outputs.version }}." # playground-app-unit-tests: # name: "Build Playground App" @@ -243,7 +730,7 @@ jobs: # # - uses: ./.github/actions/setup-auto-mobile-npm-package # -# - uses: actions/download-artifact@v4.1.8 +# - uses: actions/download-artifact@v7 # with: # name: apk # @@ -255,9 +742,105 @@ jobs: # with: # script: ":playground:app:testDebugUnitTest" + # ============================================================================= + # TypeScript Code Coverage + # ============================================================================= + + ts-code-coverage: + name: "TypeScript Code Coverage" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Cache Turborepo" + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-ubuntu-latest-${{ hashFiles('bun.lock') }}-${{ github.sha }} + restore-keys: | + turbo-ubuntu-latest-${{ hashFiles('bun.lock') }}- + turbo-ubuntu-latest- + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Tests with Coverage" + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run test:coverage --output-logs=errors-only + + - name: "Generate Coverage Badge" + run: bash scripts/coverage/generate-badge.sh coverage/lcov.info coverage/ts-coverage-badge.json "TS coverage" "3178C6" + + - name: "Upload Coverage Badge" + uses: actions/upload-artifact@v6 + with: + name: ts-coverage-badge + path: coverage/ts-coverage-badge.json + retention-days: 90 + + # ============================================================================= + # Kotlin Code Coverage + # ============================================================================= + + kotlin-code-coverage: + name: "Kotlin Code Coverage" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: ":junit-runner:test :junit-runner:jacocoTestReport :control-proxy:testDebugUnitTest :control-proxy:createDebugUnitTestCoverageReport :auto-mobile-sdk:testDebugUnitTest :auto-mobile-sdk:createDebugUnitTestCoverageReport :test-plan-validation:test :test-plan-validation:jacocoTestReport :protocol:test :protocol:jacocoTestReport" + gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: "Generate Kotlin Coverage Badge" + run: bash scripts/coverage/generate-kotlin-badge.sh + + - name: "Upload Kotlin Coverage Badge" + uses: actions/upload-artifact@v6 + with: + name: kotlin-coverage-badge + path: coverage/kotlin-coverage-badge.json + retention-days: 90 + + # ============================================================================= + # Swift Code Coverage + # ============================================================================= + + swift-code-coverage: + name: "Swift Code Coverage" + runs-on: macos-14 + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode 15.4" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "15.4" + + - name: "Generate Swift Coverage Badge" + run: bash scripts/coverage/generate-swift-coverage.sh + + - name: "Upload Swift Coverage Badge" + uses: actions/upload-artifact@v6 + with: + name: swift-coverage-badge + path: coverage/swift-coverage-badge.json + retention-days: 90 + deploy-docs: name: "Deploy Documentation" runs-on: ubuntu-latest + needs: [ts-code-coverage, kotlin-code-coverage, swift-code-coverage] if: github.ref == 'refs/heads/main' && github.event_name == 'push' permissions: contents: read @@ -290,6 +873,24 @@ jobs: - name: "Build documentation" run: scripts/github/deploy_pages.py build + - name: "Download TS Coverage Badge" + uses: actions/download-artifact@v7 + with: + name: ts-coverage-badge + path: site + + - name: "Download Kotlin Coverage Badge" + uses: actions/download-artifact@v7 + with: + name: kotlin-coverage-badge + path: site + + - name: "Download Swift Coverage Badge" + uses: actions/download-artifact@v7 + with: + name: swift-coverage-badge + path: site + - name: "Setup Pages" uses: actions/configure-pages@v5 @@ -301,3 +902,148 @@ jobs: - name: "Deploy to GitHub Pages" id: deployment uses: actions/deploy-pages@v4 + + # ============================================================================= + # MCP Context Thresholds + # ============================================================================= + + benchmark-context-thresholds: + name: "Benchmark MCP Context Thresholds" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Context Threshold Benchmark" + id: benchmark + run: | + # Create reports directory + mkdir -p reports + + # Run benchmark and capture exit code + bun run benchmark-context --output reports/context-benchmark.json || echo "FAILED=true" >> $GITHUB_ENV + + # Always preserve the report file even if benchmark failed + exit 0 + + - name: "Upload Benchmark Report" + uses: actions/upload-artifact@v6 + if: always() + with: + name: context-benchmark-report + path: reports/context-benchmark.json + retention-days: 90 + + - name: "Check Benchmark Result" + if: always() + run: | + if [ "$FAILED" = "true" ]; then + echo "::error::MCP context threshold benchmark failed - one or more thresholds were exceeded" + cat reports/context-benchmark.json + exit 1 + fi + echo "::notice::MCP context threshold benchmark passed - all thresholds satisfied" + + # ============================================================================= + # Tag Release (triggers release.yml workflow) + # ============================================================================= + + tag-release: + name: "Create Release Tag" + runs-on: ubuntu-latest + if: | + github.ref == 'refs/heads/main' && + github.event_name == 'push' && + startsWith(github.event.head_commit.message, 'chore: bump versions to v') + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: "Extract version and create tag" + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + # Extract version from commit message + VERSION=$(echo "$COMMIT_MESSAGE" | sed -n 's/chore: bump versions to v\([0-9.]*\).*/\1/p') + + if [ -z "$VERSION" ]; then + echo "Could not extract version from commit message: $COMMIT_MESSAGE" + exit 1 + fi + + TAG="v${VERSION}" + echo "Creating tag: $TAG" + + # Check if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists. Skipping." + exit 0 + fi + + # Create and push tag + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" + + echo "Successfully created and pushed tag: $TAG" + + # ============================================================================= + # Auto-update README Test Count Badges + # ============================================================================= + + update-readme-badges: + name: "Update README Test Count Badges" + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + with: + token: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + + - name: "Update badge counts" + shell: bash + run: bash scripts/update-readme-badges.sh + + - name: "Check for changes" + id: changes + shell: bash + run: | + if git diff --quiet README.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: "Create PR for badge update" + if: steps.changes.outputs.changed == 'true' + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + commit-message: "chore: update README test count badges" + title: "chore: update README test count badges" + body: | + ## Automated Badge Update + + Updated test count badges in README.md to reflect current counts. + + --- + Auto-generated by the `merge` workflow + branch: auto-update/readme-badges + delete-branch: true + labels: | + automated + documentation + + - name: "Enable auto-merge on PR" + if: steps.changes.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + env: + GH_TOKEN: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + run: | + gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..1098c1f5b --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,169 @@ +name: "Nightly" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + +permissions: + contents: write + pull-requests: write + +jobs: + build-control-proxy-apk: + uses: ./.github/workflows/build-control-proxy-apk.yml + secrets: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + + build-xctestservice-ipa: + uses: ./.github/workflows/build-xctestservice-ipa.yml + + update-sha256: + name: "Update SHA256 Checksums" + runs-on: ubuntu-latest + needs: [build-control-proxy-apk, build-xctestservice-ipa] + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Download APK artifact" + uses: actions/download-artifact@v7 + with: + name: control-proxy-apk + path: /tmp + + - name: "Download IPA artifact" + uses: actions/download-artifact@v7 + with: + name: xctestservice-ipa + path: /tmp + + - name: "Verify APK SHA256" + run: bash scripts/ci/verify-transit-sha256.sh /tmp/control-proxy-debug.apk "${{ needs.build-control-proxy-apk.outputs.sha256 }}" + + - name: "Verify IPA SHA256" + run: bash scripts/ci/verify-transit-sha256.sh /tmp/XCTestService.ipa "${{ needs.build-xctestservice-ipa.outputs.sha256 }}" + + - name: "Get current SHA256 values from source" + id: current + run: | + APK_CURRENT=$(grep 'APK_SHA256_CHECKSUM' src/constants/release.ts | sed 's/.*"\([^"]*\)".*/\1/') + IPA_CURRENT=$(grep 'XCTESTSERVICE_SHA256_CHECKSUM' src/constants/release.ts | sed 's/.*"\([^"]*\)".*/\1/') + echo "apk_sha256=$APK_CURRENT" >> $GITHUB_OUTPUT + echo "ipa_sha256=$IPA_CURRENT" >> $GITHUB_OUTPUT + echo "Current APK SHA256: ${APK_CURRENT:-(empty)}" + echo "Current IPA SHA256: ${IPA_CURRENT:-(empty)}" + + - name: "Check if updates needed" + id: check + run: | + NEW_APK="${{ needs.build-control-proxy-apk.outputs.sha256 }}" + OLD_APK="${{ steps.current.outputs.apk_sha256 }}" + NEW_IPA="${{ needs.build-xctestservice-ipa.outputs.sha256 }}" + OLD_IPA="${{ steps.current.outputs.ipa_sha256 }}" + + APK_CHANGED="false" + IPA_CHANGED="false" + + if [ "$NEW_APK" != "$OLD_APK" ]; then + APK_CHANGED="true" + echo "APK SHA256 changed" + echo " Previous: ${OLD_APK:-(empty)}" + echo " New: $NEW_APK" + else + echo "APK SHA256 unchanged" + fi + + if [ "$NEW_IPA" != "$OLD_IPA" ]; then + IPA_CHANGED="true" + echo "IPA SHA256 changed" + echo " Previous: ${OLD_IPA:-(empty)}" + echo " New: $NEW_IPA" + else + echo "IPA SHA256 unchanged" + fi + + echo "apk_changed=$APK_CHANGED" >> $GITHUB_OUTPUT + echo "ipa_changed=$IPA_CHANGED" >> $GITHUB_OUTPUT + + if [ "$APK_CHANGED" = "true" ] || [ "$IPA_CHANGED" = "true" ]; then + echo "needed=true" >> $GITHUB_OUTPUT + else + echo "needed=false" >> $GITHUB_OUTPUT + fi + + - name: "Update release constants" + if: steps.check.outputs.needed == 'true' + env: + APK_SHA256_CHECKSUM: ${{ needs.build-control-proxy-apk.outputs.sha256 }} + XCTESTSERVICE_SHA256_CHECKSUM: ${{ needs.build-xctestservice-ipa.outputs.sha256 }} + run: bash scripts/generate-release-constants.sh + + - name: "Build PR title" + if: steps.check.outputs.needed == 'true' + id: pr-title + run: | + APK_CHANGED="${{ steps.check.outputs.apk_changed }}" + IPA_CHANGED="${{ steps.check.outputs.ipa_changed }}" + + if [ "$APK_CHANGED" = "true" ] && [ "$IPA_CHANGED" = "true" ]; then + echo "title=chore: update accessibility service APK and XCTestService IPA SHA256" >> $GITHUB_OUTPUT + elif [ "$APK_CHANGED" = "true" ]; then + echo "title=chore: update accessibility service APK SHA256" >> $GITHUB_OUTPUT + else + echo "title=chore: update XCTestService IPA SHA256" >> $GITHUB_OUTPUT + fi + + - name: "Create PR for SHA256 update" + if: steps.check.outputs.needed == 'true' + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + commit-message: | + ${{ steps.pr-title.outputs.title }} + + APK: ${{ steps.current.outputs.apk_sha256 || '(empty)' }} -> ${{ needs.build-control-proxy-apk.outputs.sha256 }}${{ steps.check.outputs.apk_changed == 'true' && ' (changed)' || ' (unchanged)' }} + IPA: ${{ steps.current.outputs.ipa_sha256 || '(empty)' }} -> ${{ needs.build-xctestservice-ipa.outputs.sha256 }}${{ steps.check.outputs.ipa_changed == 'true' && ' (changed)' || ' (unchanged)' }} + title: ${{ steps.pr-title.outputs.title }} + body: | + ## Automated Checksum Update + + The nightly build produced artifacts with updated SHA256 checksums. + + | Artifact | Previous | New | Changed | + |----------|----------|-----|---------| + | **APK** | `${{ steps.current.outputs.apk_sha256 || '(empty)' }}` | `${{ needs.build-control-proxy-apk.outputs.sha256 }}` | ${{ steps.check.outputs.apk_changed == 'true' && '✅ Yes' || 'No' }} | + | **IPA** | `${{ steps.current.outputs.ipa_sha256 || '(empty)' }}` | `${{ needs.build-xctestservice-ipa.outputs.sha256 }}` | ${{ steps.check.outputs.ipa_changed == 'true' && '✅ Yes' || 'No' }} | + + ### Why did this happen? + + SHA256 checksums change when any of these change: + - Source code in `android/control-proxy/src/` or `ios/XCTestService/` + - Build configuration (`build.gradle.kts` or `project.yml`) + - Dependencies or SDK versions + + ### What to do + + 1. Review this PR to ensure the changes are expected + 2. Merge when ready + 3. Future releases will use these checksums for artifact verification + + --- + Auto-generated by the `nightly` workflow + branch: auto-update/nightly-sha256 + delete-branch: true + labels: | + automated + release engineering + + - name: "Enable auto-merge on PR" + if: steps.check.outputs.needed == 'true' && steps.create-pr.outputs.pull-request-number != '' + env: + GH_TOKEN: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + run: | + gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..10d9618f7 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,110 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: "New npm version (semver, without -SNAPSHOT)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + issues: read + +jobs: + build-xctestservice-ipa: + uses: ./.github/workflows/build-xctestservice-ipa.yml + + build-control-proxy-apk: + uses: ./.github/workflows/build-control-proxy-apk.yml + secrets: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + + prepare: + runs-on: ubuntu-latest + needs: [build-xctestservice-ipa, build-control-proxy-apk] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + + - name: Install ripgrep + run: | + if ! command -v rg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y ripgrep + fi + + - name: Bump versions + run: bash scripts/versioning/bump-versions.sh --new-version "${{ inputs.version }}" + + - name: Update changelog + env: + CURRENT_TAG: v${{ inputs.version }} + GH_TOKEN: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + run: scripts/changelog/update_changelog_from_issues.sh + + - name: "Download XCTestService IPA" + uses: actions/download-artifact@v7 + with: + name: xctestservice-ipa + path: /tmp + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 + with: + name: control-proxy-apk + path: /tmp + + - name: Calculate SHA256 checksums + id: checksum + run: | + APK_SHA256=$(sha256sum /tmp/control-proxy-debug.apk | cut -d' ' -f1) + echo "apk_sha256=$APK_SHA256" >> "$GITHUB_OUTPUT" + echo "APK SHA256: $APK_SHA256" + + IPA_SHA256=$(sha256sum /tmp/XCTestService.ipa | cut -d' ' -f1) + echo "ipa_sha256=$IPA_SHA256" >> "$GITHUB_OUTPUT" + echo "IPA SHA256: $IPA_SHA256" + + - name: Update release constants + env: + APK_SHA256_CHECKSUM: ${{ steps.checksum.outputs.apk_sha256 }} + XCTESTSERVICE_SHA256_CHECKSUM: ${{ steps.checksum.outputs.ipa_sha256 }} + run: bash scripts/generate-release-constants.sh + + - name: Create pull request + id: create_pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.AUTO_MOBILE_PR_TOKEN }} + base: main + branch: "chore/bump-versions-${{ inputs.version }}" + delete-branch: true + commit-message: "chore: bump versions to v${{ inputs.version }}" + title: "chore: bump versions to v${{ inputs.version }}" + body: | + ## Summary + - Bump versions to v${{ inputs.version }} + - Update CHANGELOG.md from closed issues since the last tag + - Refresh Accessibility Service APK SHA256 checksum + - Refresh XCTestService IPA SHA256 checksum + + ## Automation + - Generated by `prepare-release` workflow + + - name: Enable auto-merge + if: steps.create_pr.outputs.pull-request-number != '' + uses: peter-evans/enable-pull-request-automerge@v3 + with: + pull-request-number: ${{ steps.create_pr.outputs.pull-request-number }} + merge-method: squash diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ed4556b5c..2c05adf6b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -14,9 +14,261 @@ permissions: packages: write jobs: + # ============================================================================= + # Change Detection + # ============================================================================= + # Detect if only documentation files were changed to skip long-running jobs + # Detect if only SHA256 checksum was changed to skip long-running jobs + detect-changes: + name: "Detect Documentation-Only or SHA256-Only Changes" + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.filter.outputs.docs_only }} + sha256_only: ${{ steps.check-sha256.outputs.sha256_only }} + ide_plugin_changed: ${{ steps.filter-ide-plugin.outputs.ide_plugin }} + android_changed: ${{ steps.filter-android.outputs.android }} + ios_changed: ${{ steps.filter-ios.outputs.ios }} + android_should_run: ${{ steps.android_should_run.outputs.should_run }} + ios_should_run: ${{ steps.ios_should_run.outputs.should_run }} + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Check for documentation-only changes" + id: filter + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.issue.number; + + // Get list of changed files + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // Documentation file patterns + const docPatterns = [ + /\.md$/, // **/*.md + /^docs\//, // docs/** + /^docs\/.*\.(gif|png|webp)$/i, // docs/**/*.{gif,png,webp} + /^mkdocs\.yml$/, // mkdocs.yml + /^\.lychee\.toml$/ // .lychee.toml + ]; + + // Check if a file matches any documentation pattern + const isDocFile = (filename) => { + return docPatterns.some(pattern => pattern.test(filename)); + }; + + // Check if ALL files are documentation files + const allFilesAreDocs = files.length > 0 && files.every(file => isDocFile(file.filename)); + + console.log('Files changed:', files.map(f => f.filename).join(', ')); + console.log('Total files:', files.length); + console.log('All files are docs:', allFilesAreDocs); + files.forEach(file => { + console.log(` ${file.filename}: ${isDocFile(file.filename) ? 'doc' : 'code'}`); + }); + + core.setOutput('docs_only', allFilesAreDocs.toString()); + return allFilesAreDocs; + + - name: "Check for SHA256-only changes" + id: check-sha256 + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.issue.number; + + // Get PR details + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // Check if title matches the automated SHA256 update pattern + const titleMatches = pr.title === 'chore: update accessibility service APK SHA256' || + pr.title === 'chore: update XCTestService IPA SHA256'; + + // Get list of changed files + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // Check if only src/constants/release.ts was changed + const onlyReleaseFileChanged = files.length === 1 && + files[0].filename === 'src/constants/release.ts'; + + // Skip only if BOTH conditions are met + const sha256Only = titleMatches && onlyReleaseFileChanged; + + console.log('PR Title:', pr.title); + console.log('Title matches:', titleMatches); + console.log('Files changed:', files.map(f => f.filename).join(', ')); + console.log('Only release.ts changed:', onlyReleaseFileChanged); + console.log('Result - SHA256 only:', sha256Only); + + core.setOutput('sha256_only', sha256Only.toString()); + return sha256Only; + + - name: "Check for IDE Plugin changes" + id: filter-ide-plugin + uses: dorny/paths-filter@v3 + with: + filters: | + ide_plugin: + - 'android/ide-plugin/**' + + - name: "Check for Android-related changes" + id: filter-android + uses: dorny/paths-filter@v3 + with: + filters: | + android: + - 'android/**' + - 'src/**' + - '.github/workflows/android*.yml' + - 'package.json' + - 'bun.lockb' + + - name: "Check for iOS-related changes" + id: filter-ios + uses: dorny/paths-filter@v3 + with: + filters: | + ios: + - 'ios/**' + - 'src/**' + - 'scripts/**' + - '.github/workflows/**' + - '.editorconfig' + - '.gitignore' + - '.lycherc.toml' + - '.mcp.json' + - '.mcp.local.json' + - '.npmignore' + - '.swiftformat' + - '.swiftlint.yml' + - 'build.ts' + - 'bun.lock' + - 'bun.lockb' + - 'eslint.config.mjs' + - 'knip.json' + - 'mkdocs.yml' + - 'package-lock.json' + - 'package.json' + - 'tsconfig.json' + + - name: "Decide if Android jobs should run" + id: android_should_run + run: | + if [[ "${{ steps.filter-android.outputs.android }}" == "true" && "${{ steps.filter.outputs.docs_only }}" != "true" && "${{ steps.check-sha256.outputs.sha256_only }}" != "true" ]]; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + else + echo "should_run=false" >> "$GITHUB_OUTPUT" + fi + + - name: "Decide if iOS jobs should run" + id: ios_should_run + run: | + if [[ "${{ steps.filter-ios.outputs.ios }}" == "true" && "${{ steps.filter.outputs.docs_only }}" != "true" && "${{ steps.check-sha256.outputs.sha256_only }}" != "true" ]]; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + else + echo "should_run=false" >> "$GITHUB_OUTPUT" + fi + + # ============================================================================= + # Fast Validation Jobs (Always Run) + # ============================================================================= + + build-ide-plugin: + name: "Build IDE Plugin" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.ide_plugin_changed == 'true' && needs.detect-changes.outputs.sha256_only != 'true' + defaults: + run: + working-directory: android/ide-plugin + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: "build -x test" + gradle-project-directory: "android/ide-plugin" + gradle-home-directory: "~/.gradle/${{ github.job }}" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: "Build Plugin ZIP" + shell: bash + working-directory: android/ide-plugin + run: | + ./gradlew buildPlugin --stacktrace --continue + + - name: "Verify Plugin" + shell: bash + working-directory: android/ide-plugin + run: | + ./gradlew verifyPlugin --stacktrace --continue + + - name: "Upload IDE Plugin ZIP" + uses: actions/upload-artifact@v6 + if: success() + with: + name: ide-plugin-zip + path: android/ide-plugin/build/distributions/*.zip + + ide-plugin-unit-tests: + name: "Run IDE Plugin Unit Tests" + runs-on: ubuntu-latest + needs: [detect-changes, build-ide-plugin] + if: needs.detect-changes.outputs.ide_plugin_changed == 'true' && needs.detect-changes.outputs.sha256_only != 'true' + defaults: + run: + working-directory: android/ide-plugin + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: "test" + gradle-project-directory: "android/ide-plugin" + gradle-home-directory: "~/.gradle/${{ github.job }}" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: "Upload Test Results" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: ide-plugin-test-results + path: android/ide-plugin/build/reports/tests/test/ + + validate-yaml: + name: "Validate YAML Test Plans" + runs-on: ubuntu-latest + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Validate YAML Test Plans" + run: bun run validate:yaml + validate-xml: name: "Validate XML" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' steps: - name: "Git Checkout" uses: actions/checkout@v4 @@ -31,6 +283,8 @@ jobs: ktfmt: name: "ktfmt" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' steps: - name: "Git Checkout" uses: actions/checkout@v4 @@ -43,6 +297,8 @@ jobs: validate-shell-scripts: name: "Validate Shell Scripts" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' steps: - name: "Git Checkout" uses: actions/checkout@v4 @@ -52,9 +308,130 @@ jobs: run: | scripts/shellcheck/validate_shell_scripts.sh + validate-mkdocs-nav: + name: "Validate MkDocs Navigation" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Validate MkDocs Navigation" + shell: "bash" + run: | + scripts/validate_mkdocs_nav.sh + + validate-claude-plugin: + name: "Validate Claude Plugin" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Validate Claude Plugin" + shell: "bash" + run: | + scripts/claude/validate_plugin.sh + + validate-documentation-links: + name: "Validate Documentation Links" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Create contributing file" + shell: "bash" + run: | + touch docs/contributing.md + + - name: "Install Lychee" + shell: "bash" + run: | + scripts/lychee/install_lychee.sh + + - name: "Validate Links" + shell: "bash" + run: | + scripts/lychee/validate_lychee.sh + + interactive-installer: + name: "Interactive Installer (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + needs: detect-changes + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: "Skip for docs-only changes" + if: needs.detect-changes.outputs.docs_only == 'true' + run: echo "Skipping - documentation only changes" + + - name: "Git Checkout" + if: needs.detect-changes.outputs.docs_only != 'true' + uses: actions/checkout@v4 + + - name: "Run Interactive Installer" + if: needs.detect-changes.outputs.docs_only != 'true' + shell: bash + continue-on-error: true + run: | + set -uo pipefail + mkdir -p ci-logs + log_path="ci-logs/interactive-installer-${{ matrix.os }}.log" + bash ./scripts/install.sh --preset minimal --record-mode 2>&1 | tee "${log_path}" + echo "INSTALL_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV" + + - name: "Run Interactive Uninstaller" + if: needs.detect-changes.outputs.docs_only != 'true' + shell: bash + continue-on-error: true + run: | + set -uo pipefail + mkdir -p ci-logs + log_path="ci-logs/interactive-uninstaller-${{ matrix.os }}.log" + bash ./scripts/uninstall.sh --record-mode --force 2>&1 | tee "${log_path}" + echo "UNINSTALL_EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_ENV" + + - name: "Upload Installer Logs" + if: needs.detect-changes.outputs.docs_only != 'true' && env.INSTALL_EXIT_CODE != '0' + uses: actions/upload-artifact@v6 + with: + name: interactive-installer-${{ matrix.os }}-logs + path: ci-logs/interactive-installer-${{ matrix.os }}.log + retention-days: 7 + + - name: "Upload Uninstaller Logs" + if: needs.detect-changes.outputs.docs_only != 'true' && env.UNINSTALL_EXIT_CODE != '0' + uses: actions/upload-artifact@v6 + with: + name: interactive-uninstaller-${{ matrix.os }}-logs + path: ci-logs/interactive-uninstaller-${{ matrix.os }}.log + retention-days: 7 + + - name: "Fail if Installer or Uninstaller Failed" + if: needs.detect-changes.outputs.docs_only != 'true' && (env.INSTALL_EXIT_CODE != '0' || env.UNINSTALL_EXIT_CODE != '0') + shell: bash + run: | + echo "Installer exit code: ${INSTALL_EXIT_CODE:-0}" + echo "Uninstaller exit code: ${UNINSTALL_EXIT_CODE:-0}" + exit 1 + + # ============================================================================= + # Security Analysis (Skip for docs-only changes) + # ============================================================================= + codeql-node: name: "CodeQL Analysis - Node.js/TypeScript" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' timeout-minutes: 360 permissions: actions: read @@ -77,52 +454,498 @@ jobs: - uses: ./.github/actions/setup-auto-mobile-npm-package - name: "Build TypeScript" - run: npm run build + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run build --output-logs=errors-only - name: "Perform CodeQL Analysis" uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" - npm-audit: - name: "NPM Security Audit" + bun-audit: + name: "Bun Security Audit" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' steps: - name: "Git Checkout" uses: actions/checkout@v4 - uses: ./.github/actions/setup-auto-mobile-npm-package - - name: "Run NPM Audit" + - name: "Run Bun Audit" run: | - npm audit --audit-level=moderate --json > npm-audit-results.json || true + bun pm audit --json > bun-audit-results.json || true continue-on-error: true - - name: "Upload NPM Audit Results" - uses: actions/upload-artifact@v4.4.0 + - name: "Upload Bun Audit Results" + uses: actions/upload-artifact@v6 if: always() with: - name: npm-audit-results - path: npm-audit-results.json + name: bun-audit-results + path: bun-audit-results.json + + # ============================================================================= + # Node TypeScript Build and Test (Skip for docs-only changes) + # ============================================================================= mcp-build-and-test: - name: "Node TypeScript Build and Test" - runs-on: ubuntu-latest + name: "Node TypeScript Build and Test (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + needs: detect-changes + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest steps: + - name: "Skip for docs-only or SHA256-only changes" + if: needs.detect-changes.outputs.docs_only == 'true' || needs.detect-changes.outputs.sha256_only == 'true' + run: echo "Skipping - documentation or SHA256 only changes" + - name: "Git Checkout" + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' uses: actions/checkout@v4 - - uses: ./.github/actions/setup-auto-mobile-npm-package + - name: "Cache Bun dependencies" + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + key: bun-${{ matrix.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + bun-${{ matrix.os }}- + + - name: "Cache Turborepo" + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ matrix.os }}-${{ hashFiles('bun.lock') }}-${{ github.sha }} + restore-keys: | + turbo-${{ matrix.os }}-${{ hashFiles('bun.lock') }}- + turbo-${{ matrix.os }}- + + - name: "Setup Auto Mobile" + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + uses: ./.github/actions/setup-auto-mobile-npm-package - name: "Run Lint" - run: npm run lint + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run lint --output-logs=errors-only --log-order=grouped + + - name: "Run Build" + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + shell: bash + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: | + set -euo pipefail + mkdir -p ci-logs + turbo run build --output-logs=errors-only --log-order=grouped 2>&1 | tee "ci-logs/bun-build-${{ matrix.os }}.log" - name: "Run Tests" - run: npm run test + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + shell: bash + env: + # Enable debug logging on Windows to trace what's starting the adb daemon + DEBUG_ADB_EXEC: ${{ matrix.os == 'windows-latest' && 'true' || '' }} + # Force AdbClient into test mode on Windows to prevent adb daemon startup + AUTOMOBILE_TEST_MODE: ${{ matrix.os == 'windows-latest' && 'true' || '' }} + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: | + set -euo pipefail + mkdir -p ci-logs + turbo run test --output-logs=errors-only --log-order=grouped 2>&1 | tee "ci-logs/bun-test-${{ matrix.os }}.log" + + - name: "Upload Build/Test Logs" + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' && (failure() || cancelled() || matrix.os == 'windows-latest') + uses: actions/upload-artifact@v6 + with: + name: mcp-build-test-logs-${{ matrix.os }} + path: ci-logs/*.log + retention-days: 7 + + memory-leak-detection: + name: "Memory Leak Detection" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + timeout-minutes: 30 + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Setup Node for native addons" + uses: actions/setup-node@v4 + with: + node-version: "18.20.5" + + - name: "Trust heapdump" + continue-on-error: true + run: bun pm trust heapdump + + - name: "Trust memwatch-next" + continue-on-error: true + run: bun pm trust memwatch-next + + - name: "Rebuild native dependencies" + run: bun install --frozen-lockfile + + - name: "Run Memory Leak Detection" + run: bun run test:memory-leaks + + - name: "Upload Heap Snapshots" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: heap-snapshots + path: "*.heapsnapshot" + + # ============================================================================= + # TypeScript Code Coverage + # ============================================================================= + + ts-code-coverage: + name: "TypeScript Code Coverage" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Cache Turborepo" + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-ubuntu-latest-${{ hashFiles('bun.lock') }}-${{ github.sha }} + restore-keys: | + turbo-ubuntu-latest-${{ hashFiles('bun.lock') }}- + turbo-ubuntu-latest- + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Tests with Coverage" + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run test:coverage --output-logs=errors-only + + - name: "Upload Coverage" + uses: actions/upload-artifact@v6 + if: always() + with: + name: ts-code-coverage + path: coverage/ + retention-days: 7 + + # ============================================================================= + # Kotlin Code Coverage + # ============================================================================= + + kotlin-code-coverage: + name: "Kotlin Code Coverage" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.android_should_run == 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/gradle-task-run + with: + gradle-tasks: ":junit-runner:test :junit-runner:jacocoTestReport :control-proxy:testDebugUnitTest :control-proxy:createDebugUnitTestCoverageReport :auto-mobile-sdk:testDebugUnitTest :auto-mobile-sdk:createDebugUnitTestCoverageReport :test-plan-validation:test :test-plan-validation:jacocoTestReport :protocol:test :protocol:jacocoTestReport" + gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" + reuse-configuration-cache: true + gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: "Generate Kotlin Coverage Badge" + run: bash scripts/coverage/generate-kotlin-badge.sh + + - name: "Upload Kotlin Coverage Badge" + uses: actions/upload-artifact@v6 + with: + name: kotlin-coverage-badge + path: coverage/kotlin-coverage-badge.json + retention-days: 7 + + # ============================================================================= + # Swift Code Coverage + # ============================================================================= + + swift-code-coverage: + name: "Swift Code Coverage" + runs-on: macos-14 + needs: detect-changes + if: needs.detect-changes.outputs.ios_should_run == 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode 15.4" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "15.4" + + - name: "Generate Swift Coverage Badge" + run: bash scripts/coverage/generate-swift-coverage.sh + + - name: "Upload Swift Coverage Badge" + uses: actions/upload-artifact@v6 + with: + name: swift-coverage-badge + path: coverage/swift-coverage-badge.json + retention-days: 7 + + # ============================================================================= + # iOS Build and Test Jobs (Skip for docs-only changes) + # ============================================================================= + + ios-swift-build: + name: "Build Swift Packages (${{ matrix.config.name }})" + runs-on: ${{ matrix.config.runner }} + needs: detect-changes + if: needs.detect-changes.outputs.ios_should_run == 'true' + env: + IOS_SIGNING_ENABLED: ${{ secrets.IOS_CERTIFICATE_BASE64 != '' }} + MACOS_SIGNING_ENABLED: ${{ secrets.MACOS_DEVELOPER_ID_CERT_BASE64 != '' }} + strategy: + fail-fast: false + matrix: + config: + - { name: "Xcode 15", runner: "macos-14", xcode: "15.4" } + - { name: "Xcode 26", runner: "macos-26", xcode: "26.0" } + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode ${{ matrix.config.xcode }}" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.config.xcode }} + + - name: "Install macOS Developer ID certificate" + if: ${{ env.MACOS_SIGNING_ENABLED == 'true' }} + env: + MACOS_DEVELOPER_ID_CERT_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_BASE64 }} + MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }} + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: ./scripts/ios/setup-macos-signing-keychain.sh + + - name: "Install iOS signing certificate" + if: ${{ env.IOS_SIGNING_ENABLED == 'true' }} + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }} + run: ./scripts/ios/setup-signing-keychain.sh + + - name: "Build Swift Packages" + env: + MACOS_SIGNING_ENABLED: ${{ env.MACOS_SIGNING_ENABLED }} + MACOS_DEVELOPER_ID_SIGNING_IDENTITY: ${{ secrets.MACOS_DEVELOPER_ID_SIGNING_IDENTITY }} + MACOS_DEVELOPER_ID_TEAM_ID: ${{ secrets.MACOS_DEVELOPER_ID_TEAM_ID }} + MACOS_SIGNING_STRICT: ${{ env.MACOS_SIGNING_ENABLED }} + IOS_SIGNING_ENABLED: ${{ env.IOS_SIGNING_ENABLED }} + IOS_SIGNING_IDENTITY: ${{ secrets.IOS_SIGNING_IDENTITY }} + IOS_SIGNING_TEAM_ID: ${{ secrets.IOS_SIGNING_TEAM_ID }} + IOS_SIGNING_STRICT: ${{ env.IOS_SIGNING_ENABLED }} + run: ./scripts/ios/swift-build.sh + + ios-swift-test: + name: "Test Swift Packages (${{ matrix.config.name }})" + runs-on: ${{ matrix.config.runner }} + needs: [detect-changes, ios-swift-build] + if: needs.detect-changes.outputs.ios_should_run == 'true' + strategy: + fail-fast: false + matrix: + config: + - { name: "Xcode 15", runner: "macos-14", xcode: "15.4" } + - { name: "Xcode 26", runner: "macos-26", xcode: "26.0" } + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode ${{ matrix.config.xcode }}" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.config.xcode }} + + - name: "Test Swift Packages" + run: ./scripts/ios/swift-test.sh + + ios-xcodegen: + name: "Generate Xcode Projects" + runs-on: macos-26 + needs: detect-changes + if: needs.detect-changes.outputs.ios_should_run == 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Install XcodeGen" + run: brew install xcodegen + + - name: "Generate Xcode Projects" + run: ./scripts/ios/xcodegen-generate.sh + + ios-xcode-build: + name: "Build Xcode Projects (${{ matrix.config.name }})" + runs-on: ${{ matrix.config.runner }} + needs: [detect-changes, ios-xcodegen] + if: needs.detect-changes.outputs.ios_should_run == 'true' + strategy: + fail-fast: false + matrix: + config: + - { name: "Xcode 15", runner: "macos-14", xcode: "15.4" } + - { name: "Xcode 26", runner: "macos-26", xcode: "26.0" } + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode ${{ matrix.config.xcode }}" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.config.xcode }} + + - name: "Ensure iOS Simulator runtime" + uses: ./.github/actions/ensure-ios-simulator-runtime + + - name: "Install XcodeGen" + run: brew install xcodegen + + - name: "Generate Xcode Projects" + run: ./scripts/ios/xcodegen-generate.sh + + - name: "Build Xcode Projects" + run: ./scripts/ios/xcode-build.sh + + ios-xctestservice-build: + name: "Build XCTestService for Testing" + runs-on: macos-26 + needs: detect-changes + if: needs.detect-changes.outputs.ios_should_run == 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode 26.0" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.0" + + - name: "Ensure iOS Simulator runtime" + uses: ./.github/actions/ensure-ios-simulator-runtime + + - name: "Install XcodeGen" + run: brew install xcodegen + + - name: "Install xcpretty" + run: gem install xcpretty + + - name: "Build XCTestService for Testing" + run: ./scripts/ios/xctestservice-build-for-testing.sh + + - name: "Upload XCTestService Artifacts" + uses: actions/upload-artifact@v6 + with: + name: xctestservice-simulator-xcode26 + path: /tmp/automobile-xctestservice/Build/Products + retention-days: 1 + + ios-xctest-runner-simulator-tests: + name: "XCTestRunner Simulator Tests" + runs-on: macos-26 + needs: [detect-changes, ios-xctestservice-build] + if: needs.detect-changes.outputs.ios_should_run == 'true' + timeout-minutes: 30 + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Select Xcode 26.0" + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.0" + + - name: "Ensure iOS Simulator runtime" + uses: ./.github/actions/ensure-ios-simulator-runtime + + - name: "Download XCTestService Artifacts" + uses: actions/download-artifact@v7 + with: + name: xctestservice-simulator-xcode26 + path: /tmp/automobile-xctestservice/Build/Products + + - name: "Setup Bun" + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Boot iOS Simulator" + run: | + # Find the first available iPhone simulator device + DEVICE=$(xcrun simctl list devices available -j \ + | jq -r '[.devices | to_entries[] | .value[] | select(.name | startswith("iPhone"))] | first | .name') + echo "Booting simulator: ${DEVICE}" + xcrun simctl boot "${DEVICE}" + xcrun simctl bootstatus "${DEVICE}" -b + + - name: "Verify XCTestService Artifacts" + run: | + echo "Checking XCTestService artifacts..." + ls -la /tmp/automobile-xctestservice/Build/Products/ + ls -la /tmp/automobile-xctestservice/Build/Products/Debug-iphonesimulator/ || true + XCTESTRUN=$(find /tmp/automobile-xctestservice/Build/Products -name "*.xctestrun" | head -1) + echo "xctestrun file: ${XCTESTRUN}" + + - name: "Run Reminders integration tests" + timeout-minutes: 10 + env: + AUTOMOBILE_TEST_PLAN: "Plans/launch-reminders-app.yaml" + run: | + cd ios/XCTestRunner + swift test --filter RemindersLaunchPlanTests 2>&1 + + # ============================================================================= + # Android Build and Test Jobs (Android-related changes only) + # ============================================================================= junit-runner-unit-tests: name: "Run JUnit Runner Unit Tests" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.android_should_run == 'true' defaults: run: working-directory: android @@ -137,66 +960,197 @@ jobs: with: gradle-tasks: ":junit-runner:test" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + - name: "Upload Heap Dumps" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: junit-runner-heap-dumps + path: heap-dump + if-no-files-found: ignore + + # Test isolation issue resolved in #107 and #109 + # Re-enabled in #110 + junit-runner-emulator-tests: + name: "Run JUnit Runner Emulator Tests" + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [detect-changes, build-android-control-proxy] + if: needs.detect-changes.outputs.android_should_run == 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 + with: + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug # Run AutoMobile tests that require emulator - # - uses: ./.github/actions/android-emulator - # with: - # script: "./gradlew :junit-runner:test --rerun-tasks" - # working-directory: './android/' + - uses: ./.github/actions/android-emulator + with: + script: "./gradlew :junit-runner:test --stacktrace --info" + working-directory: './android/' + accessibility-apk-path: control-proxy/build/outputs/apk/debug/control-proxy-debug.apk + gradle-home-directory: "~/.gradle/${{ github.job }}" + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + - name: "Upload Heap Dumps" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: junit-runner-heap-dumps + path: heap-dump + if-no-files-found: ignore + + - name: "Upload Test Results" + if: always() + uses: actions/upload-artifact@v6 + with: + name: junit-runner-emulator-test-results + path: android/junit-runner/build/reports/tests/test/ - # - name: "Publish Test Report" - # uses: mikepenz/action-junit-report@v4 - # if: always() - # with: - # check_name: "JUnit Runner Test Report" - # report_paths: '**/build/test-results/**/*.xml' + - name: "Publish Test Report" + uses: mikepenz/action-junit-report@v4 + if: always() + with: + check_name: "JUnit Runner Emulator Test Report" + report_paths: '**/build/test-results/**/*.xml' - kotlin-test-author-unit-tests: - name: "Run Kotlin Test Author Unit Tests" + # Emulator.wtf session-based tests (see #94 for investigation context) + # Requires EMULATOR_WTF_ENABLED=true variable and EMULATOR_WTF_API_KEY secret + junit-runner-emulator-wtf-tests: + name: "Run JUnit Runner Emulator.wtf Tests" runs-on: ubuntu-latest - defaults: - run: - working-directory: android + timeout-minutes: 15 + needs: [detect-changes, build-android-control-proxy] + if: needs.detect-changes.outputs.android_should_run == 'true' && vars.EMULATOR_WTF_ENABLED == 'true' + env: + HAS_API_KEY: ${{ secrets.EMULATOR_WTF_API_KEY != '' }} steps: + - name: "Check API Key" + if: env.HAS_API_KEY != 'true' + run: | + echo "::error::EMULATOR_WTF_ENABLED is set but EMULATOR_WTF_API_KEY secret is not configured" + exit 1 + - name: "Git Checkout" uses: actions/checkout@v4 - - uses: ./.github/actions/gradle-task-run + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Compile JUnit Runner Test Classes" + uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":kotlinTestAuthor:test" + gradle-tasks: ":junit-runner:testClasses" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 + with: + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug + + - name: "Run AutoMobile tests on emulator.wtf" + uses: ./.github/actions/android-emulator-wtf + env: + EW_API_TOKEN: ${{ secrets.EMULATOR_WTF_API_KEY }} + with: + script: "./gradlew :junit-runner:test --stacktrace --info" + working-directory: "./android/" + accessibility-apk-path: control-proxy/build/outputs/apk/debug/control-proxy-debug.apk + device: "model=Pixel7,version=35,gpu=auto" + max-time-limit: "2m" + + - name: "Upload Heap Dumps" + if: failure() + uses: actions/upload-artifact@v6 + with: + name: junit-runner-emulator-wtf-heap-dumps + path: heap-dump + if-no-files-found: ignore + + - name: "Upload Test Results" + if: always() + uses: actions/upload-artifact@v6 + with: + name: junit-runner-emulator-wtf-test-results + path: android/junit-runner/build/reports/tests/test/ - name: "Publish Test Report" uses: mikepenz/action-junit-report@v4 if: always() with: - check_name: "Kotlin Test Author Test Report" + check_name: "JUnit Runner Emulator.wtf Test Report" report_paths: '**/build/test-results/**/*.xml' - android-accessibility-service-unit-tests: - name: "Run Accessibility Service Unit Tests" + playground-automobile-emulator-tests: + name: "Run Playground Automobile Emulator Tests" runs-on: ubuntu-latest - defaults: - run: - working-directory: android + timeout-minutes: 15 + needs: [detect-changes, build-android-control-proxy] + if: needs.detect-changes.outputs.android_should_run == 'true' steps: - name: "Git Checkout" uses: actions/checkout@v4 - - uses: ./.github/actions/gradle-task-run + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 with: - gradle-tasks: ":accessibility-service:testDebugUnitTest" - gradle-project-directory: "android" - reuse-configuration-cache: true - gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug - build-junit-runner-library: - name: "Build JUnitRunner Library" + # Run AutoMobile tests that require emulator + - uses: ./.github/actions/android-emulator + with: + script: "./gradlew :playground:app:test --stacktrace --info" + working-directory: './android/' + accessibility-apk-path: control-proxy/build/outputs/apk/debug/control-proxy-debug.apk + gradle-home-directory: "~/.gradle/${{ github.job }}" + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + - name: "Upload Test Results" + if: always() + uses: actions/upload-artifact@v6 + with: + name: playground-automobile-emulator-test-results + path: android/playground/app/build/reports/tests/test/ + + - name: "Publish Test Report" + uses: mikepenz/action-junit-report@v4 + if: always() + with: + check_name: "Playground Automobile Emulator Test Report" + report_paths: '**/build/test-results/**/*.xml' + + android-control-proxy-unit-tests: + name: "Run Accessibility Service Unit Tests" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.android_should_run == 'true' defaults: run: working-directory: android @@ -204,24 +1158,23 @@ jobs: - name: "Git Checkout" uses: actions/checkout@v4 - - uses: ./.github/actions/setup-auto-mobile-npm-package - - uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":junitRunner:assemble" + gradle-tasks: ":control-proxy:testDebugUnitTest" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - - name: "Store AAR" - uses: actions/upload-artifact@v4.4.0 - with: - name: aar - path: core/build/outputs/aar/core-debug.aar - - build-kotlin-test-author-clikt-app: - name: "Build Kotlin Test Author Clikt App" + build-junit-runner-library: + name: "Build JUnitRunner Library" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.android_should_run == 'true' defaults: run: working-directory: android @@ -229,22 +1182,31 @@ jobs: - name: "Git Checkout" uses: actions/checkout@v4 + - uses: ./.github/actions/setup-auto-mobile-npm-package + - uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":kotlinTestAuthor:assembleDist" + gradle-tasks: ":junitRunner:assemble" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - name: "Store AAR" - uses: actions/upload-artifact@v4.4.0 + uses: actions/upload-artifact@v6 with: name: aar path: core/build/outputs/aar/core-debug.aar - build-android-accessibility-service: + build-android-control-proxy: name: "Build Accessibility Service" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.android_should_run == 'true' defaults: run: working-directory: android @@ -254,14 +1216,27 @@ jobs: - uses: ./.github/actions/gradle-task-run with: - gradle-tasks: ":accessibility-service:assembleDebug" + gradle-tasks: ":control-proxy:assembleDebug" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} + + - name: "Store Accessibility Service APK" + uses: actions/upload-artifact@v6 + with: + name: control-proxy-apk + path: android/control-proxy/build/outputs/apk/debug/control-proxy-debug.apk build-playground-app: name: "Build Playground App" runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.android_should_run == 'true' defaults: run: working-directory: android @@ -275,12 +1250,675 @@ jobs: with: gradle-tasks: ":playground:app:assembleDebug" gradle-project-directory: "android" + gradle-home-directory: "~/.gradle/${{ github.job }}" reuse-configuration-cache: true gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + release-keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + release-keystore-password: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + release-key-alias: ${{ secrets.RELEASE_KEY_ALIAS }} + release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} - name: "Store Sample APK" - uses: actions/upload-artifact@v4.4.0 + uses: actions/upload-artifact@v6 if: success() # Only upload if build succeeded with: name: playground-app-apk path: android/playground/app/build/outputs/apk/debug/playground-app-debug.apk + + # ============================================================================= + # Docker Build and Validation + # ============================================================================= + + hadolint: + name: "Lint Dockerfile" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - name: "Run hadolint" + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile + config: .hadolint.yaml + failure-threshold: error + + # ============================================================================= + # MCP Context Thresholds + # ============================================================================= + + benchmark-context-thresholds: + name: "Benchmark MCP Context Thresholds" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Context Threshold Benchmark" + id: benchmark + run: | + # Create reports directory + mkdir -p reports + + # Run benchmark and capture exit code + bun run benchmark-context --output reports/context-benchmark.json || echo "FAILED=true" >> $GITHUB_ENV + + # Always preserve the report file even if benchmark failed + exit 0 + + - name: "Upload Benchmark Report" + uses: actions/upload-artifact@v6 + if: always() + with: + name: context-benchmark-report + path: reports/context-benchmark.json + retention-days: 90 + + - name: "Check Benchmark Result" + if: always() + run: | + if [ "$FAILED" = "true" ]; then + echo "::error::MCP context threshold benchmark failed - one or more thresholds were exceeded" + cat reports/context-benchmark.json + exit 1 + fi + echo "::notice::MCP context threshold benchmark passed - all thresholds satisfied" + + # ============================================================================= + # MCP Tool Call Throughput + # ============================================================================= + + benchmark-tool-throughput: + name: "Benchmark MCP Tool Throughput" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Tool Throughput Benchmark" + id: benchmark + run: | + # Create reports directory + mkdir -p reports + + # Run benchmark and capture exit code + bun run benchmark-tools --output reports/tool-benchmark.json || echo "FAILED=true" >> $GITHUB_ENV + + # Always preserve the report file even if benchmark failed + exit 0 + + - name: "Upload Benchmark Report" + uses: actions/upload-artifact@v6 + if: always() + with: + name: tool-benchmark-report + path: reports/tool-benchmark.json + retention-days: 90 + + - name: "Check Benchmark Result" + if: always() + run: | + if [ "$FAILED" = "true" ]; then + echo "::error::MCP tool throughput benchmark failed - performance regressions detected" + cat reports/tool-benchmark.json + exit 1 + fi + echo "::notice::MCP tool throughput benchmark passed - no regressions" + + # ============================================================================= + # MCP Startup Benchmark + # ============================================================================= + + benchmark-startup: + name: "Benchmark MCP Startup" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run Startup Benchmark" + id: benchmark + timeout-minutes: 1 + run: | + # Create reports directory + mkdir -p reports + + # Run benchmark and capture exit code + bun run benchmark-startup --compare benchmark/startup-baseline.json --output reports/startup-benchmark.json || echo "FAILED=true" >> $GITHUB_ENV + + # Always preserve the report file even if benchmark failed + exit 0 + + - name: "Upload Benchmark Report" + uses: actions/upload-artifact@v6 + if: always() + with: + name: startup-benchmark-report + path: reports/startup-benchmark.json + retention-days: 90 + + - name: "Check Benchmark Result" + if: always() + run: | + if [ "$FAILED" = "true" ]; then + echo "::error::MCP startup benchmark failed - regressions detected" + cat reports/startup-benchmark.json + exit 1 + fi + echo "::notice::MCP startup benchmark passed - no regressions" + + # ============================================================================= + # NPM Unpacked Size Benchmark + # ============================================================================= + + benchmark-npm-unpacked-size: + name: "Benchmark NPM Unpacked Size" + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.docs_only != 'true' && needs.detect-changes.outputs.sha256_only != 'true' + steps: + - name: "Git Checkout" + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-auto-mobile-npm-package + + - name: "Run NPM Unpacked Size Benchmark" + id: benchmark + run: | + # Create reports directory + mkdir -p reports + + # Run benchmark and capture exit code + bun run benchmark-npm-unpacked-size --output reports/npm-unpacked-size.json || echo "FAILED=true" >> $GITHUB_ENV + + # Always preserve the report file even if benchmark failed + exit 0 + + - name: "Upload Benchmark Report" + uses: actions/upload-artifact@v6 + if: always() + with: + name: npm-unpacked-size-report + path: reports/npm-unpacked-size.json + retention-days: 90 + + - name: "Check Benchmark Result" + if: always() + run: | + if [ "$FAILED" = "true" ]; then + echo "::error::NPM unpacked size benchmark failed - threshold exceeded" + cat reports/npm-unpacked-size.json + exit 1 + fi + echo "::notice::NPM unpacked size benchmark passed - within threshold" + + # ============================================================================= + # Consolidated MCP Benchmark Comment + # ============================================================================= + + post-benchmark-results: + name: "Post Consolidated Benchmark Results" + runs-on: ubuntu-latest + needs: [benchmark-context-thresholds, benchmark-tool-throughput, benchmark-startup, benchmark-npm-unpacked-size] + if: always() + steps: + - name: "Download Context Benchmark Report" + uses: actions/download-artifact@v7 + with: + name: context-benchmark-report + path: reports + continue-on-error: true + + - name: "Download Tool Benchmark Report" + uses: actions/download-artifact@v7 + with: + name: tool-benchmark-report + path: reports + continue-on-error: true + + - name: "Download Startup Benchmark Report" + uses: actions/download-artifact@v7 + with: + name: startup-benchmark-report + path: reports + continue-on-error: true + + - name: "Download NPM Unpacked Size Benchmark Report" + uses: actions/download-artifact@v7 + with: + name: npm-unpacked-size-report + path: reports + continue-on-error: true + + - name: "Post Consolidated Comment" + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const contextPath = 'reports/context-benchmark.json'; + const toolPath = 'reports/tool-benchmark.json'; + const startupPath = 'reports/startup-benchmark.json'; + const npmPath = 'reports/npm-unpacked-size.json'; + + const readReport = (reportPath, label) => { + if (!fs.existsSync(reportPath)) { + return { label, report: null }; + } + try { + const raw = fs.readFileSync(reportPath, 'utf8'); + return { label, report: JSON.parse(raw) }; + } catch (error) { + console.log(`Failed to read ${label} report: ${error.message}`); + return { label, report: null }; + } + }; + + const contextReport = readReport(contextPath, 'Context Thresholds'); + const toolReport = readReport(toolPath, 'Tool Call Throughput'); + const startupReport = readReport(startupPath, 'Startup Performance'); + const npmReport = readReport(npmPath, 'NPM Unpacked Size'); + + if (!contextReport.report && !toolReport.report && !startupReport.report && !npmReport.report) { + console.log('No benchmark reports found. Skipping PR comment.'); + return; + } + + let body = '## MCP Benchmarks\n\n'; + + const statuses = []; + let hasFailures = false; + let hasMissing = false; + + const trackStatus = (label, report) => { + if (!report) { + statuses.push(`${label}: ⚠️ missing`); + hasMissing = true; + return; + } + const passed = report.passed !== false; + const icon = passed ? '✅' : '❌'; + statuses.push(`${label}: ${icon}`); + if (!passed) { + hasFailures = true; + } + }; + + trackStatus('Context Thresholds', contextReport.report); + trackStatus('Tool Call Throughput', toolReport.report); + trackStatus('Startup', startupReport.report); + trackStatus('NPM Unpacked Size', npmReport.report); + + let overallStatus = '⚠️ PARTIAL'; + if (!hasFailures && !hasMissing) { + overallStatus = '✅ PASSED'; + } else if (hasFailures) { + overallStatus = '❌ FAILED'; + } + + body += `**Overall Status:** ${overallStatus}\n`; + body += `**Status by Benchmark:** ${statuses.join(' | ')}\n\n`; + + if (contextReport.report) { + const report = contextReport.report; + const formatRow = (label, result) => { + if (!result) { + return `| ${label} | n/a | n/a | n/a | ⚠️ |`; + } + const status = result.passed ? '✅' : '❌'; + const actual = typeof result.actual === 'number' ? result.actual.toLocaleString() : 'n/a'; + const threshold = typeof result.threshold === 'number' ? result.threshold.toLocaleString() : 'n/a'; + const usage = typeof result.usage === 'number' ? `${result.usage}%` : 'n/a'; + return `| ${label} | ${actual} | ${threshold} | ${usage} | ${status} |`; + }; + + body += '### Context Thresholds\n\n'; + body += '| Category | Actual | Threshold | Usage | Status |\n'; + body += '|----------|--------|-----------|-------|--------|\n'; + body += formatRow('Tools', report.results?.tools) + '\n'; + body += formatRow('Resources', report.results?.resources) + '\n'; + body += formatRow('Resource Templates', report.results?.resourceTemplates) + '\n'; + body += formatRow('**Total**', report.results?.total) + '\n'; + + if (Array.isArray(report.violations) && report.violations.length > 0) { + body += '\n### ⚠️ Threshold Violations\n\n'; + for (const violation of report.violations) { + body += `- ${violation}\n`; + } + } + + body += `\n**Overall Status:** ${report.passed ? '✅ PASSED' : '❌ FAILED'}`; + if (report.timestamp) { + body += `\n\n_Generated at ${report.timestamp}_`; + } + body += '\n\n'; + } + + if (toolReport.report) { + const report = toolReport.report; + const summary = report.summary ?? {}; + const sampleSize = report.sampleSize ?? 'n/a'; + const totalDuration = typeof report.totalDuration === 'number' + ? `${(report.totalDuration / 1000).toFixed(2)}s` + : 'n/a'; + const averageThroughput = typeof summary.averageThroughput === 'number' + ? `${summary.averageThroughput.toFixed(2)} ops/second` + : 'n/a'; + + body += '### Tool Call Throughput\n\n'; + body += `**Sample Size:** ${sampleSize} iterations per tool\n`; + body += `**Total Duration:** ${totalDuration}\n`; + body += `**Average Throughput:** ${averageThroughput}\n\n`; + + const categories = [ + { name: 'Fast Operations', expectedLatency: '<100ms', tools: ['listDevices', 'getForegroundApp', 'pressButton'] }, + { name: 'Medium Operations', expectedLatency: '100ms-1s', tools: ['observe', 'tapOn', 'inputText', 'swipe'] }, + { name: 'Slow Operations', expectedLatency: '1s+', tools: ['launchApp', 'installApp'] } + ]; + + const results = Array.isArray(report.results) ? report.results : []; + let renderedAnyCategory = false; + + for (const category of categories) { + const categoryResults = results.filter(r => category.tools.includes(r.toolName)); + if (categoryResults.length === 0) continue; + renderedAnyCategory = true; + + body += `#### ${category.name} (${category.expectedLatency})\n\n`; + body += '| Tool | P50 | P95 | Mean | Success | Status |\n'; + body += '|------|-----|-----|------|---------|--------|\n'; + + for (const result of categoryResults) { + const status = result.overallPassed === false ? '❌' : '✅'; + const p50 = typeof result.p50 === 'number' ? `${result.p50.toFixed(1)}ms` : 'n/a'; + const p95 = typeof result.p95 === 'number' ? `${result.p95.toFixed(1)}ms` : 'n/a'; + const mean = typeof result.mean === 'number' ? `${result.mean.toFixed(1)}ms` : 'n/a'; + const success = typeof result.successRate === 'number' ? `${result.successRate.toFixed(0)}%` : 'n/a'; + body += `| ${result.toolName} | ${p50} | ${p95} | ${mean} | ${success} | ${status} |\n`; + + if (Array.isArray(result.thresholdChecks) && result.overallPassed === false) { + for (const check of result.thresholdChecks.filter(c => !c.passed)) { + const metric = check.metric ? check.metric.toUpperCase() : 'METRIC'; + const actual = typeof check.actual === 'number' ? `${check.actual.toFixed(2)}ms` : 'n/a'; + const threshold = typeof check.threshold === 'number' ? `${check.threshold.toFixed(2)}ms` : 'n/a'; + const regression = typeof check.regression === 'number' ? `+${check.regression.toFixed(1)}%` : 'n/a'; + body += `| └─ ${metric} regression | ${actual} | | | ${threshold} | ${regression} | |\n`; + } + } + } + body += '\n'; + } + + if (!renderedAnyCategory) { + body += '_No tool results found._\n\n'; + } + + if (Array.isArray(report.violations) && report.violations.length > 0) { + body += '#### ⚠️ Performance Regressions\n\n'; + for (const violation of report.violations) { + body += `- ${violation}\n`; + } + body += '\n'; + } + + const passedTools = typeof summary.passedTools === 'number' ? summary.passedTools : 'n/a'; + const totalTools = typeof summary.totalTools === 'number' ? summary.totalTools : 'n/a'; + body += `**Summary:** ${passedTools}/${totalTools} tools passed\n`; + body += `**Overall Status:** ${report.passed ? '✅ PASSED' : '❌ FAILED'}\n\n`; + if (report.timestamp) { + body += `_Generated at ${report.timestamp}_\n\n`; + } + } + + if (startupReport.report) { + const report = startupReport.report; + const formatMs = value => (typeof value === 'number' ? `${value.toFixed(1)}ms` : 'n/a'); + const formatBytes = value => (typeof value === 'number' ? `${(value / (1024 * 1024)).toFixed(1)}MB` : 'n/a'); + const formatNumber = value => (typeof value === 'number' ? value.toLocaleString() : 'n/a'); + + body += '### Startup Performance\n\n'; + + if (report.results?.mcpServer) { + body += '#### MCP Server (stdio)\n\n'; + body += '| Mode | Ready | First Tool Call | Heap Used |\n'; + body += '|------|-------|-----------------|-----------|\n'; + for (const run of report.results.mcpServer.runs || []) { + const heap = run.memoryUsage?.heapUsed ? formatBytes(run.memoryUsage.heapUsed) : 'n/a'; + body += `| ${run.mode} | ${formatMs(run.timeToReadyMs)} | ${formatMs(run.timeToFirstToolCallMs)} | ${heap} |\n`; + } + body += '\n'; + + const discovery = report.results.mcpServer.deviceDiscovery; + if (discovery) { + if (discovery.skipped) { + body += `Device discovery: skipped (${discovery.reason || 'no reason provided'})\n\n`; + } else if (discovery.scenarios?.length) { + body += 'Device discovery scenarios:\n'; + for (const scenario of discovery.scenarios) { + body += `- ${scenario.name}: ${formatMs(scenario.durationMs)} (${scenario.deviceCount} device(s))\n`; + } + body += '\n'; + } + } + } + + if (report.results?.daemon) { + body += '#### Daemon\n\n'; + body += '| Mode | Spawn | Ready | Responsive | Heap Used |\n'; + body += '|------|-------|-------|------------|-----------|\n'; + for (const run of report.results.daemon.runs || []) { + const heap = run.memoryUsage?.heapUsed ? formatBytes(run.memoryUsage.heapUsed) : 'n/a'; + body += `| ${run.mode} | ${formatMs(run.spawnMs)} | ${formatMs(run.timeToReadyMs)} | ${formatMs(run.timeToResponsiveMs)} | ${heap} |\n`; + } + body += '\n'; + } + + if (report.comparisons?.regressions?.length) { + body += '#### Startup Thresholds\n\n'; + body += '| Metric | Actual | Threshold | Regression | Status |\n'; + body += '|--------|--------|-----------|------------|--------|\n'; + for (const regression of report.comparisons.regressions) { + const actual = formatNumber(regression.actual); + const threshold = formatNumber(regression.threshold); + const regressionPct = typeof regression.regression === 'number' + ? `${regression.regression >= 0 ? '+' : ''}${regression.regression.toFixed(1)}%` + : 'n/a'; + const status = regression.passed ? '✅' : '❌'; + body += `| ${regression.metric} | ${actual} | ${threshold} | ${regressionPct} | ${status} |\n`; + } + body += '\n'; + } + + if (report.comparisons?.regressions?.length) { + const failures = report.comparisons.regressions.filter(r => !r.passed); + if (failures.length > 0) { + body += '#### ⚠️ Regressions\n\n'; + for (const regression of failures) { + body += `- ${regression.metric}: ${regression.actual.toFixed(2)} > ${regression.threshold.toFixed(2)} (+${regression.regression.toFixed(1)}%)\n`; + } + body += '\n'; + } + } + + if (report.skips?.length) { + body += '#### Skipped Checks\n\n'; + for (const skip of report.skips) { + body += `- ${skip}\n`; + } + body += '\n'; + } + + body += `**Overall Status:** ${report.passed ? '✅ PASSED' : '❌ FAILED'}\n`; + if (report.timestamp) { + body += `\n_Generated at ${report.timestamp}_`; + } + body += '\n\n'; + } + + if (npmReport.report) { + const report = npmReport.report; + const formatBytes = value => (typeof value === 'number' ? `${(value / (1024 * 1024)).toFixed(1)}MB` : 'n/a'); + const result = report.results?.unpackedSize; + const actual = result && typeof result.actual === 'number' ? formatBytes(result.actual) : 'n/a'; + const threshold = result && typeof result.threshold === 'number' ? formatBytes(result.threshold) : 'n/a'; + const usage = result && typeof result.usage === 'number' ? `${result.usage}%` : 'n/a'; + const status = result ? (result.passed ? '✅' : '❌') : '⚠️'; + const packageName = report.package?.name ?? 'unknown'; + const packageVersion = report.package?.version ?? 'unknown'; + const tarballSize = typeof report.package?.tarballBytes === 'number' + ? formatBytes(report.package.tarballBytes) + : 'n/a'; + + body += '### NPM Unpacked Size\n\n'; + body += '| Metric | Actual | Threshold | Usage | Status |\n'; + body += '|--------|--------|-----------|-------|--------|\n'; + body += `| Unpacked Size | ${actual} | ${threshold} | ${usage} | ${status} |\n`; + body += `\n**Package:** ${packageName}@${packageVersion}\n`; + body += `**Tarball Size:** ${tarballSize}\n`; + + if (Array.isArray(report.violations) && report.violations.length > 0) { + body += '\n#### ⚠️ Size Violations\n\n'; + for (const violation of report.violations) { + body += `- ${violation}\n`; + } + } + + body += `\n**Overall Status:** ${report.passed ? '✅ PASSED' : '❌ FAILED'}`; + if (report.timestamp) { + body += `\n\n_Generated at ${report.timestamp}_`; + } + body += '\n\n'; + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('## MCP Benchmarks') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } + + # ============================================================================= + # Gate Jobs for Branch Protection + # ============================================================================= + # These jobs always run and aggregate pass/skip/fail status of their group. + # Branch protection required checks should point to these gate jobs instead + # of the individual conditional jobs, avoiding the GitHub Actions issue where + # skipped matrix jobs produce uninterpolated check names that block merges. + + ios-gate: + name: "iOS" + if: always() + needs: + - ios-swift-build + - ios-swift-test + - ios-xcodegen + - ios-xcode-build + - ios-xctestservice-build + - ios-xctest-runner-simulator-tests + runs-on: ubuntu-latest + steps: + - name: "Check results" + run: | + results=( \ + "${{ needs.ios-swift-build.result }}" \ + "${{ needs.ios-swift-test.result }}" \ + "${{ needs.ios-xcodegen.result }}" \ + "${{ needs.ios-xcode-build.result }}" \ + "${{ needs.ios-xctestservice-build.result }}" \ + "${{ needs.ios-xctest-runner-simulator-tests.result }}" \ + ) + for r in "${results[@]}"; do + if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi + done + + android-gate: + name: "Android" + if: always() + needs: + - build-android-control-proxy + - build-junit-runner-library + - build-playground-app + - junit-runner-unit-tests + - junit-runner-emulator-tests + - junit-runner-emulator-wtf-tests + - playground-automobile-emulator-tests + - android-control-proxy-unit-tests + runs-on: ubuntu-latest + steps: + - name: "Check results" + run: | + results=( \ + "${{ needs.build-android-control-proxy.result }}" \ + "${{ needs.build-junit-runner-library.result }}" \ + "${{ needs.build-playground-app.result }}" \ + "${{ needs.junit-runner-unit-tests.result }}" \ + "${{ needs.junit-runner-emulator-tests.result }}" \ + "${{ needs.junit-runner-emulator-wtf-tests.result }}" \ + "${{ needs.playground-automobile-emulator-tests.result }}" \ + "${{ needs.android-control-proxy-unit-tests.result }}" \ + ) + for r in "${results[@]}"; do + if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi + done + + ide-plugin-gate: + name: "IDE Plugin" + if: always() + needs: + - build-ide-plugin + - ide-plugin-unit-tests + runs-on: ubuntu-latest + steps: + - name: "Check results" + run: | + results=( \ + "${{ needs.build-ide-plugin.result }}" \ + "${{ needs.ide-plugin-unit-tests.result }}" \ + ) + for r in "${results[@]}"; do + if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..b5f59d38c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,296 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + pull-requests: write + id-token: write + packages: write + +jobs: + build-xctestservice-ipa: + uses: ./.github/workflows/build-xctestservice-ipa.yml + + build-control-proxy-apk: + uses: ./.github/workflows/build-control-proxy-apk.yml + secrets: + GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + + verify-and-release: + runs-on: ubuntu-latest + needs: [build-xctestservice-ipa, build-control-proxy-apk] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Ensure npm CLI is 11.5.1+ + run: npm install -g npm@11.5.1 + + - name: Extract version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: "Download Accessibility Service APK" + uses: actions/download-artifact@v7 + with: + name: control-proxy-apk + path: /tmp + + - name: "Download XCTestService IPA" + uses: actions/download-artifact@v7 + with: + name: xctestservice-ipa + path: /tmp + + - name: "Verify APK SHA256 matches source" + id: verify_checksum + run: | + bash scripts/ci/verify-artifact-sha256.sh /tmp/control-proxy-debug.apk APK_SHA256_CHECKSUM | tee /tmp/apk-verify.log + CHECKSUM=$(grep '^checksum=' /tmp/apk-verify.log | cut -d= -f2) + echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + + - name: "Verify XCTestService IPA SHA256 matches source" + id: verify_ipa_checksum + run: | + bash scripts/ci/verify-artifact-sha256.sh /tmp/XCTestService.ipa XCTESTSERVICE_SHA256_CHECKSUM | tee /tmp/ipa-verify.log + CHECKSUM=$(grep '^checksum=' /tmp/ipa-verify.log | cut -d= -f2) + echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + + - name: Generate release constants + run: | + RELEASE_VERSION="${{ steps.version.outputs.version }}" \ + XCTESTSERVICE_RELEASE_VERSION="${{ steps.version.outputs.version }}" \ + bash scripts/generate-release-constants.sh + + - name: Run tests + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run test --output-logs=errors-only + + - name: Run lint + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run lint --output-logs=errors-only + + - name: Build TypeScript with injected checksum + env: + TURBO_TELEMETRY_DISABLED: "1" + TURBO_NO_UPDATE_NOTIFIER: "1" + DO_NOT_TRACK: "1" + NO_COLOR: "1" + run: turbo run build --output-logs=errors-only + + - name: Create release notes + id: release_notes + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Extract changelog for this version if it exists + if [ -f CHANGELOG.md ]; then + NOTES=$(awk -v version="$VERSION" ' + $0 ~ "^## \\[v?"version"\\]" {in_section=1; next} + in_section && $0 ~ "^## \\[" {exit} + in_section {print} + ' CHANGELOG.md | sed '/^$/d') + if [ -z "$NOTES" ]; then + NOTES="Release v${VERSION}" + fi + else + NOTES="Release v${VERSION}" + fi + + # Add checksum to notes + APK_CHECKSUM="${{ steps.verify_checksum.outputs.checksum }}" + IPA_CHECKSUM="${{ steps.verify_ipa_checksum.outputs.checksum }}" + NOTES="${NOTES}"$'\n\n'"## Accessibility Service APK"$'\n\n'"**SHA256 Checksum:** \`${APK_CHECKSUM}\`"$'\n\n'"Download the APK from the release assets below." + NOTES="${NOTES}"$'\n\n'"## XCTestService IPA"$'\n\n'"**SHA256 Checksum:** \`${IPA_CHECKSUM}\`"$'\n\n'"Download the IPA from the release assets below." + + # Save to file for GitHub release + echo "$NOTES" > release_notes.txt + + # Also output for later steps + echo "notes<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Publish to npm + run: npm publish --provenance --access public + + - name: Install mcp-publisher + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher + + - name: Publish to MCP Registry + run: | + ./mcp-publisher login dns --domain jasonpearson.dev --private-key "${{ secrets.MCP_DNS_PRIVATE_KEY }}" + ./mcp-publisher publish + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Make gradlew executable + run: chmod +x android/gradlew + + - name: Setup Release Keystore + run: | + mkdir -p android/keystore + echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > android/keystore/release.keystore + + - name: Publish Android Libraries to Maven Central + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }} + RELEASE_KEYSTORE_PATH: keystore/release.keystore + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: | + cd android + # Override VERSION_NAME with the release version from git tag (not the SNAPSHOT in gradle.properties) + # Publish dependency modules first — a separate invocation guarantees they + # complete before the consumer modules that reference them as transitive deps. + ./gradlew :protocol:publish :test-plan-validation:publish \ + -PVERSION_NAME=${{ steps.version.outputs.version }} \ + --no-configuration-cache + ./gradlew :junit-runner:publish :auto-mobile-sdk:publish \ + -PVERSION_NAME=${{ steps.version.outputs.version }} \ + --no-configuration-cache + + - name: Free Disk Space + uses: endersonmenezes/free-disk-space@v3 + with: + remove_android: true # ~14 GB + remove_dotnet: true # ~2.7 GB + remove_haskell: true # Minimal + remove_tool_cache: false # Keep Node.js since we need it + remove_swap: true # ~4 GB + remove_codeql: true # ~5.9 GB (we run CodeQL separately) + use_rmz: true # 3x faster deletion + + - name: Clean up Docker + run: docker system prune -af --volumes + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract Docker metadata + id: docker_meta + uses: docker/metadata-action@v5 + with: + images: kaeawc/auto-mobile + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: AutoMobile v${{ steps.version.outputs.version }} + body_path: release_notes.txt + files: | + /tmp/control-proxy-debug.apk + /tmp/XCTestService.ipa + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**APK Built:** control-proxy-debug.apk" >> $GITHUB_STEP_SUMMARY + echo "**APK SHA256:** \`${{ steps.verify_checksum.outputs.checksum }}\`" >> $GITHUB_STEP_SUMMARY + echo "**IPA Built:** XCTestService.ipa" >> $GITHUB_STEP_SUMMARY + echo "**IPA SHA256:** \`${{ steps.verify_ipa_checksum.outputs.checksum }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published Artifacts" >> $GITHUB_STEP_SUMMARY + echo "- **npm:** https://www.npmjs.com/package/@kaeawc/auto-mobile/v/${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Docker Hub:** https://hub.docker.com/r/kaeawc/auto-mobile/tags" >> $GITHUB_STEP_SUMMARY + echo "- **GitHub Release:** https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **Maven Central:** [auto-mobile-protocol](https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-protocol)" >> $GITHUB_STEP_SUMMARY + echo "- **Maven Central:** [auto-mobile-test-plan-validation](https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-test-plan-validation)" >> $GITHUB_STEP_SUMMARY + echo "- **Maven Central:** [auto-mobile-junit-runner](https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-junit-runner)" >> $GITHUB_STEP_SUMMARY + echo "- **Maven Central:** [auto-mobile-sdk](https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-sdk)" >> $GITHUB_STEP_SUMMARY + echo "- **MCP Registry:** [dev.jasonpearson/auto-mobile](https://registry.modelcontextprotocol.io/)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Images" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "docker pull kaeawc/auto-mobile:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "docker pull kaeawc/auto-mobile:latest" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Maven Central" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`kotlin" >> $GITHUB_STEP_SUMMARY + echo "testImplementation(\"dev.jasonpearson.auto-mobile:auto-mobile-junit-runner:${{ steps.version.outputs.version }}\")" >> $GITHUB_STEP_SUMMARY + echo "implementation(\"dev.jasonpearson.auto-mobile:auto-mobile-sdk:${{ steps.version.outputs.version }}\")" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Checksums Baked Into Package" >> $GITHUB_STEP_SUMMARY + echo "The npm package includes the APK and IPA checksums at build time, ensuring integrity verification." >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 86d2e82fc..77d5e36aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,56 @@ .auto-mobile-config.json .idea node_modules +.turbo /coverage /dist /venv /test_screenshots .view_hierarchy_cache/ -screenshots/ +/screenshots **/.DS_Store .nyc_output/ npm-debug.log* /.gitlab-ci-local *.tgz +*.hprof +/heap-dump/ +.nvmrc + +# Ignore jar files except gradle-wrapper.jar +*.jar +!gradle-wrapper.jar + +# iOS simulator destination (generated by scripts/ios/setup-ios-simulator.sh) +.ios-simulator-destination # Places to locally write while thinking or planning /scratch /roadmap /blog /presentation +android/junit-runner/scratch +android/junit-runner/.cache/screen-size/ # Generated MkDocs for GitHub Pages /site docs/changelog.md -docs/contributing/index.md +docs/contributing.md /.cache +.mcp.json +.mcp.local.json +*.pid +*.pem +# IntelliJ Platform Gradle plugin artifacts +android/ide-plugin/.intellijPlatform/ +.lycheecache + +# Gradle test reports (in case they're generated at root) +/classes/ +/css/ +/index.html +/js/ +/packages/ +demo/tmp-home/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index a94d15814..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "ios/WebDriverAgent"] - path = ios/WebDriverAgent - url = git@github.com:appium/WebDriverAgent.git diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 000000000..62a942d6e --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,33 @@ +# Hadolint configuration for Dockerfile linting +# https://github.com/hadolint/hadolint + +# Ignore rules +ignored: + # DL3018: Pin versions in apk add - Alpine package versions change frequently + - DL3018 + # DL3029: Do not use --platform flag - intentional to enforce x86_64 architecture + - DL3029 + # DL3059: Multiple consecutive RUN instructions - optimizing for readability + - DL3059 + +# Trusted registries for base images +trustedRegistries: + - docker.io + - gcr.io + - ghcr.io + +# Override specific rules +override: + error: + # Always use COPY instead of ADD + - DL3020 + warning: + # Use SHELL to change default shell + - DL4005 + info: + # Use JSON notation for CMD and ENTRYPOINT + - DL3025 + style: [] + +# Allow inline ignores +inline-ignores: true diff --git a/.lycherc.toml b/.lycherc.toml new file mode 100644 index 000000000..acd8c1802 --- /dev/null +++ b/.lycherc.toml @@ -0,0 +1,59 @@ +# Lychee link checker configuration +# https://lychee.cli.rs/ + +# Maximum number of concurrent network requests +max_concurrency = 10 + +# Exclude URL patterns (regex) +exclude = [ + # Ignore common localhost URLs + "^http://localhost", + "^http://127\\.0\\.0\\.1", + # Ignore example domains + "^https?://example\\.com", + # Ignore links to private fork repos (issues may not be publicly accessible) + "^https://github\\.com/jasonpearson/", +] + +# Exclude specific paths +exclude_path = [ + # Build artifacts + "site/", + "build/", + "dist/", + # Dependencies + "node_modules/", + # Git + ".git/", + # Files not deployed (in .gitignore) + "docs/contributing/index.md", +] + +# Accept status codes +accept = [ + "200..=299", # 2xx success + "403", # Forbidden (auth-protected URLs like console.anthropic.com) + "429", # Too Many Requests (common for rate-limited APIs) + "503", # Service Unavailable (temporary for Anthropic URLs under load) +] + +# Timeout for each request in seconds +timeout = 20 + +# Number of retries per link +max_retries = 3 + +# HTTP method to use for checking links +method = "get" + +# User agent string +user_agent = "Mozilla/5.0 (compatible; lychee/AutoMobile)" + +# Cache results to speed up repeated checks +cache = true + +# Verbose output level: "error", "warn", "info", "debug", or "trace" +# verbose = "warn" + +# Base URL for relative links (useful for local docs) +# base = "https://kaeawc.github.io/auto-mobile/" diff --git a/.mcp.json.example b/.mcp.json.example new file mode 100644 index 000000000..dde0528ce --- /dev/null +++ b/.mcp.json.example @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "auto-mobile": { + "type": "http", + "url": "http://localhost:9000/auto-mobile/streamable" + } + } +} diff --git a/.mocharc.json b/.mocharc.json deleted file mode 100644 index bc77453c0..000000000 --- a/.mocharc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "require": "esbuild-register", - "extensions": [ - "ts" - ], - "spec": "test/**/*.ts" -} diff --git a/.npmignore b/.npmignore index ee37ff43b..c91f22af1 100644 --- a/.npmignore +++ b/.npmignore @@ -7,6 +7,8 @@ test/ # Development files .eslint* tsconfig.json +turbo.json +.turbo/ .nyc_output/ coverage/ *.tsbuildinfo @@ -22,7 +24,6 @@ roadmap/ scratch/ screenshots/ test_screenshots/ -docs/ai/ # Android project files android/ @@ -51,3 +52,4 @@ examples/ # Temp files npm-debug.log* +README.md.backup diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 000000000..9d4d925d7 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,85 @@ +# SwiftFormat Configuration +# https://github.com/nicklockwood/SwiftFormat + +# Swift version +--swiftversion 5.0 + +# File options +--exclude ios/**/build,ios/**/DerivedData,ios/**/.build,ios/**/Pods,ios/**/Carthage + +# Format options + +# Indentation +--indent 4 +--tabwidth 4 +--smarttabs enabled +--indentcase false +--ifdef indent + +# Wrapping +--wraparguments before-first +--wrapparameters before-first +--wrapcollections before-first +--wrapconditions after-first +--wrapreturntype if-multiline +--wrapeffects if-multiline +--wraptypealiases before-first +--maxwidth 120 +--closingparen balanced + +# Spacing +--operatorfunc spaced +--nospaceoperators +--ranges spaced +--typeattributes prev-line +--funcattributes prev-line +--varattributes prev-line +--storedvarattrs same-line +--computedvarattrs same-line + +# Braces +--allman false + +# Blank lines +--trimwhitespace always +--emptybraces no-space + +# Semicolons +--semicolons inline + +# Import sorting +--importgrouping alpha + +# Self +--self remove +--selfrequired + +# Redundancy +--redundanttype inferred + +# Trailing +--commas always +--trailingclosures + +# Void +--voidtype void + +# Extensions +--extensionacl on-declarations + +# Marks +--marktypes always +--markextensions always +--markcategories true + +# Type declarations +--organizetypes class,struct,enum,actor +--structthreshold 0 +--enumthreshold 0 +--extensionlength 0 + +# Rules (disable rules that overlap with SwiftLint or cause issues) +--disable blankLinesBetweenImports +--disable sortSwitchCases +--disable wrapSwitchCases +--disable wrapEnumCases diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..fca7774fc --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,129 @@ +# SwiftLint Configuration +# https://github.com/realm/SwiftLint + +# Paths to lint +included: + - ios + +# Paths to exclude +excluded: + - ios/**/build + - ios/**/DerivedData + - ios/**/.build + - ios/**/Pods + - ios/**/Carthage + - ios/**/.swiftpm + +# Disable rules that overlap with SwiftFormat (formatting is handled by SwiftFormat) +disabled_rules: + # Formatting rules (handled by SwiftFormat) + - opening_brace + - closing_brace + - colon + - comma + - leading_whitespace + - trailing_whitespace + - trailing_newline + - trailing_semicolon + - statement_position + - return_arrow_whitespace + - vertical_whitespace + - void_return + - sorted_imports + - closure_spacing + - trailing_comma # SwiftFormat uses --commas always + + # Optional rules we don't want to enforce + - line_length # SwiftFormat handles line wrapping + - file_length # Can be overly restrictive for valid large files + - type_body_length # Can be overly restrictive + - function_body_length # Can be overly restrictive + - cyclomatic_complexity # Can be too restrictive + - function_parameter_count # Some APIs legitimately need many parameters + +# Opt-in rules (not enabled by default) +# Only enable rules that add clear value without being too noisy +opt_in_rules: + - array_init + - closure_end_indentation + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - discouraged_none_name + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - explicit_init + - fatal_error_message + - file_name_no_space + - first_where + - flatmap_over_map_reduce + - identical_operands + - joined_default_parameter + - last_where + - legacy_multiple + - literal_expression_end_indentation + - lower_acl_than_parent + - overridden_super_call + - override_in_extension + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_super_call + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - sorted_first_last + - toggle_bool + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - yoda_condition + +# Rule configurations +identifier_name: + min_length: + error: 1 + max_length: + warning: 60 + error: 80 + excluded: + - id + - x + - y + - z + - i + - j + - k + - n + - ip + - fd + - ws + - lhs + - rhs + - x1 + - y1 + - x2 + - y2 + +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 + excluded: + - ID + +nesting: + type_level: 2 + function_level: 3 + +large_tuple: + warning: 3 + error: 4 + +# Reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, sonarqube, markdown, github-actions-logging) +reporter: "xcode" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..1dececb38 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AutoMobile + +Bun TypeScript MCP server providing Android & iOS device automation capabilities through its tools and resources. Kotlin & Swift supporting libraries and apps in `android/` and `ios/` respectively. + +## Key Rules +- TypeScript only (no JavaScript) +- After implementation changes, run relevant validation commands +- Write terminal output to `scratch/` when not visible +- Local validation scripts live under `scripts/` and should almost always be written in bash with shellcheck validation +- Always use interfaces & fakes & FakeTimer to decouple implementations and keep tests extremely fast and non-flaky +- Unit tests should pass in 100ms or less. Do not assume that a failing test can be allowed to fail. + +# Project Structure + +This document summarizes the AutoMobile repo layout and where to find key components. + +## Core Code +- `src/` - MCP server source code (TypeScript) +- `test/` - MCP server test code (TypeScript) +- `schemas/` - Generated schemas and tool definitions +- `dist/` - Build output + +## Mobile Platforms +- `android/` - Android Kotlin Gradle project (apps, libraries, IDE plugin) +- `ios/` - Swift packages and Xcode projects + +## Tooling and Automation +- `scripts/` - Local validation and utility scripts +- `benchmark/` - Benchmarks and baselines +- `docs/` - User and developer documentation + +# Build & Validate TypeScript + +Bun is the primary task runner for TypeScript tooling. + +- +-```bash +-bun run build # Compile TypeScript +-bun run lint # Lint with auto-fix (run before manual fixes) +-bun test # Run all tests +-bun test --bail # Stop on first failure +-bun test # Run specific test file +-``` + +# MCP Tools Reference + +This is a high-level summary of core MCP tools exposed by the server. + +## Observation +- `observe` - Capture screen state and view hierarchy + +## Interaction +- `tapOn`, `swipeOn`, `dragAndDrop`, `pinchOn` +- `inputText`, `clearText`, `pressButton`, `pressKey` + +## App Management +- `launchApp`, `terminateApp`, `installApp` + +## Device Management +- `listDevices`, `startDevice`, `killDevice`, `setActiveDevice` + +# Codex specific + +- GitHub interactions use the GitHub CLI (`gh`). +- Create or edit PRs with `gh pr create`/`gh pr edit` using `--body-file` to preserve newlines. +- Android tasks run via the Gradle wrapper from `android/` (e.g., `(cd android && ./gradlew )`). +- Local validations live under `scripts/` (prefer existing scripts over ad-hoc checks). +- Bun tasks are defined in `package.json` (run with `bun run - - - - - - - """ - .trimIndent() -} - -/** Processes code to apply highlighting by wrapping lines in spans with appropriate CSS classes. */ -internal fun processCodeWithHighlighting( - code: String, - highlight: String, - isDarkMode: Boolean -): String { - val codeLines = code.lines() - val highlightLines = highlight.lines().map { it.trim() }.filter { it.isNotEmpty() } - - return codeLines.joinToString("\n") { line -> - val escapedLine = line.replace("<", "<").replace(">", ">") - val isHighlighted = - highlightLines.any { highlightLine -> - line.trim().contains(highlightLine.trim(), ignoreCase = false) - } - - if (isHighlighted) { - "$escapedLine" - } else { - "$escapedLine" - } - } -} - -@Preview(showBackground = true, name = "Light Mode") -@Composable -fun CodeSampleSlideItemPreview() { - MaterialTheme { - CodeSampleSlideItem( - code = - """ - @Test - fun testLoginFlow() { - // Launch the app - tapOn(text = "Login") - - // Enter credentials - inputText("user@example.com") - tapOn(text = "Next") - inputText("password123") - - // Submit login - tapOn(text = "Sign In") - - // Verify success - assertVisible(text = "Welcome") - } - """ - .trimIndent(), - language = "kotlin", - isDarkMode = false) - } -} - -@Preview(showBackground = true, name = "Dark Mode") -@Composable -fun CodeSampleSlideItemDarkPreview() { - MaterialTheme { - CodeSampleSlideItem( - code = - """ - @Test - fun testLoginFlow() { - // Launch the app - tapOn(text = "Login") - - // Enter credentials - inputText("user@example.com") - tapOn(text = "Next") - inputText("password123") - - // Submit login - tapOn(text = "Sign In") - - // Verify success - assertVisible(text = "Welcome") - } - """ - .trimIndent(), - language = "kotlin", - isDarkMode = true) - } -} - -@Preview(showBackground = true, name = "Highlighted Code") -@Composable -fun CodeSampleSlideItemHighlightPreview() { - MaterialTheme { - CodeSampleSlideItem( - code = - """ - keepClearAreas: restricted=[], unrestricted=[] - mPrepareSyncSeqId=0 - - mGlobalConfiguration={1.0 310mcc260mnc [en_US] ldltr sw448dp w997dp h448dp 360dpi nrml long hdr widecg land finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 2244, 1008) mAppBounds=Rect(0, 0 - 2244, 1008) mMaxBounds=Rect(0, 0 - 2244, 1008) mDisplayRotation=ROTATION_90 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_90} s.6257 fontWeightAdjustment=0} - mHasPermanentDpad=false - mTopFocusedDisplayId=0 - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - Minimum task size of display#0 220 mBlurEnabled=true - mLastDisplayFreezeDuration=0 due to new-config - mDisableSecureWindows=false - mHighResSnapshotScale=0.8 - mSnapshotEnabled=true - SnapshotCache Task - """ - .trimIndent(), - language = "shell", - highlight = - """ - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - """ - .trimIndent(), - isDarkMode = true) - } -} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/EmojiSlideItem.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/EmojiSlideItem.kt deleted file mode 100644 index 096b61801..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/EmojiSlideItem.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.zillow.automobile.slides.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.zillow.automobile.slides.model.PresentationEmoji - -/** - * Emoji slide component that displays a large emoji with optional caption. Uses the - * LargeTextSlideItem for consistent auto-resizing behavior. - */ -@Composable -fun EmojiSlideItem( - emoji: PresentationEmoji, - caption: String? = null, - modifier: Modifier = Modifier, - captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant -) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - // Large emoji display - reduced size to account for window insets - Text( - text = emoji.unicode, - fontSize = if (caption != null) 100.sp else 140.sp, - modifier = Modifier.padding(bottom = if (caption != null) 24.dp else 0.dp)) - - // Optional caption with better spacing - caption?.let { - Text( - text = it, - style = - MaterialTheme.typography.headlineMedium.copy( - textAlign = TextAlign.Center, - color = captionColor, - fontWeight = FontWeight.Medium, - lineHeight = 30.sp), - modifier = Modifier.padding(horizontal = 16.dp)) - } - } -} - -@Preview(showBackground = true) -@Composable -fun EmojiSlideItemPreview() { - MaterialTheme { - EmojiSlideItem(emoji = PresentationEmoji.ROCKET, caption = "AutoMobile is Lightning Fast!") - } -} - -@Preview(showBackground = true) -@Composable -fun EmojiSlideItemNoCaption() { - MaterialTheme { EmojiSlideItem(emoji = PresentationEmoji.THINKING) } -} - -@Preview(showBackground = true) -@Composable -fun EmojiSlideItemConstruction() { - MaterialTheme { - EmojiSlideItem(emoji = PresentationEmoji.CONSTRUCTION, caption = "Work in Progress") - } -} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/MermaidDiagramSlideItem.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/MermaidDiagramSlideItem.kt deleted file mode 100644 index 96592a624..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/MermaidDiagramSlideItem.kt +++ /dev/null @@ -1,597 +0,0 @@ -package com.zillow.automobile.slides.components - -import android.util.Log -import android.view.GestureDetector -import android.view.MotionEvent -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.zillow.automobile.design.system.theme.AutoMobileBlack -import com.zillow.automobile.design.system.theme.AutoMobileRed -import com.zillow.automobile.design.system.theme.AutoMobileWhite -import com.zillow.automobile.design.system.theme.PromoBlue -import com.zillow.automobile.design.system.theme.PromoOrange -import kotlinx.coroutines.delay - -/** - * Mermaid diagram slide component that renders interactive diagrams using Mermaid.js. Supports - * dark/light theming with design system colors and optional title/caption text. - */ -@Composable -fun MermaidDiagramSlideItem( - modifier: Modifier = Modifier, - mermaidCode: String, - title: String? = null, - isDarkMode: Boolean = false -) { - - val TAG = "MermaidDiagramSlideItem" - val context = LocalContext.current - var showLoading by remember { mutableStateOf(true) } - var zoomLevel by remember { mutableFloatStateOf(1f) } - var contentWidth by remember { mutableFloatStateOf(0f) } - - val backgroundColor = if (isDarkMode) AutoMobileBlack else AutoMobileWhite - val textColor = if (isDarkMode) AutoMobileWhite else AutoMobileBlack - - // Calculate opacity based on zoom level - fade out when zooming in - val contentOpacity by - animateFloatAsState( - targetValue = - when { - zoomLevel <= 1.2f -> 1f - zoomLevel >= 2f -> 0f - else -> 1f - ((zoomLevel - 1.2f) / 0.8f) // Linear fade between 1.2x and 2x zoom - }, - animationSpec = tween(durationMillis = 300), - label = "contentOpacity") - - // Hide loading overlay after 1 second - LaunchedEffect(Unit) { - delay(1000) - showLoading = false - } - - Column( - modifier = modifier.fillMaxSize().background(backgroundColor).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Optional title - title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium, - color = textColor, - modifier = Modifier.padding(bottom = 16.dp).alpha(contentOpacity)) - } - - // Mermaid diagram - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - AndroidView( - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = true - settings.loadWithOverviewMode = true - settings.useWideViewPort = true - settings.setSupportZoom(true) - settings.builtInZoomControls = true - settings.displayZoomControls = false - - // Enhanced zoom gesture support - settings.allowFileAccess = true - settings.allowContentAccess = true - settings.domStorageEnabled = true - - // Set background color to prevent white flashing - setBackgroundColor(backgroundColor.toArgb()) - - // Add JavaScript interface for zoom tracking - addJavascriptInterface( - object { - @android.webkit.JavascriptInterface - fun onZoomChanged(scale: Float) { - // Post to main thread since this is called from JavaScript thread - post { - zoomLevel = scale - Log.i(TAG, "AutoMobile: JS Zoom level: $scale") - } - } - }, - "Android") - - // Custom WebViewClient to track zoom changes - webViewClient = - object : WebViewClient() { - override fun onScaleChanged( - view: WebView?, - oldScale: Float, - newScale: Float - ) { - super.onScaleChanged(view, oldScale, newScale) - zoomLevel = newScale - Log.i(TAG, "AutoMobile: Zoom changed from $oldScale to $newScale") - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - - // Get content dimensions via JavaScript - view?.postDelayed( - { - // Get the content width - view.evaluateJavascript( - "(function() { return JSON.stringify({width: document.body.scrollWidth, height: document.body.scrollHeight}); })();") { - result -> - try { - val cleanResult = result?.replace("\"", "") ?: "" - if (cleanResult.isNotEmpty() && cleanResult != "null") { - // Parse the JSON-like string to get dimensions - val contentData = - cleanResult.substringAfter("{").substringBefore("}") - val widthStr = - contentData - .substringAfter("width:") - .substringBefore(",") - val parsedContentWidth = widthStr.toFloatOrNull() ?: 0f - - contentWidth = parsedContentWidth - } - } catch (e: Exception) { - Log.i( - TAG, - "AutoMobile: Error parsing content dimensions: ${e.message}") - } - } - }, - 1000) // Slightly longer delay to ensure Mermaid rendering is complete - - // Auto-zoom to fit or 2.5x when page loads - view?.postDelayed( - { - // Get content dimensions via JavaScript - view.evaluateJavascript( - "(function() { return JSON.stringify({width: document.body.scrollWidth, height: document.body.scrollHeight}); })();") { - result -> - try { - val cleanResult = result?.replace("\"", "") ?: "" - if (cleanResult.isNotEmpty() && cleanResult != "null") { - // Parse the JSON-like string to get dimensions - val contentData = - cleanResult.substringAfter("{").substringBefore("}") - val widthStr = - contentData - .substringAfter("width:") - .substringBefore(",") - Log.i(TAG, "widthStr: ${widthStr}") - val contentWidth = widthStr.toFloatOrNull() ?: 0f - Log.i(TAG, "contentWidth: ${contentWidth}") - val viewWidth = view.width.toFloat() - Log.i(TAG, "viewWidth: ${viewWidth}") - - if (contentWidth > 0 && viewWidth > 0) { - // Calculate scale to fit content with some padding - val scaleToFit = (viewWidth * 0.9f) / contentWidth - - // Choose between fit-to-window or 2.5x zoom - val targetZoom = - if (scaleToFit > 1f && scaleToFit < 2.5f) { - scaleToFit // Use fit-to-window if it's reasonable - } else { - 2.5f // Otherwise use 2.5x zoom - } - - // Apply the zoom - view.zoomBy(targetZoom) - zoomLevel = targetZoom - - // Pan to top center after zoom - view.postDelayed( - { - // Use JavaScript to get the actual rendered - // dimensions after zoom - view.evaluateJavascript( - "(function() { return JSON.stringify({width: document.body.scrollWidth, height: document.body.scrollHeight}); })();") { - dimensionResult -> - try { - val cleanDimResult = - dimensionResult?.replace("\"", "") - ?: "" - if (cleanDimResult.isNotEmpty() && - cleanDimResult != "null") { - val dimData = - cleanDimResult - .substringAfter("{") - .substringBefore("}") - val actualWidthStr = - dimData - .substringAfter("width:") - .substringBefore(",") - val actualContentWidth = - actualWidthStr.toFloatOrNull() ?: 0f - val viewWidth = view.width.toFloat() - - // Calculate horizontal scroll to center - // the content - val scrollX = - if (actualContentWidth > - viewWidth) { - ((actualContentWidth - - viewWidth) / 2) - .toInt() - } else { - 0 // Content fits in view, no - // horizontal scroll needed - } - - val scrollY = 0 // Top of the content - - Log.i( - TAG, - "view.scrollTo(scrollX, scrollY) (${scrollX}, ${scrollY})") - view.scrollTo(scrollX, scrollY) - Log.i( - TAG, - "AutoMobile: Panned to top center ($scrollX, $scrollY) - content: $actualContentWidth, view: $viewWidth") - } else { - // Fallback: try to center using initial - // calculation - val scaledContentWidth = - (contentWidth * targetZoom).toInt() - val viewWidth = view.width - val scrollX = - maxOf( - 0, - (scaledContentWidth - - viewWidth) / 2) - Log.i( - TAG, - "view.scrollTo(scrollX, 0) (${scrollX}, 0)") - view.scrollTo(scrollX, 0) - Log.i( - TAG, - "AutoMobile: Panned to top center (calc fallback) ($scrollX, 0)") - } - } catch (e: Exception) { - // Last resort: try basic centering - val scaledContentWidth = - (contentWidth * targetZoom).toInt() - val viewWidth = view.width - val scrollX = - maxOf( - 0, - (scaledContentWidth - viewWidth) / - 2) - Log.i( - TAG, - "view.scrollTo(scrollX, 0) (${scrollX}, 0)") - view.scrollTo(scrollX, 0) - Log.i( - TAG, - "AutoMobile: Panned to top center (error fallback) ($scrollX, 0): ${e.message}") - } - } - }, - 200) // Small delay after zoom to ensure it's - // applied - - Log.i( - TAG, - "AutoMobile: Auto-zoomed to $targetZoom (scaleToFit: $scaleToFit)") - } else { - // Fallback to 2.5x - view.zoomBy(2.5f) - zoomLevel = 2.5f - - // Pan to top center after zoom - view.postDelayed( - { - Log.i( - TAG, - "view.scrollTo(scrollX, 0) (${scrollX}, 0)") - view.scrollTo(scrollX, 0) - - val scrollX = - maxOf(0, (view.width * 0.75).toInt()) - Log.i( - TAG, - "view.scrollTo(view.width / 2, 0) ($scrollX 0)") - view.scrollTo(scrollX, 0) - Log.i( - TAG, - "AutoMobile: Panned to top center (fallback) ($scrollX, 0)") - }, - 200) - - Log.i( - TAG, - "AutoMobile: Auto-zoomed to 2.5x (dimensions fallback)") - } - } else { - // Fallback to 2.5x if JavaScript fails - view.zoomBy(2.5f) - zoomLevel = 2.5f - - // Pan to top center after zoom - view.postDelayed( - { - Log.i( - TAG, - "view.scrollTo(view.width / 2, 0) (${view.width / 2} 0)") - view.scrollTo(view.width / 2, 0) - Log.i( - TAG, - "AutoMobile: Panned to top center (JS fallback") - }, - 200) - - Log.i( - TAG, "AutoMobile: Auto-zoomed to 2.5x (JS fallback)") - } - } catch (e: Exception) { - // Fallback to 2.5x if parsing fails - view.zoomBy(2.5f) - zoomLevel = 2.5f - - // Pan to top center after zoom - view.postDelayed( - { - val scaledContentWidth = (contentWidth * 2.5f).toInt() - val viewWidth = view.width - val scrollX = - maxOf(0, (scaledContentWidth - viewWidth) / 2) - Log.i( - TAG, "view.scrollTo(scrollX, 0) (${scrollX}, 0)") - view.scrollTo(scrollX, 0) - Log.i( - TAG, - "AutoMobile: Panned to top center (error fallback) ($scrollX, 0): ${e.message}") - }, - 200) - - Log.i( - TAG, - "AutoMobile: Auto-zoomed to 2.5x (error fallback): ${e.message}") - } - } - }, - 1000) // Slightly longer delay to ensure Mermaid rendering is complete - - // Inject JavaScript to monitor zoom changes more reliably - view?.evaluateJavascript( - """ - (function() { - let lastScale = 1; - function checkZoom() { - const currentScale = window.outerWidth / window.innerWidth; - if (Math.abs(currentScale - lastScale) > 0.1) { - lastScale = currentScale; - Android.onZoomChanged(currentScale); - } - } - setInterval(checkZoom, 100); - })(); - """, - null) - } - } - - // Double tap to zoom gesture detector - val gestureDetector = - GestureDetector( - context, - object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent): Boolean { - val currentZoom = zoomLevel - val targetZoom = - if (currentZoom > 1.5f) { - 1f // Zoom out to fit - } else { - 2.5f // Zoom in - } - - // Use zoomBy for smooth transition - val zoomFactor = targetZoom / currentZoom - zoomBy(zoomFactor) - - // Update zoom level immediately for responsive UI - zoomLevel = targetZoom - Log.i( - TAG, - "AutoMobile: Double tap zoom from $currentZoom to $targetZoom") - - return true - } - }) - - // Set touch listener for double tap detection - setOnTouchListener { view, event -> - gestureDetector.onTouchEvent(event) - false // Let WebView handle other touch events normally - } - - val htmlContent = - createMermaidDiagramHtml( - mermaidCode = mermaidCode, - isDarkMode = isDarkMode, - backgroundColor = backgroundColor, - textColor = textColor) - - loadDataWithBaseURL( - "https://cdn.jsdelivr.net/", htmlContent, "text/html", "UTF-8", null) - } - }, - modifier = Modifier.fillMaxSize()) - - // Loading overlay - if (showLoading) { - Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } - } - } -} - -/** Creates HTML content with Mermaid.js diagram rendering using design system colors. */ -private fun createMermaidDiagramHtml( - mermaidCode: String, - isDarkMode: Boolean, - backgroundColor: Color, - textColor: Color -): String { - val bgColorHex = String.format("#%06X", 0xFFFFFF and backgroundColor.toArgb()) - val textColorHex = String.format("#%06X", 0xFFFFFF and textColor.toArgb()) - - // Design system colors - val autoMobileRedHex = String.format("#%06X", 0xFFFFFF and AutoMobileRed.toArgb()) - val orangeHex = String.format("#%06X", 0xFFFFFF and PromoOrange.toArgb()) - val blueHex = String.format("#%06X", 0xFFFFFF and PromoBlue.toArgb()) - - // Mermaid theme configuration - val theme = if (isDarkMode) "dark" else "default" - - return """ - - - - - - - - - -
- ${mermaidCode.trim()} -
- - - - """ - .trimIndent() -} - -@Preview(showBackground = true, name = "Flowchart Light") -@Composable -fun MermaidDiagramSlideItemFlowchartPreview() { - MaterialTheme { - MermaidDiagramSlideItem( - title = "AutoMobile Test Flow", - mermaidCode = - """ - flowchart TD - A[Start Test] --> B{Launch App} - B -->|Success| C[Execute Actions] - B -->|Fail| D[Report Error] - C --> E[Verify Results] - E -->|Pass| F[Test Complete] - E -->|Fail| G[Capture Screenshot] - G --> H[Report Failure] - """ - .trimIndent(), - isDarkMode = false) - } -} - -@Preview(showBackground = true, name = "Sequence Dark") -@Composable -fun MermaidDiagramSlideItemSequencePreview() { - MaterialTheme { - MermaidDiagramSlideItem( - title = "Test Interaction Sequence", - mermaidCode = - """ - sequenceDiagram - participant T as Test - participant A as App - participant U as UI Element - participant S as System - - T->>A: Launch App - A->>U: Render UI - T->>U: Tap Button - U->>S: Trigger Action - S-->>A: Update State - A->>U: Update Display - T->>U: Assert Visible - U-->>T: Verification Result - """ - .trimIndent(), - isDarkMode = true) - } -} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/ScreenshotSlideItem.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/ScreenshotSlideItem.kt deleted file mode 100644 index 698eb5765..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/ScreenshotSlideItem.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.zillow.automobile.slides.components - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter - -/** - * Screenshot slide component that displays app screenshots with day/night theme support. - * Automatically selects the appropriate screenshot based on the current theme. Falls back to the - * available resource if theme-specific version doesn't exist. - */ -@Composable -fun ScreenshotSlideItem( - @DrawableRes lightScreenshot: Int? = null, - @DrawableRes darkScreenshot: Int? = null, - caption: String? = null, - contentDescription: String? = null, - modifier: Modifier = Modifier, - captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - forceTheme: Boolean? = null // For testing - null uses system theme -) { - val isDarkTheme = forceTheme ?: isSystemInDarkTheme() - - // Select appropriate screenshot based on theme and availability - val screenshotRes = - when { - isDarkTheme && darkScreenshot != null -> darkScreenshot - !isDarkTheme && lightScreenshot != null -> lightScreenshot - darkScreenshot != null -> darkScreenshot - lightScreenshot != null -> lightScreenshot - else -> null - } - - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Screenshot display - Card( - modifier = Modifier.weight(1f).fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (screenshotRes != null) { - AsyncImage( - model = screenshotRes, - contentDescription = contentDescription ?: caption, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Fit, - onState = { state -> - when (state) { - is AsyncImagePainter.State.Loading -> { - // Loading indicator will be shown by the Box below - } - is AsyncImagePainter.State.Error -> { - // Error state handled by placeholder in Box below - } - is AsyncImagePainter.State.Success -> { - // Screenshot loaded successfully - } - else -> { - // Other states - } - } - }) - } else { - // No screenshot available - ScreenshotErrorState() - } - } - } - - // Caption - caption?.let { - Text( - text = it, - style = - MaterialTheme.typography.headlineSmall.copy( - textAlign = TextAlign.Center, - color = captionColor, - fontWeight = FontWeight.Medium), - modifier = Modifier.padding(horizontal = 16.dp)) - } - } -} - -/** Error state component for when no screenshot is available. */ -@Composable -private fun ScreenshotErrorState(modifier: Modifier = Modifier) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - Text( - text = "📱", - style = MaterialTheme.typography.displayMedium, - modifier = Modifier.padding(bottom = 8.dp)) - Text( - text = "No screenshot available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center) - } -} - -@Preview(showBackground = true, name = "Light Theme") -@Composable -fun ScreenshotSlideItemLightPreview() { - MaterialTheme { - ScreenshotSlideItem( - lightScreenshot = android.R.drawable.ic_menu_gallery, - darkScreenshot = android.R.drawable.ic_menu_camera, - caption = "App Screenshot - Light Mode", - contentDescription = "Screenshot showing the app in light mode", - forceTheme = false) - } -} - -@Preview(showBackground = true, name = "Dark Theme") -@Composable -fun ScreenshotSlideItemDarkPreview() { - MaterialTheme { - ScreenshotSlideItem( - lightScreenshot = android.R.drawable.ic_menu_gallery, - darkScreenshot = android.R.drawable.ic_menu_camera, - caption = "App Screenshot - Dark Mode", - contentDescription = "Screenshot showing the app in dark mode", - forceTheme = true) - } -} - -@Preview(showBackground = true, name = "Light Only") -@Composable -fun ScreenshotSlideItemLightOnlyPreview() { - MaterialTheme { - ScreenshotSlideItem( - lightScreenshot = android.R.drawable.ic_menu_gallery, - caption = "App Screenshot - Light Only Available", - contentDescription = "Screenshot available only in light mode") - } -} - -@Preview(showBackground = true, name = "No Screenshots") -@Composable -fun ScreenshotSlideItemNoScreenshotsPreview() { - MaterialTheme { - ScreenshotSlideItem( - caption = "Missing Screenshot", contentDescription = "No screenshots available") - } -} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/VideoPlayerSlideItem.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/VideoPlayerSlideItem.kt deleted file mode 100644 index b5e61e090..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/VideoPlayerSlideItem.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.zillow.automobile.slides.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.PlayerView - -/** - * Video player slide component using ExoPlayer with proper lifecycle management. Auto-pauses when - * navigating away from the slide. - */ -@Composable -fun VideoPlayerSlideItem( - videoUrl: String, - caption: String? = null, - contentDescription: String? = null, - modifier: Modifier = Modifier, - captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - autoPlay: Boolean = false -) { - val context = LocalContext.current - - val exoPlayer = remember { - ExoPlayer.Builder(context).build().apply { - val mediaItem = MediaItem.fromUri(videoUrl) - setMediaItem(mediaItem) - prepare() - playWhenReady = autoPlay - } - } - - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Video player - Card( - modifier = Modifier.weight(1f).fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)) { - AndroidView( - factory = { context -> - PlayerView(context).apply { - player = exoPlayer - useController = true - setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) - controllerAutoShow = true - controllerHideOnTouch = false - setShutterBackgroundColor(android.graphics.Color.TRANSPARENT) - } - }, - modifier = Modifier.fillMaxSize()) - } - - // Caption - caption?.let { - Text( - text = it, - style = - MaterialTheme.typography.headlineSmall.copy( - textAlign = TextAlign.Center, - color = captionColor, - fontWeight = FontWeight.Medium), - modifier = Modifier.padding(horizontal = 16.dp)) - } - } - - // Lifecycle management - DisposableEffect(exoPlayer) { onDispose { exoPlayer.release() } } -} - -/** Simplified video player for preview purposes. */ -@Composable -private fun VideoPlayerPreview(caption: String? = null, modifier: Modifier = Modifier) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Video placeholder - Card( - modifier = Modifier.weight(1f).fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - Text( - text = "🎬", - style = MaterialTheme.typography.displayLarge, - modifier = Modifier.padding(bottom = 16.dp)) - Text( - text = "Video Player", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - // Caption - caption?.let { - Text( - text = it, - style = - MaterialTheme.typography.headlineSmall.copy( - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium), - modifier = Modifier.padding(horizontal = 16.dp)) - } - } -} - -@Preview(showBackground = true) -@Composable -fun VideoPlayerSlideItemPreview() { - MaterialTheme { VideoPlayerPreview(caption = "AutoMobile Demo: Testing a Shopping App") } -} - -@Preview(showBackground = true) -@Composable -fun VideoPlayerSlideItemNoCaptionPreview() { - MaterialTheme { VideoPlayerPreview() } -} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/VisualizationSlideItem.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/VisualizationSlideItem.kt deleted file mode 100644 index b0ac99c68..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/VisualizationSlideItem.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.zillow.automobile.slides.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter - -/** - * Visualization slide component for displaying images with loading states. Supports both local and - * remote images using Coil with proper error handling. - */ -@Composable -fun VisualizationSlideItem( - imageUrl: String, - caption: String? = null, - contentDescription: String? = null, - modifier: Modifier = Modifier, - captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant -) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - // Image display - Card( - modifier = Modifier.weight(1f).fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AsyncImage( - model = imageUrl, - contentDescription = contentDescription ?: caption, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Fit, - onState = { state -> - when (state) { - is AsyncImagePainter.State.Loading -> { - // Loading indicator will be shown by the Box below - } - - is AsyncImagePainter.State.Error -> { - // Error state handled by placeholder in Box below - } - - is AsyncImagePainter.State.Success -> { - // Image loaded successfully - } - - else -> { - // Other states - } - } - }) - - // Loading indicator overlay - CircularProgressIndicator( - modifier = Modifier.size(48.dp), color = MaterialTheme.colorScheme.primary) - } - } - - // Caption - caption?.let { - Text( - text = it, - style = - MaterialTheme.typography.headlineSmall.copy( - textAlign = TextAlign.Center, - color = captionColor, - fontWeight = FontWeight.Medium), - modifier = Modifier.padding(horizontal = 16.dp)) - } - } -} - -/** Error state component for failed image loads. */ -@Composable -private fun ImageErrorState(modifier: Modifier = Modifier) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - Text( - text = "📷", - style = MaterialTheme.typography.displayMedium, - modifier = Modifier.padding(bottom = 8.dp)) - Text( - text = "Unable to load image", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center) - } -} - -@Preview(showBackground = true) -@Composable -fun VisualizationSlideItemPreview() { - MaterialTheme { - VisualizationSlideItem( - imageUrl = "https://example.com/architecture-diagram.png", - caption = "AutoMobile Architecture Overview", - contentDescription = - "Diagram showing AutoMobile's architecture with Android and iOS components") - } -} - -@Preview(showBackground = true) -@Composable -fun VisualizationSlideItemNoCaptionPreview() { - MaterialTheme { VisualizationSlideItem(imageUrl = "https://example.com/screenshot.png") } -} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/01-IntroductionSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/01-IntroductionSlides.kt deleted file mode 100644 index dc741e80a..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/01-IntroductionSlides.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -/** Slides for Introduction to AutoMobile? */ -fun getIntroductionSlides(): List = - listOf( - SlideContent.LargeText(title = "AutoMobile", subtitle = "Jason Pearson @ Zillow"), - SlideContent.Emoji(emoji = PresentationEmoji.PROGRAMMER, caption = "Who am I?"), - - // Swipe screen to show promo video - - SlideContent.BulletPoints( - title = "UI testing up until now", - points = - listOf( - BulletPoint(text = "Manual"), - BulletPoint(text = "Automated"), - )), - SlideContent.LargeText( - title = "AutoMobile is a set of tools for automating mobile engineering"), - SlideContent.BulletPoints( - title = "AutoMobile includes:", - points = - listOf( - BulletPoint("MCP server that doubles as a CLI tool"), - BulletPoint("A Kotlin test authoring Clikt app"), - BulletPoint("A custom JUnitRunner"), - BulletPoint("Accessibility service to expose data quickly"), - // TODO: Uncomment if koog lands BulletPoint("An agentic loop for intelligently - // self-healing tests") - )), - SlideContent.BulletPoints( - title = "How does it work?", - points = - listOf( - BulletPoint(text = "Works on any Android debug or prod app"), - BulletPoint(text = "Directly uses Android platform tools"), - BulletPoint(text = "Indexes your project source code"), - BulletPoint(text = "Can write tests for you"), - )), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/02-OriginSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/02-OriginSlides.kt deleted file mode 100644 index 3951038d7..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/02-OriginSlides.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -/** Slides for Introduction to AutoMobile? */ -fun getOriginSlides(): List = - listOf( - // - Origin Story - // - Was looking into open source MCP servers because I knew from early experiences that - // they could be powerful - // - Didn't find what I was looking for - // - Most MCP tool calls were wrapper implementations of existing tools - // - Diagram of screenshot, tap, screenshot, swipe with AI agent. That's like throwing - // instructions at someone who has never used a mobile phone before - // - What if instead I looked at it from the perspective of someone who knows how to - // navigate mobile devices? After all we'd all love to have a UI testing tool that could - // innately understand that. - // - Big text slides - // - Open recent apps - // - Swipe down to see notifications - // - Double tap on text, tap "Select All", tap "Cut" or press Delete key to clear - // text field - // - Mermaid diagram showing system design of automatic observation on interaction - SlideContent.LargeText(title = "Origin Story"), - // SlideContent.Emoji( - // emoji = PresentationEmoji.DOLPHIN, caption = "Flipper got deprecated in - // 2024"), - SlideContent.Emoji( - emoji = PresentationEmoji.PROGRAMMER, - caption = "I got tasked with looking at OSS AI tools for UI testing"), - SlideContent.Emoji( - emoji = PresentationEmoji.SHRUG, caption = "Didn't find what I was looking for"), - SlideContent.Emoji( - emoji = PresentationEmoji.GIFT, caption = "Most MCP servers are just thin wrappers"), - - // - Mermaid Diagram of screenshot, tap, screenshot, swipe with AI agent. That's like - // throwing instructions at someone who has never used a mobile phone before - - SlideContent.LargeText(title = "Screenshot"), - SlideContent.LargeText(title = "Tap"), - SlideContent.LargeText(title = "Screenshot"), - SlideContent.LargeText(title = "Swipe"), - SlideContent.Emoji( - emoji = PresentationEmoji.THINKING, - caption = "What if instead I made tool calls the way we navigate mobile devices?"), - SlideContent.LargeText( - title = "Open recent apps", - subtitle = - "Depending on device settings either swipe up from bottom edge or tap the recent apps button"), - SlideContent.LargeText( - title = "Looking at notifications", - subtitle = "Swipe down on system bar, scroll to find relevant icon/text"), - // SlideContent.LargeText( - // title = "Selecting text", - // subtitle = - // "Double tap on text, tap \"Select All\", tap \"Cut\" or press Delete key - // to clear text field"), - SlideContent.Emoji( - emoji = PresentationEmoji.THINKING, - caption = "What if I provided the AI agent with the exact relevant context it needs?"), - SlideContent.MermaidDiagram( - title = "Automatic observation on interaction", - code = - """ - sequenceDiagram - participant Agent as AI Agent - participant MCP as MCP Server - participant Device as Device - - Agent->>MCP: 🤖 Interaction Request - MCP->>Device: 👀 Observe - Device-->>MCP: 📱 UI State/Data (Cached) - - MCP->>Device: ⚡ Execute Actions - Device-->>MCP: ✅ Result - - MCP->>Device: 👀 Observe - Device-->>MCP: 📱 UI State/Data - MCP-->>Agent: 🔄 Interaction Response with UI State - """ - .trimIndent()), - SlideContent.Emoji( - emoji = PresentationEmoji.FIRE, - caption = "Leveraged Firebender & Claude to quickly iterate")) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/03-EarlySuccessSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/03-EarlySuccessSlides.kt deleted file mode 100644 index 7952b6847..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/03-EarlySuccessSlides.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -/** Slides for Introduction to AutoMobile? */ -fun getEarlySuccessSlides(): List = - listOf( - SlideContent.LargeText(title = "Early Success & Demos"), - - // TODO: demo slide, run clock test that returns to automobile - SlideContent.Emoji(emoji = PresentationEmoji.CLOCK, caption = "Clock app set alarm demo"), - SlideContent.BulletPoints( - title = "Quickly iterated to explore all of Zillow", - points = - listOf( - BulletPoint(text = "Tons of different form fields and UX patterns"), - BulletPoint(text = "Edge cases in parsing active windows"), - )), - - // TODO: demo slide, run Zillow test that returns to automobile - SlideContent.Emoji(emoji = PresentationEmoji.HOME, caption = "Zillow full feature demo"), - SlideContent.Emoji(emoji = PresentationEmoji.GLOBE, caption = "Decided to pursue OSS"), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/04-ViewHierarchyCacheSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/04-ViewHierarchyCacheSlides.kt deleted file mode 100644 index fa1361891..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/04-ViewHierarchyCacheSlides.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -fun getViewHierarchyCacheSlides(): List = - listOf( - SlideContent.Emoji(emoji = PresentationEmoji.SLOW, caption = "adb View Hierarchy is slow"), - SlideContent.Emoji(emoji = PresentationEmoji.PICTURE, caption = "pHash + fuzzy matching"), - SlideContent.MermaidDiagram( - title = "View Hierarchy Cache System", - code = - """ -flowchart LR - A["Observe()"] --> B["Screenshot
+dHash"]; - B --> C{"hash
match?"}; - C -->|"✅"| D["pixelmatch"]; - C -->|"❌"| E["uiautomator dump"]; - D --> F{>99.8%?}; - F -->|"✅"| G["Return"]; - F -->|"❌"| E; - E --> H["Cache"]; - H --> I["Return New Hierarchy"]; -classDef decision fill:#FF3300,stroke-width:0px,color:white; -classDef logic fill:#525FE1,stroke-width:0px,color:white; -classDef result stroke-width:0px; -class A,G,I result; -class D,E,H logic; -class B,C,F decision; - """ - .trimIndent()), - SlideContent.MermaidDiagram( - title = "View Hierarchy Cache System", - code = - """ -flowchart LR -A["Observe()"] --> B{"installed?"}; -B -->|"✅"| C{"running?"}; -B -->|"❌"| E["caching system"]; -C -->|"✅"| D["cat vh.json"]; -C -->|"❌"| E["uiautomator dump"]; -D --> I["Return"] -E --> I; -classDef decision fill:#FF3300,stroke-width:0px,color:white; -classDef logic fill:#525FE1,stroke-width:0px,color:white; -classDef result stroke-width:0px; -class A,G,I result; -class D,E,H logic; -class B,C,F decision; - """ - .trimIndent()), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/05-EnvSetupSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/05-EnvSetupSlides.kt deleted file mode 100644 index b03661bb5..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/05-EnvSetupSlides.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -fun getMcpLearningsSlides(): List = - listOf( - SlideContent.LargeText(title = "MCP Learnings"), - - // request action - // action attempts to find current device session - // if no session look for active devices - // if no active devices look for avds - // if no avds create one that matches project configuration latest target API - SlideContent.MermaidDiagram( - title = "Automatic Device Session", - code = - """ - flowchart LR - A([Tool]) --> B{Find Session} - B -->|"❌"| C{Find Active Device} - B -->|"✅"| D([Device Session]) - C -->|"❌"| E{Find AVD} - C -->|"✅"| D - E -->|"❌"| F([Create AVD]) - F --> D - - classDef decision fill:#FF3300,stroke-width:0px,color:white; - classDef logic fill:#525FE1,stroke-width:0px,color:white; - classDef result stroke-width:0px; - class A,G,I result; - class B,D,E,H logic; - class C,F decision; - """ - .trimIndent()), - SlideContent.Emoji( - emoji = PresentationEmoji.LAPTOP, - caption = "Automatic Android Platform Tool Installation"), - SlideContent.Emoji( - emoji = PresentationEmoji.TOOLS, - caption = "The mere presence of tools suggests agents should invoke them"), - SlideContent.Emoji( - emoji = PresentationEmoji.GLOBE, - caption = - "The emergent behavior of the workflow is more important than providing a complete solution"), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/06-SourceMappingSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/06-SourceMappingSlides.kt deleted file mode 100644 index 9095371c4..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/06-SourceMappingSlides.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -fun getSourceMappingSlides(): List = - listOf( - // - Source mapping - // - Mention the inspiration - // - Find the activity - // - Find the fragment - // - Find the composable - // - WebView / ReactNative support is possible, after - // - Configurable by looking at - // - root common module of plurality of composables (default) - // - the plurality of composables - // - ignore composables and just goto activity/fragment - // - application module - SlideContent.LargeText(title = "Source Mapping"), - SlideContent.Emoji( - emoji = PresentationEmoji.LIGHTBULB, caption = "React Dev Tools inspired"), - SlideContent.Emoji( - emoji = PresentationEmoji.WINDOWS, caption = "adb shell dumpsys window windows"), - SlideContent.CodeSample( - code = - """ - keepClearAreas: restricted=[], unrestricted=[] - mPrepareSyncSeqId=0 - - mGlobalConfiguration={1.0 310mcc260mnc [en_US] ldltr sw448dp w997dp h448dp 360dpi nrml long hdr widecg land finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 2244, 1008) mAppBounds=Rect(0, 0 - 2244, 1008) mMaxBounds=Rect(0, 0 - 2244, 1008) mDisplayRotation=ROTATION_90 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_90} s.6257 fontWeightAdjustment=0} - mHasPermanentDpad=false - mTopFocusedDisplayId=0 - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - Minimum task size of display#0 220 mBlurEnabled=true - mLastDisplayFreezeDuration=0 due to new-config - mDisableSecureWindows=false - mHighResSnapshotScale=0.8 - mSnapshotEnabled=true - SnapshotCache Task - """ - .trimIndent(), - language = "shell"), - SlideContent.Emoji( - emoji = PresentationEmoji.ONE, caption = "Find the activity with exact package"), - SlideContent.Emoji( - emoji = PresentationEmoji.TWO, caption = "Find the fragment, approximate package"), - SlideContent.Emoji( - emoji = PresentationEmoji.THREE, - caption = "Find the Composables via string associations"), - SlideContent.Emoji(emoji = PresentationEmoji.RUST, caption = "ripgrep is our friend"), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/07-TestAuthoringExecutionSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/07-TestAuthoringExecutionSlides.kt deleted file mode 100644 index fea1326b9..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/07-TestAuthoringExecutionSlides.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -/** Slides for Introduction to AutoMobile? */ -fun getTestAuthoringExecutionSlides(): List = - listOf( - // - Test Authoring & Execution - // - yaml plan because - // - I want to support iOS in the same tool - // - easy to record/replay - // - JUnitRunner Android library - // - Live demo of running a test to run through the AutoMobile Playground app and - // resume the slideshow - // - User credentials & Experiments - // - Code example of test with credentials - // - Live demo of login in AutoMobile Playground - // - Code example of experiment and treatment with credentials - // - Live demo of control vs party mode in AutoMobile Playground - SlideContent.LargeText(title = "Test Authoring & Execution"), - SlideContent.CodeSample( - title = "YAML Plan Sample", - code = - """ ---- -name: set-alarm-6-30am-demo-mode -description: Create 6:30 AM alarm in Clock app -steps: - - tool: launchApp - appId: com.google.android.deskclock - forceCold: true - clearPackageData: true - - - tool: tapOn - text: "Alarm" - - - tool: tapOn - id: "com.google.android.deskclock:id/fab" - - - tool: tapOn - text: "6" - - - tool: tapOn - text: "30" - - - tool: tapOn - text: "OK" - - """ - .trimIndent(), - language = "yaml"), - SlideContent.BulletPoints( - title = "AutoMobile JUnitRunner", - points = - listOf( - BulletPoint(text = "Android library"), - BulletPoint(text = "JUnit4 prioritized with JUnit5 compatibility"), - BulletPoint(text = "Runs AutoMobile in CLI mode until failure"), - // TODO: Update when koog lands - BulletPoint(text = "koog integration for self healing (untested)"), - )), - SlideContent.Emoji( - emoji = PresentationEmoji.PLAYGROUND, - caption = "Demo: AutoMobile Playground", - ), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/08-AutomaticTestAuthoringSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/08-AutomaticTestAuthoringSlides.kt deleted file mode 100644 index 04931d411..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/08-AutomaticTestAuthoringSlides.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -fun getAutomaticTestAuthoringSlides(): List = - listOf( - SlideContent.LargeText(title = "Automatic Test Authoring"), - SlideContent.BulletPoints( - title = "Kotlin Test Author Clikt app", - points = - listOf( - BulletPoint( - text = - "Writes Kotlin test files with KotlinPoet + public modifier scrubbing"), - BulletPoint(text = "Highly configurable to put tests in the right place"), - )), - SlideContent.CodeSample( - title = "Environment Credentials Example", - code = - """ - @Test - fun `given valid credentials, login should succeed`() { - val result = AutoMobilePlan("test-plans/login.yaml", { - "username" to "jason@zillow.com" - "password" to "hunter2" - }).execute() - assertTrue(result.status) - } - """ - .trimIndent(), - language = "kotlin"), - SlideContent.Emoji(emoji = PresentationEmoji.THINKING, caption = "Why is this important?"), - SlideContent.Emoji(emoji = PresentationEmoji.EASY, caption = "Allows for easier adoption"), - SlideContent.Emoji( - emoji = PresentationEmoji.FAST, caption = "Allows for easier parallelization"), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/09-DevWorkflowAssist.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/09-DevWorkflowAssist.kt deleted file mode 100644 index 5da8e1cdc..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/09-DevWorkflowAssist.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -fun getDevWorkflowAssistSlides(): List = - listOf( - SlideContent.LargeText(title = "Dev Workflow Assistance"), - SlideContent.BulletPoints( - title = "Why is this important?", - points = - listOf( - BulletPoint(text = "Constantly seeking ways to be more productive"), - BulletPoint( - text = - "Hope we're always applying all the deep technical knowledge for all the things"), - )), - SlideContent.Emoji( - emoji = PresentationEmoji.NEW_EMPLOYEE, - caption = "Demo: Onboarding a new dev", - ), - SlideContent.Emoji( - emoji = PresentationEmoji.MAGNIFYING_GLASS, - caption = "Demo: Identifying UI issues", - ), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/10-VisionSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/10-VisionSlides.kt deleted file mode 100644 index 37e895644..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/10-VisionSlides.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.R -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent - -fun getVisionSlides(): List = - listOf( - SlideContent.LargeText(title = "Vision"), - SlideContent.LargeText(title = "Built-in opt-in testing"), - SlideContent.Emoji( - emoji = PresentationEmoji.ACCESSIBILITY, caption = "Accessibility Testing"), - SlideContent.Emoji( - emoji = PresentationEmoji.SECURE, caption = "Security and Privacy Testing"), - SlideContent.Emoji(emoji = PresentationEmoji.FAST, caption = "Performance Testing"), - SlideContent.Emoji( - emoji = PresentationEmoji.DATA_TRANSFER, caption = "State Capture and Restoration"), - SlideContent.Emoji( - emoji = PresentationEmoji.TOOLBOX, caption = "We can build a better toolbox"), - SlideContent.LargeText(title = "Project Status"), - SlideContent.LargeText(title = "Works on all Android apps today"), - SlideContent.LargeText(title = "iOS support prototyped"), - SlideContent.BulletPoints( - title = "Parameterization of everything", - points = - listOf( - BulletPoint(text = "Day/Night"), - BulletPoint(text = "Portrait/Landscape"), - BulletPoint(text = "API Levels"), - BulletPoint(text = "Input devices"), - )), - SlideContent.LargeText(title = "Self healing soon"), - SlideContent.BulletPoints( - title = "Android MCP SDK which is also OSS", - points = - listOf( - BulletPoint(text = "Tools: View Hierarchy, Storage, Network"), - BulletPoint(text = "Resources: App Resources, Filesystem"), - BulletPoint(text = "https://github.com/kaeawc/android-mcp-sdk"), - )), - SlideContent.Screenshot( - title = "Questions?", - lightScreenshot = R.drawable.auto_mobile_qr_code, - caption = "https://www.jasonpearson.dev"), - ) diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/AllSlides.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/AllSlides.kt deleted file mode 100644 index 558d0fbfa..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/data/AllSlides.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.zillow.automobile.slides.data - -import com.zillow.automobile.slides.model.SlideContent - -/** - * Combined slides data for the complete AutoMobile presentation. Combines all individual slide - * sections into one comprehensive presentation. - */ -fun getAllSlides(): List = - getIntroductionSlides() + - getOriginSlides() + - getEarlySuccessSlides() + - listOf(SlideContent.LargeText(title = "Optimizations & Automations")) + - getViewHierarchyCacheSlides() + - getMcpLearningsSlides() + - getSourceMappingSlides() + - getTestAuthoringExecutionSlides() + - getAutomaticTestAuthoringSlides() + - getDevWorkflowAssistSlides() + - getVisionSlides() diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/examples/HighlightingExample.kt b/android/playground/slides/src/main/java/com/zillow/automobile/slides/examples/HighlightingExample.kt deleted file mode 100644 index d8485a5cf..000000000 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/examples/HighlightingExample.kt +++ /dev/null @@ -1,192 +0,0 @@ -package com.zillow.automobile.slides.examples - -import com.zillow.automobile.slides.model.SlideContent - -/** - * Example usage of the new CodeSample highlighting feature. This demonstrates how to highlight - * specific lines in code samples for presentations. - */ -object HighlightingExample { - - /** Example 1: Highlighting specific lines in Android log output */ - fun createWindowManagerLogSlide(): SlideContent.CodeSample { - return SlideContent.CodeSample( - code = - """ - keepClearAreas: restricted=[], unrestricted=[] - mPrepareSyncSeqId=0 - - mGlobalConfiguration={1.0 310mcc260mnc [en_US] ldltr sw448dp w997dp h448dp 360dpi nrml long hdr widecg land finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 2244, 1008) mAppBounds=Rect(0, 0 - 2244, 1008) mMaxBounds=Rect(0, 0 - 2244, 1008) mDisplayRotation=ROTATION_90 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_90} s.6257 fontWeightAdjustment=0} - mHasPermanentDpad=false - mTopFocusedDisplayId=0 - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - Minimum task size of display#0 220 mBlurEnabled=true - mLastDisplayFreezeDuration=0 due to new-config - mDisableSecureWindows=false - mHighResSnapshotScale=0.8 - mSnapshotEnabled=true - SnapshotCache Task - """ - .trimIndent(), - language = "shell", - title = "Window Manager Debug Output", - highlight = - """ - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - """ - .trimIndent()) - } - - /** Example 2: Highlighting key test methods in Kotlin code */ - fun createTestCodeSlide(): SlideContent.CodeSample { - return SlideContent.CodeSample( - code = - """ - @Test - fun testLoginFlow() { - // Launch the app - tapOn(text = "Login") - - // Enter credentials - inputText("user@example.com") - tapOn(text = "Next") - inputText("password123") - - // Submit login - tapOn(text = "Sign In") - - // Verify success - assertVisible(text = "Welcome") - } - - @Test - fun testLogoutFlow() { - // Navigate to settings - tapOn(text = "Settings") - - // Tap logout - tapOn(text = "Logout") - - // Confirm logout - tapOn(text = "Confirm") - - // Verify back to login screen - assertVisible(text = "Login") - } - """ - .trimIndent(), - language = "kotlin", - title = "AutoMobile Test Examples", - highlight = - """ - tapOn(text = "Login") - inputText("user@example.com") - tapOn(text = "Sign In") - assertVisible(text = "Welcome") - """ - .trimIndent()) - } - - /** Example 3: Highlighting configuration changes in YAML */ - fun createConfigurationSlide(): SlideContent.CodeSample { - return SlideContent.CodeSample( - code = - """ - # AutoMobile Test Plan - name: "User Login Flow" - version: "1.0" - - steps: - - action: "tap" - selector: - text: "Login" - description: "Tap the login button" - - - action: "inputText" - text: "user@example.com" - description: "Enter email address" - - - action: "tap" - selector: - text: "Next" - description: "Proceed to password" - - - action: "inputText" - text: "password123" - description: "Enter password" - - - action: "tap" - selector: - text: "Sign In" - description: "Submit credentials" - - - action: "assertVisible" - selector: - text: "Welcome" - description: "Verify successful login" - """ - .trimIndent(), - language = "yaml", - title = "AutoMobile Test Plan Configuration", - highlight = - """ - action: "tap" - action: "inputText" - action: "assertVisible" - """ - .trimIndent()) - } - - /** Example 4: Highlighting specific API calls in JSON response */ - fun createApiResponseSlide(): SlideContent.CodeSample { - return SlideContent.CodeSample( - code = - """ - { - "status": "success", - "data": { - "user": { - "id": 12345, - "name": "John Doe", - "email": "john.doe@example.com", - "preferences": { - "theme": "dark", - "notifications": true, - "language": "en" - } - }, - "session": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "expires": "2024-12-31T23:59:59Z", - "refresh_token": "rt_abc123def456" - } - }, - "timestamp": "2024-01-15T10:30:00Z", - "request_id": "req_789xyz" - } - """ - .trimIndent(), - language = "json", - title = "API Response Structure", - highlight = - """ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - "expires": "2024-12-31T23:59:59Z" - "refresh_token": "rt_abc123def456" - """ - .trimIndent()) - } - - /** Get all highlighting examples as a list of slides */ - fun getAllExamples(): List { - return listOf( - createWindowManagerLogSlide(), - createTestCodeSlide(), - createConfigurationSlide(), - createApiResponseSlide()) - } -} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/SlidesScreen.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/SlidesScreen.kt new file mode 100644 index 000000000..55b694429 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/SlidesScreen.kt @@ -0,0 +1,304 @@ +package dev.jasonpearson.automobile.slides + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import dev.jasonpearson.automobile.design.system.theme.AutoMobileTheme +import dev.jasonpearson.automobile.sdk.TrackRecomposition +import dev.jasonpearson.automobile.slides.components.BulletPointSlideItem +import dev.jasonpearson.automobile.slides.components.CodeSampleSlideItem +import dev.jasonpearson.automobile.slides.components.EmojiSlideItem +import dev.jasonpearson.automobile.slides.components.LargeTextSlideItem +import dev.jasonpearson.automobile.slides.components.MermaidDiagramSlideItem +import dev.jasonpearson.automobile.slides.components.ScreenshotSlideItem +import dev.jasonpearson.automobile.slides.components.VideoPlayerSlideItem +import dev.jasonpearson.automobile.slides.components.VisualizationSlideItem +import dev.jasonpearson.automobile.slides.data.getAllSlides +import dev.jasonpearson.automobile.slides.model.SlideContent +import kotlinx.coroutines.launch + +/** + * Main slides screen with horizontal paging for conference presentations. Supports deep linking to + * specific slide indices and navigation controls. Uses AutoMobile design system theme for + * consistent colors across day/night modes. + */ +@Composable +fun SlidesScreen( + slides: List = getAllSlides(), + initialSlideIndex: Int = 0, + modifier: Modifier = Modifier, + onNavigateBack: (() -> Unit)? = null, +) { + TrackRecomposition(id = "screen.slides", composableName = "SlidesScreen") { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val themeManager = remember(context) { SlidesThemeManager(context) } + val isDarkMode = themeManager.isDarkMode + val coroutineScope = rememberCoroutineScope() + + // Enable immersive mode + DisposableEffect(Unit) { + val activity = context as? androidx.activity.ComponentActivity + activity?.let { + val window = it.window + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + + // Hide system bars + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + // Make content appear behind system bars + WindowCompat.setDecorFitsSystemWindows(window, false) + } + + onDispose { + // Restore normal mode when leaving + activity?.let { + val window = it.window + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + insetsController.show(WindowInsetsCompat.Type.systemBars()) + WindowCompat.setDecorFitsSystemWindows(window, true) + } + } + } + + // Remember PagerState with saveable current page + val savedPage: MutableState = rememberSaveable { mutableIntStateOf(initialSlideIndex) } + + val pagerState = + rememberPagerState( + initialPage = savedPage.value.coerceIn(0, slides.size - 1), + pageCount = { slides.size }, + ) + + // Update saved page when current page changes + LaunchedEffect(pagerState.currentPage) { savedPage.value = pagerState.currentPage } + + // Restore to saved position if needed (e.g., after configuration change) + LaunchedEffect(Unit) { + if (pagerState.currentPage != savedPage.value && savedPage.value in 0 until slides.size) { + pagerState.scrollToPage(savedPage.value) + } + } + + AutoMobileTheme(darkTheme = isDarkMode) { + Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + // Slide content with floating day/night button and tap navigation + Box(modifier = Modifier.weight(1f)) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.fillMaxSize(), + ) { page -> + SlideItem( + slideContent = slides[page], + isDarkMode = isDarkMode, + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.displayCutout), + ) + } + + // Left tap area for previous slide + Box( + modifier = + Modifier.width((configuration.screenWidthDp * 0.3f).dp) + .fillMaxHeight() + .align(Alignment.CenterStart) + .semantics { contentDescription = "Previous" } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (pagerState.currentPage > 0) { + coroutineScope.launch { + pagerState.scrollToPage(pagerState.currentPage - 1) + } + } + } + ) + + // Right tap area for next slide + Box( + modifier = + Modifier.width((configuration.screenWidthDp * 0.3f).dp) + .fillMaxHeight() + .align(Alignment.CenterEnd) + .semantics { contentDescription = "Next" } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (pagerState.currentPage < slides.size - 1) { + coroutineScope.launch { + pagerState.scrollToPage(pagerState.currentPage + 1) + } + } + } + ) + + // Day/Night mode toggle - floating in bottom right corner + IconButton( + onClick = { themeManager.toggleTheme() }, + modifier = + Modifier.size(64.dp) + .align(Alignment.BottomEnd) + .windowInsetsPadding(WindowInsets.systemBars) + .padding(16.dp), + ) { + Icon( + imageVector = if (!isDarkMode) Icons.Filled.LightMode else Icons.Filled.DarkMode, + contentDescription = if (!isDarkMode) "Light Mode" else "Dark Mode", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + // Progress bar - pinned to bottom with minimal height and no padding + LinearProgressIndicator( + progress = (pagerState.currentPage + 1f) / slides.size, + modifier = + Modifier.fillMaxWidth() + .size(height = 2.dp, width = 0.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + ) + } + } + } +} + +/** Individual slide item that renders different slide content types. */ +@androidx.media3.common.util.UnstableApi +@Composable +private fun SlideItem( + slideContent: SlideContent, + isDarkMode: Boolean, + modifier: Modifier = Modifier, +) { + when (slideContent) { + is SlideContent.LargeText -> { + LargeTextSlideItem( + title = slideContent.title, + subtitle = slideContent.subtitle, + modifier = modifier, + ) + } + + is SlideContent.BulletPoints -> { + BulletPointSlideItem( + title = slideContent.title, + points = slideContent.points, + modifier = modifier, + ) + } + + is SlideContent.Emoji -> { + EmojiSlideItem( + emoji = slideContent.emoji, + caption = slideContent.caption, + modifier = modifier, + ) + } + + is SlideContent.CodeSample -> { + CodeSampleSlideItem( + code = slideContent.code, + language = slideContent.language, + title = slideContent.title, + highlight = slideContent.highlight, + isDarkMode = isDarkMode, + modifier = modifier, + ) + } + + is SlideContent.Visualization -> { + VisualizationSlideItem( + imageUrl = slideContent.imageUrl, + caption = slideContent.caption, + contentDescription = slideContent.contentDescription, + modifier = modifier, + ) + } + + is SlideContent.Video -> { + VideoPlayerSlideItem( + videoUrl = slideContent.videoUrl, + caption = slideContent.caption, + contentDescription = slideContent.contentDescription, + modifier = modifier, + ) + } + + is SlideContent.MermaidDiagram -> { + MermaidDiagramSlideItem( + mermaidCode = slideContent.code, + title = slideContent.title, + isDarkMode = isDarkMode, + modifier = modifier, + ) + } + + is SlideContent.Screenshot -> { + ScreenshotSlideItem( + lightScreenshot = slideContent.lightScreenshot, + darkScreenshot = slideContent.darkScreenshot, + caption = slideContent.caption, + contentDescription = slideContent.contentDescription, + modifier = modifier, + ) + } + } +} + +@androidx.media3.common.util.UnstableApi +@Preview(showBackground = true) +@Composable +fun SlidesScreenPreview() { + AutoMobileTheme { + SlidesScreen(slides = listOf(SlideContent.LargeText("Slide Title")), initialSlideIndex = 0) + } +} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/SlidesThemeManager.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/SlidesThemeManager.kt similarity index 97% rename from android/playground/slides/src/main/java/com/zillow/automobile/slides/SlidesThemeManager.kt rename to android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/SlidesThemeManager.kt index 3015d0597..cab883713 100644 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/SlidesThemeManager.kt +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/SlidesThemeManager.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.slides +package dev.jasonpearson.automobile.slides import android.content.Context import android.content.SharedPreferences diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/BulletPointSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/BulletPointSlideItem.kt new file mode 100644 index 000000000..f13a2fcb6 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/BulletPointSlideItem.kt @@ -0,0 +1,156 @@ +package dev.jasonpearson.automobile.slides.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.jasonpearson.automobile.slides.model.BulletPoint + +/** + * Bullet point slide component that displays hierarchical lists. Supports both main points and + * sub-points with proper indentation. + */ +@Composable +fun BulletPointSlideItem( + title: String?, + points: List, + modifier: Modifier = Modifier, + titleColor: Color = MaterialTheme.colorScheme.onBackground, + bulletColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + subBulletColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), +) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Title + if (title != null) { + Text( + text = title, + style = + MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = titleColor, + ), + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + // Bullet points + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + points.forEach { bulletPoint -> + BulletPointItem(bulletPoint = bulletPoint, bulletColor = bulletColor) + } + } + } +} + +/** Individual bullet point item. */ +@Composable +private fun BulletPointItem( + bulletPoint: BulletPoint, + bulletColor: Color, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = "•", + style = MaterialTheme.typography.displaySmall, + color = bulletColor, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = bulletPoint.text, + style = MaterialTheme.typography.displaySmall.copy(color = bulletColor, lineHeight = 48.sp), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun BulletPointSlideItemPreview() { + MaterialTheme { + BulletPointSlideItem( + title = "AutoMobile Features", + points = + listOf( + BulletPoint( + text = "Source Intelligence", + subPoints = + listOf( + "Analyzes Android app source code", + "Generates intelligent test selectors", + ), + ), + BulletPoint( + text = "Cross-Platform Testing", + subPoints = + listOf("Supports Android and iOS", "Unified API for both platforms"), + ), + BulletPoint( + text = "JUnit Integration", + subPoints = + listOf( + "Drop-in replacement for Espresso", + "Compatible with existing test infrastructure", + ), + ), + ), + ) + } +} + +@Preview(showBackground = true, widthDp = 800, heightDp = 480, name = "Landscape") +@Composable +fun BulletPointSlideItemLandscapePreview() { + MaterialTheme { + BulletPointSlideItem( + title = "AutoMobile Features", + points = + listOf( + BulletPoint( + text = "Source Intelligence", + subPoints = + listOf( + "Analyzes Android app source code", + "Generates intelligent test selectors", + ), + ), + BulletPoint( + text = "Cross-Platform Testing", + subPoints = + listOf("Supports Android and iOS", "Unified API for both platforms"), + ), + BulletPoint( + text = "JUnit Integration", + subPoints = + listOf( + "Drop-in replacement for Espresso", + "Compatible with existing test infrastructure", + ), + ), + ), + ) + } +} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/CodeSampleSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/CodeSampleSlideItem.kt new file mode 100644 index 000000000..44a6145b1 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/CodeSampleSlideItem.kt @@ -0,0 +1,269 @@ +package dev.jasonpearson.automobile.slides.components + +import android.webkit.WebView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.delay + +/** + * Code sample slide component with syntax highlighting via WebView and Prism.js. Full-screen + * display with high contrast colors optimized for presentations. Uses cached local assets for + * faster loading and supports dark/light themes. + */ +@Composable +fun CodeSampleSlideItem( + code: String, + language: String, + title: String? = null, // Kept for API compatibility but not used + highlight: String? = null, + isDarkMode: Boolean = false, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var showLoading by remember { mutableStateOf(true) } + + val backgroundColor = if (isDarkMode) Color(0xFF1E1E1E) else Color.White + val textColor = if (isDarkMode) Color(0xFFF8F8F2) else Color.Black + + // Hide loading overlay after 1 second + LaunchedEffect(Unit) { + delay(1000) + showLoading = false + } + + Box(modifier = modifier.fillMaxSize().background(backgroundColor)) { + // WebView with syntax highlighting + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.setSupportZoom(false) + + val htmlContent = + createHighlightedCodeHtml( + code = code, + language = language, + highlight = highlight, + isDarkMode = isDarkMode, + ) + + loadDataWithBaseURL("file:///android_asset/", htmlContent, "text/html", "UTF-8", null) + } + }, + modifier = Modifier.fillMaxSize(), + ) + + // Loading overlay + if (showLoading) { + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +/** Creates HTML content with Prism.js syntax highlighting. */ +private fun createHighlightedCodeHtml( + code: String, + language: String, + isDarkMode: Boolean, + highlight: String? = null, +): String { + val themeFile = if (isDarkMode) "prism-dark.css" else "prism-light.css" + + // Process highlighting if provided + val processedCode = + if (highlight != null) { + processCodeWithHighlighting(code, highlight, isDarkMode) + } else { + code.replace("<", "<").replace(">", ">") + } + + return """ + + + + + + + + + +
$processedCode
+ + + + + + + + """ + .trimIndent() +} + +/** Processes code to apply highlighting by wrapping lines in spans with appropriate CSS classes. */ +internal fun processCodeWithHighlighting( + code: String, + highlight: String, + isDarkMode: Boolean, +): String { + val codeLines = code.lines() + val highlightLines = highlight.lines().map { it.trim() }.filter { it.isNotEmpty() } + + return codeLines.joinToString("\n") { line -> + val escapedLine = line.replace("<", "<").replace(">", ">") + val isHighlighted = + highlightLines.any { highlightLine -> + line.trim().contains(highlightLine.trim(), ignoreCase = false) + } + + if (isHighlighted) { + "$escapedLine" + } else { + "$escapedLine" + } + } +} + +@Preview(showBackground = true, name = "Light Mode") +@Composable +fun CodeSampleSlideItemPreview() { + MaterialTheme { + CodeSampleSlideItem( + code = + """ + @Test + fun testLoginFlow() { + // Launch the app + tapOn(text = "Login") + + // Enter credentials + inputText("user@example.com") + tapOn(text = "Next") + inputText("password123") + + // Submit login + tapOn(text = "Sign In") + + // Verify success + assertVisible(text = "Welcome") + } + """ + .trimIndent(), + language = "kotlin", + isDarkMode = false, + ) + } +} + +@Preview(showBackground = true, name = "Dark Mode") +@Composable +fun CodeSampleSlideItemDarkPreview() { + MaterialTheme { + CodeSampleSlideItem( + code = + """ + @Test + fun testLoginFlow() { + // Launch the app + tapOn(text = "Login") + + // Enter credentials + inputText("user@example.com") + tapOn(text = "Next") + inputText("password123") + + // Submit login + tapOn(text = "Sign In") + + // Verify success + assertVisible(text = "Welcome") + } + """ + .trimIndent(), + language = "kotlin", + isDarkMode = true, + ) + } +} + +@Preview(showBackground = true, name = "Highlighted Code") +@Composable +fun CodeSampleSlideItemHighlightPreview() { + MaterialTheme { + CodeSampleSlideItem( + code = + """ + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + + mGlobalConfiguration={1.0 310mcc260mnc [en_US] ldltr sw448dp w997dp h448dp 360dpi nrml long hdr widecg land finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 2244, 1008) mAppBounds=Rect(0, 0 - 2244, 1008) mMaxBounds=Rect(0, 0 - 2244, 1008) mDisplayRotation=ROTATION_90 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_90} s.6257 fontWeightAdjustment=0} + mHasPermanentDpad=false + mTopFocusedDisplayId=0 + imeLayeringTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeInputTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeControlTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + Minimum task size of display#0 220 mBlurEnabled=true + mLastDisplayFreezeDuration=0 due to new-config + mDisableSecureWindows=false + mHighResSnapshotScale=0.8 + mSnapshotEnabled=true + SnapshotCache Task + """ + .trimIndent(), + language = "shell", + highlight = + """ + imeLayeringTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeInputTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeControlTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + """ + .trimIndent(), + isDarkMode = true, + ) + } +} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/EmojiSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/EmojiSlideItem.kt new file mode 100644 index 000000000..cc46a71cf --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/EmojiSlideItem.kt @@ -0,0 +1,80 @@ +package dev.jasonpearson.automobile.slides.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.jasonpearson.automobile.slides.model.PresentationEmoji + +/** + * Emoji slide component that displays a large emoji with optional caption. Uses the + * LargeTextSlideItem for consistent auto-resizing behavior. + */ +@Composable +fun EmojiSlideItem( + emoji: PresentationEmoji, + caption: String? = null, + modifier: Modifier = Modifier, + captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // Large emoji display - reduced size to account for window insets + Text( + text = emoji.unicode, + fontSize = if (caption != null) 100.sp else 140.sp, + modifier = Modifier.padding(bottom = if (caption != null) 24.dp else 0.dp), + ) + + // Optional caption with better spacing + caption?.let { + Text( + text = it, + style = + MaterialTheme.typography.headlineMedium.copy( + textAlign = TextAlign.Center, + color = captionColor, + fontWeight = FontWeight.Medium, + lineHeight = 30.sp, + ), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun EmojiSlideItemPreview() { + MaterialTheme { + EmojiSlideItem(emoji = PresentationEmoji.ROCKET, caption = "AutoMobile is Lightning Fast!") + } +} + +@Preview(showBackground = true) +@Composable +fun EmojiSlideItemNoCaption() { + MaterialTheme { EmojiSlideItem(emoji = PresentationEmoji.THINKING) } +} + +@Preview(showBackground = true) +@Composable +fun EmojiSlideItemConstruction() { + MaterialTheme { + EmojiSlideItem(emoji = PresentationEmoji.CONSTRUCTION, caption = "Work in Progress") + } +} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/LargeTextSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/LargeTextSlideItem.kt similarity index 78% rename from android/playground/slides/src/main/java/com/zillow/automobile/slides/components/LargeTextSlideItem.kt rename to android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/LargeTextSlideItem.kt index e4587972f..4322e4f03 100644 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/components/LargeTextSlideItem.kt +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/LargeTextSlideItem.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.slides.components +package dev.jasonpearson.automobile.slides.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,7 +34,7 @@ fun LargeTextSlideItem( subtitle: String? = null, modifier: Modifier = Modifier, titleColor: Color = MaterialTheme.colorScheme.onBackground, - subtitleColor: Color = MaterialTheme.colorScheme.onSurfaceVariant + subtitleColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { val configuration = LocalConfiguration.current val isLandscape = @@ -43,33 +43,40 @@ fun LargeTextSlideItem( Column( modifier = modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - // Auto-resizing title - AutoResizingText( - text = title, - style = - MaterialTheme.typography.displayLarge.copy( - fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, color = titleColor), - modifier = Modifier.weight(if (subtitle != null) 0.5f else 0.5f), - isLandscape = isLandscape, - hasSubtitle = subtitle != null) + verticalArrangement = Arrangement.Center, + ) { + // Auto-resizing title + AutoResizingText( + text = title, + style = + MaterialTheme.typography.displayLarge.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = titleColor, + ), + modifier = Modifier.weight(if (subtitle != null) 0.5f else 0.5f), + isLandscape = isLandscape, + hasSubtitle = subtitle != null, + ) - // Optional subtitle - subtitle?.let { - AutoResizingText( - text = it, - style = - MaterialTheme.typography.headlineMedium.copy( - textAlign = TextAlign.Center, - color = subtitleColor, - fontWeight = FontWeight.Normal), - maxFontSize = 48f, - minFontSize = 12f, - modifier = Modifier.weight(0.3f).padding(top = 16.dp), - isLandscape = isLandscape, - hasSubtitle = false) - } - } + // Optional subtitle + subtitle?.let { + AutoResizingText( + text = it, + style = + MaterialTheme.typography.headlineMedium.copy( + textAlign = TextAlign.Center, + color = subtitleColor, + fontWeight = FontWeight.Normal, + ), + maxFontSize = 48f, + minFontSize = 12f, + modifier = Modifier.weight(0.3f).padding(top = 16.dp), + isLandscape = isLandscape, + hasSubtitle = false, + ) + } + } } /** @@ -84,7 +91,7 @@ private fun AutoResizingText( maxFontSize: Float = 96f, minFontSize: Float = 12f, isLandscape: Boolean, - hasSubtitle: Boolean + hasSubtitle: Boolean, ) { val maxLines = when { @@ -130,7 +137,8 @@ private fun AutoResizingText( } else { readyToDraw = true } - }) + }, + ) } @Preview(showBackground = true) @@ -138,7 +146,9 @@ private fun AutoResizingText( fun LargeTextSlideItemPreview() { MaterialTheme { LargeTextSlideItem( - title = "Welcome to AutoMobile", subtitle = "The Future of Android UI Testing") + title = "Welcome to AutoMobile", + subtitle = "The Future of Android UI Testing", + ) } } diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/MermaidDiagramSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/MermaidDiagramSlideItem.kt new file mode 100644 index 000000000..06ec73535 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/MermaidDiagramSlideItem.kt @@ -0,0 +1,606 @@ +package dev.jasonpearson.automobile.slides.components + +import android.util.Log +import android.view.GestureDetector +import android.view.MotionEvent +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import dev.jasonpearson.automobile.design.system.theme.AutoMobileBlack +import dev.jasonpearson.automobile.design.system.theme.AutoMobileRed +import dev.jasonpearson.automobile.design.system.theme.AutoMobileWhite +import dev.jasonpearson.automobile.design.system.theme.PromoBlue +import dev.jasonpearson.automobile.design.system.theme.PromoOrange +import kotlinx.coroutines.delay + +/** + * Mermaid diagram slide component that renders interactive diagrams using Mermaid.js. Supports + * dark/light theming with design system colors and optional title/caption text. + */ +@Composable +fun MermaidDiagramSlideItem( + modifier: Modifier = Modifier, + mermaidCode: String, + title: String? = null, + isDarkMode: Boolean = false, +) { + + val TAG = "MermaidDiagramSlideItem" + val context = LocalContext.current + var showLoading by remember { mutableStateOf(true) } + var zoomLevel by remember { mutableFloatStateOf(1f) } + var contentWidth by remember { mutableFloatStateOf(0f) } + + val backgroundColor = if (isDarkMode) AutoMobileBlack else AutoMobileWhite + val textColor = if (isDarkMode) AutoMobileWhite else AutoMobileBlack + + // Calculate opacity based on zoom level - fade out when zooming in + val contentOpacity by + animateFloatAsState( + targetValue = + when { + zoomLevel <= 1.2f -> 1f + zoomLevel >= 2f -> 0f + else -> 1f - ((zoomLevel - 1.2f) / 0.8f) // Linear fade between 1.2x and 2x zoom + }, + animationSpec = tween(durationMillis = 300), + label = "contentOpacity", + ) + + // Hide loading overlay after 1 second + LaunchedEffect(Unit) { + delay(1000) + showLoading = false + } + + Column( + modifier = modifier.fillMaxSize().background(backgroundColor).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Optional title + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium, + color = textColor, + modifier = Modifier.padding(bottom = 16.dp).alpha(contentOpacity), + ) + } + + // Mermaid diagram + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.setSupportZoom(true) + settings.builtInZoomControls = true + settings.displayZoomControls = false + + // Enhanced zoom gesture support + settings.allowFileAccess = true + settings.allowContentAccess = true + settings.domStorageEnabled = true + + // Set background color to prevent white flashing + setBackgroundColor(backgroundColor.toArgb()) + + // Add JavaScript interface for zoom tracking + addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun onZoomChanged(scale: Float) { + // Post to main thread since this is called from JavaScript thread + post { + zoomLevel = scale + Log.i(TAG, "AutoMobile: JS Zoom level: $scale") + } + } + }, + "Android", + ) + + // Custom WebViewClient to track zoom changes + webViewClient = + object : WebViewClient() { + override fun onScaleChanged(view: WebView?, oldScale: Float, newScale: Float) { + super.onScaleChanged(view, oldScale, newScale) + zoomLevel = newScale + Log.i(TAG, "AutoMobile: Zoom changed from $oldScale to $newScale") + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + + // Get content dimensions via JavaScript + view?.postDelayed( + { + // Get the content width + view.evaluateJavascript( + "(function() { return JSON.stringify({width: document.body.scrollWidth, height: document.body.scrollHeight}); })();" + ) { result -> + try { + val cleanResult = result?.replace("\"", "") ?: "" + if (cleanResult.isNotEmpty() && cleanResult != "null") { + // Parse the JSON-like string to get dimensions + val contentData = + cleanResult.substringAfter("{").substringBefore("}") + val widthStr = + contentData.substringAfter("width:").substringBefore(",") + val parsedContentWidth = widthStr.toFloatOrNull() ?: 0f + + contentWidth = parsedContentWidth + } + } catch (e: Exception) { + Log.i( + TAG, + "AutoMobile: Error parsing content dimensions: ${e.message}", + ) + } + } + }, + 1000, + ) // Slightly longer delay to ensure Mermaid rendering is complete + + // Auto-zoom to fit or 2.5x when page loads + view?.postDelayed( + { + // Get content dimensions via JavaScript + view.evaluateJavascript( + "(function() { return JSON.stringify({width: document.body.scrollWidth, height: document.body.scrollHeight}); })();" + ) { result -> + try { + val cleanResult = result?.replace("\"", "") ?: "" + if (cleanResult.isNotEmpty() && cleanResult != "null") { + // Parse the JSON-like string to get dimensions + val contentData = + cleanResult.substringAfter("{").substringBefore("}") + val widthStr = + contentData.substringAfter("width:").substringBefore(",") + Log.i(TAG, "widthStr: ${widthStr}") + val contentWidth = widthStr.toFloatOrNull() ?: 0f + Log.i(TAG, "contentWidth: ${contentWidth}") + val viewWidth = view.width.toFloat() + Log.i(TAG, "viewWidth: ${viewWidth}") + + if (contentWidth > 0 && viewWidth > 0) { + // Calculate scale to fit content with some padding + val scaleToFit = (viewWidth * 0.9f) / contentWidth + + // Choose between fit-to-window or 2.5x zoom + val targetZoom = + if (scaleToFit > 1f && scaleToFit < 2.5f) { + scaleToFit // Use fit-to-window if it's reasonable + } else { + 2.5f // Otherwise use 2.5x zoom + } + + // Apply the zoom + view.zoomBy(targetZoom) + zoomLevel = targetZoom + + // Pan to top center after zoom + view.postDelayed( + { + // Use JavaScript to get the actual rendered + // dimensions after zoom + view.evaluateJavascript( + "(function() { return JSON.stringify({width: document.body.scrollWidth, height: document.body.scrollHeight}); })();" + ) { dimensionResult -> + try { + val cleanDimResult = + dimensionResult?.replace("\"", "") ?: "" + if ( + cleanDimResult.isNotEmpty() && + cleanDimResult != "null" + ) { + val dimData = + cleanDimResult + .substringAfter("{") + .substringBefore("}") + val actualWidthStr = + dimData + .substringAfter("width:") + .substringBefore(",") + val actualContentWidth = + actualWidthStr.toFloatOrNull() ?: 0f + val viewWidth = view.width.toFloat() + + // Calculate horizontal scroll to center + // the content + val scrollX = + if (actualContentWidth > viewWidth) { + ((actualContentWidth - viewWidth) / 2).toInt() + } else { + 0 // Content fits in view, no + // horizontal scroll needed + } + + val scrollY = 0 // Top of the content + + Log.i( + TAG, + "view.scrollTo(scrollX, scrollY) (${scrollX}, ${scrollY})", + ) + view.scrollTo(scrollX, scrollY) + Log.i( + TAG, + "AutoMobile: Panned to top center ($scrollX, $scrollY) - content: $actualContentWidth, view: $viewWidth", + ) + } else { + // Fallback: try to center using initial + // calculation + val scaledContentWidth = + (contentWidth * targetZoom).toInt() + val viewWidth = view.width + val scrollX = + maxOf(0, (scaledContentWidth - viewWidth) / 2) + Log.i( + TAG, + "view.scrollTo(scrollX, 0) (${scrollX}, 0)", + ) + view.scrollTo(scrollX, 0) + Log.i( + TAG, + "AutoMobile: Panned to top center (calc fallback) ($scrollX, 0)", + ) + } + } catch (e: Exception) { + // Last resort: try basic centering + val scaledContentWidth = + (contentWidth * targetZoom).toInt() + val viewWidth = view.width + val scrollX = + maxOf(0, (scaledContentWidth - viewWidth) / 2) + Log.i( + TAG, + "view.scrollTo(scrollX, 0) (${scrollX}, 0)", + ) + view.scrollTo(scrollX, 0) + Log.i( + TAG, + "AutoMobile: Panned to top center (error fallback) ($scrollX, 0): ${e.message}", + ) + } + } + }, + 200, + ) // Small delay after zoom to ensure it's + // applied + + Log.i( + TAG, + "AutoMobile: Auto-zoomed to $targetZoom (scaleToFit: $scaleToFit)", + ) + } else { + // Fallback to 2.5x + view.zoomBy(2.5f) + zoomLevel = 2.5f + + // Pan to top center after zoom + view.postDelayed( + { + Log.i(TAG, "view.scrollTo(scrollX, 0) (${scrollX}, 0)") + view.scrollTo(scrollX, 0) + + val scrollX = maxOf(0, (view.width * 0.75).toInt()) + Log.i( + TAG, + "view.scrollTo(view.width / 2, 0) ($scrollX 0)", + ) + view.scrollTo(scrollX, 0) + Log.i( + TAG, + "AutoMobile: Panned to top center (fallback) ($scrollX, 0)", + ) + }, + 200, + ) + + Log.i( + TAG, + "AutoMobile: Auto-zoomed to 2.5x (dimensions fallback)", + ) + } + } else { + // Fallback to 2.5x if JavaScript fails + view.zoomBy(2.5f) + zoomLevel = 2.5f + + // Pan to top center after zoom + view.postDelayed( + { + Log.i( + TAG, + "view.scrollTo(view.width / 2, 0) (${view.width / 2} 0)", + ) + view.scrollTo(view.width / 2, 0) + Log.i(TAG, "AutoMobile: Panned to top center (JS fallback") + }, + 200, + ) + + Log.i(TAG, "AutoMobile: Auto-zoomed to 2.5x (JS fallback)") + } + } catch (e: Exception) { + // Fallback to 2.5x if parsing fails + view.zoomBy(2.5f) + zoomLevel = 2.5f + + // Pan to top center after zoom + view.postDelayed( + { + val scaledContentWidth = (contentWidth * 2.5f).toInt() + val viewWidth = view.width + val scrollX = maxOf(0, (scaledContentWidth - viewWidth) / 2) + Log.i(TAG, "view.scrollTo(scrollX, 0) (${scrollX}, 0)") + view.scrollTo(scrollX, 0) + Log.i( + TAG, + "AutoMobile: Panned to top center (error fallback) ($scrollX, 0): ${e.message}", + ) + }, + 200, + ) + + Log.i( + TAG, + "AutoMobile: Auto-zoomed to 2.5x (error fallback): ${e.message}", + ) + } + } + }, + 1000, + ) // Slightly longer delay to ensure Mermaid rendering is complete + + // Inject JavaScript to monitor zoom changes more reliably + view?.evaluateJavascript( + """ + (function() { + let lastScale = 1; + function checkZoom() { + const currentScale = window.outerWidth / window.innerWidth; + if (Math.abs(currentScale - lastScale) > 0.1) { + lastScale = currentScale; + Android.onZoomChanged(currentScale); + } + } + setInterval(checkZoom, 100); + })(); + """, + null, + ) + } + } + + // Double tap to zoom gesture detector + val gestureDetector = + GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + val currentZoom = zoomLevel + val targetZoom = + if (currentZoom > 1.5f) { + 1f // Zoom out to fit + } else { + 2.5f // Zoom in + } + + // Use zoomBy for smooth transition + val zoomFactor = targetZoom / currentZoom + zoomBy(zoomFactor) + + // Update zoom level immediately for responsive UI + zoomLevel = targetZoom + Log.i(TAG, "AutoMobile: Double tap zoom from $currentZoom to $targetZoom") + + return true + } + }, + ) + + // Set touch listener for double tap detection + setOnTouchListener { view, event -> + gestureDetector.onTouchEvent(event) + false // Let WebView handle other touch events normally + } + + val htmlContent = + createMermaidDiagramHtml( + mermaidCode = mermaidCode, + isDarkMode = isDarkMode, + backgroundColor = backgroundColor, + textColor = textColor, + ) + + loadDataWithBaseURL( + "https://cdn.jsdelivr.net/", + htmlContent, + "text/html", + "UTF-8", + null, + ) + } + }, + modifier = Modifier.fillMaxSize(), + ) + + // Loading overlay + if (showLoading) { + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } + } +} + +/** Creates HTML content with Mermaid.js diagram rendering using design system colors. */ +private fun createMermaidDiagramHtml( + mermaidCode: String, + isDarkMode: Boolean, + backgroundColor: Color, + textColor: Color, +): String { + val bgColorHex = String.format("#%06X", 0xFFFFFF and backgroundColor.toArgb()) + val textColorHex = String.format("#%06X", 0xFFFFFF and textColor.toArgb()) + + // Design system colors + val autoMobileRedHex = String.format("#%06X", 0xFFFFFF and AutoMobileRed.toArgb()) + val orangeHex = String.format("#%06X", 0xFFFFFF and PromoOrange.toArgb()) + val blueHex = String.format("#%06X", 0xFFFFFF and PromoBlue.toArgb()) + + // Mermaid theme configuration + val theme = if (isDarkMode) "dark" else "default" + + return """ + + + + + + + + + +
+ ${mermaidCode.trim()} +
+ + + + """ + .trimIndent() +} + +@Preview(showBackground = true, name = "Flowchart Light") +@Composable +fun MermaidDiagramSlideItemFlowchartPreview() { + MaterialTheme { + MermaidDiagramSlideItem( + title = "AutoMobile Test Flow", + mermaidCode = + """ + flowchart TD + A[Start Test] --> B{Launch App} + B -->|Success| C[Execute Actions] + B -->|Fail| D[Report Error] + C --> E[Verify Results] + E -->|Pass| F[Test Complete] + E -->|Fail| G[Capture Screenshot] + G --> H[Report Failure] + """ + .trimIndent(), + isDarkMode = false, + ) + } +} + +@Preview(showBackground = true, name = "Sequence Dark") +@Composable +fun MermaidDiagramSlideItemSequencePreview() { + MaterialTheme { + MermaidDiagramSlideItem( + title = "Test Interaction Sequence", + mermaidCode = + """ + sequenceDiagram + participant T as Test + participant A as App + participant U as UI Element + participant S as System + + T->>A: Launch App + A->>U: Render UI + T->>U: Tap Button + U->>S: Trigger Action + S-->>A: Update State + A->>U: Update Display + T->>U: Assert Visible + U-->>T: Verification Result + """ + .trimIndent(), + isDarkMode = true, + ) + } +} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/ScreenshotSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/ScreenshotSlideItem.kt new file mode 100644 index 000000000..14b106ec5 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/ScreenshotSlideItem.kt @@ -0,0 +1,185 @@ +package dev.jasonpearson.automobile.slides.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter + +/** + * Screenshot slide component that displays app screenshots with day/night theme support. + * Automatically selects the appropriate screenshot based on the current theme. Falls back to the + * available resource if theme-specific version doesn't exist. + */ +@Composable +fun ScreenshotSlideItem( + @DrawableRes lightScreenshot: Int? = null, + @DrawableRes darkScreenshot: Int? = null, + caption: String? = null, + contentDescription: String? = null, + modifier: Modifier = Modifier, + captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + forceTheme: Boolean? = null, // For testing - null uses system theme +) { + val isDarkTheme = forceTheme ?: isSystemInDarkTheme() + + // Select appropriate screenshot based on theme and availability + val screenshotRes = + when { + isDarkTheme && darkScreenshot != null -> darkScreenshot + !isDarkTheme && lightScreenshot != null -> lightScreenshot + darkScreenshot != null -> darkScreenshot + lightScreenshot != null -> lightScreenshot + else -> null + } + + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Screenshot display + Card( + modifier = Modifier.weight(1f).fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (screenshotRes != null) { + AsyncImage( + model = screenshotRes, + contentDescription = contentDescription ?: caption, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Fit, + onState = { state -> + when (state) { + is AsyncImagePainter.State.Loading -> { + // Loading indicator will be shown by the Box below + } + is AsyncImagePainter.State.Error -> { + // Error state handled by placeholder in Box below + } + is AsyncImagePainter.State.Success -> { + // Screenshot loaded successfully + } + else -> { + // Other states + } + } + }, + ) + } else { + // No screenshot available + ScreenshotErrorState() + } + } + } + + // Caption + caption?.let { + Text( + text = it, + style = + MaterialTheme.typography.headlineSmall.copy( + textAlign = TextAlign.Center, + color = captionColor, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +/** Error state component for when no screenshot is available. */ +@Composable +private fun ScreenshotErrorState(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "📱", + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "No screenshot available", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true, name = "Light Theme") +@Composable +fun ScreenshotSlideItemLightPreview() { + MaterialTheme { + ScreenshotSlideItem( + lightScreenshot = android.R.drawable.ic_menu_gallery, + darkScreenshot = android.R.drawable.ic_menu_camera, + caption = "App Screenshot - Light Mode", + contentDescription = "Screenshot showing the app in light mode", + forceTheme = false, + ) + } +} + +@Preview(showBackground = true, name = "Dark Theme") +@Composable +fun ScreenshotSlideItemDarkPreview() { + MaterialTheme { + ScreenshotSlideItem( + lightScreenshot = android.R.drawable.ic_menu_gallery, + darkScreenshot = android.R.drawable.ic_menu_camera, + caption = "App Screenshot - Dark Mode", + contentDescription = "Screenshot showing the app in dark mode", + forceTheme = true, + ) + } +} + +@Preview(showBackground = true, name = "Light Only") +@Composable +fun ScreenshotSlideItemLightOnlyPreview() { + MaterialTheme { + ScreenshotSlideItem( + lightScreenshot = android.R.drawable.ic_menu_gallery, + caption = "App Screenshot - Light Only Available", + contentDescription = "Screenshot available only in light mode", + ) + } +} + +@Preview(showBackground = true, name = "No Screenshots") +@Composable +fun ScreenshotSlideItemNoScreenshotsPreview() { + MaterialTheme { + ScreenshotSlideItem( + caption = "Missing Screenshot", + contentDescription = "No screenshots available", + ) + } +} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/VideoPlayerSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/VideoPlayerSlideItem.kt new file mode 100644 index 000000000..bc2bdf34f --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/VideoPlayerSlideItem.kt @@ -0,0 +1,158 @@ +package dev.jasonpearson.automobile.slides.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView + +/** + * Video player slide component using ExoPlayer with proper lifecycle management. Auto-pauses when + * navigating away from the slide. + */ +@Composable +fun VideoPlayerSlideItem( + videoUrl: String, + caption: String? = null, + contentDescription: String? = null, + modifier: Modifier = Modifier, + captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + autoPlay: Boolean = false, +) { + val context = LocalContext.current + + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + val mediaItem = MediaItem.fromUri(videoUrl) + setMediaItem(mediaItem) + prepare() + playWhenReady = autoPlay + } + } + + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Video player + Card( + modifier = Modifier.weight(1f).fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + AndroidView( + factory = { context -> + PlayerView(context).apply { + player = exoPlayer + useController = true + setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) + controllerAutoShow = true + controllerHideOnTouch = false + setShutterBackgroundColor(android.graphics.Color.TRANSPARENT) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + + // Caption + caption?.let { + Text( + text = it, + style = + MaterialTheme.typography.headlineSmall.copy( + textAlign = TextAlign.Center, + color = captionColor, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + // Lifecycle management + DisposableEffect(exoPlayer) { onDispose { exoPlayer.release() } } +} + +/** Simplified video player for preview purposes. */ +@Composable +private fun VideoPlayerPreview(caption: String? = null, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Video placeholder + Card( + modifier = Modifier.weight(1f).fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "🎬", + style = MaterialTheme.typography.displayLarge, + modifier = Modifier.padding(bottom = 16.dp), + ) + Text( + text = "Video Player", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Caption + caption?.let { + Text( + text = it, + style = + MaterialTheme.typography.headlineSmall.copy( + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun VideoPlayerSlideItemPreview() { + MaterialTheme { VideoPlayerPreview(caption = "AutoMobile Demo: Testing a Shopping App") } +} + +@Preview(showBackground = true) +@Composable +fun VideoPlayerSlideItemNoCaptionPreview() { + MaterialTheme { VideoPlayerPreview() } +} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/VisualizationSlideItem.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/VisualizationSlideItem.kt new file mode 100644 index 000000000..e25656a3b --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/components/VisualizationSlideItem.kt @@ -0,0 +1,143 @@ +package dev.jasonpearson.automobile.slides.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter + +/** + * Visualization slide component for displaying images with loading states. Supports both local and + * remote images using Coil with proper error handling. + */ +@Composable +fun VisualizationSlideItem( + imageUrl: String, + caption: String? = null, + contentDescription: String? = null, + modifier: Modifier = Modifier, + captionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Image display + Card( + modifier = Modifier.weight(1f).fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription ?: caption, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Fit, + onState = { state -> + when (state) { + is AsyncImagePainter.State.Loading -> { + // Loading indicator will be shown by the Box below + } + + is AsyncImagePainter.State.Error -> { + // Error state handled by placeholder in Box below + } + + is AsyncImagePainter.State.Success -> { + // Image loaded successfully + } + + else -> { + // Other states + } + } + }, + ) + + // Loading indicator overlay + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + } + + // Caption + caption?.let { + Text( + text = it, + style = + MaterialTheme.typography.headlineSmall.copy( + textAlign = TextAlign.Center, + color = captionColor, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +/** Error state component for failed image loads. */ +@Composable +private fun ImageErrorState(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "📷", + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Unable to load image", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun VisualizationSlideItemPreview() { + MaterialTheme { + VisualizationSlideItem( + imageUrl = "https://example.com/architecture-diagram.png", + caption = "AutoMobile Architecture Overview", + contentDescription = + "Diagram showing AutoMobile's architecture with Android and iOS components", + ) + } +} + +@Preview(showBackground = true) +@Composable +fun VisualizationSlideItemNoCaptionPreview() { + MaterialTheme { VisualizationSlideItem(imageUrl = "https://example.com/screenshot.png") } +} diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/01-IntroductionSlides.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/01-IntroductionSlides.kt new file mode 100644 index 000000000..9965a046a --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/01-IntroductionSlides.kt @@ -0,0 +1,46 @@ +package dev.jasonpearson.automobile.slides.data + +import dev.jasonpearson.automobile.slides.model.BulletPoint +import dev.jasonpearson.automobile.slides.model.PresentationEmoji +import dev.jasonpearson.automobile.slides.model.SlideContent + +/** Slides introducing the problem space and AutoMobile's mission. */ +fun getIntroductionSlides(): List = + listOf( + SlideContent.LargeText(title = "AutoMobile", subtitle = "Jason Pearson"), + SlideContent.Emoji(emoji = PresentationEmoji.PROGRAMMER, caption = "Who am I?"), + SlideContent.LargeText( + title = "The best of mobile tooling has been inaccessible", + ), + SlideContent.BulletPoints( + title = "Behind walls of", + points = + listOf( + BulletPoint(text = "Cost"), + BulletPoint(text = "Specialized expertise"), + ), + ), + SlideContent.LargeText( + title = "No cohesive UX ties them together", + ), + SlideContent.Emoji( + emoji = PresentationEmoji.SLOW, + caption = "More like AutoCAD 2008", + ), + SlideContent.LargeText( + title = "Automating tedium in mobile engineering has been out of reach", + ), + SlideContent.LargeText( + title = "I built AutoMobile to change that", + ), + SlideContent.BulletPoints( + title = "AutoMobile is:", + points = + listOf( + BulletPoint("Open source MCP server"), + BulletPoint("AI agents control Android & iOS devices"), + BulletPoint("Natural language interaction"), + BulletPoint("Most features require no SDK dependency"), + ), + ), + ) diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/02-OriginSlides.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/02-OriginSlides.kt new file mode 100644 index 000000000..264143db7 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/02-OriginSlides.kt @@ -0,0 +1,48 @@ +package dev.jasonpearson.automobile.slides.data + +import dev.jasonpearson.automobile.slides.model.BulletPoint +import dev.jasonpearson.automobile.slides.model.PresentationEmoji +import dev.jasonpearson.automobile.slides.model.SlideContent + +/** Slides covering the rise of browser use and the mobile gap. */ +fun getMobileUseSlides(): List = + listOf( + SlideContent.LargeText(title = "Mobile Use"), + SlideContent.LargeText( + title = "2025 kicked off with browser use", + subtitle = "Playwright + MCP", + ), + SlideContent.BulletPoints( + title = "The web got:", + points = + listOf( + BulletPoint(text = "AI-driven tests for a wider audience"), + BulletPoint(text = "Explore, debug, prototype"), + BulletPoint(text = "An explosion of vibe coding"), + ), + ), + SlideContent.Emoji( + emoji = PresentationEmoji.PHONE, + caption = "But not as much for mobile", + ), + SlideContent.LargeText( + title = "What did web folks have that mobile didn't?", + ), + SlideContent.BulletPoints( + title = "A frontend engineer could ask an AI agent to:", + points = + listOf( + BulletPoint(text = "Inspect a page"), + BulletPoint(text = "Click through a user flow"), + BulletPoint(text = "Check local storage, profile performance"), + BulletPoint(text = "File a bug — all in one session"), + ), + ), + SlideContent.Emoji( + emoji = PresentationEmoji.SHRUG, + caption = "Mobile engineers were still alt-tabbing between Cursor and Android Studio", + ), + SlideContent.LargeText( + title = "Solving UI testing tooling problems opens up full mobile use", + ), + ) diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/03-EarlySuccessSlides.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/03-EarlySuccessSlides.kt new file mode 100644 index 000000000..f956e80ea --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/03-EarlySuccessSlides.kt @@ -0,0 +1,66 @@ +package dev.jasonpearson.automobile.slides.data + +import dev.jasonpearson.automobile.slides.model.BulletPoint +import dev.jasonpearson.automobile.slides.model.PresentationEmoji +import dev.jasonpearson.automobile.slides.model.SlideContent + +/** Slides explaining what AutoMobile does and its key capabilities. */ +fun getWhatAutoMobileDoesSlides(): List = + listOf( + SlideContent.LargeText(title = "What AutoMobile Does"), + SlideContent.LargeText( + title = "You describe what you want to do", + subtitle = "The AI handles the implementation details", + ), + SlideContent.Emoji( + emoji = PresentationEmoji.MAGNIFYING_GLASS, + caption = "Explore the app and explain how it works", + ), + SlideContent.BulletPoints( + title = "App exploration", + points = + listOf( + BulletPoint(text = "Navigate and inspect like real users do"), + BulletPoint(text = "Discover features through well designed UX"), + BulletPoint(text = "Helpful early development feedback cycle"), + ), + ), + SlideContent.Emoji( + emoji = PresentationEmoji.TARGET, + caption = "Reproduce a bug from a crash report", + ), + SlideContent.BulletPoints( + title = "Bug reproduction", + points = + listOf( + BulletPoint(text = "Paste a bug report and say \"reproduce this\""), + BulletPoint(text = "Automatic video recording and device snapshot"), + BulletPoint(text = "Time series performance data correlated with actions"), + ), + ), + SlideContent.Emoji( + emoji = PresentationEmoji.FAST, + caption = "Profile app performance", + ), + SlideContent.BulletPoints( + title = "Performance profiling", + points = + listOf( + BulletPoint(text = "Constant performance indicator for debug and release"), + BulletPoint(text = "Real time numbers in IDE companion plugin"), + BulletPoint(text = "Making performance work more accessible"), + ), + ), + SlideContent.LargeText( + title = "Element search is highly reproducible", + ), + SlideContent.BulletPoints( + title = "How it keeps working:", + points = + listOf( + BulletPoint(text = "Automatically determines element and screen position"), + BulletPoint(text = "Survives design changes and different devices"), + BulletPoint(text = "Does not rely on AI for every interaction"), + ), + ), + ) diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/10-VisionSlides.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/10-VisionSlides.kt new file mode 100644 index 000000000..985f0d697 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/10-VisionSlides.kt @@ -0,0 +1,40 @@ +package dev.jasonpearson.automobile.slides.data + +import dev.jasonpearson.automobile.slides.R +import dev.jasonpearson.automobile.slides.model.BulletPoint +import dev.jasonpearson.automobile.slides.model.SlideContent + +fun getVisionSlides(): List = + listOf( + SlideContent.LargeText(title = "State of the Project"), + SlideContent.LargeText(title = "Android platform support is solid"), + SlideContent.LargeText( + title = "iOS support prototyped", + subtitle = "AI agent & IDE plugin running on iOS simulators", + ), + SlideContent.LargeText(title = "What's Next"), + SlideContent.BulletPoints( + title = "Actively building toward 1.0", + points = + listOf( + BulletPoint(text = "Design docs to organize project vision and architecture"), + BulletPoint(text = "Technical blog posts on the journey"), + BulletPoint(text = "Standalone desktop apps in the works"), + ), + ), + SlideContent.BulletPoints( + title = "Writing about:", + points = + listOf( + BulletPoint(text = "Performance improvements"), + BulletPoint(text = "How it reuses its observability"), + BulletPoint(text = "Challenges of emulator/simulator operation"), + BulletPoint(text = "Keeping a mono-repo with big ambitions organized"), + ), + ), + SlideContent.Screenshot( + title = "Questions?", + lightScreenshot = R.drawable.auto_mobile_qr_code, + caption = "https://www.jasonpearson.dev", + ), + ) diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/AllSlides.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/AllSlides.kt new file mode 100644 index 000000000..290ca070f --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/data/AllSlides.kt @@ -0,0 +1,20 @@ +package dev.jasonpearson.automobile.slides.data + +import dev.jasonpearson.automobile.slides.model.PresentationEmoji +import dev.jasonpearson.automobile.slides.model.SlideContent + +/** + * Combined slides data for the complete AutoMobile presentation. Combines all individual slide + * sections into one comprehensive presentation. + */ +fun getAllSlides(): List = + getIntroductionSlides() + + getMobileUseSlides() + + getWhatAutoMobileDoesSlides() + + listOf( + SlideContent.Emoji( + emoji = PresentationEmoji.PLAYGROUND, + caption = "Demo: AutoMobile Playground", + ), + ) + + getVisionSlides() diff --git a/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/examples/HighlightingExample.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/examples/HighlightingExample.kt new file mode 100644 index 000000000..fd506f5c3 --- /dev/null +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/examples/HighlightingExample.kt @@ -0,0 +1,194 @@ +package dev.jasonpearson.automobile.slides.examples + +import dev.jasonpearson.automobile.slides.model.SlideContent + +/** + * Example usage of the new CodeSample highlighting feature. This demonstrates how to highlight + * specific lines in code samples for presentations. + */ +object HighlightingExample { + + /** Example 1: Highlighting specific lines in Android log output */ + fun createWindowManagerLogSlide(): SlideContent.CodeSample { + return SlideContent.CodeSample( + code = + """ + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + + mGlobalConfiguration={1.0 310mcc260mnc [en_US] ldltr sw448dp w997dp h448dp 360dpi nrml long hdr widecg land finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 2244, 1008) mAppBounds=Rect(0, 0 - 2244, 1008) mMaxBounds=Rect(0, 0 - 2244, 1008) mDisplayRotation=ROTATION_90 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_90} s.6257 fontWeightAdjustment=0} + mHasPermanentDpad=false + mTopFocusedDisplayId=0 + imeLayeringTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeInputTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeControlTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + Minimum task size of display#0 220 mBlurEnabled=true + mLastDisplayFreezeDuration=0 due to new-config + mDisableSecureWindows=false + mHighResSnapshotScale=0.8 + mSnapshotEnabled=true + SnapshotCache Task + """ + .trimIndent(), + language = "shell", + title = "Window Manager Debug Output", + highlight = + """ + imeLayeringTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeInputTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeControlTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + """ + .trimIndent(), + ) + } + + /** Example 2: Highlighting key test methods in Kotlin code */ + fun createTestCodeSlide(): SlideContent.CodeSample { + return SlideContent.CodeSample( + code = + """ + @Test + fun testLoginFlow() { + // Launch the app + tapOn(text = "Login") + + // Enter credentials + inputText("user@example.com") + tapOn(text = "Next") + inputText("password123") + + // Submit login + tapOn(text = "Sign In") + + // Verify success + assertVisible(text = "Welcome") + } + + @Test + fun testLogoutFlow() { + // Navigate to settings + tapOn(text = "Settings") + + // Tap logout + tapOn(text = "Logout") + + // Confirm logout + tapOn(text = "Confirm") + + // Verify back to login screen + assertVisible(text = "Login") + } + """ + .trimIndent(), + language = "kotlin", + title = "AutoMobile Test Examples", + highlight = + """ + tapOn(text = "Login") + inputText("user@example.com") + tapOn(text = "Sign In") + assertVisible(text = "Welcome") + """ + .trimIndent(), + ) + } + + /** Example 3: Highlighting configuration changes in YAML */ + fun createConfigurationSlide(): SlideContent.CodeSample { + return SlideContent.CodeSample( + code = + """ + # AutoMobile Test Plan + name: "User Login Flow" + version: "1.0" + + steps: + - action: "tap" + text: "Login" + description: "Tap the login button" + + - action: "inputText" + text: "user@example.com" + description: "Enter email address" + + - action: "tap" + text: "Next" + description: "Proceed to password" + + - action: "inputText" + text: "password123" + description: "Enter password" + + - action: "tap" + text: "Sign In" + description: "Submit credentials" + + - action: "assertVisible" + selector: + text: "Welcome" + description: "Verify successful login" + """ + .trimIndent(), + language = "yaml", + title = "AutoMobile Test Plan Configuration", + highlight = + """ + action: "tap" + action: "inputText" + action: "assertVisible" + """ + .trimIndent(), + ) + } + + /** Example 4: Highlighting specific API calls in JSON response */ + fun createApiResponseSlide(): SlideContent.CodeSample { + return SlideContent.CodeSample( + code = + """ + { + "status": "success", + "data": { + "user": { + "id": 12345, + "name": "John Doe", + "email": "john.doe@example.com", + "preferences": { + "theme": "dark", + "notifications": true, + "language": "en" + } + }, + "session": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires": "2024-12-31T23:59:59Z", + "refresh_token": "rt_abc123def456" + } + }, + "timestamp": "2024-01-15T10:30:00Z", + "request_id": "req_789xyz" + } + """ + .trimIndent(), + language = "json", + title = "API Response Structure", + highlight = + """ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + "expires": "2024-12-31T23:59:59Z" + "refresh_token": "rt_abc123def456" + """ + .trimIndent(), + ) + } + + /** Get all highlighting examples as a list of slides */ + fun getAllExamples(): List { + return listOf( + createWindowManagerLogSlide(), + createTestCodeSlide(), + createConfigurationSlide(), + createApiResponseSlide(), + ) + } +} diff --git a/android/playground/slides/src/main/java/com/zillow/automobile/slides/model/SlideContent.kt b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/model/SlideContent.kt similarity index 94% rename from android/playground/slides/src/main/java/com/zillow/automobile/slides/model/SlideContent.kt rename to android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/model/SlideContent.kt index 062fbef2a..be51fdd37 100644 --- a/android/playground/slides/src/main/java/com/zillow/automobile/slides/model/SlideContent.kt +++ b/android/playground/slides/src/main/kotlin/dev/jasonpearson/automobile/slides/model/SlideContent.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.slides.model +package dev.jasonpearson.automobile.slides.model /** * Sealed class representing different types of slide content. Each slide type has its own data @@ -13,8 +13,7 @@ sealed class SlideContent { /** * Bulleted list slide with hierarchical support. Each bullet point can contain nested sub-points. */ - data class BulletPoints(val title: String? = null, val points: List) : - SlideContent() + data class BulletPoints(val title: String? = null, val points: List) : SlideContent() /** * Emoji slide with large emoji display and optional caption. Uses predefined emoji set for @@ -30,7 +29,7 @@ sealed class SlideContent { val code: String, val language: String, val title: String? = null, - val highlight: String? = null + val highlight: String? = null, ) : SlideContent() /** @@ -40,7 +39,7 @@ sealed class SlideContent { data class Visualization( val imageUrl: String, val caption: String? = null, - val contentDescription: String? = null + val contentDescription: String? = null, ) : SlideContent() /** @@ -49,7 +48,7 @@ sealed class SlideContent { data class Video( val videoUrl: String, val caption: String? = null, - val contentDescription: String? = null + val contentDescription: String? = null, ) : SlideContent() /** @@ -70,7 +69,7 @@ sealed class SlideContent { val darkScreenshot: Int? = null, val title: String? = null, val caption: String? = null, - val contentDescription: String? = null + val contentDescription: String? = null, ) : SlideContent() } @@ -151,5 +150,5 @@ enum class PresentationEmoji(val unicode: String, val description: String) { PLAYGROUND("🛝", "Playground/Testing"), ONE("1️⃣", "One/Single"), TWO("2️⃣", "Two/Double"), - THREE("3️⃣", "Three/Triple") + THREE("3️⃣", "Three/Triple"), } diff --git a/android/playground/slides/src/test/java/com/zillow/automobile/slides/LiveDemoTest.kt b/android/playground/slides/src/test/java/com/zillow/automobile/slides/LiveDemoTest.kt deleted file mode 100644 index c592bd772..000000000 --- a/android/playground/slides/src/test/java/com/zillow/automobile/slides/LiveDemoTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.zillow.automobile.slides - -import com.zillow.automobile.junit.AutoMobilePlan -import com.zillow.automobile.junit.AutoMobileRunner -import com.zillow.automobile.junit.AutoMobileTest -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AutoMobileRunner::class) -class LiveDemoTest { - - @Test - fun `Given we have a Clock app we should be able to set an alarm`() { - - // ab test or - val result = - AutoMobilePlan("test-plans/clock-set-alarm.yaml", { "username" to "jason@zillow.com" }) - .execute() - - assertTrue(result.success) - } - - // - // @Test - // @AutoMobileTest(plan = "test-plans/zillow-testing.yaml") - // fun `Given Zillow has 3D homes we should be able to tour them`() {} - - // @Test - // @AutoMobileTest(plan = "test-plans/zillow-3d-home-exploration.yaml") - // fun `Zillow tour 3d home`() { - // // Traditional annotation-based approach - // // AI assistance disabled for this test - // } - // - // @Test - // @AutoMobileTest(plan = "test-plans/zillow-full-feature.yaml") - // fun `Zillow full feature test`() { - // // Traditional annotation-based approach - // // AI assistance disabled for this test - // } - - @Test - fun `AutoMobile playground`() { - - val result = - AutoMobilePlan("test-plans/auto-mobile-playground.yaml", { "slide" to "46" }).execute() - - assertTrue(result.success) - } - - @Test - fun `AutoMobile restart slide`() { - - val result = - AutoMobilePlan("test-plans/auto-mobile-restart-slide.yaml", { "slide" to "81" }).execute() - - assertTrue(result.success) - } - - @Test @AutoMobileTest(plan = "test-plans/bluesky-ready-to-go.yaml") fun `Ready for the talk`() {} - - @Test - fun `Announce AutoMobile is OSS on GitHub`() { - - val result = - AutoMobilePlan("test-plans/bluesky-announcement.yaml", { "slide" to "83" }).execute() - - assertTrue(result.success) - } - - @Test - fun `Victory Fanfare`() { - - val result = - AutoMobilePlan( - "test-plans/system-notification-youtube-music-play.yaml", { "slide" to "83" }) - .execute() - - assertTrue(result.success) - } -} diff --git a/android/playground/slides/src/test/java/com/zillow/automobile/slides/SlidesScreenTest.kt b/android/playground/slides/src/test/java/com/zillow/automobile/slides/SlidesScreenTest.kt deleted file mode 100644 index 896d6a5c6..000000000 --- a/android/playground/slides/src/test/java/com/zillow/automobile/slides/SlidesScreenTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package com.zillow.automobile.slides - -import com.zillow.automobile.slides.data.getAllSlides -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class SlidesScreenTest { - - @Test - fun `getAllSlides should return non-empty list of slides`() { - val slides = getAllSlides() - assertTrue("Sample slides should not be empty", slides.isNotEmpty()) - assertTrue("Should have multiple slides", slides.size > 3) - } - - @Test - fun `sample slides should include different slide types`() { - val slides = getAllSlides() - - val hasLargeText = slides.any { it is SlideContent.LargeText } - val hasEmoji = slides.any { it is SlideContent.Emoji } - val hasBulletPoints = slides.any { it is SlideContent.BulletPoints } - val hasCodeSample = slides.any { it is SlideContent.CodeSample } - val hasMermaidDiagram = slides.any { it is SlideContent.MermaidDiagram } - - assertTrue("Should include LargeText slides", hasLargeText) - assertTrue("Should include Emoji slides", hasEmoji) - assertTrue("Should include BulletPoints slides", hasBulletPoints) - assertTrue("Should include CodeSample slides", hasCodeSample) - assertTrue("Should include MermaidDiagram slides", hasMermaidDiagram) - } - - @Test - fun `getAllSlides should contain MermaidDiagram slides`() { - val slides = getAllSlides() - val mermaidSlides = slides.filterIsInstance() - assertTrue("Should contain at least one Mermaid diagram slide", mermaidSlides.isNotEmpty()) - - // Verify the Mermaid slide has proper content - val testFlowSlide = mermaidSlides.find { it.title == "AutoMobile Test Flow" } - assertTrue("Should contain the AutoMobile Test Flow diagram", testFlowSlide != null) - testFlowSlide?.let { slide -> - assertTrue("Should contain flowchart syntax", slide.code.contains("flowchart TD")) - assertTrue( - "Should contain AutoMobile-specific content", slide.code.contains("observe Screen")) - } - } - - @Test - fun `sample slides should have meaningful content`() { - val slides = getAllSlides() - - // Check first slide is a title slide - val firstSlide = slides.first() - assertTrue("First slide should be LargeText", firstSlide is SlideContent.LargeText) - - val titleSlide = firstSlide as SlideContent.LargeText - assertTrue("Title should mention AutoMobile", titleSlide.title.contains("AutoMobile")) - - // Check there are emoji slides with captions - val emojiSlides = slides.filterIsInstance() - assertTrue("Should have emoji slides", emojiSlides.isNotEmpty()) - - val emojiSlidesWithCaptions = emojiSlides.filter { it.caption != null } - assertTrue("Some emoji slides should have captions", emojiSlidesWithCaptions.isNotEmpty()) - - // Check bullet point slides have multiple points - val bulletPointSlides = slides.filterIsInstance() - assertTrue("Should have bullet point slides", bulletPointSlides.isNotEmpty()) - - bulletPointSlides.forEach { slide -> - assertTrue("Bullet point slides should have multiple points", slide.points.size >= 2) - } - - // Check code sample slides have valid content - val codeSlides = slides.filterIsInstance() - assertTrue("Should have code sample slides", codeSlides.isNotEmpty()) - - codeSlides.forEach { slide -> - assertTrue("Code should not be empty", slide.code.isNotEmpty()) - assertTrue("Language should not be empty", slide.language.isNotEmpty()) - // Updated to be more flexible for mermaid diagrams and other content - assertTrue( - "Code should contain meaningful content", - slide.code.contains("@Test") || - slide.code.contains("fun ") || - slide.code.contains("flowchart") || - slide.code.contains("sequenceDiagram") || - slide.code.length > 20) - } - } -} - -class SlideContentTestHelper { - - @Test - fun `createTestSlides should generate valid slide content`() { - val slides = createTestSlides() - - assertEquals("Should have 5 test slides", 5, slides.size) - - // Verify each slide type - assertTrue("First slide should be LargeText", slides[0] is SlideContent.LargeText) - assertTrue("Second slide should be Emoji", slides[1] is SlideContent.Emoji) - assertTrue("Third slide should be BulletPoints", slides[2] is SlideContent.BulletPoints) - assertTrue("Fourth slide should be CodeSample", slides[3] is SlideContent.CodeSample) - assertTrue("Fifth slide should be Visualization", slides[4] is SlideContent.Visualization) - } - - @Test - fun `createEmptySlideList should return empty list`() { - val slides = createEmptySlideList() - - assertTrue("Slide list should be empty", slides.isEmpty()) - } - - @Test - fun `createSingleSlide should return list with one slide`() { - val slide = SlideContent.LargeText("Test Title", "Test Subtitle") - val slides = createSingleSlideList(slide) - - assertEquals("Should have exactly one slide", 1, slides.size) - assertEquals("Should be the same slide", slide, slides[0]) - } - - private fun createTestSlides(): List = - listOf( - SlideContent.LargeText("Test Title", "Test Subtitle"), - SlideContent.Emoji(PresentationEmoji.ROCKET, "Test Caption"), - SlideContent.BulletPoints( - "Test Features", - listOf(BulletPoint("Feature 1", listOf("Sub 1", "Sub 2")), BulletPoint("Feature 2"))), - SlideContent.CodeSample("fun test() {}", "kotlin", "Test Code"), - SlideContent.Visualization("test-image.png", "Test Image")) - - private fun createEmptySlideList(): List = emptyList() - - private fun createSingleSlideList(slide: SlideContent): List = listOf(slide) -} - -class SlideNavigationTest { - - @Test - fun `slide index should be coerced within valid range`() { - val slides = - listOf( - SlideContent.LargeText("Slide 1"), - SlideContent.LargeText("Slide 2"), - SlideContent.LargeText("Slide 3")) - - // Test negative index - val negativeIndex = -5 - val coercedNegative = negativeIndex.coerceIn(0, slides.size - 1) - assertEquals("Negative index should be coerced to 0", 0, coercedNegative) - - // Test index beyond range - val beyondIndex = 10 - val coercedBeyond = beyondIndex.coerceIn(0, slides.size - 1) - assertEquals("Index beyond range should be coerced to last slide", 2, coercedBeyond) - - // Test valid index - val validIndex = 1 - val coercedValid = validIndex.coerceIn(0, slides.size - 1) - assertEquals("Valid index should remain unchanged", 1, coercedValid) - } - - @Test - fun `slide validation should work correctly`() { - val slides = listOf(SlideContent.LargeText("Slide 1"), SlideContent.LargeText("Slide 2")) - - assertTrue("Index 0 should be valid", 0 in slides.indices) - assertTrue("Index 1 should be valid", 1 in slides.indices) - assertFalse("Index 2 should be invalid", 2 in slides.indices) - assertFalse("Negative index should be invalid", -1 in slides.indices) - } - - @Test - fun `slide count should be calculated correctly`() { - val emptySlides = emptyList() - assertEquals("Empty slides should have count 0", 0, emptySlides.size) - - val singleSlide = listOf(SlideContent.LargeText("Single")) - assertEquals("Single slide should have count 1", 1, singleSlide.size) - - val multipleSlides = - listOf( - SlideContent.LargeText("First"), - SlideContent.Emoji(PresentationEmoji.ROCKET), - SlideContent.BulletPoints("Third", emptyList())) - assertEquals("Multiple slides should have correct count", 3, multipleSlides.size) - } -} diff --git a/android/playground/slides/src/test/java/com/zillow/automobile/slides/components/CodeSampleSlideItemTest.kt b/android/playground/slides/src/test/java/com/zillow/automobile/slides/components/CodeSampleSlideItemTest.kt deleted file mode 100644 index c17b06daa..000000000 --- a/android/playground/slides/src/test/java/com/zillow/automobile/slides/components/CodeSampleSlideItemTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.zillow.automobile.slides.components - -import org.junit.Assert.assertTrue -import org.junit.Test - -/** Tests for CodeSampleSlideItem highlighting functionality. */ -class CodeSampleSlideItemTest { - - @Test - fun `processCodeWithHighlighting should highlight matching lines`() { - val code = - """ - keepClearAreas: restricted=[], unrestricted=[] - mPrepareSyncSeqId=0 - - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - Minimum task size of display#0 220 mBlurEnabled=true - """ - .trimIndent() - - val highlight = - """ - imeLayeringTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeInputTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - imeControlTarget in display# 0 Window{ea58714 u0 com.zillow.automobile.playground/com.zillow.automobile.playground.MainActivity} - """ - .trimIndent() - - val result = processCodeWithHighlighting(code, highlight, true) - - // Verify that highlighted lines contain the highlighted-line class - assertTrue("Result should contain highlighted-line spans", result.contains("highlighted-line")) - assertTrue("Result should contain dimmed-line spans", result.contains("dimmed-line")) - } - - @Test - fun `processCodeWithHighlighting should handle empty highlight`() { - val code = "fun main() { println(\"Hello\") }" - val highlight = "" - - val result = processCodeWithHighlighting(code, highlight, false) - - // When highlight is empty, all lines should be dimmed - assertTrue("Should contain only dimmed-line spans", result.contains("dimmed-line")) - assertTrue("Should not contain highlighted-line spans", !result.contains("highlighted-line")) - } - - @Test - fun `processCodeWithHighlighting should escape HTML characters`() { - val code = "content" - val highlight = "content" - - val result = processCodeWithHighlighting(code, highlight, false) - - // HTML characters should be escaped - assertTrue("Should escape < character", result.contains("<")) - assertTrue("Should escape > character", result.contains(">")) - assertTrue("Should not contain raw < character", !result.contains("")) - } -} diff --git a/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/LiveDemoTest.kt b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/LiveDemoTest.kt new file mode 100644 index 000000000..08ba7bdc8 --- /dev/null +++ b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/LiveDemoTest.kt @@ -0,0 +1,89 @@ +package dev.jasonpearson.automobile.slides + +import dev.jasonpearson.automobile.junit.AutoMobilePlan +import dev.jasonpearson.automobile.junit.AutoMobileRunner +import dev.jasonpearson.automobile.junit.AutoMobileTest +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith + +@Ignore("Live demo tests - require connected device and specific apps installed") +@RunWith(AutoMobileRunner::class) +class LiveDemoTest { + + @Test + fun `Given we have a Clock app we should be able to set an alarm`() { + + // ab test or + val result = AutoMobilePlan("test-plans/clock-set-alarm.yaml").execute() + + assertTrue(result.success) + } + + @Test + fun `AutoMobile playground`() { + + val result = + AutoMobilePlan("test-plans/auto-mobile-playground.yaml").execute() + + assertTrue(result.success) + } + + @Ignore + @Test + fun `Bug Reproduction Demo`() { + + val result = + AutoMobilePlan("test-plans/bug-repro.yaml").execute() + + assertTrue(result.success) + } + + @Test + fun `Browse YouTube`() { + + val result = + AutoMobilePlan("test-plans/youtube-search.yaml").execute() + + assertTrue(result.success) + } + + @Test + fun `Browse Google Maps`() { + + val result = + AutoMobilePlan("test-plans/google-maps.yaml").execute() + + assertTrue(result.success) + } + + @Test + fun `Explore Camera App`() { + + val result = + AutoMobilePlan("test-plans/camera-app.yaml").execute() + + assertTrue(result.success) + } + + @Ignore + @Test + fun `AutoMobile restart slide`() { + + val result = + AutoMobilePlan("test-plans/auto-mobile-restart-slide.yaml", { "slide" to "81" }).execute() + + assertTrue(result.success) + } + + @Ignore + @Test + fun `Announce AutoMobile is OSS on GitHub`() { + + val result = + AutoMobilePlan("test-plans/bluesky-announcement.yaml", { "slide" to "83" }).execute() + + assertTrue(result.success) + } +} diff --git a/android/playground/slides/src/test/java/com/zillow/automobile/slides/SlidesLogicTest.kt b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/SlidesLogicTest.kt similarity index 91% rename from android/playground/slides/src/test/java/com/zillow/automobile/slides/SlidesLogicTest.kt rename to android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/SlidesLogicTest.kt index c9c62339b..964ed2989 100644 --- a/android/playground/slides/src/test/java/com/zillow/automobile/slides/SlidesLogicTest.kt +++ b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/SlidesLogicTest.kt @@ -1,8 +1,8 @@ -package com.zillow.automobile.slides +package dev.jasonpearson.automobile.slides -import com.zillow.automobile.slides.model.BulletPoint -import com.zillow.automobile.slides.model.PresentationEmoji -import com.zillow.automobile.slides.model.SlideContent +import dev.jasonpearson.automobile.slides.model.BulletPoint +import dev.jasonpearson.automobile.slides.model.PresentationEmoji +import dev.jasonpearson.automobile.slides.model.SlideContent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -44,14 +44,17 @@ class SlidesLogicTest { assertTrue("Should be able to navigate to last slide", (totalSlides - 1) in 0 until totalSlides) assertFalse("Should not be able to navigate to negative index", -1 in 0 until totalSlides) assertFalse( - "Should not be able to navigate beyond last slide", totalSlides in 0 until totalSlides) + "Should not be able to navigate beyond last slide", + totalSlides in 0 until totalSlides, + ) // Test coercion logic assertEquals("Negative index should coerce to 0", 0, (-5).coerceIn(0, totalSlides - 1)) assertEquals( "Index beyond range should coerce to last", totalSlides - 1, - (totalSlides + 5).coerceIn(0, totalSlides - 1)) + (totalSlides + 5).coerceIn(0, totalSlides - 1), + ) assertEquals("Valid index should remain unchanged", 2, 2.coerceIn(0, totalSlides - 1)) } @@ -98,7 +101,8 @@ class SlidesLogicTest { is SlideContent.Screenshot -> { assertTrue( "Screenshot should have at least one screenshot", - slide.lightScreenshot != null || slide.darkScreenshot != null) + slide.lightScreenshot != null || slide.darkScreenshot != null, + ) } } } @@ -110,7 +114,8 @@ class SlidesLogicTest { listOf( BulletPoint("Main point 1", listOf("Sub 1", "Sub 2")), BulletPoint("Main point 2", emptyList()), - BulletPoint("Main point 3", listOf("Sub A", "Sub B", "Sub C"))) + BulletPoint("Main point 3", listOf("Sub A", "Sub B", "Sub C")), + ) val slide = SlideContent.BulletPoints("Test Features", bulletPoints) @@ -131,7 +136,8 @@ class SlidesLogicTest { PresentationEmoji.CHECKMARK to "✅", PresentationEmoji.WARNING to "⚠️", PresentationEmoji.FIRE to "🔥", - PresentationEmoji.THUMBS_UP to "👍") + PresentationEmoji.THUMBS_UP to "👍", + ) expectedEmojis.forEach { (emoji, expectedUnicode) -> assertEquals("Emoji $emoji should have correct unicode", expectedUnicode, emoji.unicode) @@ -202,15 +208,22 @@ class SlidesLogicTest { listOf( BulletPoint("Source Intelligence", listOf("Code analysis", "Smart selectors")), BulletPoint("Cross-platform", listOf("Android", "iOS")), - BulletPoint("JUnit Integration"))), + BulletPoint("JUnit Integration"), + ), + ), SlideContent.CodeSample( code = "@Test\nfun testExample() {\n tapOn(text = \"Button\")\n assertVisible(text = \"Success\")\n}", language = "kotlin", - title = "Simple Test"), + title = "Simple Test", + ), SlideContent.Visualization("architecture.png", "System Architecture"), SlideContent.Video("demo.mp4", "Live Demo"), SlideContent.MermaidDiagram("mermaidCode", "Mermaid Diagram"), SlideContent.Screenshot( - lightScreenshot = 1, darkScreenshot = 2, caption = "Screenshot Example")) + lightScreenshot = 1, + darkScreenshot = 2, + caption = "Screenshot Example", + ), + ) } diff --git a/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/SlidesScreenTest.kt b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/SlidesScreenTest.kt new file mode 100644 index 000000000..87cd278e1 --- /dev/null +++ b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/SlidesScreenTest.kt @@ -0,0 +1,164 @@ +package dev.jasonpearson.automobile.slides + +import dev.jasonpearson.automobile.slides.data.getAllSlides +import dev.jasonpearson.automobile.slides.model.BulletPoint +import dev.jasonpearson.automobile.slides.model.PresentationEmoji +import dev.jasonpearson.automobile.slides.model.SlideContent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SlidesScreenTest { + + @Test + fun `getAllSlides should return non-empty list of slides`() { + val slides = getAllSlides() + assertTrue("Sample slides should not be empty", slides.isNotEmpty()) + assertTrue("Should have multiple slides", slides.size > 3) + } + + @Test + fun `sample slides should include different slide types`() { + val slides = getAllSlides() + + val hasLargeText = slides.any { it is SlideContent.LargeText } + val hasEmoji = slides.any { it is SlideContent.Emoji } + val hasBulletPoints = slides.any { it is SlideContent.BulletPoints } + + assertTrue("Should include LargeText slides", hasLargeText) + assertTrue("Should include Emoji slides", hasEmoji) + assertTrue("Should include BulletPoints slides", hasBulletPoints) + } + + @Test + fun `sample slides should have meaningful content`() { + val slides = getAllSlides() + + // Check first slide is a title slide + val firstSlide = slides.first() + assertTrue("First slide should be LargeText", firstSlide is SlideContent.LargeText) + + val titleSlide = firstSlide as SlideContent.LargeText + assertTrue("Title should mention AutoMobile", titleSlide.title.contains("AutoMobile")) + + // Check there are emoji slides with captions + val emojiSlides = slides.filterIsInstance() + assertTrue("Should have emoji slides", emojiSlides.isNotEmpty()) + + val emojiSlidesWithCaptions = emojiSlides.filter { it.caption != null } + assertTrue("Some emoji slides should have captions", emojiSlidesWithCaptions.isNotEmpty()) + + // Check bullet point slides have multiple points + val bulletPointSlides = slides.filterIsInstance() + assertTrue("Should have bullet point slides", bulletPointSlides.isNotEmpty()) + + bulletPointSlides.forEach { slide -> + assertTrue("Bullet point slides should have multiple points", slide.points.size >= 2) + } + } +} + +class SlideContentTestHelper { + + @Test + fun `createTestSlides should generate valid slide content`() { + val slides = createTestSlides() + + assertEquals("Should have 5 test slides", 5, slides.size) + + // Verify each slide type + assertTrue("First slide should be LargeText", slides[0] is SlideContent.LargeText) + assertTrue("Second slide should be Emoji", slides[1] is SlideContent.Emoji) + assertTrue("Third slide should be BulletPoints", slides[2] is SlideContent.BulletPoints) + assertTrue("Fourth slide should be CodeSample", slides[3] is SlideContent.CodeSample) + assertTrue("Fifth slide should be Visualization", slides[4] is SlideContent.Visualization) + } + + @Test + fun `createEmptySlideList should return empty list`() { + val slides = createEmptySlideList() + + assertTrue("Slide list should be empty", slides.isEmpty()) + } + + @Test + fun `createSingleSlide should return list with one slide`() { + val slide = SlideContent.LargeText("Test Title", "Test Subtitle") + val slides = createSingleSlideList(slide) + + assertEquals("Should have exactly one slide", 1, slides.size) + assertEquals("Should be the same slide", slide, slides[0]) + } + + private fun createTestSlides(): List = + listOf( + SlideContent.LargeText("Test Title", "Test Subtitle"), + SlideContent.Emoji(PresentationEmoji.ROCKET, "Test Caption"), + SlideContent.BulletPoints( + "Test Features", + listOf(BulletPoint("Feature 1", listOf("Sub 1", "Sub 2")), BulletPoint("Feature 2")), + ), + SlideContent.CodeSample("fun test() {}", "kotlin", "Test Code"), + SlideContent.Visualization("test-image.png", "Test Image"), + ) + + private fun createEmptySlideList(): List = emptyList() + + private fun createSingleSlideList(slide: SlideContent): List = listOf(slide) +} + +class SlideNavigationTest { + + @Test + fun `slide index should be coerced within valid range`() { + val slides = + listOf( + SlideContent.LargeText("Slide 1"), + SlideContent.LargeText("Slide 2"), + SlideContent.LargeText("Slide 3"), + ) + + // Test negative index + val negativeIndex = -5 + val coercedNegative = negativeIndex.coerceIn(0, slides.size - 1) + assertEquals("Negative index should be coerced to 0", 0, coercedNegative) + + // Test index beyond range + val beyondIndex = 10 + val coercedBeyond = beyondIndex.coerceIn(0, slides.size - 1) + assertEquals("Index beyond range should be coerced to last slide", 2, coercedBeyond) + + // Test valid index + val validIndex = 1 + val coercedValid = validIndex.coerceIn(0, slides.size - 1) + assertEquals("Valid index should remain unchanged", 1, coercedValid) + } + + @Test + fun `slide validation should work correctly`() { + val slides = listOf(SlideContent.LargeText("Slide 1"), SlideContent.LargeText("Slide 2")) + + assertTrue("Index 0 should be valid", 0 in slides.indices) + assertTrue("Index 1 should be valid", 1 in slides.indices) + assertFalse("Index 2 should be invalid", 2 in slides.indices) + assertFalse("Negative index should be invalid", -1 in slides.indices) + } + + @Test + fun `slide count should be calculated correctly`() { + val emptySlides = emptyList() + assertEquals("Empty slides should have count 0", 0, emptySlides.size) + + val singleSlide = listOf(SlideContent.LargeText("Single")) + assertEquals("Single slide should have count 1", 1, singleSlide.size) + + val multipleSlides = + listOf( + SlideContent.LargeText("First"), + SlideContent.Emoji(PresentationEmoji.ROCKET), + SlideContent.BulletPoints("Third", emptyList()), + ) + assertEquals("Multiple slides should have correct count", 3, multipleSlides.size) + } +} diff --git a/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/components/CodeSampleSlideItemTest.kt b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/components/CodeSampleSlideItemTest.kt new file mode 100644 index 000000000..13aceeba8 --- /dev/null +++ b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/components/CodeSampleSlideItemTest.kt @@ -0,0 +1,62 @@ +package dev.jasonpearson.automobile.slides.components + +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tests for CodeSampleSlideItem highlighting functionality. */ +class CodeSampleSlideItemTest { + + @Test + fun `processCodeWithHighlighting should highlight matching lines`() { + val code = + """ + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + + imeLayeringTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeInputTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeControlTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + Minimum task size of display#0 220 mBlurEnabled=true + """ + .trimIndent() + + val highlight = + """ + imeLayeringTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeInputTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + imeControlTarget in display# 0 Window{ea58714 u0 dev.jasonpearson.automobile.playground/dev.jasonpearson.automobile.playground.MainActivity} + """ + .trimIndent() + + val result = processCodeWithHighlighting(code, highlight, true) + + // Verify that highlighted lines contain the highlighted-line class + assertTrue("Result should contain highlighted-line spans", result.contains("highlighted-line")) + assertTrue("Result should contain dimmed-line spans", result.contains("dimmed-line")) + } + + @Test + fun `processCodeWithHighlighting should handle empty highlight`() { + val code = "fun main() { println(\"Hello\") }" + val highlight = "" + + val result = processCodeWithHighlighting(code, highlight, false) + + // When highlight is empty, all lines should be dimmed + assertTrue("Should contain only dimmed-line spans", result.contains("dimmed-line")) + assertTrue("Should not contain highlighted-line spans", !result.contains("highlighted-line")) + } + + @Test + fun `processCodeWithHighlighting should escape HTML characters`() { + val code = "content" + val highlight = "content" + + val result = processCodeWithHighlighting(code, highlight, false) + + // HTML characters should be escaped + assertTrue("Should escape < character", result.contains("<")) + assertTrue("Should escape > character", result.contains(">")) + assertTrue("Should not contain raw < character", !result.contains("")) + } +} diff --git a/android/playground/slides/src/test/java/com/zillow/automobile/slides/model/SlideContentTest.kt b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/model/SlideContentTest.kt similarity index 97% rename from android/playground/slides/src/test/java/com/zillow/automobile/slides/model/SlideContentTest.kt rename to android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/model/SlideContentTest.kt index e0244cef4..7fec0e31a 100644 --- a/android/playground/slides/src/test/java/com/zillow/automobile/slides/model/SlideContentTest.kt +++ b/android/playground/slides/src/test/kotlin/dev/jasonpearson/automobile/slides/model/SlideContentTest.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.slides.model +package dev.jasonpearson.automobile.slides.model import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -33,7 +33,8 @@ class SlideContentTest { val points = listOf( BulletPoint("Feature 1", listOf("Sub-point 1", "Sub-point 2")), - BulletPoint("Feature 2")) + BulletPoint("Feature 2"), + ) val slideContent = SlideContent.BulletPoints(title, points) @@ -149,7 +150,7 @@ class PresentationEmojiTest { val allEmojis = PresentationEmoji.values() // Verify we have the expected number of emojis - assertEquals(51, allEmojis.size) + assertEquals(71, allEmojis.size) // Verify each emoji has both unicode and description allEmojis.forEach { emoji -> diff --git a/android/playground/slides/src/test/resources/test-plans/auto-mobile-playground.yaml b/android/playground/slides/src/test/resources/test-plans/auto-mobile-playground.yaml index a6dddfd55..af1e3819e 100644 --- a/android/playground/slides/src/test/resources/test-plans/auto-mobile-playground.yaml +++ b/android/playground/slides/src/test/resources/test-plans/auto-mobile-playground.yaml @@ -1,66 +1,73 @@ --- name: automobile-playground-discover-screens description: Comprehensive test of AutoMobile Playground discover screens with playful robot-themed interactions +appId: "dev.jasonpearson.automobile.playground" +platform: "android" + steps: - tool: rotate orientation: portrait label: Orient device to portrait mode - tool: terminateApp - appId: com.zillow.automobile.playground + appId: dev.jasonpearson.automobile.playground label: Stop the AutoMobile playground app - tool: openLink url: "automobile://playground/discover" - tool: tapOn - action: "tap" text: "Tap" + action: "tap" label: Make sure we're on the tap screen - tool: tapOn - action: "tap" text: "Button" + action: "tap" label: Tap basic button on Tap screen - tool: tapOn - action: "tap" text: "Elevated" + action: "tap" label: Tap elevated button on Tap screen - tool: tapOn - action: "tap" text: "Swipe" + action: "tap" label: Navigate to Swipe screen - - tool: swipeOnScreen - direction: left - duration: 500 - includeSystemInsets: false - label: Perform left swipe gesture - - tool: tapOn - action: "tap" text: "Media" + action: "tap" label: Navigate to Media screen - tool: tapOn - action: "tap" text: "Video" + action: "tap" label: Play AutoMobile Promo video + - tool: observe + waitFor: + text: "AutoMobile" + timeout: 1000 + - tool: pressButton button: back label: Return from video player + - tool: observe + waitFor: + text: "Text" + timeout: 1000 + - tool: tapOn - action: "tap" text: "Text" + action: "tap" label: Navigate to Text screen - tool: tapOn - action: "tap" text: "Basic Text Field" + action: "tap" label: Focus on basic text field - tool: inputText @@ -68,21 +75,26 @@ steps: label: Enter playful robot message in basic text field - tool: tapOn - action: "tap" text: "Email" + action: "tap" label: Focus on email field - tool: inputText text: "robot@example.com" - tool: tapOn - action: "tap" text: "Chat" + action: "tap" label: Navigate to Chat screen + - tool: observe + waitFor: + text: "What do you want to say?" + timeout: 500 + - tool: tapOn - action: "tap" text: "What do you want to say?" + action: "tap" label: Focus on chat input field - tool: inputText @@ -90,8 +102,8 @@ steps: label: Enter robot circus chat message - tool: tapOn - action: "tap" text: "Send" + action: "tap" label: Send chat message - tool: inputText @@ -99,17 +111,10 @@ steps: label: Enter robot circus chat message with heart emoji - tool: tapOn - action: "tap" text: "Send" + action: "tap" label: Send chat message - tool: terminateApp - appId: com.zillow.automobile.playground + appId: dev.jasonpearson.automobile.playground label: Stop the AutoMobile playground app - - - tool: openLink - url: "automobile://playground/slides/${slide}" - - - tool: rotate - orientation: landscape - label: Rotate screen to landscape mode diff --git a/android/playground/slides/src/test/resources/test-plans/auto-mobile-restart-slide.yaml b/android/playground/slides/src/test/resources/test-plans/auto-mobile-restart-slide.yaml index 4b948e6e0..698afab67 100644 --- a/android/playground/slides/src/test/resources/test-plans/auto-mobile-restart-slide.yaml +++ b/android/playground/slides/src/test/resources/test-plans/auto-mobile-restart-slide.yaml @@ -6,11 +6,11 @@ parameters: steps: - tool: terminateApp - appId: com.zillow.automobile.playground + appId: dev.jasonpearson.automobile.playground label: Stop the AutoMobile playground app - tool: openLink - url: "automobile://playground/slides/${slide}" + url: "automobile:playground/slides/${slide}" - tool: rotate orientation: landscape diff --git a/android/playground/slides/src/test/resources/test-plans/bluesky-announcement.yaml b/android/playground/slides/src/test/resources/test-plans/bluesky-announcement.yaml index 22beeceb0..0d23d3381 100644 --- a/android/playground/slides/src/test/resources/test-plans/bluesky-announcement.yaml +++ b/android/playground/slides/src/test/resources/test-plans/bluesky-announcement.yaml @@ -8,8 +8,8 @@ steps: orientation: portrait label: Orient device to portrait mode - - tool: stopApp - appId: com.zillow.automobile.playground + - tool: terminateApp + appId: dev.jasonpearson.automobile.playground label: Stop the AutoMobile playground app # Launch Bluesky app @@ -22,7 +22,7 @@ steps: # Enter the main post text - tool: "inputText" - text: "AutoMobile is OSS on GitHub! Come check it out at https://zillow.github.io/auto-mobile #LiveDemo #AndroidDev #dcnyc25" + text: "automobile:kaeawc.github.io/auto-mobile #LiveDemo #AndroidDev #dcnyc25" # Add video to the post - tool: "tapOn" @@ -91,12 +91,12 @@ steps: - tool: "tapOn" id: "composerPublishBtn" - - tool: stopApp - appId: com.zillow.automobile.playground + - tool: terminateApp + appId: dev.jasonpearson.automobile.playground label: Stop the AutoMobile playground app - tool: openLink - url: "automobile://playground/slides/${slide}" + url: "automobile:playground/slides/${slide}" - tool: rotate orientation: landscape diff --git a/android/playground/slides/src/test/resources/test-plans/bug-repro.yaml b/android/playground/slides/src/test/resources/test-plans/bug-repro.yaml new file mode 100644 index 000000000..e72dbcfb0 --- /dev/null +++ b/android/playground/slides/src/test/resources/test-plans/bug-repro.yaml @@ -0,0 +1,58 @@ +--- +name: automobile-playground-bug-repro-demo +description: Demonstrate reproducing a bug +platform: "android" + +steps: + - tool: terminateApp + appId: dev.jasonpearson.automobile.playground + label: Stop the AutoMobile playground app + + - tool: openLink + url: "automobile://playground/demos/bugs/repro" + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: tapOn + action: "tap" + id: "bug_repro_toggle" + + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: highlight + action: "tap" + text: "Displayed" + + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: tapOn + action: "tap" + id: "bug_repro_reset" + + - tool: tapOn + action: "tap" + id: "bug_repro_toggle" + + - tool: tapOn + action: "tap" + id: "bug_repro_add" + + - tool: terminateApp + appId: "dev.jasonpearson.automobile.playground" diff --git a/android/playground/slides/src/test/resources/test-plans/camera-app.yaml b/android/playground/slides/src/test/resources/test-plans/camera-app.yaml new file mode 100644 index 000000000..68f70beac --- /dev/null +++ b/android/playground/slides/src/test/resources/test-plans/camera-app.yaml @@ -0,0 +1,69 @@ +--- +name: automobile-camera-app-demo +description: Camera App demo +platform: "android" + +steps: + - tool: launchApp + appId: "com.android.camera2" + coldBoot: true + + - tool: observe + waitFor: + elementId: "com.android.camera2:id/shutter_button" + timeout: 1500 + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/shutter_button" + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/mode_options_toggle" + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/camera_toggle_button" + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/shutter_button" + + - tool: observe + waitFor: + elementId: "com.android.camera2:id/rounded_thumbnail_view" + timeout: 500 + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/rounded_thumbnail_view" + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/filmstrip_bottom_control_edit" + + - tool: tapOn + action: "tap" + text: "Markup" + + - tool: tapOn + action: "tap" + id: "android:id/button_once" + + - tool: tapOn + action: "tap" + text: "Markup" + + - tool: tapOn + action: "tap" + id: "com.google.android.markup:id/save" + + - tool: tapOn + action: "tap" + id: "com.android.camera2:id/filmstrip_bottom_control_share" + + - tool: pressButton + button: "back" + + - tool: terminateApp + appId: "com.android.camera2" diff --git a/android/playground/slides/src/test/resources/test-plans/clock-set-alarm.yaml b/android/playground/slides/src/test/resources/test-plans/clock-set-alarm.yaml index b64979df7..4ca069921 100644 --- a/android/playground/slides/src/test/resources/test-plans/clock-set-alarm.yaml +++ b/android/playground/slides/src/test/resources/test-plans/clock-set-alarm.yaml @@ -1,6 +1,9 @@ --- name: clock-set-alarm description: Set demo mode to 8pm and create 7:30 AM alarm in Clock app +appId: "com.google.android.deskclock" +platform: "android" + steps: - tool: launchApp appId: com.google.android.deskclock @@ -27,6 +30,11 @@ steps: label: Select 30 for minutes action: "tap" + - tool: tapOn + text: "AM" + label: Select AM + action: "tap" + - tool: tapOn text: "OK" label: Confirm alarm time selection diff --git a/android/playground/slides/src/test/resources/test-plans/deep-link-slide-navigation.yaml b/android/playground/slides/src/test/resources/test-plans/deep-link-slide-navigation.yaml deleted file mode 100644 index f0d0727f2..000000000 --- a/android/playground/slides/src/test/resources/test-plans/deep-link-slide-navigation.yaml +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: deep-link-slide-navigation -description: Test deep link navigation to specific slides after screen rotation and app restart -steps: - - tool: openLink - url: "automobile://playground/slides/4" - label: Open deep link to slide 4 initially - - - tool: rotate - orientation: landscape - label: Rotate screen to landscape mode - - - tool: pressButton - button: back - label: Navigate back to discover screen - - - tool: stopApp - appId: com.zillow.automobile.playground - label: Stop the AutoMobile playground app - - - tool: openLink - url: "automobile://playground/slides/4" - label: Reopen deep link to slide 4 after app restart - - - tool: assertVisible - text: "🤔" - label: Verify slide 4 content is displayed - - - tool: assertVisible - text: "How?" - label: Verify slide 4 title is displayed - - - tool: openLink - url: "automobile://playground/slides/20" - label: Test navigation to slide 20 - - - tool: assertVisible - text: "🏠" - label: Verify slide 20 content is displayed - - - tool: assertVisible - text: "Zillow 3D home navigation demo" - label: Verify slide 20 title is displayed - - - tool: openLink - url: "automobile://playground/slides/0" - label: Test navigation to slide 0 - - - tool: rotate - orientation: portrait - label: Rotate back to portrait mode - - - tool: stopApp - appId: com.zillow.automobile.playground - label: Clean up by stopping the app diff --git a/android/playground/slides/src/test/resources/test-plans/droidcon-leave-feedback.yaml b/android/playground/slides/src/test/resources/test-plans/droidcon-leave-feedback.yaml deleted file mode 100644 index be9982790..000000000 --- a/android/playground/slides/src/test/resources/test-plans/droidcon-leave-feedback.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: droidcon-leave-feedback -description: Complete fake onboarding flow with experiment-specific behavior -parameters: - experiment: ${experiment} - environment: ${environment} - user_email: ${user_email} - skip_intro: ${skip_intro} - -steps: - - tool: observe - label: "Observe initial app state with ${experiment}" diff --git a/android/playground/slides/src/test/resources/test-plans/google-maps.yaml b/android/playground/slides/src/test/resources/test-plans/google-maps.yaml new file mode 100644 index 000000000..5345b072c --- /dev/null +++ b/android/playground/slides/src/test/resources/test-plans/google-maps.yaml @@ -0,0 +1,28 @@ +--- +name: automobile-google-maps-demo +description: Google Maps demo +platform: "android" + +steps: + - tool: launchApp + appId: "com.google.android.apps.maps" + coldBoot: true + + - tool: pinchOn + direction: "in" + scale: 0.3 + + - tool: tapOn + action: "tap" + text: "Search here" + + - tool: inputText + text: "New York City" + + - tool: imeAction + action: "search" + + - tool: observe + waitFor: + text: "Newark" + timeout: 3000 diff --git a/android/playground/slides/src/test/resources/test-plans/playground-login.yaml b/android/playground/slides/src/test/resources/test-plans/playground-login.yaml deleted file mode 100644 index 8d02d583a..000000000 --- a/android/playground/slides/src/test/resources/test-plans/playground-login.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: playground-login -description: Complete fake onboarding flow with experiment-specific behavior -parameters: - experiment: ${experiment} - environment: ${environment} - user_email: ${user_email} - skip_intro: ${skip_intro} - -steps: - - tool: observe - label: "Observe initial app state with ${experiment}" diff --git a/android/playground/slides/src/test/resources/test-plans/playground-party-mode.yaml b/android/playground/slides/src/test/resources/test-plans/playground-party-mode.yaml deleted file mode 100644 index 155381a04..000000000 --- a/android/playground/slides/src/test/resources/test-plans/playground-party-mode.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: playground-party-mode -description: Complete fake onboarding flow with experiment-specific behavior -parameters: - experiment: ${experiment} - environment: ${environment} - user_email: ${user_email} - skip_intro: ${skip_intro} - -steps: - - tool: observe - label: "Observe initial app state with ${experiment}" diff --git a/android/playground/slides/src/test/resources/test-plans/system-notification-youtube-music-play.yaml b/android/playground/slides/src/test/resources/test-plans/system-notification-youtube-music-play.yaml deleted file mode 100644 index e258e9fe4..000000000 --- a/android/playground/slides/src/test/resources/test-plans/system-notification-youtube-music-play.yaml +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: "system-notification-youtube-music-play" -description: "Test workflow to open the system notification tray, find YouTube Music playback notification, and play the song" -generated: "2025-06-26T12:57:30.000Z" -appId: "com.android.systemui" -steps: - - tool: rotate - orientation: portrait - label: Orient device to portrait mode - - - tool: pressButton - button: back - label: Use back button to exit slides - - - tool: "openSystemTray" - - - tool: "tapOn" - id: "com.android.systemui:id/actionPlayPause" - action: "tap" - - - tool: terminateApp - appId: com.zillow.automobile.playground - label: Stop the AutoMobile playground app - - - tool: openLink - url: "automobile://playground/slides/${slide}" - - - tool: rotate - orientation: landscape - label: Rotate screen to landscape mode diff --git a/android/playground/slides/src/test/resources/test-plans/youtube-music-final-fantasy-6-fanfare-falconsquall.yaml b/android/playground/slides/src/test/resources/test-plans/youtube-music-final-fantasy-6-fanfare-falconsquall.yaml deleted file mode 100644 index 938d87fe9..000000000 --- a/android/playground/slides/src/test/resources/test-plans/youtube-music-final-fantasy-6-fanfare-falconsquall.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: "youtube-music-final-fantasy-6-fanfare-falconsquall" -description: "Test workflow to open YouTube Music, search for Final Fantasy 6 Victory Fanfare by FalconSquall, and play the music" -generated: "2025-06-26T12:55:32.000Z" -appId: "com.google.android.apps.youtube.music" -steps: - - tool: "tapOn" - text: "YT Music" - - - tool: "tapOn" - id: "com.google.android.apps.youtube.music:id/action_search_button" - - - tool: "tapOn" - text: "06 Victory Fanfare - Final Fantasy VI - FFVI OST" diff --git a/android/playground/slides/src/test/resources/test-plans/youtube-music-final-fantasy-fanfare.yaml b/android/playground/slides/src/test/resources/test-plans/youtube-music-final-fantasy-fanfare.yaml deleted file mode 100644 index decc45cda..000000000 --- a/android/playground/slides/src/test/resources/test-plans/youtube-music-final-fantasy-fanfare.yaml +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: "youtube-music-final-fantasy-fanfare" -description: "Test workflow to orient to portrait, open YouTube Music, search for Final Fantasy 7 Victory Fanfare, and play the music" -generated: "2025-06-26T12:52:30.000Z" -appId: "com.google.android.apps.youtube.music" -steps: - - tool: "changeOrientation" - orientation: "portrait" - - - tool: "tapOn" - text: "YT Music" - - - tool: "tapOn" - id: "com.google.android.apps.youtube.music:id/action_search_button" - - - tool: "inputText" - text: "final fantasy 7 victory fanfare" - - - tool: "tapOn" - id: "com.google.android.apps.youtube.music:id/two_column_item_content_parent" diff --git a/android/playground/slides/src/test/resources/test-plans/youtube-search.yaml b/android/playground/slides/src/test/resources/test-plans/youtube-search.yaml new file mode 100644 index 000000000..916513386 --- /dev/null +++ b/android/playground/slides/src/test/resources/test-plans/youtube-search.yaml @@ -0,0 +1,81 @@ +--- +name: automobile-youtube-search-demo +description: YouTube Search demo +platform: "android" + +steps: + - tool: launchApp + appId: "com.android.chrome" + coldBoot: true + + - tool: tapOn + action: "tap" + id: "com.android.chrome:id/tab_switcher_button" + + - tool: tapOn + action: "tap" + id: "com.android.chrome:id/menu_button" + + - tool: tapOn + action: "tap" + id: "com.android.chrome:id/close_all_tabs_menu_id" + + - tool: tapOn + action: "tap" + id: "com.android.chrome:id/positive_button" + + - tool: tapOn + action: "tap" + id: "com.android.chrome:id/toolbar_action_button" + + - tool: tapOn + action: "tap" + id: "com.android.chrome:id/search_box_text" + + - tool: inputText + text: "https://m.youtube.com" + imeAction: "go" + + - tool: observe + waitFor: + text: "Try searching to get started" + timeout: 2000 + + - tool: tapOn + action: "tap" + text: "Search YouTube" + + - tool: inputText + text: "Droidcon NYC 2025 AutoMobile" + imeAction: "go" + + - tool: observe + waitFor: + text: "AutoMobile - Jason Pearson | droidcon New York 2025" + timeout: 2000 + + - tool: swipeOn + direction: up + lookFor: + text: "AutoMobile - Jason Pearson | droidcon New York 2025" + + - tool: tapOn + action: "tap" + text: "AutoMobile - Jason Pearson | droidcon New York 2025" + + - tool: observe + waitFor: + text: "Skip" + timeout: 6000 + + - tool: tapOn + action: "tap" + text: "Skip" + + - tool: observe + waitFor: + text: "AutoMobile - Jason Pearson | droidcon New York 2025" + timeout: 20000 + + - tool: terminateApp + appId: "com.android.chrome" diff --git a/android/playground/slides/src/test/resources/test-plans/zillow-3d-home-exploration.yaml b/android/playground/slides/src/test/resources/test-plans/zillow-3d-home-exploration.yaml deleted file mode 100644 index f1abf7b46..000000000 --- a/android/playground/slides/src/test/resources/test-plans/zillow-3d-home-exploration.yaml +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: zillow-3d-home-exploration -description: Automated exploration of Zillow app 3D home tours, including bypassing login, searching for 3D tour homes in NYC, and navigating through different rooms using navigation buttons -steps: - # Install Zillow app - - tool: installApp - apkPath: com.zillow.android.zillowmap - force: false - - # Launch Zillow app - - tool: launchApp - appId: com.zillow.android.zillowmap - label: Launch Zillow application - - # Open search interface - - tool: tapOn - id: com.zillow.android.zillowmap:id/search_toolbar - action: "tap" - label: Open search interface - - - tool: inputText - text: "32 Flatlands 9 St UNIT 22A, Brooklyn, NY 11236" - action: "tap" - label: Enter search query for 3D tour homes in NYC - - # Execute search by tapping the suggestion - - tool: tapOn - text: "32 Flatlands 9 St #22A Brooklyn, NY 11236" - action: "tap" - label: Execute search from suggestion dropdown - - - tool: "tapOn" - text: "3D Home" - action: "tap" - - - tool: "tapOn" - text: "Living room" - action: "tap" - - - tool: "swipeOnScreen" - direction: "left" - action: "tap" - includeSystemInsets: false - duration: 1000 - - - tool: "tapOn" - text: "Kitchen" - action: "tap" - - - tool: "swipeOnScreen" - direction: "right" - action: "tap" - includeSystemInsets: false - duration: 1000 - - - tool: "tapOn" - text: "Front yard" - action: "tap" - - - tool: "tapOn" - text: "Photos" - action: "tap" - - - tool: "tapOn" - text: "Request a tour" - action: "tap" - - - tool: "tapOn" - text: "{book-date-1}" - action: "tap" - - - tool: "tapOn" - text: "{book-date-2}" - action: "tap" - - - tool: "tapOn" - text: "3:30 PM" - action: "tap" - - - tool: "tapOn" - text: "Select time" - action: "tap" - - - tool: "tapOn" - text: "Next" - action: "tap" - - # Terminate the app - - tool: terminateApp - appId: com.zillow.android.zillowmap - label: Stop Zillow app diff --git a/android/playground/slides/src/test/resources/test-plans/zillow-buyability.yaml b/android/playground/slides/src/test/resources/test-plans/zillow-buyability.yaml deleted file mode 100644 index 65d10a9c1..000000000 --- a/android/playground/slides/src/test/resources/test-plans/zillow-buyability.yaml +++ /dev/null @@ -1,229 +0,0 @@ ---- -name: zillow -description: Comprehensive test exploring Zillow buyability setup, Seattle home search with 3D features, photo exploration, and tour booking for June 17th at 3:30pm -steps: - # Set up demo mode with 1pm time and 4G connectivity - - tool: enableDemoMode - time: "1300" - mobileDataType: "4g" - mobileSignalLevel: 4 - wifiLevel: 0 - batteryLevel: 85 - batteryPlugged: false - label: Enable demo mode with 1pm time and 4G connectivity - - # Launch Zillow app - - tool: launchApp - appId: com.zillow.android.zillowmap - label: Launch Zillow application - - # Navigate to Home Loans section - - tool: tapOn - text: "Home Loans" - label: Navigate to Home Loans section - - # Access BuyAbility feature - - tool: tapOn - text: "Get your BuyAbility" - label: Access Get your BuyAbility feature - - # Set location to Arizona - - tool: tapOn - text: "State" - label: Open state selection - - - tool: tapOn - text: "Arizona" - label: Select Arizona as location - - # Set credit score to good (720 & above) - - tool: tapOn - x: 800 - y: 1168 - label: Open credit score selection - - - tool: tapOn - text: "720 & above" - label: Select good credit score (720 & above) - - # Enter annual income - - tool: tapOn - x: 540 - y: 1232 - label: Focus on annual income field - - - tool: inputText - text: "80000" - label: Enter annual income of $80,000 - - - tool: pressButton - button: "back" - label: Confirm income entry - - # Enter down payment - - tool: tapOn - x: 280 - y: 1770 - label: Focus on down payment field - - - tool: inputText - text: "15000" - label: Enter down payment of $15,000 - - # Enter monthly debt - - tool: tapOn - x: 800 - y: 1349 - label: Focus on monthly debt field - - - tool: inputText - text: "500" - label: Enter monthly debt of $500 - - # Get BuyAbility results - - tool: tapOn - text: "Get your BuyAbility℠" - label: Submit BuyAbility form - - # Navigate back to search - - tool: pressButton - button: "back" - label: Go back to main app - - - tool: pressButton - button: "back" - label: Return to search area - - - tool: tapOn - text: "Search" - label: Navigate to search section - - # Search for Seattle, WA homes - - tool: tapOn - x: 442 - y: 219 - label: Open search field - - - tool: tapOn - id: "com.zillow.android.zillowmap:id/search_close_btn" - label: Clear current search - - - tool: tapOn - text: "Seattle, WA" - label: Select Seattle, WA from search history - - # Look for homes with 3D features - - tool: tapOn - text: "3D Tour, $1399000" - label: Explore property with 3D tour feature - - # Pull up property listings - - tool: swipeOnElement - elementId: "com.zillow.android.zillowmap:id/homes_map_drawer_bottom_sheet" - direction: "up" - duration: 1000 - label: Expand property listings - - # Select affordable home for exploration - - tool: tapOn - x: 540 - y: 1665 - label: Select $689,000 home for detailed exploration - - # Explore home photos - simulate finding different rooms - - tool: swipeOnScreen - direction: "left" - duration: 1000 - includeSystemInsets: false - label: Enter full-screen photo viewing mode - - - tool: swipeOnScreen - direction: "left" - duration: 1000 - includeSystemInsets: false - label: View second photo - - - tool: swipeOnScreen - direction: "left" - duration: 1000 - includeSystemInsets: false - label: View third photo (exploring kitchen area) - - - tool: swipeOnScreen - direction: "left" - duration: 500 - includeSystemInsets: false - label: View fourth photo (exploring living area) - - - tool: swipeOnScreen - direction: "left" - duration: 300 - includeSystemInsets: false - label: View fifth photo (exploring dining room) - - - tool: swipeOnScreen - direction: "left" - duration: 300 - includeSystemInsets: false - label: View sixth photo (exploring bedroom) - - - tool: swipeOnScreen - direction: "left" - duration: 300 - includeSystemInsets: false - label: View seventh photo (exploring bathroom) - - - tool: swipeOnScreen - direction: "left" - duration: 200 - includeSystemInsets: false - label: View eighth photo (additional room exploration) - - # Book a tour for June 17th at 3:30pm - - tool: tapOn - text: "Request a tour" - label: Initiate tour booking process - - # Select tour date and time - - tool: tapOn - text: "Select a time (optional)" - label: Open date and time selection - - # Navigate to June 17th - - tool: tapOn - text: "Jun 16" - label: Navigate to see more dates - - - tool: tapOn - text: "Jun 17" - label: Select June 17th for tour - - # Select 3:30 PM time slot - - tool: tapOn - text: "3:30 PM" - label: Select 3:30 PM time slot - - # Confirm time selection - - tool: tapOn - text: "Select time" - label: Confirm date and time selection - - # Proceed to contact form - - tool: tapOn - text: "Next" - label: Proceed to contact information form - - # Cancel tour request (demo purposes) - - tool: pressButton - button: "back" - label: Cancel tour request for demo - - - tool: tapOn - x: 75 - y: 221 - label: Exit tour booking flow - - # Final observation - - tool: observe - withViewHierarchy: true - label: Final observation of home photo gallery diff --git a/android/playground/slides/src/test/resources/test-plans/zillow-full-feature.yaml b/android/playground/slides/src/test/resources/test-plans/zillow-full-feature.yaml deleted file mode 100644 index dd33a4796..000000000 --- a/android/playground/slides/src/test/resources/test-plans/zillow-full-feature.yaml +++ /dev/null @@ -1,100 +0,0 @@ -name: "zillow-comprehensive-exploration-2025-07-11T22-21" -description: "Comprehensive Zillow app exploration with feature discovery and tour booking" -steps: - - tool: "launchApp" - appId: "com.zillow.android.zillowmap" - coldBoot: false - clearAppData: false - - tool: "tapOn" - text: "Home features, school, location" - action: "tap" - - tool: "inputText" - text: "Seattle, WA" - action: "tap" - - tool: "tapOn" - text: "Seattle WA homes" - action: "tap" - - tool: "swipeOnScreen" - direction: "up" - includeSystemInsets: false - duration: 300 - - tool: "tapOn" - text: "Home Loans" - action: "tap" - - tool: "swipeOnScreen" - direction: "up" - includeSystemInsets: false - duration: 1000 - - tool: "tapOn" - text: "Saved Homes" - action: "tap" - - tool: "tapOn" - text: "Updates" - action: "tap" - - tool: "tapOn" - text: "Inbox" - action: "tap" - - tool: "tapOn" - text: "Resources" - action: "tap" - - tool: "tapOn" - text: "Search" - action: "tap" - - tool: "tapOn" - text: "Filters" - containerElementId: "com.zillow.android.zillowmap:id/search_toolbar" - action: "tap" - - tool: "tapOn" - text: "For rent" - action: "tap" - - tool: "tapOn" - text: "See" - action: "tap" - - tool: "tapOn" - text: "Seattle, WA" - containerElementId: "com.zillow.android.zillowmap:id/search_toolbar" - action: "focus" - - tool: "clearText" - - tool: "tapOn" - text: "Home features, school, location" - action: "tap" - - tool: "inputText" - text: "2315 4th Ave, Seattle, WA" - action: "tap" - - tool: "tapOn" - text: "2315 4th Ave" - action: "tap" - - tool: "tapOn" - text: "Explore 3D Tour" - action: "tap" - - tool: "tapOn" - text: "3D Home 1" - action: "tap" - - tool: "tapOn" - id: "pano-tab" - action: "tap" - - tool: "tapOn" - text: "Living room" - action: "tap" - - tool: "tapOn" - text: "3D Home 3" - action: "tap" - - tool: "pressButton" - button: "back" - - tool: "tapOn" - text: "Book tour now" - action: "tap" - - tool: "tapOn" - text: "Continue" - action: "tap" - - tool: "tapOn" - text: "Sat" - action: "tap" - - tool: "tapOn" - text: "PM" - action: "tap" - - tool: "tapOn" - text: "Continue" - action: "tap" - - tool: "pressButton" - button: "home" diff --git a/android/playground/slides/src/test/resources/test-plans/zillow-testing.yaml b/android/playground/slides/src/test/resources/test-plans/zillow-testing.yaml deleted file mode 100644 index e96540d45..000000000 --- a/android/playground/slides/src/test/resources/test-plans/zillow-testing.yaml +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: "auto-generated-zillowmap-2025-06-26T11-48" -description: "Automatically generated test plan for com.zillow.android.zillowmap" -generated: "2025-06-26T11:48:02.903Z" -appId: "com.zillow.android.zillowmap" -steps: - - tool: "launchApp" - appId: "com.zillow.android.zillowmap" - - - tool: "tapOn" - text: "32 Flatlands 9 St UNIT 22A, Brooklyn, NY" - - - tool: "tapOn" - text: "3D Home" - - - tool: "tapOn" - text: "Living room" - - - tool: "swipeOnScreen" - direction: "left" - includeSystemInsets: false - duration: 1000 - - - tool: "tapOn" - text: "Kitchen" - - - tool: "swipeOnScreen" - direction: "right" - includeSystemInsets: false - duration: 1000 - - - tool: "tapOn" - text: "Front yard" - - - tool: "tapOn" - text: "Photos" - - - tool: "tapOn" - text: "Request a tour" - - - tool: "tapOn" - text: "Jun 26" - - - tool: "tapOn" - text: "Jun 27" - - - tool: "tapOn" - text: "3:30 PM" - - - tool: "tapOn" - text: "Select time" - - - tool: "tapOn" - text: "Next" diff --git a/android/playground/storage/build.gradle.kts b/android/playground/storage/build.gradle.kts index 86ed35821..a312fd8d6 100644 --- a/android/playground/storage/build.gradle.kts +++ b/android/playground/storage/build.gradle.kts @@ -1,10 +1,7 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} +plugins { alias(libs.plugins.android.library) } android { - namespace = "com.zillow.automobile.storage" + namespace = "dev.jasonpearson.automobile.storage" compileSdk = libs.versions.build.android.compileSdk.get().toInt() buildToolsVersion = libs.versions.build.android.buildTools.get() @@ -24,9 +21,18 @@ android { sourceCompatibility = JavaVersion.toVersion(libs.versions.build.java.target.get()) targetCompatibility = JavaVersion.toVersion(libs.versions.build.java.target.get()) } - kotlinOptions { - jvmTarget = libs.versions.build.java.target.get() - languageVersion = libs.versions.build.kotlin.language.get() +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set( + org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(libs.versions.build.java.target.get()) + ) + languageVersion.set( + org.jetbrains.kotlin.gradle.dsl.KotlinVersion.fromVersion( + libs.versions.build.kotlin.language.get() + ) + ) } } diff --git a/android/playground/storage/src/main/java/com/zillow/automobile/storage/AnalyticsRepository.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AnalyticsRepository.kt similarity index 95% rename from android/playground/storage/src/main/java/com/zillow/automobile/storage/AnalyticsRepository.kt rename to android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AnalyticsRepository.kt index 5806cad91..0068d530b 100644 --- a/android/playground/storage/src/main/java/com/zillow/automobile/storage/AnalyticsRepository.kt +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AnalyticsRepository.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.storage +package dev.jasonpearson.automobile.storage import android.content.Context import android.content.SharedPreferences @@ -23,7 +23,7 @@ data class UserStats( val swipeBreakdown: Map = emptyMap(), val screenBreakdown: Map = emptyMap(), val todayStats: DailyStats = DailyStats(), - val weeklyStats: List = emptyList() + val weeklyStats: List = emptyList(), ) data class DailyStats( @@ -32,7 +32,7 @@ data class DailyStats( val swipes: Int = 0, val screens: Int = 0, val sessionCount: Int = 0, - val totalDuration: Long = 0 + val totalDuration: Long = 0, ) data class AnalyticsEvent( @@ -42,7 +42,7 @@ data class AnalyticsEvent( val elementId: String? = null, val elementType: String? = null, val screenName: String? = null, - val coordinates: Pair? = null + val coordinates: Pair? = null, ) data class DetailedEventStats( @@ -50,7 +50,7 @@ data class DetailedEventStats( val recentEvents: List, val topElements: Map, val topScreens: Map, - val hourlyDistribution: Map + val hourlyDistribution: Map, ) class AnalyticsRepository(context: Context) { @@ -114,7 +114,8 @@ class AnalyticsRepository(context: Context) { swipeBreakdown = getSwipeBreakdown(), screenBreakdown = getScreenBreakdown(), todayStats = getTodayStats(), - weeklyStats = getWeeklyStats()) + weeklyStats = getWeeklyStats(), + ) } private fun getTapBreakdown(): Map { @@ -164,7 +165,8 @@ class AnalyticsRepository(context: Context) { incrementTaps( elementType = properties["elementType"] as? String, elementId = properties["elementId"] as? String, - coordinates = coordinates) + coordinates = coordinates, + ) "swipe", "scroll", @@ -173,7 +175,8 @@ class AnalyticsRepository(context: Context) { elementType = properties["elementType"] as? String, elementId = properties["elementId"] as? String, direction = properties["direction"] as? String, - coordinates = coordinates) + coordinates = coordinates, + ) "navigation", "screen_view" -> incrementScreensNavigated(screenName = properties["screenName"] as? String) @@ -194,7 +197,8 @@ class AnalyticsRepository(context: Context) { swipes = todaySwipes, screens = todayScreens, sessionCount = todaySessions, - totalDuration = todayDuration) + totalDuration = todayDuration, + ) } private fun getWeeklyStats(): List { @@ -220,7 +224,7 @@ class AnalyticsRepository(context: Context) { count: Int = 1, elementId: String? = null, elementType: String? = null, - coordinates: Pair? = null + coordinates: Pair? = null, ) { if (!isTrackingEnabled) return @@ -257,7 +261,7 @@ class AnalyticsRepository(context: Context) { elementId: String? = null, elementType: String? = null, direction: String? = null, - coordinates: Pair? = null + coordinates: Pair? = null, ) { if (!isTrackingEnabled) return @@ -291,7 +295,8 @@ class AnalyticsRepository(context: Context) { elementId, elementType, coordinates, - mapOf("direction" to (direction ?: "unknown"))) + mapOf("direction" to (direction ?: "unknown")), + ) // Performance monitoring monitorPerformance("swipe") @@ -358,7 +363,7 @@ class AnalyticsRepository(context: Context) { elementId: String? = null, elementType: String? = null, coordinates: Pair? = null, - additionalProperties: Map = emptyMap() + additionalProperties: Map = emptyMap(), ) { val event = AnalyticsEvent( @@ -368,7 +373,8 @@ class AnalyticsRepository(context: Context) { elementId = elementId, elementType = elementType, screenName = _currentScreen.value, - coordinates = coordinates) + coordinates = coordinates, + ) // Keep only last 1000 events to prevent memory issues if (eventHistory.size >= 1000) { @@ -427,7 +433,8 @@ class AnalyticsRepository(context: Context) { recentEvents = filteredEvents.takeLast(50), topElements = topElements, topScreens = topScreens, - hourlyDistribution = hourlyDistribution) + hourlyDistribution = hourlyDistribution, + ) } fun getPerformanceMetrics(): Map> { @@ -438,7 +445,8 @@ class AnalyticsRepository(context: Context) { mapOf( "avg" to times.average(), "min" to (times.minOrNull()?.toDouble() ?: 0.0), - "max" to (times.maxOrNull()?.toDouble() ?: 0.0)) + "max" to (times.maxOrNull()?.toDouble() ?: 0.0), + ) } } } @@ -448,7 +456,8 @@ class AnalyticsRepository(context: Context) { "userStats" to getUserStats(), "eventHistory" to eventHistory.takeLast(500), // Export last 500 events "performanceMetrics" to getPerformanceMetrics(), - "exportTimestamp" to System.currentTimeMillis()) + "exportTimestamp" to System.currentTimeMillis(), + ) } fun updateUserStats(stats: UserStats) { diff --git a/android/playground/storage/src/main/java/com/zillow/automobile/storage/AnalyticsTracker.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AnalyticsTracker.kt similarity index 88% rename from android/playground/storage/src/main/java/com/zillow/automobile/storage/AnalyticsTracker.kt rename to android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AnalyticsTracker.kt index 96f3bf291..f86f55423 100644 --- a/android/playground/storage/src/main/java/com/zillow/automobile/storage/AnalyticsTracker.kt +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AnalyticsTracker.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.storage +package dev.jasonpearson.automobile.storage import android.app.Activity import android.content.Context @@ -38,7 +38,7 @@ class AnalyticsTracker private constructor() { elementId: String? = null, elementType: String? = null, coordinates: Pair? = null, - properties: Map = emptyMap() + properties: Map = emptyMap(), ) { getRepository() ?.incrementTaps(elementId = elementId, elementType = elementType, coordinates = coordinates) @@ -50,14 +50,15 @@ class AnalyticsTracker private constructor() { elementType: String? = null, direction: String? = null, coordinates: Pair? = null, - properties: Map = emptyMap() + properties: Map = emptyMap(), ) { getRepository() ?.incrementSwipes( elementId = elementId, elementType = elementType, direction = direction, - coordinates = coordinates) + coordinates = coordinates, + ) } /** Track screen navigation events */ @@ -91,7 +92,8 @@ fun Activity.enableAnalyticsTracking() { tracker.trackTap( elementId = view.id.toString(), elementType = view.javaClass.simpleName, - coordinates = event.x to event.y) + coordinates = event.x to event.y, + ) } MotionEvent.ACTION_MOVE -> { @@ -114,7 +116,8 @@ fun Activity.enableAnalyticsTracking() { elementId = view.id.toString(), elementType = view.javaClass.simpleName, direction = direction, - coordinates = event.x to event.y) + coordinates = event.x to event.y, + ) } } } @@ -128,7 +131,7 @@ fun Activity.enableAnalyticsTracking() { fun View.trackInteraction( elementId: String? = null, elementType: String? = null, - action: String = "tap" + action: String = "tap", ) { val tracker = AnalyticsTracker.getInstance() @@ -136,7 +139,8 @@ fun View.trackInteraction( tracker.trackTap( elementId = elementId ?: this.id.toString(), elementType = elementType ?: this.javaClass.simpleName, - coordinates = null) + coordinates = null, + ) } this.setOnTouchListener { _, event -> @@ -145,7 +149,8 @@ fun View.trackInteraction( tracker.trackTap( elementId = elementId ?: this.id.toString(), elementType = elementType ?: this.javaClass.simpleName, - coordinates = event.x to event.y) + coordinates = event.x to event.y, + ) } } false @@ -167,7 +172,7 @@ object MediaPlayerTracker { action: String, videoId: String? = null, position: Long? = null, - duration: Long? = null + duration: Long? = null, ) { val tracker = AnalyticsTracker.getInstance() val properties = mutableMapOf("action" to action) @@ -182,13 +187,19 @@ object MediaPlayerTracker { "stop", "seek" -> { tracker.trackTap( - elementType = "media_control", elementId = "player_$action", coordinates = null) + elementType = "media_control", + elementId = "player_$action", + coordinates = null, + ) } "fullscreen", "exit_fullscreen" -> { tracker.trackTap( - elementType = "media_control", elementId = "player_fullscreen", coordinates = null) + elementType = "media_control", + elementId = "player_fullscreen", + coordinates = null, + ) } } diff --git a/android/playground/storage/src/main/java/com/zillow/automobile/storage/AuthRepository.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AuthRepository.kt similarity index 94% rename from android/playground/storage/src/main/java/com/zillow/automobile/storage/AuthRepository.kt rename to android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AuthRepository.kt index 0742487d0..c9ab0566e 100644 --- a/android/playground/storage/src/main/java/com/zillow/automobile/storage/AuthRepository.kt +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/AuthRepository.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.storage +package dev.jasonpearson.automobile.storage import android.content.Context import android.content.SharedPreferences @@ -37,7 +37,9 @@ class AuthRepository(context: Context) { if (username.isNotEmpty() && password.isNotEmpty()) { val loggedInUser = LoggedInUserRecord( - userId = username, displayName = username.replaceFirstChar { it.uppercase() }) + userId = username, + displayName = username.replaceFirstChar { it.uppercase() }, + ) setLoggedInUser(loggedInUser) LoginResult(success = loggedInUser) } else { diff --git a/android/playground/storage/src/main/java/com/zillow/automobile/storage/OnboardingRepository.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/OnboardingRepository.kt similarity index 94% rename from android/playground/storage/src/main/java/com/zillow/automobile/storage/OnboardingRepository.kt rename to android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/OnboardingRepository.kt index ff74e0cab..62c3215b4 100644 --- a/android/playground/storage/src/main/java/com/zillow/automobile/storage/OnboardingRepository.kt +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/OnboardingRepository.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.storage +package dev.jasonpearson.automobile.storage import android.content.Context import android.content.SharedPreferences @@ -7,7 +7,7 @@ import androidx.core.content.edit data class OnboardingRecord( val hasCompletedOnboarding: Boolean, val completedSteps: List, - val lastStepCompleted: String? + val lastStepCompleted: String?, ) class OnboardingRepository(context: Context) { @@ -26,7 +26,8 @@ class OnboardingRepository(context: Context) { return OnboardingRecord( hasCompletedOnboarding = hasCompletedOnboarding, completedSteps = completedSteps, - lastStepCompleted = lastStep) + lastStepCompleted = lastStep, + ) } fun markStepCompleted(stepName: String) { diff --git a/android/playground/storage/src/main/java/com/zillow/automobile/storage/UserPreferences.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/UserPreferences.kt similarity index 95% rename from android/playground/storage/src/main/java/com/zillow/automobile/storage/UserPreferences.kt rename to android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/UserPreferences.kt index ba3c195c0..853942e80 100644 --- a/android/playground/storage/src/main/java/com/zillow/automobile/storage/UserPreferences.kt +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/UserPreferences.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.storage +package dev.jasonpearson.automobile.storage import android.content.Context diff --git a/android/playground/storage/src/main/java/com/zillow/automobile/storage/UserRepository.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/UserRepository.kt similarity index 94% rename from android/playground/storage/src/main/java/com/zillow/automobile/storage/UserRepository.kt rename to android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/UserRepository.kt index d346a6b61..06d44a2be 100644 --- a/android/playground/storage/src/main/java/com/zillow/automobile/storage/UserRepository.kt +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/UserRepository.kt @@ -1,4 +1,4 @@ -package com.zillow.automobile.storage +package dev.jasonpearson.automobile.storage import android.content.Context import android.content.SharedPreferences @@ -8,7 +8,7 @@ data class UserProfile( val name: String, val email: String, val profileImageUrl: String? = null, - val createdAt: Long = System.currentTimeMillis() + val createdAt: Long = System.currentTimeMillis(), ) class UserRepository(context: Context) { @@ -24,7 +24,8 @@ class UserRepository(context: Context) { name = "Anon Ymous", email = "you@somewhere.net", profileImageUrl = null, - createdAt = System.currentTimeMillis()) + createdAt = System.currentTimeMillis(), + ) } else { val name = sharedPreferences.getString(KEY_NAME, null) val email = sharedPreferences.getString(KEY_EMAIL, null) diff --git a/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/session/SessionDatabase.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/session/SessionDatabase.kt new file mode 100644 index 000000000..c8effcc3d --- /dev/null +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/session/SessionDatabase.kt @@ -0,0 +1,35 @@ +package dev.jasonpearson.automobile.storage.session + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class SessionDatabase(context: Context) : + SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_SESSIONS_TABLE) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("DROP TABLE IF EXISTS sessions") + onCreate(db) + } + + companion object { + private const val DATABASE_NAME = "sessions.db" + private const val DATABASE_VERSION = 1 + + private const val CREATE_SESSIONS_TABLE = + """ + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + app_version TEXT, + device_model TEXT, + os_version TEXT + ) + """ + } +} diff --git a/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/session/SessionRepository.kt b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/session/SessionRepository.kt new file mode 100644 index 000000000..01c2059a0 --- /dev/null +++ b/android/playground/storage/src/main/kotlin/dev/jasonpearson/automobile/storage/session/SessionRepository.kt @@ -0,0 +1,31 @@ +package dev.jasonpearson.automobile.storage.session + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import java.util.UUID + +class SessionRepository(private val context: Context) { + + private val database = SessionDatabase(context) + + fun recordSessionStart() { + val appVersion = + try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + } catch (e: Exception) { + null + } + + val values = + ContentValues().apply { + put("session_id", UUID.randomUUID().toString()) + put("started_at", System.currentTimeMillis()) + put("app_version", appVersion) + put("device_model", Build.MODEL) + put("os_version", Build.VERSION.RELEASE) + } + + database.writableDatabase.insert("sessions", null, values) + } +} diff --git a/android/playground/storage/src/test/java/com/zillow/automobile/storage/ExampleUnitTest.kt b/android/playground/storage/src/test/java/com/zillow/automobile/storage/ExampleUnitTest.kt deleted file mode 100644 index ac16b0067..000000000 --- a/android/playground/storage/src/test/java/com/zillow/automobile/storage/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zillow.automobile.storage - -import org.junit.Assert.* -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/android/playground/storage/src/test/kotlin/dev/jasonpearson/automobile/storage/ExampleUnitTest.kt b/android/playground/storage/src/test/kotlin/dev/jasonpearson/automobile/storage/ExampleUnitTest.kt new file mode 100644 index 000000000..4131e2d4c --- /dev/null +++ b/android/playground/storage/src/test/kotlin/dev/jasonpearson/automobile/storage/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package dev.jasonpearson.automobile.storage + +import org.junit.Assert.* +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts new file mode 100644 index 000000000..9a322d8f4 --- /dev/null +++ b/android/protocol/build.gradle.kts @@ -0,0 +1,74 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlin.serialization) + `java-library` + alias(libs.plugins.mavenPublish) +} + +java { + toolchain { languageVersion.set(JavaLanguageVersion.of(libs.versions.build.java.target.get())) } +} + +dependencies { + implementation(libs.kotlinx.serialization) + + testImplementation(libs.kotlin.test) + testImplementation(libs.bundles.unit.test) + testImplementation(libs.junit.jupiter) +} + +tasks.test { + useJUnitPlatform() +} + +// Version comes from root project's gradle.properties (VERSION_NAME) + +mavenPublishing { + // Coordinates: group and version from root, artifact from local gradle.properties + coordinates( + property("GROUP").toString(), + property("POM_ARTIFACT_ID").toString(), + version.toString(), + ) + + pom { + name.set(property("POM_NAME").toString()) + description.set(property("POM_DESCRIPTION").toString()) + inceptionYear.set("2025") + url.set(property("POM_URL").toString()) + licenses { + license { + name.set(property("POM_LICENCE_NAME").toString()) + url.set(property("POM_LICENCE_URL").toString()) + distribution.set("repo") + } + } + developers { + developer { + id.set(property("POM_DEVELOPER_ID").toString()) + name.set(property("POM_DEVELOPER_NAME").toString()) + url.set("https://github.com/${property("POM_DEVELOPER_ID")}/") + email.set(property("POM_DEVELOPER_EMAIL").toString()) + } + } + scm { + url.set(property("POM_SCM_URL").toString()) + connection.set(property("POM_SCM_CONNECTION").toString()) + developerConnection.set(property("POM_SCM_DEV_CONNECTION").toString()) + } + } +} + +// Configure Kotlin compilation options +tasks.withType().configureEach { + compilerOptions { + languageVersion.set( + KotlinVersion.valueOf( + "KOTLIN_${libs.versions.build.kotlin.language.get().replace(".", "_")}" + ) + ) + } +} diff --git a/android/protocol/gradle.properties b/android/protocol/gradle.properties new file mode 100644 index 000000000..bba952142 --- /dev/null +++ b/android/protocol/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=auto-mobile-protocol +POM_NAME=AutoMobile Protocol +POM_DESCRIPTION=Shared protocol and serialization types for AutoMobile SDK. diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/HighlightModels.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/HighlightModels.kt new file mode 100644 index 000000000..4e2251b2f --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/HighlightModels.kt @@ -0,0 +1,86 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Bounds for a highlight shape. + * Pure Kotlin version without Android dependencies. + */ +@Serializable +data class HighlightBounds( + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val sourceWidth: Int? = null, + val sourceHeight: Int? = null, +) { + fun hasValidSize(): Boolean = width > 0 && height > 0 +} + +/** + * A point in a highlight path. + */ +@Serializable +data class HighlightPoint( + val x: Float, + val y: Float, +) + +/** + * Smoothing algorithm for path highlights. + */ +@Serializable +enum class SmoothingAlgorithm { + @SerialName("none") NONE, + @SerialName("catmull-rom") CATMULL_ROM, + @SerialName("bezier") BEZIER, + @SerialName("douglas-peucker") DOUGLAS_PEUCKER, +} + +/** + * Line cap style for highlight strokes. + */ +@Serializable +enum class HighlightLineCap { + @SerialName("butt") BUTT, + @SerialName("round") ROUND, + @SerialName("square") SQUARE, +} + +/** + * Line join style for highlight strokes. + */ +@Serializable +enum class HighlightLineJoin { + @SerialName("miter") MITER, + @SerialName("round") ROUND, + @SerialName("bevel") BEVEL, +} + +/** + * Style configuration for a highlight. + */ +@Serializable +data class HighlightStyle( + val strokeColor: String? = null, + val strokeWidth: Float? = null, + val dashPattern: List? = null, + val smoothing: SmoothingAlgorithm? = null, + val tension: Float? = null, + val capStyle: HighlightLineCap? = null, + val joinStyle: HighlightLineJoin? = null, +) + +/** + * A highlight shape to draw on the device screen. + * Supports box, circle, and path shapes. + */ +@Serializable +data class HighlightShape( + val type: String, // "box", "circle", or "path" + val bounds: HighlightBounds? = null, + val points: List? = null, + val style: HighlightStyle? = null, +) diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/SdkEvent.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/SdkEvent.kt new file mode 100644 index 000000000..fffc98214 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/SdkEvent.kt @@ -0,0 +1,191 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Sealed class hierarchy for SDK events broadcast from the AutoMobile SDK. + * + * These events are sent from the app-under-test to the accessibility service, + * which then forwards them over WebSocket to the MCP server. + * + * Event types: + * - Navigation events: App screen/destination changes + * - Handled exceptions: Non-fatal errors caught by the app + * - Notification events: Push notification interactions + * - Recomposition events: Compose recomposition tracking data + */ +@Serializable +sealed class SdkEvent { + abstract val timestamp: Long + abstract val applicationId: String? +} + +// ============================================================================= +// Navigation Events +// ============================================================================= + +/** + * Navigation source/framework identifier. + */ +@Serializable +enum class NavigationSourceType { + @SerialName("NAVIGATION_COMPONENT") NAVIGATION_COMPONENT, + @SerialName("COMPOSE_NAVIGATION") COMPOSE_NAVIGATION, + @SerialName("CIRCUIT") CIRCUIT, + @SerialName("CUSTOM") CUSTOM, + @SerialName("DEEP_LINK") DEEP_LINK, + @SerialName("ACTIVITY") ACTIVITY, +} + +/** + * Represents a navigation event - the user moved to a new screen/destination. + */ +@Serializable +@SerialName("navigation") +data class SdkNavigationEvent( + override val timestamp: Long, + override val applicationId: String? = null, + /** The destination identifier (route, screen name, deep link, etc.) */ + val destination: String, + /** The navigation framework that generated this event */ + val source: NavigationSourceType, + /** Optional navigation arguments as string key-value pairs */ + val arguments: Map? = null, + /** Additional metadata about the navigation event */ + val metadata: Map? = null, +) : SdkEvent() + +// ============================================================================= +// Exception Events +// ============================================================================= + +/** + * Device information captured at the time of an exception. + */ +@Serializable +data class SdkDeviceInfo( + val model: String, + val manufacturer: String, + val osVersion: String, + val sdkInt: Int, +) + +/** + * Represents a handled (non-fatal) exception that was caught and reported by the app. + */ +@Serializable +@SerialName("handled_exception") +data class SdkHandledExceptionEvent( + override val timestamp: Long, + override val applicationId: String? = null, + /** Fully qualified class name of the exception (e.g., "java.lang.NullPointerException") */ + val exceptionClass: String, + /** Exception message, if any */ + val exceptionMessage: String?, + /** Full stack trace as a string */ + val stackTrace: String, + /** Optional custom message provided by the developer */ + val customMessage: String? = null, + /** Current screen/destination at the time of the exception */ + val currentScreen: String? = null, + /** Application version name, if available */ + val appVersion: String? = null, + /** Device information */ + val deviceInfo: SdkDeviceInfo? = null, +) : SdkEvent() + +// ============================================================================= +// Notification Events +// ============================================================================= + +/** + * Represents a notification action triggered by the user. + */ +@Serializable +@SerialName("notification_action") +data class SdkNotificationActionEvent( + override val timestamp: Long, + override val applicationId: String? = null, + /** The notification ID */ + val notificationId: String, + /** The action ID that was tapped */ + val actionId: String, + /** The action label that was displayed */ + val actionLabel: String, +) : SdkEvent() + +// ============================================================================= +// Recomposition Events +// ============================================================================= + +/** + * Represents a Compose recomposition tracking snapshot. + */ +@Serializable +@SerialName("recomposition_snapshot") +data class SdkRecompositionSnapshotEvent( + override val timestamp: Long, + override val applicationId: String? = null, + /** JSON string containing the recomposition tracking data */ + val snapshotJson: String, +) : SdkEvent() + +// ============================================================================= +// Crash Events +// ============================================================================= + +/** + * Represents an unhandled crash detected by the SDK's UncaughtExceptionHandler. + * This is a fatal crash that will terminate the app. + */ +@Serializable +@SerialName("crash") +data class SdkCrashEvent( + override val timestamp: Long, + override val applicationId: String? = null, + /** Fully qualified class name of the exception (e.g., "java.lang.NullPointerException") */ + val exceptionClass: String, + /** Exception message, if any */ + val exceptionMessage: String?, + /** Full stack trace as a string */ + val stackTrace: String, + /** Thread name where the crash occurred */ + val threadName: String, + /** Current screen/destination at the time of the crash */ + val currentScreen: String? = null, + /** Application version name, if available */ + val appVersion: String? = null, + /** Device information */ + val deviceInfo: SdkDeviceInfo? = null, +) : SdkEvent() + +// ============================================================================= +// ANR Events +// ============================================================================= + +/** + * Represents an ANR (Application Not Responding) detected via ApplicationExitInfo API. + * This is captured on app restart after an ANR occurred in a previous session. + * Requires Android 11+ (API 30). + */ +@Serializable +@SerialName("anr") +data class SdkAnrEvent( + override val timestamp: Long, + override val applicationId: String? = null, + /** Process ID that experienced the ANR */ + val pid: Int, + /** Process name */ + val processName: String, + /** Process importance when ANR occurred (FOREGROUND, VISIBLE, CACHED, etc.) */ + val importance: String, + /** Full thread dump from ApplicationExitInfo.traceInputStream */ + val trace: String?, + /** Human-readable reason description */ + val reason: String, + /** Application version name, if available */ + val appVersion: String? = null, + /** Device information */ + val deviceInfo: SdkDeviceInfo? = null, +) : SdkEvent() diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/SdkEventSerializer.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/SdkEventSerializer.kt new file mode 100644 index 000000000..000fda4d7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/SdkEventSerializer.kt @@ -0,0 +1,183 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.json.Json + +/** + * Serializer for converting SdkEvent sealed classes to/from JSON strings. + * + * This provides type-safe serialization for SDK events that can be used for + * Intent extras, Bundle values, or any other string-based transport. + * + * The protocol module is pure Kotlin/JVM without Android dependencies, so Intent/Bundle + * wrapping must be done by the consumer (SDK or AccessibilityService). + * + * Usage: + * ```kotlin + * // Serialization + * val event = SdkNavigationEvent( + * timestamp = System.currentTimeMillis(), + * applicationId = packageName, + * destination = "home", + * source = NavigationSourceType.COMPOSE_NAVIGATION + * ) + * val json = SdkEventSerializer.toJson(event) + * + * // Deserialization + * val event = SdkEventSerializer.fromJson(json) + * when (event) { + * is SdkNavigationEvent -> handleNavigation(event) + * is SdkHandledExceptionEvent -> handleException(event) + * // ... + * } + * ``` + */ +object SdkEventSerializer { + /** + * Intent extra key for the serialized event JSON. + * Uses a namespaced key to avoid collisions with other Intent extras. + */ + const val EXTRA_SDK_EVENT_JSON = "dev.jasonpearson.automobile.sdk.EVENT_JSON" + + /** + * Intent extra key for the event type discriminator. + * This allows receivers to quickly determine the event type without parsing JSON. + */ + const val EXTRA_SDK_EVENT_TYPE = "dev.jasonpearson.automobile.sdk.EVENT_TYPE" + + /** + * Event type discriminator values. + */ + object EventTypes { + const val NAVIGATION = "navigation" + const val HANDLED_EXCEPTION = "handled_exception" + const val NOTIFICATION_ACTION = "notification_action" + const val RECOMPOSITION_SNAPSHOT = "recomposition_snapshot" + const val CRASH = "crash" + const val ANR = "anr" + } + + /** + * JSON instance configured for lenient parsing. + */ + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + /** + * Serialize an SdkEvent to JSON string. + * + * @param event The SdkEvent to serialize + * @return JSON string representation + */ + fun toJson(event: SdkEvent): String { + return json.encodeToString(SdkEvent.serializer(), event) + } + + /** + * Deserialize an SdkEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The deserialized SdkEvent, or null if parsing fails + */ + fun fromJson(jsonString: String): SdkEvent? { + return try { + json.decodeFromString(SdkEvent.serializer(), jsonString) + } catch (e: Exception) { + null + } + } + + /** + * Deserialize an SdkEvent from JSON string, throwing on failure. + * + * @param jsonString The JSON string to deserialize + * @return The deserialized SdkEvent + * @throws kotlinx.serialization.SerializationException if parsing fails + */ + fun fromJsonOrThrow(jsonString: String): SdkEvent { + return json.decodeFromString(SdkEvent.serializer(), jsonString) + } + + /** + * Get the event type discriminator for an event. + * + * @param event The SdkEvent to get the type for + * @return The event type string + */ + fun getEventType(event: SdkEvent): String { + return when (event) { + is SdkNavigationEvent -> EventTypes.NAVIGATION + is SdkHandledExceptionEvent -> EventTypes.HANDLED_EXCEPTION + is SdkNotificationActionEvent -> EventTypes.NOTIFICATION_ACTION + is SdkRecompositionSnapshotEvent -> EventTypes.RECOMPOSITION_SNAPSHOT + is SdkCrashEvent -> EventTypes.CRASH + is SdkAnrEvent -> EventTypes.ANR + } + } + + // ========================================================================= + // Type-safe deserialization helpers + // ========================================================================= + + /** + * Deserialize a SdkNavigationEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The navigation event, or null if parsing fails or not a navigation event + */ + fun navigationEventFromJson(jsonString: String): SdkNavigationEvent? { + return fromJson(jsonString) as? SdkNavigationEvent + } + + /** + * Deserialize a SdkHandledExceptionEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The exception event, or null if parsing fails or not an exception event + */ + fun handledExceptionEventFromJson(jsonString: String): SdkHandledExceptionEvent? { + return fromJson(jsonString) as? SdkHandledExceptionEvent + } + + /** + * Deserialize a SdkNotificationActionEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The notification event, or null if parsing fails or not a notification event + */ + fun notificationActionEventFromJson(jsonString: String): SdkNotificationActionEvent? { + return fromJson(jsonString) as? SdkNotificationActionEvent + } + + /** + * Deserialize a SdkRecompositionSnapshotEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The recomposition event, or null if parsing fails or not a recomposition event + */ + fun recompositionSnapshotEventFromJson(jsonString: String): SdkRecompositionSnapshotEvent? { + return fromJson(jsonString) as? SdkRecompositionSnapshotEvent + } + + /** + * Deserialize a SdkCrashEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The crash event, or null if parsing fails or not a crash event + */ + fun crashEventFromJson(jsonString: String): SdkCrashEvent? { + return fromJson(jsonString) as? SdkCrashEvent + } + + /** + * Deserialize a SdkAnrEvent from JSON string. + * + * @param jsonString The JSON string to deserialize + * @return The ANR event, or null if parsing fails or not an ANR event + */ + fun anrEventFromJson(jsonString: String): SdkAnrEvent? { + return fromJson(jsonString) as? SdkAnrEvent + } +} diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/StorageProtocol.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/StorageProtocol.kt new file mode 100644 index 000000000..7a5a763e8 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/StorageProtocol.kt @@ -0,0 +1,363 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Protocol types for SharedPreferences inspection via ContentProvider. + * + * These types provide type-safe communication between the AccessibilityService + * and the SDK's SharedPreferencesInspectorProvider. + * + * The protocol uses JSON serialization for Bundle transport, with these key conventions: + * - Bundle key "success" (Boolean): Whether the operation succeeded + * - Bundle key "result" (String): JSON-encoded StorageResponse on success + * - Bundle key "errorType" (String): Error type name on failure + * - Bundle key "error" (String): Error message on failure + */ + +// ============================================================================= +// Requests (Service → SDK ContentProvider) +// ============================================================================= + +/** + * Sealed class hierarchy for storage inspection requests. + * + * Each request type maps to a ContentProvider `call()` method. + */ +@Serializable +sealed class StorageRequest { + /** + * Check if SDK storage inspection is available and enabled. + * Method: "checkAvailability" + */ + @Serializable + @SerialName("check_availability") + data object CheckAvailability : StorageRequest() + + /** + * List all SharedPreferences files in the app. + * Method: "listFiles" + */ + @Serializable + @SerialName("list_files") + data object ListFiles : StorageRequest() + + /** + * Get all key-value entries from a SharedPreferences file. + * Method: "getPreferences" + * Extras: fileName (String) + */ + @Serializable + @SerialName("get_preferences") + data class GetPreferences( + val fileName: String, + ) : StorageRequest() + + /** + * Subscribe to changes on a SharedPreferences file. + * Method: "subscribeToFile" + * Extras: fileName (String) + */ + @Serializable + @SerialName("subscribe") + data class Subscribe( + val fileName: String, + ) : StorageRequest() + + /** + * Unsubscribe from changes on a SharedPreferences file. + * Method: "unsubscribeFromFile" + * Extras: fileName (String) + */ + @Serializable + @SerialName("unsubscribe") + data class Unsubscribe( + val fileName: String, + ) : StorageRequest() + + /** + * Get queued changes for a file since a sequence number. + * Method: "getChanges" + * Extras: fileName (String), sinceSequence (Long, optional) + */ + @Serializable + @SerialName("get_changes") + data class GetChanges( + val fileName: String, + val sinceSequence: Long = 0, + ) : StorageRequest() + + /** + * Get list of files currently being monitored. + * Method: "getListenedFiles" + */ + @Serializable + @SerialName("get_listened_files") + data object GetListenedFiles : StorageRequest() + + /** + * Get a single preference value by key. + * Method: "getPreference" + * Extras: fileName (String), key (String) + */ + @Serializable + @SerialName("get_preference") + data class GetPreference( + val fileName: String, + val key: String, + ) : StorageRequest() + + /** + * Set a preference value. + * Method: "setValue" + * Extras: fileName (String), key (String), value (String), type (String) + */ + @Serializable + @SerialName("set_value") + data class SetValue( + val fileName: String, + val key: String, + val value: String?, + val type: String, + ) : StorageRequest() + + /** + * Remove a preference value. + * Method: "removeValue" + * Extras: fileName (String), key (String) + */ + @Serializable + @SerialName("remove_value") + data class RemoveValue( + val fileName: String, + val key: String, + ) : StorageRequest() + + /** + * Clear all preferences in a file. + * Method: "clearFile" + * Extras: fileName (String) + */ + @Serializable + @SerialName("clear_file") + data class ClearFile( + val fileName: String, + ) : StorageRequest() +} + +// ============================================================================= +// Responses (SDK ContentProvider → Service) +// ============================================================================= + +/** + * Sealed class hierarchy for storage inspection responses. + */ +@Serializable +sealed class StorageResponse { + /** + * Response for checkAvailability request. + */ + @Serializable + @SerialName("availability") + data class Availability( + val available: Boolean, + val version: Int, + ) : StorageResponse() + + /** + * Response for listFiles request. + */ + @Serializable + @SerialName("files") + data class FileList( + val files: List, + ) : StorageResponse() + + /** + * Response for getPreferences request. + */ + @Serializable + @SerialName("preferences") + data class Preferences( + val file: StorageFileInfo? = null, + val entries: List, + ) : StorageResponse() + + /** + * Response for subscribe/unsubscribe requests. + */ + @Serializable + @SerialName("subscription") + data class SubscriptionResult( + val fileName: String, + val subscribed: Boolean, + ) : StorageResponse() + + /** + * Response for getChanges request. + */ + @Serializable + @SerialName("changes") + data class Changes( + val fileName: String, + val changes: List, + ) : StorageResponse() + + /** + * Response for getListenedFiles request. + */ + @Serializable + @SerialName("listened_files") + data class ListenedFiles( + val files: List, + ) : StorageResponse() + + /** + * Error response. + */ + @Serializable + @SerialName("error") + data class Error( + val errorType: String, + val message: String, + ) : StorageResponse() + + /** + * Response for getPreference request. + */ + @Serializable + @SerialName("single_preference") + data class SinglePreference( + val fileName: String, + val key: String, + /** The preference entry, or null if key not found. */ + val entry: StorageEntry?, + ) : StorageResponse() + + /** + * Response for successful write operations (setValue, removeValue, clearFile). + */ + @Serializable + @SerialName("operation_success") + data class OperationSuccess( + val operation: String, + val fileName: String, + val key: String?, + ) : StorageResponse() +} + +// ============================================================================= +// Data Types +// ============================================================================= + +/** + * Information about a SharedPreferences file. + */ +@Serializable +data class StorageFileInfo( + val name: String, + val path: String, + val entryCount: Int, +) + +/** + * A key-value entry from SharedPreferences. + */ +@Serializable +data class StorageEntry( + val key: String, + /** JSON-encoded value (null if the value itself is null). */ + val value: String?, + /** Type name: STRING, INT, LONG, FLOAT, BOOLEAN, STRING_SET */ + val type: String, +) + +/** + * A change event for a preference value. + */ +@Serializable +data class StorageChangeEvent( + val fileName: String, + /** The key that changed, or null if the file was cleared. */ + val key: String?, + /** JSON-encoded new value (null if key was removed). */ + val value: String?, + /** Type name: STRING, INT, LONG, FLOAT, BOOLEAN, STRING_SET */ + val type: String, + /** Timestamp when the change occurred (milliseconds since epoch). */ + val timestamp: Long, + /** Monotonically increasing sequence number for ordering changes. */ + val sequenceNumber: Long, +) + +// ============================================================================= +// Serializer +// ============================================================================= + +/** + * Serializer for StorageProtocol types. + */ +object StorageProtocolSerializer { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + /** + * Serialize a StorageRequest to JSON string. + */ + fun requestToJson(request: StorageRequest): String { + return json.encodeToString(StorageRequest.serializer(), request) + } + + /** + * Deserialize a StorageRequest from JSON string. + */ + fun requestFromJson(jsonString: String): StorageRequest? { + return try { + json.decodeFromString(StorageRequest.serializer(), jsonString) + } catch (e: Exception) { + null + } + } + + /** + * Serialize a StorageResponse to JSON string. + */ + fun responseToJson(response: StorageResponse): String { + return json.encodeToString(StorageResponse.serializer(), response) + } + + /** + * Deserialize a StorageResponse from JSON string. + */ + fun responseFromJson(jsonString: String): StorageResponse? { + return try { + json.decodeFromString(StorageResponse.serializer(), jsonString) + } catch (e: Exception) { + null + } + } + + /** + * Get the ContentProvider method name for a request. + */ + fun getMethodName(request: StorageRequest): String { + return when (request) { + is StorageRequest.CheckAvailability -> "checkAvailability" + is StorageRequest.ListFiles -> "listFiles" + is StorageRequest.GetPreferences -> "getPreferences" + is StorageRequest.GetPreference -> "getPreference" + is StorageRequest.SetValue -> "setValue" + is StorageRequest.RemoveValue -> "removeValue" + is StorageRequest.ClearFile -> "clearFile" + is StorageRequest.Subscribe -> "subscribeToFile" + is StorageRequest.Unsubscribe -> "unsubscribeFromFile" + is StorageRequest.GetChanges -> "getChanges" + is StorageRequest.GetListenedFiles -> "getListenedFiles" + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketMessageHandler.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketMessageHandler.kt new file mode 100644 index 000000000..b1c585dff --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketMessageHandler.kt @@ -0,0 +1,19 @@ +package dev.jasonpearson.automobile.protocol + +/** + * Interface for handling WebSocket messages from the MCP server. + * + * Implementations receive typed request objects and return optional response objects. + * When a response is returned, the server broadcasts it to connected clients. + * When null is returned, no response is broadcast (useful for async operations + * that broadcast their own responses later). + */ +interface WebSocketMessageHandler { + /** + * Handle an incoming WebSocket request. + * + * @param request The typed request object + * @return A response to broadcast, or null if the handler will broadcast its own response + */ + suspend fun handleMessage(request: WebSocketRequest): WebSocketResponse? +} diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketRequest.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketRequest.kt new file mode 100644 index 000000000..905e9dcf9 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketRequest.kt @@ -0,0 +1,320 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Sealed class hierarchy for all inbound WebSocket messages from MCP server to Android. + * + * Each request type is a separate data class with only the fields it needs, + * replacing the flat WebSocketRequest with 25+ optional fields. + */ +@Serializable +sealed class WebSocketRequest { + abstract val requestId: String? +} + +// ============================================================================= +// Hierarchy Requests +// ============================================================================= + +@Serializable +@SerialName("request_hierarchy") +data class RequestHierarchy( + override val requestId: String? = null, + val disableAllFiltering: Boolean = false, +) : WebSocketRequest() + +@Serializable +@SerialName("request_hierarchy_if_stale") +data class RequestHierarchyIfStale( + override val requestId: String? = null, + val sinceTimestamp: Long, + val disableAllFiltering: Boolean = false, +) : WebSocketRequest() + +// ============================================================================= +// Screenshot Request +// ============================================================================= + +@Serializable +@SerialName("request_screenshot") +data class RequestScreenshot( + override val requestId: String? = null, +) : WebSocketRequest() + +// ============================================================================= +// Gesture Requests +// ============================================================================= + +@Serializable +@SerialName("request_tap_coordinates") +data class RequestTapCoordinates( + override val requestId: String? = null, + val x: Int, + val y: Int, + val duration: Long = 10L, +) : WebSocketRequest() + +@Serializable +@SerialName("request_swipe") +data class RequestSwipe( + override val requestId: String? = null, + val x1: Int, + val y1: Int, + val x2: Int, + val y2: Int, + val duration: Long = 300L, +) : WebSocketRequest() + +@Serializable +@SerialName("request_two_finger_swipe") +data class RequestTwoFingerSwipe( + override val requestId: String? = null, + val x1: Int, + val y1: Int, + val x2: Int, + val y2: Int, + val duration: Long = 300L, + val offset: Int = 100, +) : WebSocketRequest() + +@Serializable +@SerialName("request_drag") +data class RequestDrag( + override val requestId: String? = null, + val x1: Int, + val y1: Int, + val x2: Int, + val y2: Int, + val pressDurationMs: Long = 600L, + val dragDurationMs: Long = 300L, + val holdDurationMs: Long = 100L, + // Legacy field names for backward compatibility + val holdTime: Long? = null, + val duration: Long? = null, +) : WebSocketRequest() { + /** Resolved press duration, using legacy holdTime as fallback */ + val resolvedPressDurationMs: Long + get() = if (pressDurationMs == 600L && holdTime != null) holdTime else pressDurationMs + + /** Resolved drag duration, using legacy duration as fallback */ + val resolvedDragDurationMs: Long + get() = if (dragDurationMs == 300L && duration != null) duration else dragDurationMs +} + +@Serializable +@SerialName("request_pinch") +data class RequestPinch( + override val requestId: String? = null, + val centerX: Int, + val centerY: Int, + val distanceStart: Int, + val distanceEnd: Int, + val rotationDegrees: Float = 0f, + val duration: Long = 300L, +) : WebSocketRequest() + +// ============================================================================= +// Text Input Requests +// ============================================================================= + +@Serializable +@SerialName("request_set_text") +data class RequestSetText( + override val requestId: String? = null, + val text: String, + val resourceId: String? = null, +) : WebSocketRequest() + +@Serializable +@SerialName("request_ime_action") +data class RequestImeAction( + override val requestId: String? = null, + val action: String, // done, next, search, send, go, previous +) : WebSocketRequest() + +@Serializable +@SerialName("request_select_all") +data class RequestSelectAll( + override val requestId: String? = null, +) : WebSocketRequest() + +// ============================================================================= +// Node Action Request +// ============================================================================= + +@Serializable +@SerialName("request_action") +data class RequestAction( + override val requestId: String? = null, + val action: String, // e.g., long_click + val resourceId: String? = null, +) : WebSocketRequest() + +// ============================================================================= +// Clipboard Request +// ============================================================================= + +@Serializable +@SerialName("request_clipboard") +data class RequestClipboard( + override val requestId: String? = null, + val action: String, // copy, paste, clear, get + val text: String? = null, // Required for 'copy' action +) : WebSocketRequest() + +// ============================================================================= +// Certificate Requests +// ============================================================================= + +@Serializable +@SerialName("install_ca_cert") +data class InstallCaCert( + override val requestId: String? = null, + val certificate: String, +) : WebSocketRequest() + +@Serializable +@SerialName("install_ca_cert_from_path") +data class InstallCaCertFromPath( + override val requestId: String? = null, + val devicePath: String, +) : WebSocketRequest() + +@Serializable +@SerialName("remove_ca_cert") +data class RemoveCaCert( + override val requestId: String? = null, + val alias: String? = null, + val certificate: String? = null, +) : WebSocketRequest() + +// ============================================================================= +// Device Info Requests +// ============================================================================= + +@Serializable +@SerialName("get_device_owner_status") +data class GetDeviceOwnerStatus( + override val requestId: String? = null, +) : WebSocketRequest() + +@Serializable +@SerialName("get_permission") +data class GetPermission( + override val requestId: String? = null, + val permission: String?, + val requestPermission: Boolean? = null, +) : WebSocketRequest() + +// ============================================================================= +// Accessibility Focus Requests +// ============================================================================= + +@Serializable +@SerialName("get_current_focus") +data class GetCurrentFocus( + override val requestId: String? = null, +) : WebSocketRequest() + +@Serializable +@SerialName("get_traversal_order") +data class GetTraversalOrder( + override val requestId: String? = null, +) : WebSocketRequest() + +// ============================================================================= +// Highlight Request +// ============================================================================= + +@Serializable +@SerialName("add_highlight") +data class AddHighlight( + override val requestId: String? = null, + val id: String? = null, + val shape: HighlightShape? = null, +) : WebSocketRequest() + +// ============================================================================= +// Storage Requests +// ============================================================================= + +@Serializable +@SerialName("list_preference_files") +data class ListPreferenceFiles( + override val requestId: String? = null, + val packageName: String, +) : WebSocketRequest() + +@Serializable +@SerialName("get_preferences") +data class GetPreferences( + override val requestId: String? = null, + val packageName: String, + val fileName: String, +) : WebSocketRequest() + +@Serializable +@SerialName("subscribe_storage") +data class SubscribeStorage( + override val requestId: String? = null, + val packageName: String, + val fileName: String, +) : WebSocketRequest() + +@Serializable +@SerialName("unsubscribe_storage") +data class UnsubscribeStorage( + override val requestId: String? = null, + val packageName: String, + val fileName: String, +) : WebSocketRequest() + +@Serializable +@SerialName("get_preference") +data class GetPreference( + override val requestId: String? = null, + val packageName: String, + val fileName: String, + val key: String, +) : WebSocketRequest() + +@Serializable +@SerialName("set_preference") +data class SetPreference( + override val requestId: String? = null, + val packageName: String, + val fileName: String, + val key: String, + val value: String?, + val valueType: String, +) : WebSocketRequest() + +@Serializable +@SerialName("remove_preference") +data class RemovePreference( + override val requestId: String? = null, + val packageName: String, + val fileName: String, + val key: String, +) : WebSocketRequest() + +@Serializable +@SerialName("clear_preferences") +data class ClearPreferences( + override val requestId: String? = null, + val packageName: String, + val fileName: String, +) : WebSocketRequest() + +// ============================================================================= +// Configuration Requests +// ============================================================================= + +@Serializable +@SerialName("set_recomposition_tracking") +data class SetRecompositionTracking( + override val requestId: String? = null, + val enabled: Boolean, +) : WebSocketRequest() diff --git a/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketResponse.kt b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketResponse.kt new file mode 100644 index 000000000..e76b3bf16 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/jasonpearson/automobile/protocol/WebSocketResponse.kt @@ -0,0 +1,505 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Sealed class hierarchy for all outbound WebSocket messages from Android to MCP server. + * + * Messages are grouped into: + * - Events: Push notifications (hierarchy updates, navigation, interactions) + * - Results: Responses to specific requests + */ +@Serializable +sealed class WebSocketResponse { + abstract val timestamp: Long +} + +// ============================================================================= +// Connection +// ============================================================================= + +@Serializable +@SerialName("connected") +data class ConnectedResponse( + val id: Int, + override val timestamp: Long = System.currentTimeMillis(), +) : WebSocketResponse() + +// ============================================================================= +// Push Events (unsolicited messages) +// ============================================================================= + +@Serializable +@SerialName("hierarchy_update") +data class HierarchyUpdateEvent( + override val timestamp: Long, + val data: String, // JSON string of hierarchy data + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("interaction_event") +data class InteractionEvent( + override val timestamp: Long, + val event: String, // JSON string of interaction event +) : WebSocketResponse() + +@Serializable +@SerialName("package_event") +data class PackageEvent( + override val timestamp: Long, + val event: PackageEventData, +) : WebSocketResponse() + +@Serializable +data class PackageEventData( + val eventType: String, + val packageName: String, + val className: String? = null, +) + +@Serializable +@SerialName("navigation_event") +data class NavigationEventResponse( + override val timestamp: Long, + val event: NavigationEventData, +) : WebSocketResponse() + +@Serializable +data class NavigationEventData( + val destination: String, + val source: String? = null, + val arguments: Map? = null, + val metadata: Map? = null, + val applicationId: String? = null, + /** Monotonically increasing sequence number for ordering */ + val sequenceNumber: Long? = null, +) + +@Serializable +@SerialName("handled_exception_event") +data class HandledExceptionEvent( + override val timestamp: Long, + val event: HandledExceptionData, +) : WebSocketResponse() + +/** + * Device information captured at the time of an exception. + */ +@Serializable +data class DeviceInfo( + val model: String, + val manufacturer: String, + val osVersion: String, + val sdkInt: Int, +) + +@Serializable +data class HandledExceptionData( + val exceptionClass: String, + val message: String?, + val stackTrace: String, + val customMessage: String? = null, + val currentScreen: String? = null, + val packageName: String? = null, + val appVersion: String? = null, + val deviceInfo: DeviceInfo? = null, + val applicationId: String? = null, +) + +@Serializable +@SerialName("storage_changed") +data class StorageChangedEvent( + override val timestamp: Long, + val packageName: String, + val fileName: String, + val data: String, // JSON string of preferences +) : WebSocketResponse() + +@Serializable +@SerialName("crash_event") +data class CrashEvent( + override val timestamp: Long, + val event: CrashData, +) : WebSocketResponse() + +@Serializable +data class CrashData( + val exceptionClass: String, + val message: String?, + val stackTrace: String, + val threadName: String, + val currentScreen: String? = null, + val packageName: String? = null, + val appVersion: String? = null, + val deviceInfo: DeviceInfo? = null, + val applicationId: String? = null, +) + +@Serializable +@SerialName("anr_event") +data class AnrEvent( + override val timestamp: Long, + val event: AnrData, +) : WebSocketResponse() + +@Serializable +data class AnrData( + /** Process ID that experienced the ANR */ + val pid: Int, + /** Process name */ + val processName: String, + /** Process importance when ANR occurred (FOREGROUND, VISIBLE, etc.) */ + val importance: String, + /** Full thread dump from ApplicationExitInfo.traceInputStream */ + val trace: String?, + /** Human-readable reason description */ + val reason: String, + /** Package name of the app */ + val packageName: String? = null, + /** App version */ + val appVersion: String? = null, + /** Device information */ + val deviceInfo: DeviceInfo? = null, +) + +// ============================================================================= +// Screenshot Results +// ============================================================================= + +@Serializable +@SerialName("screenshot") +data class ScreenshotResult( + override val timestamp: Long, + val requestId: String? = null, + val data: String, // Base64 encoded image + val format: String = "jpeg", + val width: Int? = null, + val height: Int? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("screenshot_error") +data class ScreenshotErrorResult( + override val timestamp: Long, + val requestId: String? = null, + val error: String, +) : WebSocketResponse() + +// ============================================================================= +// Gesture Results +// ============================================================================= + +@Serializable +@SerialName("swipe_result") +data class SwipeResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val totalTimeMs: Long, + val gestureTimeMs: Long? = null, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("tap_coordinates_result") +data class TapCoordinatesResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val totalTimeMs: Long, + val gestureTimeMs: Long? = null, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("drag_result") +data class DragResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val totalTimeMs: Long, + val gestureTimeMs: Long? = null, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("pinch_result") +data class PinchResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val totalTimeMs: Long, + val gestureTimeMs: Long? = null, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Text Input Results +// ============================================================================= + +@Serializable +@SerialName("set_text_result") +data class SetTextResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("ime_action_result") +data class ImeActionResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val action: String? = null, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("select_all_result") +data class SelectAllResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Action Result +// ============================================================================= + +@Serializable +@SerialName("action_result") +data class ActionResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val action: String? = null, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Clipboard Result +// ============================================================================= + +@Serializable +@SerialName("clipboard_result") +data class ClipboardResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val action: String, + val text: String? = null, // For 'get' action + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Certificate Result +// ============================================================================= + +@Serializable +@SerialName("ca_cert_result") +data class CaCertResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val action: String, // install, remove + val alias: String? = null, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Device Info Results +// ============================================================================= + +@Serializable +@SerialName("device_owner_status_result") +data class DeviceOwnerStatusResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val isDeviceOwner: Boolean, + val isAdminActive: Boolean, + val packageName: String? = null, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("permission_result") +data class PermissionResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val permission: String, + val granted: Boolean, + val requestLaunched: Boolean = false, + val canRequest: Boolean = false, + val requiresSettings: Boolean = false, + val instructions: String? = null, + val adbCommand: String? = null, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Accessibility Focus Results +// ============================================================================= + +@Serializable +@SerialName("current_focus_result") +data class CurrentFocusResult( + override val timestamp: Long, + val requestId: String? = null, + val focusedElement: String? = null, // JSON string of focused element + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("traversal_order_result") +data class TraversalOrderResult( + override val timestamp: Long, + val requestId: String? = null, + val result: TraversalOrderData? = null, + val totalTimeMs: Long, + val error: String? = null, + val perfTiming: String? = null, +) : WebSocketResponse() + +@Serializable +data class TraversalOrderData( + val elements: List, // JSON strings of elements + val focusedIndex: Int?, + val totalCount: Int, +) + +@Serializable +@SerialName("highlight_response") +data class HighlightResponse( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val error: String? = null, +) : WebSocketResponse() + +// ============================================================================= +// Storage Results +// ============================================================================= + +@Serializable +@SerialName("preference_files") +data class PreferenceFilesResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val files: List? = null, + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("preferences") +data class PreferencesResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val data: String? = null, // JSON string of preferences + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("subscribe_storage_result") +data class SubscribeStorageResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("unsubscribe_storage_result") +data class UnsubscribeStorageResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("get_preference_result") +data class GetPreferenceResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val key: String, + val value: String? = null, + val type: String? = null, + val found: Boolean = false, + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("set_preference_result") +data class SetPreferenceResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val key: String, + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("remove_preference_result") +data class RemovePreferenceResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val key: String, + val error: String? = null, +) : WebSocketResponse() + +@Serializable +@SerialName("clear_preferences_result") +data class ClearPreferencesResult( + override val timestamp: Long, + val requestId: String? = null, + val success: Boolean, + val packageName: String, + val fileName: String, + val error: String? = null, +) : WebSocketResponse() diff --git a/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/SdkEventSerializerTest.kt b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/SdkEventSerializerTest.kt new file mode 100644 index 000000000..d53223ed7 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/SdkEventSerializerTest.kt @@ -0,0 +1,233 @@ +package dev.jasonpearson.automobile.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SdkEventSerializerTest { + + @Test + fun `toJson and fromJson roundtrip for navigation event`() { + val event = SdkNavigationEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + destination = "/home", + source = NavigationSourceType.COMPOSE_NAVIGATION, + arguments = mapOf("id" to "123", "name" to "test"), + metadata = mapOf("tag" to "main"), + ) + + val json = SdkEventSerializer.toJson(event) + val deserialized = SdkEventSerializer.fromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(event.timestamp, deserialized.timestamp) + assertEquals(event.applicationId, deserialized.applicationId) + assertEquals(event.destination, deserialized.destination) + assertEquals(event.source, deserialized.source) + assertEquals(event.arguments, deserialized.arguments) + assertEquals(event.metadata, deserialized.metadata) + } + + @Test + fun `toJson and fromJson roundtrip for handled exception event`() { + val event = SdkHandledExceptionEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + exceptionClass = "java.lang.NullPointerException", + exceptionMessage = "Object reference is null", + stackTrace = "at com.example.MyClass.method(MyClass.kt:42)", + customMessage = "Custom context message", + currentScreen = "/home", + appVersion = "1.0.0", + deviceInfo = SdkDeviceInfo( + model = "Pixel 6", + manufacturer = "Google", + osVersion = "13", + sdkInt = 33, + ), + ) + + val json = SdkEventSerializer.toJson(event) + val deserialized = SdkEventSerializer.fromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(event.timestamp, deserialized.timestamp) + assertEquals(event.applicationId, deserialized.applicationId) + assertEquals(event.exceptionClass, deserialized.exceptionClass) + assertEquals(event.exceptionMessage, deserialized.exceptionMessage) + assertEquals(event.stackTrace, deserialized.stackTrace) + assertEquals(event.customMessage, deserialized.customMessage) + assertEquals(event.currentScreen, deserialized.currentScreen) + assertEquals(event.appVersion, deserialized.appVersion) + assertEquals(event.deviceInfo, deserialized.deviceInfo) + } + + @Test + fun `toJson and fromJson roundtrip for notification action event`() { + val event = SdkNotificationActionEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + notificationId = "notif-123", + actionId = "action-reply", + actionLabel = "Reply", + ) + + val json = SdkEventSerializer.toJson(event) + val deserialized = SdkEventSerializer.fromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(event.timestamp, deserialized.timestamp) + assertEquals(event.applicationId, deserialized.applicationId) + assertEquals(event.notificationId, deserialized.notificationId) + assertEquals(event.actionId, deserialized.actionId) + assertEquals(event.actionLabel, deserialized.actionLabel) + } + + @Test + fun `toJson and fromJson roundtrip for recomposition snapshot event`() { + val event = SdkRecompositionSnapshotEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + snapshotJson = """{"composables": []}""", + ) + + val json = SdkEventSerializer.toJson(event) + val deserialized = SdkEventSerializer.fromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(event.timestamp, deserialized.timestamp) + assertEquals(event.applicationId, deserialized.applicationId) + assertEquals(event.snapshotJson, deserialized.snapshotJson) + } + + @Test + fun `fromJson returns null for invalid json`() { + val result = SdkEventSerializer.fromJson("not valid json") + assertNull(result) + } + + @Test + fun `fromJson returns null for empty string`() { + val result = SdkEventSerializer.fromJson("") + assertNull(result) + } + + @Test + fun `getEventType returns correct type for each event`() { + assertEquals( + SdkEventSerializer.EventTypes.NAVIGATION, + SdkEventSerializer.getEventType( + SdkNavigationEvent( + timestamp = 0L, + destination = "", + source = NavigationSourceType.CUSTOM, + ), + ), + ) + assertEquals( + SdkEventSerializer.EventTypes.HANDLED_EXCEPTION, + SdkEventSerializer.getEventType( + SdkHandledExceptionEvent( + timestamp = 0L, + exceptionClass = "", + exceptionMessage = null, + stackTrace = "", + ), + ), + ) + assertEquals( + SdkEventSerializer.EventTypes.NOTIFICATION_ACTION, + SdkEventSerializer.getEventType( + SdkNotificationActionEvent( + timestamp = 0L, + notificationId = "", + actionId = "", + actionLabel = "", + ), + ), + ) + assertEquals( + SdkEventSerializer.EventTypes.RECOMPOSITION_SNAPSHOT, + SdkEventSerializer.getEventType( + SdkRecompositionSnapshotEvent( + timestamp = 0L, + snapshotJson = "", + ), + ), + ) + } + + @Test + fun `navigationEventFromJson returns event for valid navigation json`() { + val event = SdkNavigationEvent( + timestamp = 1234567890L, + destination = "/home", + source = NavigationSourceType.COMPOSE_NAVIGATION, + ) + val json = SdkEventSerializer.toJson(event) + + val result = SdkEventSerializer.navigationEventFromJson(json) + + assertNotNull(result) + assertEquals(event.destination, result.destination) + } + + @Test + fun `navigationEventFromJson returns null for non-navigation json`() { + val event = SdkHandledExceptionEvent( + timestamp = 0L, + exceptionClass = "Exception", + exceptionMessage = null, + stackTrace = "", + ) + val json = SdkEventSerializer.toJson(event) + + val result = SdkEventSerializer.navigationEventFromJson(json) + + assertNull(result) + } + + @Test + fun `handledExceptionEventFromJson returns event for valid exception json`() { + val event = SdkHandledExceptionEvent( + timestamp = 1234567890L, + exceptionClass = "java.lang.Exception", + exceptionMessage = "Test error", + stackTrace = "at Test.method(Test.kt:1)", + ) + val json = SdkEventSerializer.toJson(event) + + val result = SdkEventSerializer.handledExceptionEventFromJson(json) + + assertNotNull(result) + assertEquals(event.exceptionClass, result.exceptionClass) + } + + @Test + fun `navigation event with null optional fields serializes correctly`() { + val event = SdkNavigationEvent( + timestamp = 1234567890L, + applicationId = null, + destination = "/home", + source = NavigationSourceType.CUSTOM, + arguments = null, + metadata = null, + ) + + val json = SdkEventSerializer.toJson(event) + val deserialized = SdkEventSerializer.fromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertNull(deserialized.applicationId) + assertNull(deserialized.arguments) + assertNull(deserialized.metadata) + } +} diff --git a/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/SdkEventTest.kt b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/SdkEventTest.kt new file mode 100644 index 000000000..3ce0911b0 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/SdkEventTest.kt @@ -0,0 +1,183 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SdkEventTest { + private val json = Json { + classDiscriminator = "type" + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Test + fun `serialize navigation event`() { + val event: SdkEvent = SdkNavigationEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + destination = "home", + source = NavigationSourceType.COMPOSE_NAVIGATION, + arguments = mapOf("userId" to "123"), + metadata = mapOf("screenType" to "main") + ) + + val encoded = json.encodeToString(SdkEvent.serializer(), event) + + assertTrue(encoded.contains(""""type":"navigation"""")) + assertTrue(encoded.contains(""""destination":"home"""")) + assertTrue(encoded.contains(""""source":"COMPOSE_NAVIGATION"""")) + assertTrue(encoded.contains(""""applicationId":"com.example.app"""")) + } + + @Test + fun `deserialize navigation event`() { + val message = """{ + "type": "navigation", + "timestamp": 1234567890, + "applicationId": "com.example.app", + "destination": "profile", + "source": "CIRCUIT", + "arguments": {"userId": "456"} + }""" + + val event = json.decodeFromString(message) + + assertIs(event) + assertEquals("profile", event.destination) + assertEquals(NavigationSourceType.CIRCUIT, event.source) + assertEquals("com.example.app", event.applicationId) + assertEquals("456", event.arguments?.get("userId")) + } + + @Test + fun `serialize handled exception event`() { + val event: SdkEvent = SdkHandledExceptionEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + exceptionClass = "java.lang.NullPointerException", + exceptionMessage = "Value cannot be null", + stackTrace = "at com.example.MyClass.method(MyClass.kt:42)", + customMessage = "Failed to load user data", + currentScreen = "profile", + appVersion = "1.2.3", + deviceInfo = SdkDeviceInfo( + model = "Pixel 6", + manufacturer = "Google", + osVersion = "14", + sdkInt = 34 + ) + ) + + val encoded = json.encodeToString(SdkEvent.serializer(), event) + + assertTrue(encoded.contains(""""type":"handled_exception"""")) + assertTrue(encoded.contains(""""exceptionClass":"java.lang.NullPointerException"""")) + assertTrue(encoded.contains(""""customMessage":"Failed to load user data"""")) + assertTrue(encoded.contains(""""model":"Pixel 6"""")) + } + + @Test + fun `deserialize handled exception event`() { + val message = """{ + "type": "handled_exception", + "timestamp": 1234567890, + "applicationId": "com.example.app", + "exceptionClass": "java.io.IOException", + "exceptionMessage": "Network error", + "stackTrace": "at com.example.Network.fetch(Network.kt:100)", + "currentScreen": "settings" + }""" + + val event = json.decodeFromString(message) + + assertIs(event) + assertEquals("java.io.IOException", event.exceptionClass) + assertEquals("Network error", event.exceptionMessage) + assertEquals("settings", event.currentScreen) + } + + @Test + fun `serialize notification action event`() { + val event: SdkEvent = SdkNotificationActionEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + notificationId = "notif_123", + actionId = "reply", + actionLabel = "Reply" + ) + + val encoded = json.encodeToString(SdkEvent.serializer(), event) + + assertTrue(encoded.contains(""""type":"notification_action"""")) + assertTrue(encoded.contains(""""notificationId":"notif_123"""")) + assertTrue(encoded.contains(""""actionId":"reply"""")) + assertTrue(encoded.contains(""""actionLabel":"Reply"""")) + } + + @Test + fun `deserialize notification action event`() { + val message = """{ + "type": "notification_action", + "timestamp": 1234567890, + "applicationId": "com.example.app", + "notificationId": "notif_456", + "actionId": "dismiss", + "actionLabel": "Dismiss" + }""" + + val event = json.decodeFromString(message) + + assertIs(event) + assertEquals("notif_456", event.notificationId) + assertEquals("dismiss", event.actionId) + } + + @Test + fun `serialize recomposition snapshot event`() { + val event: SdkEvent = SdkRecompositionSnapshotEvent( + timestamp = 1234567890L, + applicationId = "com.example.app", + snapshotJson = """{"composables":[{"name":"MyComposable","count":5}]}""" + ) + + val encoded = json.encodeToString(SdkEvent.serializer(), event) + + assertTrue(encoded.contains(""""type":"recomposition_snapshot"""")) + assertTrue(encoded.contains(""""snapshotJson":""")) + } + + @Test + fun `deserialize recomposition snapshot event`() { + val message = """{ + "type": "recomposition_snapshot", + "timestamp": 1234567890, + "applicationId": "com.example.app", + "snapshotJson": "{\"count\":10}" + }""" + + val event = json.decodeFromString(message) + + assertIs(event) + assertEquals("""{"count":10}""", event.snapshotJson) + } + + @Test + fun `all navigation sources are serializable`() { + NavigationSourceType.entries.forEach { source -> + val event: SdkEvent = SdkNavigationEvent( + timestamp = 1234567890L, + destination = "test", + source = source + ) + + val encoded = json.encodeToString(SdkEvent.serializer(), event) + val decoded = json.decodeFromString(encoded) + + assertIs(decoded) + assertEquals(source, decoded.source) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/StorageProtocolTest.kt b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/StorageProtocolTest.kt new file mode 100644 index 000000000..f983f6fb4 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/StorageProtocolTest.kt @@ -0,0 +1,272 @@ +package dev.jasonpearson.automobile.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class StorageProtocolTest { + + // ============================================================================= + // Request Serialization Tests + // ============================================================================= + + @Test + fun `CheckAvailability request roundtrip`() { + val request = StorageRequest.CheckAvailability + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + } + + @Test + fun `ListFiles request roundtrip`() { + val request = StorageRequest.ListFiles + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + } + + @Test + fun `GetPreferences request roundtrip`() { + val request = StorageRequest.GetPreferences(fileName = "auth_prefs") + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("auth_prefs", deserialized.fileName) + } + + @Test + fun `Subscribe request roundtrip`() { + val request = StorageRequest.Subscribe(fileName = "settings") + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("settings", deserialized.fileName) + } + + @Test + fun `Unsubscribe request roundtrip`() { + val request = StorageRequest.Unsubscribe(fileName = "settings") + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("settings", deserialized.fileName) + } + + @Test + fun `GetChanges request roundtrip with default sinceSequence`() { + val request = StorageRequest.GetChanges(fileName = "prefs") + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("prefs", deserialized.fileName) + assertEquals(0L, deserialized.sinceSequence) + } + + @Test + fun `GetChanges request roundtrip with custom sinceSequence`() { + val request = StorageRequest.GetChanges(fileName = "prefs", sinceSequence = 42L) + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("prefs", deserialized.fileName) + assertEquals(42L, deserialized.sinceSequence) + } + + @Test + fun `GetListenedFiles request roundtrip`() { + val request = StorageRequest.GetListenedFiles + + val json = StorageProtocolSerializer.requestToJson(request) + val deserialized = StorageProtocolSerializer.requestFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + } + + // ============================================================================= + // Response Serialization Tests + // ============================================================================= + + @Test + fun `Availability response roundtrip`() { + val response = StorageResponse.Availability(available = true, version = 1) + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(true, deserialized.available) + assertEquals(1, deserialized.version) + } + + @Test + fun `FileList response roundtrip`() { + val response = StorageResponse.FileList( + files = listOf( + StorageFileInfo(name = "prefs", path = "/data/data/app/shared_prefs/prefs.xml", entryCount = 5), + StorageFileInfo(name = "auth", path = "/data/data/app/shared_prefs/auth.xml", entryCount = 2), + ), + ) + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(2, deserialized.files.size) + assertEquals("prefs", deserialized.files[0].name) + assertEquals(5, deserialized.files[0].entryCount) + } + + @Test + fun `Preferences response roundtrip`() { + val response = StorageResponse.Preferences( + file = StorageFileInfo(name = "prefs", path = "/path/to/prefs.xml", entryCount = 2), + entries = listOf( + StorageEntry(key = "user_name", value = "\"John\"", type = "STRING"), + StorageEntry(key = "logged_in", value = "true", type = "BOOLEAN"), + ), + ) + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertNotNull(deserialized.file) + assertEquals("prefs", deserialized.file!!.name) + assertEquals(2, deserialized.entries.size) + assertEquals("user_name", deserialized.entries[0].key) + assertEquals("STRING", deserialized.entries[0].type) + } + + @Test + fun `SubscriptionResult response roundtrip`() { + val response = StorageResponse.SubscriptionResult(fileName = "prefs", subscribed = true) + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("prefs", deserialized.fileName) + assertEquals(true, deserialized.subscribed) + } + + @Test + fun `Changes response roundtrip`() { + val response = StorageResponse.Changes( + fileName = "prefs", + changes = listOf( + StorageChangeEvent( + fileName = "prefs", + key = "counter", + value = "42", + type = "INT", + timestamp = 1234567890L, + sequenceNumber = 1L, + ), + StorageChangeEvent( + fileName = "prefs", + key = null, + value = null, + type = "CLEARED", + timestamp = 1234567891L, + sequenceNumber = 2L, + ), + ), + ) + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("prefs", deserialized.fileName) + assertEquals(2, deserialized.changes.size) + assertEquals("counter", deserialized.changes[0].key) + assertNull(deserialized.changes[1].key) + } + + @Test + fun `ListenedFiles response roundtrip`() { + val response = StorageResponse.ListenedFiles(files = listOf("prefs", "auth", "settings")) + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals(3, deserialized.files.size) + assertEquals("prefs", deserialized.files[0]) + } + + @Test + fun `Error response roundtrip`() { + val response = StorageResponse.Error(errorType = "DISABLED", message = "Inspection is disabled") + + val json = StorageProtocolSerializer.responseToJson(response) + val deserialized = StorageProtocolSerializer.responseFromJson(json) + + assertNotNull(deserialized) + assertIs(deserialized) + assertEquals("DISABLED", deserialized.errorType) + assertEquals("Inspection is disabled", deserialized.message) + } + + // ============================================================================= + // Method Name Tests + // ============================================================================= + + @Test + fun `getMethodName returns correct method for each request type`() { + assertEquals("checkAvailability", StorageProtocolSerializer.getMethodName(StorageRequest.CheckAvailability)) + assertEquals("listFiles", StorageProtocolSerializer.getMethodName(StorageRequest.ListFiles)) + assertEquals("getPreferences", StorageProtocolSerializer.getMethodName(StorageRequest.GetPreferences("f"))) + assertEquals("subscribeToFile", StorageProtocolSerializer.getMethodName(StorageRequest.Subscribe("f"))) + assertEquals("unsubscribeFromFile", StorageProtocolSerializer.getMethodName(StorageRequest.Unsubscribe("f"))) + assertEquals("getChanges", StorageProtocolSerializer.getMethodName(StorageRequest.GetChanges("f"))) + assertEquals("getListenedFiles", StorageProtocolSerializer.getMethodName(StorageRequest.GetListenedFiles)) + } + + // ============================================================================= + // Error Handling Tests + // ============================================================================= + + @Test + fun `requestFromJson returns null for invalid json`() { + val result = StorageProtocolSerializer.requestFromJson("not valid json") + assertNull(result) + } + + @Test + fun `responseFromJson returns null for invalid json`() { + val result = StorageProtocolSerializer.responseFromJson("not valid json") + assertNull(result) + } +} diff --git a/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/WebSocketRequestTest.kt b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/WebSocketRequestTest.kt new file mode 100644 index 000000000..342965296 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/WebSocketRequestTest.kt @@ -0,0 +1,146 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class WebSocketRequestTest { + private val json = Json { + classDiscriminator = "type" + ignoreUnknownKeys = true + } + + @Test + fun `deserialize request_hierarchy`() { + val message = """{"type":"request_hierarchy","requestId":"test-1"}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("test-1", request.requestId) + assertEquals(false, request.disableAllFiltering) + } + + @Test + fun `deserialize request_hierarchy with filtering disabled`() { + val message = """{"type":"request_hierarchy","requestId":"test-2","disableAllFiltering":true}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("test-2", request.requestId) + assertEquals(true, request.disableAllFiltering) + } + + @Test + fun `deserialize request_tap_coordinates`() { + val message = """{"type":"request_tap_coordinates","requestId":"tap-1","x":100,"y":200}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("tap-1", request.requestId) + assertEquals(100, request.x) + assertEquals(200, request.y) + assertEquals(10L, request.duration) // default + } + + @Test + fun `deserialize request_swipe`() { + val message = """{"type":"request_swipe","requestId":"swipe-1","x1":0,"y1":100,"x2":0,"y2":500,"duration":400}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("swipe-1", request.requestId) + assertEquals(0, request.x1) + assertEquals(100, request.y1) + assertEquals(0, request.x2) + assertEquals(500, request.y2) + assertEquals(400L, request.duration) + } + + @Test + fun `deserialize request_drag with legacy fields`() { + val message = """{"type":"request_drag","requestId":"drag-1","x1":50,"y1":50,"x2":150,"y2":150,"holdTime":800,"duration":500}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("drag-1", request.requestId) + assertEquals(50, request.x1) + assertEquals(50, request.y1) + assertEquals(150, request.x2) + assertEquals(150, request.y2) + // Legacy fields are used as fallback + assertEquals(800L, request.resolvedPressDurationMs) + assertEquals(500L, request.resolvedDragDurationMs) + } + + @Test + fun `deserialize request_pinch`() { + val message = """{"type":"request_pinch","requestId":"pinch-1","centerX":540,"centerY":960,"distanceStart":100,"distanceEnd":300,"rotationDegrees":45.0,"duration":500}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("pinch-1", request.requestId) + assertEquals(540, request.centerX) + assertEquals(960, request.centerY) + assertEquals(100, request.distanceStart) + assertEquals(300, request.distanceEnd) + assertEquals(45.0f, request.rotationDegrees) + assertEquals(500L, request.duration) + } + + @Test + fun `deserialize request_set_text`() { + val message = """{"type":"request_set_text","requestId":"text-1","text":"Hello World","resourceId":"input_field"}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("text-1", request.requestId) + assertEquals("Hello World", request.text) + assertEquals("input_field", request.resourceId) + } + + @Test + fun `deserialize request_ime_action`() { + val message = """{"type":"request_ime_action","requestId":"ime-1","action":"search"}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("ime-1", request.requestId) + assertEquals("search", request.action) + } + + @Test + fun `deserialize request_clipboard`() { + val message = """{"type":"request_clipboard","requestId":"clip-1","action":"copy","text":"Copied text"}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("clip-1", request.requestId) + assertEquals("copy", request.action) + assertEquals("Copied text", request.text) + } + + @Test + fun `deserialize add_highlight`() { + val message = """{"type":"add_highlight","requestId":"hl-1","id":"highlight-1","shape":{"type":"box","bounds":{"x":0,"y":0,"width":100,"height":50}}}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("hl-1", request.requestId) + assertEquals("highlight-1", request.id) + assertEquals("box", request.shape?.type) + assertEquals(100, request.shape?.bounds?.width) + assertEquals(50, request.shape?.bounds?.height) + } + + @Test + fun `deserialize get_preferences`() { + val message = """{"type":"get_preferences","requestId":"pref-1","packageName":"com.example.app","fileName":"settings.xml"}""" + val request = json.decodeFromString(message) + + assertIs(request) + assertEquals("pref-1", request.requestId) + assertEquals("com.example.app", request.packageName) + assertEquals("settings.xml", request.fileName) + } +} diff --git a/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/WebSocketResponseTest.kt b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/WebSocketResponseTest.kt new file mode 100644 index 000000000..e60b81c2c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/jasonpearson/automobile/protocol/WebSocketResponseTest.kt @@ -0,0 +1,116 @@ +package dev.jasonpearson.automobile.protocol + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WebSocketResponseTest { + private val json = Json { + classDiscriminator = "type" + encodeDefaults = true + } + + @Test + fun `serialize swipe_result`() { + val response: WebSocketResponse = SwipeResult( + timestamp = 1234567890L, + requestId = "swipe-1", + success = true, + totalTimeMs = 350L, + gestureTimeMs = 300L + ) + + val encoded = json.encodeToString(WebSocketResponse.serializer(), response) + + assertTrue(encoded.contains(""""type":"swipe_result"""")) + assertTrue(encoded.contains(""""requestId":"swipe-1"""")) + assertTrue(encoded.contains(""""success":true""")) + assertTrue(encoded.contains(""""totalTimeMs":350""")) + assertTrue(encoded.contains(""""gestureTimeMs":300""")) + } + + @Test + fun `serialize screenshot_result`() { + val response: WebSocketResponse = ScreenshotResult( + timestamp = 1234567890L, + requestId = "ss-1", + data = "base64data", + format = "png", + width = 1080, + height = 1920 + ) + + val encoded = json.encodeToString(WebSocketResponse.serializer(), response) + + assertTrue(encoded.contains(""""type":"screenshot"""")) + assertTrue(encoded.contains(""""requestId":"ss-1"""")) + assertTrue(encoded.contains(""""data":"base64data"""")) + assertTrue(encoded.contains(""""format":"png"""")) + assertTrue(encoded.contains(""""width":1080""")) + assertTrue(encoded.contains(""""height":1920""")) + } + + @Test + fun `serialize hierarchy_update event`() { + val response: WebSocketResponse = HierarchyUpdateEvent( + timestamp = 1234567890L, + data = """{"nodes":[]}""", + perfTiming = """{"total":50}""" + ) + + val encoded = json.encodeToString(WebSocketResponse.serializer(), response) + + assertTrue(encoded.contains(""""type":"hierarchy_update"""")) + assertTrue(encoded.contains(""""data":"{\"nodes\":[]}"""")) + assertTrue(encoded.contains(""""perfTiming":"{\"total\":50}"""")) + } + + @Test + fun `serialize connected response`() { + val response: WebSocketResponse = ConnectedResponse( + id = 1, + timestamp = 1234567890L + ) + + val encoded = json.encodeToString(WebSocketResponse.serializer(), response) + + assertTrue(encoded.contains(""""type":"connected"""")) + assertTrue(encoded.contains(""""id":1""")) + } + + @Test + fun `serialize permission_result`() { + val response: WebSocketResponse = PermissionResult( + timestamp = 1234567890L, + requestId = "perm-1", + success = true, + permission = "android.permission.CAMERA", + granted = true, + canRequest = false, + totalTimeMs = 10L + ) + + val encoded = json.encodeToString(WebSocketResponse.serializer(), response) + + assertTrue(encoded.contains(""""type":"permission_result"""")) + assertTrue(encoded.contains(""""permission":"android.permission.CAMERA"""")) + assertTrue(encoded.contains(""""granted":true""")) + } + + @Test + fun `serialize error result`() { + val response: WebSocketResponse = SwipeResult( + timestamp = 1234567890L, + requestId = "swipe-error", + success = false, + totalTimeMs = 100L, + error = "Gesture failed: timeout" + ) + + val encoded = json.encodeToString(WebSocketResponse.serializer(), response) + + assertTrue(encoded.contains(""""success":false""")) + assertTrue(encoded.contains(""""error":"Gesture failed: timeout"""")) + } +} diff --git a/android/scripts/README.md b/android/scripts/README.md new file mode 100644 index 000000000..724d04557 --- /dev/null +++ b/android/scripts/README.md @@ -0,0 +1,80 @@ +# AutoMobile Scripts + +Utility scripts for testing and managing the AutoMobile AccessibilityService. + +## test-websocket-server.sh + +Automated test script that verifies the WebSocket server in the AccessibilityService is running and accessible. + +### What it checks: + +1. **adb availability** - Ensures Android Debug Bridge is installed +2. **Device connection** - Verifies an Android device/emulator is connected +3. **App installation** - Checks if the AccessibilityService app is installed +4. **Service enabled** - Verifies the accessibility service is enabled +5. **Service running** - Confirms the service process is active +6. **Port forwarding** - Sets up adb port forwarding (localhost:8765 → device:8765) +7. **Health endpoint** - Tests HTTP health check endpoint +8. **WebSocket connection** - Verifies WebSocket handshake succeeds +9. **Server logs** - Shows recent WebSocket server activity + +### Usage: + +```bash +# From the android directory +./scripts/test-websocket-server.sh +``` + +### Output: + +- ✓ Green checkmarks for passing tests +- ✗ Red X's for failing tests with helpful error messages +- Connection information and test commands at the end + +### Example output: + +``` +═══════════════════════════════════════════════════════════ + AutoMobile WebSocket Server Test +═══════════════════════════════════════════════════════════ + +▶ Checking for adb... +✓ adb found: /path/to/adb +▶ Checking for connected devices... +✓ Device connected: emulator-5554 +... +✓ All Tests Passed! + +WebSocket Server: + Health Check: http://localhost:8765/health + WebSocket: ws://localhost:8765/ws +``` + +### Requirements: + +- Android SDK with `adb` in PATH +- Python 3 (for WebSocket handshake test) +- curl (for health endpoint test) + +### Troubleshooting: + +If the script fails, it will provide specific error messages and suggestions: + +- **No device**: Start an emulator or connect a device +- **App not installed**: Run `./gradlew :control-proxy:installDebug` +- **Service not enabled**: Enable in Settings → Accessibility +- **Connection failed**: Check service logs with `adb logcat -s CtrlProxy:*` + +### Testing the connection manually: + +```bash +# Health check +curl http://localhost:8765/health + +# WebSocket (requires wscat) +npm install -g wscat +wscat -c ws://localhost:8765/ws + +# View logs +adb logcat -s CtrlProxy:* +``` diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 14f5ca675..66443e87a 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,5 +1,5 @@ dependencyResolutionManagement { - @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) // Use Maven Central as the default repository (where Gradle will download dependencies) in all // subprojects. @Suppress("UnstableApiUsage") @@ -25,17 +25,25 @@ pluginManagement { plugins { // Use the Foojay Toolchains plugin to automatically download JDKs required by subprojects. - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") -include(":accessibility-service") +include(":control-proxy") -include(":kotlin-test-author") +include(":auto-mobile-sdk") + +include(":protocol") include(":junit-runner") +include(":test-plan-validation") + +include(":video-server") + +include(":ide-plugin") + include(":playground:analytics") include(":playground:app") @@ -46,6 +54,8 @@ include(":playground:design:system") include(":playground:discover") +include(":playground:demos") + include(":playground:experimentation") include(":playground:home") diff --git a/android/test-plan-validation/build.gradle.kts b/android/test-plan-validation/build.gradle.kts new file mode 100644 index 000000000..dcbcd22e4 --- /dev/null +++ b/android/test-plan-validation/build.gradle.kts @@ -0,0 +1,75 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlin.serialization) + `java-library` + alias(libs.plugins.mavenPublish) +} + +java { + toolchain { languageVersion.set(JavaLanguageVersion.of(libs.versions.build.java.target.get())) } +} + +dependencies { + // YAML processing and schema validation + implementation(libs.snakeyaml) + implementation(libs.json.schema.validator) + + // Kotlin serialization for JSON conversion + implementation(libs.kotlinx.serialization) + + // Test dependencies + testImplementation(libs.kotlin.test) + testImplementation(libs.bundles.unit.test) +} + +// Version comes from root project's gradle.properties (VERSION_NAME) + +mavenPublishing { + // Coordinates: group and version from root, artifact from local gradle.properties + coordinates( + property("GROUP").toString(), + property("POM_ARTIFACT_ID").toString(), + version.toString(), + ) + + pom { + name.set(property("POM_NAME").toString()) + description.set(property("POM_DESCRIPTION").toString()) + inceptionYear.set("2025") + url.set(property("POM_URL").toString()) + licenses { + license { + name.set(property("POM_LICENCE_NAME").toString()) + url.set(property("POM_LICENCE_URL").toString()) + distribution.set("repo") + } + } + developers { + developer { + id.set(property("POM_DEVELOPER_ID").toString()) + name.set(property("POM_DEVELOPER_NAME").toString()) + url.set("https://github.com/${property("POM_DEVELOPER_ID")}/") + email.set(property("POM_DEVELOPER_EMAIL").toString()) + } + } + scm { + url.set(property("POM_SCM_URL").toString()) + connection.set(property("POM_SCM_CONNECTION").toString()) + developerConnection.set(property("POM_SCM_DEV_CONNECTION").toString()) + } + } +} + +// Configure Kotlin compilation options +tasks.withType().configureEach { + compilerOptions { + languageVersion.set( + KotlinVersion.valueOf( + "KOTLIN_${libs.versions.build.kotlin.language.get().replace(".", "_")}" + ) + ) + } +} diff --git a/android/test-plan-validation/gradle.properties b/android/test-plan-validation/gradle.properties new file mode 100644 index 000000000..9a0305975 --- /dev/null +++ b/android/test-plan-validation/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=auto-mobile-test-plan-validation +POM_NAME=AutoMobile Test Plan Validation +POM_DESCRIPTION=YAML test plan parsing and JSON schema validation for AutoMobile. diff --git a/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/TestPlanValidator.kt b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/TestPlanValidator.kt new file mode 100644 index 000000000..6b5d86c90 --- /dev/null +++ b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/TestPlanValidator.kt @@ -0,0 +1,340 @@ +package dev.jasonpearson.automobile.validation + +import com.networknt.schema.Error +import com.networknt.schema.InputFormat +import com.networknt.schema.Schema +import com.networknt.schema.SchemaRegistry +import com.networknt.schema.SpecificationVersion +import org.yaml.snakeyaml.Yaml + +/** + * Validates AutoMobile test plan YAML files against JSON schema. Supports schema versioning based + * on mcpVersion field. + */ +object TestPlanValidator { + private var schema: Schema? = null + private val yaml = Yaml() + + /** Load the JSON schema from resources */ + @Synchronized + private fun loadSchema(): Schema { + if (schema != null) { + return schema!! + } + + val schemaStream = + javaClass.classLoader.getResourceAsStream("schemas/test-plan.schema.json") + ?: throw IllegalStateException( + "Could not find test-plan.schema.json in classpath resources. " + + "Ensure schemas/test-plan.schema.json is included in the resources." + ) + + val schemaJson = schemaStream.bufferedReader().use { it.readText() } + + // Use V7 to match junit-runner implementation + // Note: V7 doesn't officially support $defs, but the validator tolerates it + val registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7) + schema = registry.getSchema(schemaJson, InputFormat.JSON) + + return schema!! + } + + /** + * Validate YAML content against the test plan schema + * + * @param yamlContent YAML string to validate + * @return Validation result with errors if invalid + */ + fun validateYaml(yamlContent: String): ValidationResult { + val schema = loadSchema() + + // Parse YAML to object + val parsedObject: Any? + try { + parsedObject = yaml.load(yamlContent) + } catch (e: Exception) { + return ValidationResult( + valid = false, + errors = + listOf( + ValidationError( + field = "root", + message = "YAML parsing failed: ${e.message}", + severity = ValidationSeverity.ERROR, + ) + ), + ) + } + + // Convert to JSON string for validation + val jsonString = + try { + kotlinx.serialization.json.Json.encodeToString( + kotlinx.serialization.json.JsonElement.serializer(), + convertToJsonElement(parsedObject), + ) + } catch (e: Exception) { + return ValidationResult( + valid = false, + errors = + listOf( + ValidationError( + field = "root", + message = "Failed to convert YAML to JSON: ${e.message}", + severity = ValidationSeverity.ERROR, + ) + ), + ) + } + + // Validate against schema + val validationErrors: List = schema.validate(jsonString, InputFormat.JSON) + + // Validate tool names + val toolNameErrors = validateToolNames(parsedObject, yamlContent) + + if (validationErrors.isEmpty() && toolNameErrors.isEmpty()) { + return ValidationResult(valid = true) + } + + // Format validation errors + val errors = validationErrors.map { error -> formatError(error, yamlContent) }.toMutableList() + + // Add tool name validation errors + errors.addAll(toolNameErrors) + + return ValidationResult(valid = false, errors = errors) + } + + /** Validate that all tool names in steps are valid AutoMobile tools */ + private fun validateToolNames(parsedObject: Any?, yamlContent: String): List { + val errors = mutableListOf() + + if (parsedObject !is Map<*, *>) { + return errors + } + + val steps = parsedObject["steps"] + if (steps !is List<*>) { + return errors + } + + steps.forEachIndexed { index, step -> + if (step is Map<*, *>) { + val toolName = step["tool"] as? String + if (toolName != null && toolName.isNotEmpty() && !ValidTools.TOOLS.contains(toolName)) { + val lineInfo = findToolNameLine(yamlContent, index, toolName) + errors.add( + ValidationError( + field = "steps[$index].tool", + message = "Unknown tool '$toolName'. Must be one of the valid AutoMobile tools.", + severity = ValidationSeverity.ERROR, + line = lineInfo?.line, + column = lineInfo?.column, + ) + ) + } + } + } + + return errors + } + + /** Find the line number of a tool name in a specific step */ + private fun findToolNameLine(yamlContent: String, stepIndex: Int, toolName: String): LineInfo? { + val lines = yamlContent.split("\n") + var inSteps = false + var currentStepIndex = -1 + var inTargetStep = false + + lines.forEachIndexed { lineIndex, line -> + // Check if we're entering the steps section + if (line.trim().startsWith("steps:")) { + inSteps = true + return@forEachIndexed + } + + // Count step entries (YAML list items starting with -) + if (inSteps && line.trim().startsWith("- ")) { + currentStepIndex++ + inTargetStep = (currentStepIndex == stepIndex) + } + + // If we're at the right step, look for the tool line + if (inTargetStep) { + // Match both inline (- tool: asdf) and separate line ( tool: asdf) + val toolPattern = + Regex("(?:^\\s*-\\s+)?tool:\\s*[\"']?${Regex.escape(toolName)}[\"']?\\s*$") + if (toolPattern.find(line) != null) { + val column = line.indexOf("tool") + 1 + return LineInfo(line = lineIndex + 1, column = column) + } + } + + // Stop if we've passed the target step and hit another list item + if (inTargetStep && line.trim().startsWith("- ") && currentStepIndex > stepIndex) { + return@forEachIndexed + } + } + + return null + } + + /** Convert a YAML-parsed object to kotlinx.serialization JsonElement */ + private fun convertToJsonElement(obj: Any?): kotlinx.serialization.json.JsonElement { + return when (obj) { + null -> kotlinx.serialization.json.JsonNull + is String -> kotlinx.serialization.json.JsonPrimitive(obj) + is Number -> kotlinx.serialization.json.JsonPrimitive(obj) + is Boolean -> kotlinx.serialization.json.JsonPrimitive(obj) + is Map<*, *> -> { + val map = obj.entries.associate { (k, v) -> k.toString() to convertToJsonElement(v) } + kotlinx.serialization.json.JsonObject(map) + } + is List<*> -> { + val list = obj.map { convertToJsonElement(it) } + kotlinx.serialization.json.JsonArray(list) + } + else -> kotlinx.serialization.json.JsonPrimitive(obj.toString()) + } + } + + /** Format a validation error message */ + private fun formatError(msg: Error, yamlContent: String): ValidationError { + // Get the field path from the validation message + var field = msg.instanceLocation?.toString() ?: "root" + + // Remove leading / + if (field.startsWith("/")) { + field = field.substring(1) + } + + // Convert JSON pointer format to more readable format + // e.g., /steps/0/tool -> steps[0].tool + field = field.replace(Regex("/([0-9]+)"), "[$1]").replace("/", ".") + + if (field.isEmpty()) { + field = "root" + } + + // Extract friendly error message from the validation message + val rawMessage = msg.message ?: "Validation error" + val messageType = msg.messageKey ?: msg.keyword ?: "" + + // Determine severity based on whether this is a deprecated field + val severity = + if (isDeprecatedFieldError(field, rawMessage, messageType)) { + ValidationSeverity.WARNING + } else { + ValidationSeverity.ERROR + } + + // Create more user-friendly messages based on error type + val message = + when { + messageType == "required" || rawMessage.contains("required") -> { + // Try to extract property name from message + val propertyMatch = Regex("required property '([^']+)'").find(rawMessage) + val property = propertyMatch?.groupValues?.getOrNull(1) ?: "property" + "Missing required property '$property'" + } + messageType.contains("additionalProperties") || rawMessage.contains("additional") -> { + val propertyMatch = Regex("property '([^']+)'").find(rawMessage) + val property = propertyMatch?.groupValues?.getOrNull(1) ?: "property" + if (severity == ValidationSeverity.WARNING) { + "Property '$property' is deprecated. Consider using the new format." + } else { + "Unknown property '$property'. This property is not allowed by the schema." + } + } + messageType == "enum" || rawMessage.contains("enum") -> { + "Must be one of the allowed values" + } + messageType == "type" || rawMessage.contains("type") -> { + rawMessage + } + messageType.contains("minItems") || rawMessage.contains("minimum") -> { + rawMessage + } + messageType.contains("minLength") -> { + rawMessage + } + else -> rawMessage + } + + // Try to find line number for the field in YAML + val lineInfo = findLineNumber(yamlContent, field) + + return ValidationError( + field = field.ifEmpty { "root" }, + message = message, + severity = severity, + line = lineInfo?.line, + column = lineInfo?.column, + ) + } + + /** Determine if an error is related to a deprecated field */ + private fun isDeprecatedFieldError(field: String, message: String, messageType: String): Boolean { + // Check if the field itself is deprecated + val fieldName = field.substringAfterLast('.').substringAfterLast(']') + if (fieldName in ValidTools.DEPRECATED_FIELDS) { + return true + } + + // Check if the message mentions a deprecated field + if (messageType.contains("additionalProperties") || message.contains("additional")) { + val propertyMatch = Regex("property '([^']+)'").find(message) + val property = propertyMatch?.groupValues?.getOrNull(1) + if (property in ValidTools.DEPRECATED_FIELDS) { + return true + } + } + + return false + } + + /** + * Attempt to find the line number of a field in YAML content This is a best-effort approach using + * regex matching + */ + private fun findLineNumber(yamlContent: String, fieldPath: String): LineInfo? { + val lines = yamlContent.split("\n") + + // Handle root-level fields + if (!fieldPath.contains(".") && !fieldPath.contains("[")) { + val pattern = Regex("^\\s*$fieldPath\\s*:") + lines.forEachIndexed { index, line -> + val match = pattern.find(line) + if (match != null) { + return LineInfo(line = index + 1, column = (match.range.first) + 1) + } + } + } + + // Handle nested fields like "steps[0].tool" or "metadata.version" + val parts = fieldPath.split(Regex("[.\\[\\]]+")).filter { it.isNotEmpty() } + + // Try to find the deepest field we can locate + for (depth in parts.size downTo 1) { + val searchField = parts[depth - 1] + + // Skip numeric indices + if (searchField.matches(Regex("^\\d+$"))) { + continue + } + + val pattern = Regex("^\\s*$searchField\\s*:") + lines.forEachIndexed { index, line -> + val match = pattern.find(line) + if (match != null) { + return LineInfo(line = index + 1, column = (match.range.first) + 1) + } + } + } + + return null + } + + private data class LineInfo(val line: Int, val column: Int) +} diff --git a/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ToolResultModels.kt b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ToolResultModels.kt new file mode 100644 index 000000000..e969311c6 --- /dev/null +++ b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ToolResultModels.kt @@ -0,0 +1,206 @@ +@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + +package dev.jasonpearson.automobile.validation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNames + +@Serializable +data class McpToolResponse( + val content: List = emptyList(), +) + +@Serializable +data class McpToolContent( + val type: String, + val text: String? = null, + val data: String? = null, + val mimeType: String? = null, +) + +sealed interface ToolResultEntry { + val stepIndex: Int +} + +@Serializable +data class ToolResult( + override val stepIndex: Int, + val toolName: String, + val success: Boolean, + val response: ToolResponse, + val error: String? = null, +) : ToolResultEntry + +@Serializable +data class ErrorToolResult( + override val stepIndex: Int, + val toolName: String? = null, + val errorMessage: String, + val payload: JsonElement? = null, +) : ToolResultEntry + +@Serializable +sealed interface ToolResponse { + val success: Boolean? +} + +@Serializable +@SerialName("tapOn") +data class TapOnResponse( + override val success: Boolean, + val action: String? = null, + val message: String? = null, + val element: Element? = null, + val observation: ObservationSummary? = null, + val selectedElement: SelectedElement? = null, + val selectedElements: List? = null, + val error: String? = null, + val pressRecognized: Boolean? = null, + val contextMenuOpened: Boolean? = null, + val selectionStarted: Boolean? = null, + val searchUntil: SearchUntilStats? = null, + val debug: JsonElement? = null, +) : ToolResponse + +@Serializable +@SerialName("observe") +data class ObserveResponse( + override val success: Boolean? = null, + val selectedElements: List? = null, + val focusedElement: Element? = null, + val accessibilityFocusedElement: Element? = null, + val activeWindow: ActiveWindowInfo? = null, + val awaitedElement: Element? = null, + val awaitDuration: Long? = null, + val awaitTimeout: Boolean? = null, + val error: String? = null, +) : ToolResponse + +@Serializable +@SerialName("executePlan") +data class ExecutePlanResponse( + override val success: Boolean, + val executedSteps: Int, + val totalSteps: Int, + val failedStep: ExecutePlanFailedStep? = null, + val error: String? = null, + val platform: String? = null, + val deviceMapping: Map? = null, + val debug: ExecutePlanDebug? = null, +) : ToolResponse + +@Serializable +@SerialName("generic") +data class GenericToolResponse( + override val success: Boolean? = null, + val payload: JsonElement? = null, +) : ToolResponse + +@Serializable +data class SearchUntilStats( + val durationMs: Long? = null, + val requestCount: Int? = null, + val changeCount: Int? = null, +) + +@Serializable +data class ObservationSummary( + val selectedElements: List? = null, + val focusedElement: Element? = null, + val accessibilityFocusedElement: Element? = null, + val activeWindow: ActiveWindowInfo? = null, +) + +@Serializable +data class ActiveWindowInfo( + val appId: String? = null, + val activityName: String? = null, + val layoutSeqSum: Long? = null, + val type: String? = null, +) + +@Serializable +data class SelectedElement( + val text: String? = null, + @JsonNames("resourceId", "resource-id") val resourceId: String? = null, + @JsonNames("contentDesc", "content-desc") val contentDesc: String? = null, + val bounds: ElementBounds? = null, + val indexInMatches: Int? = null, + val totalMatches: Int? = null, + val selectionStrategy: String? = null, + val selectedState: SelectedElementState? = null, +) + +@Serializable +data class SelectedElementState( + val method: String? = null, + val confidence: Double? = null, + val reason: String? = null, +) + +@Serializable +data class Element( + val bounds: ElementBounds? = null, + val text: String? = null, + @JsonNames("resource-id", "resourceId") val resourceId: String? = null, + @JsonNames("content-desc", "contentDesc") val contentDesc: String? = null, + @SerialName("class") val className: String? = null, + @SerialName("package") val packageName: String? = null, + val checkable: Boolean? = null, + val checked: Boolean? = null, + val clickable: Boolean? = null, + val enabled: Boolean? = null, + val focusable: Boolean? = null, + val focused: Boolean? = null, + @JsonNames("accessibility-focused", "accessibilityFocused") + val accessibilityFocused: Boolean? = null, + val scrollable: Boolean? = null, + val selected: Boolean? = null, +) + +@Serializable +data class ElementBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + val centerX: Int? = null, + val centerY: Int? = null, +) { + val computedCenterX: Int + get() = (left + right) / 2 + + val computedCenterY: Int + get() = (top + bottom) / 2 +} + +@Serializable +data class ExecutePlanFailedStep( + val stepIndex: Int, + val tool: String, + val error: String, + val device: String? = null, +) + +@Serializable +data class ExecutePlanDebug( + val executionTimeMs: Long, + val steps: List = emptyList(), + val deviceState: ExecutePlanDeviceState? = null, +) + +@Serializable +data class ExecutePlanDebugStep( + val step: String, + val status: String, + val durationMs: Long, + val details: JsonElement? = null, +) + +@Serializable +data class ExecutePlanDeviceState( + val currentActivity: String? = null, + val focusedWindow: String? = null, +) diff --git a/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ToolResultParser.kt b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ToolResultParser.kt new file mode 100644 index 000000000..84f6ce89a --- /dev/null +++ b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ToolResultParser.kt @@ -0,0 +1,73 @@ +package dev.jasonpearson.automobile.validation + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive + +object ToolResultParser { + val json: Json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parseToolResult(stepIndex: Int, toolName: String, jsonString: String): ToolResult { + val element = json.parseToJsonElement(jsonString) + return parseToolResult(stepIndex, toolName, element) + } + + fun parseToolResult(stepIndex: Int, toolName: String, element: JsonElement): ToolResult { + val objectElement = + element as? JsonObject ?: throw SerializationException("Tool result is not a JSON object") + + val success = inferSuccess(objectElement) + val error = objectElement["error"]?.jsonPrimitive?.content + + val response = + when (toolName) { + "tapOn" -> json.decodeFromJsonElement(objectElement) + "observe" -> json.decodeFromJsonElement(objectElement) + "executePlan" -> json.decodeFromJsonElement(objectElement) + else -> GenericToolResponse(success = success, payload = objectElement) + } + + return ToolResult( + stepIndex = stepIndex, + toolName = toolName, + success = success, + response = response, + error = error, + ) + } + + fun parseToolResultFromMcpResponse( + stepIndex: Int, + toolName: String, + mcpResult: JsonElement, + ): ToolResult { + val response = json.decodeFromJsonElement(mcpResult) + val textPayload = + response.content.firstOrNull { it.type == "text" }?.text + ?: throw SerializationException("MCP response did not contain text content") + return parseToolResult(stepIndex, toolName, textPayload) + } + + fun parseTapOnResponse(jsonString: String): TapOnResponse = + json.decodeFromString(TapOnResponse.serializer(), jsonString) + + fun parseObserveResponse(jsonString: String): ObserveResponse = + json.decodeFromString(ObserveResponse.serializer(), jsonString) + + fun parseExecutePlanResponse(jsonString: String): ExecutePlanResponse = + json.decodeFromString(ExecutePlanResponse.serializer(), jsonString) + + private fun inferSuccess(result: JsonObject): Boolean { + val successValue = result["success"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() + if (successValue != null) { + return successValue + } + return result["error"] == null + } +} diff --git a/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ValidTools.kt b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ValidTools.kt new file mode 100644 index 000000000..9f3b5df42 --- /dev/null +++ b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ValidTools.kt @@ -0,0 +1,53 @@ +package dev.jasonpearson.automobile.validation + +object ValidTools { + val TOOLS = + setOf( + "launchApp", + "terminateApp", + "installApp", + "tapOn", + "swipeOn", + "pinchOn", + "dragAndDrop", + "inputText", + "clearText", + "selectAllText", + "imeAction", + "keyboard", + "pressButton", + "pressKey", + "homeScreen", + "recentApps", + "openLink", + "navigateTo", + "observe", + "listDevices", + "startDevice", + "killDevice", + "setActiveDevice", + "rotate", + "shake", + "systemTray", + "changeLocalization", + "executePlan", + "criticalSection", + "getDeepLinks", + "getNavigationGraph", + "explore", + "identifyInteractions", + "captureDeviceSnapshot", + "restoreDeviceSnapshot", + "listSnapshots", + "deleteSnapshot", + "videoRecording", + "listDeviceImages", + "debugSearch", + "bugReport", + "doctor", + "biometricAuth", + "clipboard", + ) + + val DEPRECATED_FIELDS = setOf("generated", "appId", "parameters", "description") +} diff --git a/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ValidationResult.kt b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ValidationResult.kt new file mode 100644 index 000000000..6b5155c35 --- /dev/null +++ b/android/test-plan-validation/src/main/kotlin/dev/jasonpearson/automobile/validation/ValidationResult.kt @@ -0,0 +1,19 @@ +package dev.jasonpearson.automobile.validation + +/** Severity level for validation errors */ +enum class ValidationSeverity { + ERROR, + WARNING, +} + +/** Result of test plan validation */ +data class ValidationResult(val valid: Boolean, val errors: List = emptyList()) + +/** Structured validation error with severity and location information */ +data class ValidationError( + val field: String, + val message: String, + val severity: ValidationSeverity = ValidationSeverity.ERROR, + val line: Int? = null, + val column: Int? = null, +) diff --git a/android/test-plan-validation/src/main/resources/schemas/test-plan.schema.json b/android/test-plan-validation/src/main/resources/schemas/test-plan.schema.json new file mode 100644 index 000000000..097a7e6a8 --- /dev/null +++ b/android/test-plan-validation/src/main/resources/schemas/test-plan.schema.json @@ -0,0 +1,715 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/anthropics/auto-mobile/schemas/test-plan.schema.json", + "title": "AutoMobile Test Plan", + "description": "Schema for AutoMobile test plan YAML files used with executePlan and JUnit Runner", + "type": "object", + "required": ["name", "steps"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the test plan", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Human-readable description of what this test plan does" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Optional platform hint for plan execution" + }, + "devices": { + "type": "array", + "description": "List of device labels or device definitions for multi-device test execution. Required when using device parameters or criticalSection.", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/planDevice" + } + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "steps": { + "type": "array", + "description": "Ordered list of tool execution steps", + "minItems": 1, + "items": { + "$ref": "#/$defs/planStep" + } + }, + "mcpVersion": { + "type": "string", + "description": "MCP server version this plan was created with", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "generated": { + "type": "string", + "description": "DEPRECATED: ISO 8601 timestamp (legacy field, use metadata.createdAt instead)", + "format": "date-time" + }, + "appId": { + "type": "string", + "description": "DEPRECATED: Application bundle ID (legacy field, use metadata.appId instead)" + }, + "parameters": { + "type": "object", + "description": "DEPRECATED: Plan parameters (legacy field)", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "description": "Additional metadata about the plan", + "properties": { + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp of plan creation", + "format": "date-time" + }, + "version": { + "type": "string", + "description": "Plan format version", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "appId": { + "type": "string", + "description": "Application bundle ID or package name" + }, + "sessionId": { + "type": "string", + "description": "Session identifier for plan execution" + }, + "toolCallCount": { + "type": "integer", + "description": "Number of tool calls in original session", + "minimum": 0 + }, + "duration": { + "type": "number", + "description": "Duration of original session in milliseconds", + "minimum": 0 + }, + "generatedFromToolCalls": { + "type": "boolean", + "description": "Whether this plan was auto-generated from tool call logs" + }, + "experiments": { + "type": "array", + "description": "Active experiments during plan creation", + "items": { + "type": "string" + } + }, + "treatments": { + "type": "object", + "description": "Experiment treatment assignments", + "additionalProperties": { + "type": "string" + } + }, + "featureFlags": { + "type": "object", + "description": "Feature flag values during plan creation", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "$defs": { + "planDevice": { + "type": "object", + "required": ["label", "platform"], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "Device label for multi-device routing", + "minLength": 1 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Target platform for this device" + }, + "simulatorType": { + "type": "string", + "description": "iOS simulator type (e.g., iPhone 15 Pro)" + }, + "iosVersion": { + "type": "string", + "description": "iOS version for simulator selection" + } + } + }, + "planStep": { + "type": "object", + "required": ["tool"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Name of the MCP tool to execute", + "minLength": 1 + }, + "params": { + "description": "Tool-specific parameters (can also be specified as top-level properties)" + }, + "device": { + "type": "string", + "description": "Device label indicating which device this step runs on (required in multi-device plans for non-device-agnostic tools)", + "minLength": 1 + }, + "label": { + "type": "string", + "description": "Human-readable description of what this step does" + }, + "description": { + "type": "string", + "description": "DEPRECATED: Use 'label' instead (legacy field)" + }, + "expectations": { + "type": "array", + "description": "Assertions to validate after step execution", + "items": { + "$ref": "#/$defs/expectation" + } + } + }, + "description": "A step can have tool-specific parameters either in a 'params' object or as top-level properties (e.g., 'id', 'text', 'appId', 'direction', etc.)", + "allOf": [ + { + "if": { + "properties": { + "tool": { + "const": "dragAndDrop" + } + }, + "required": [ + "tool" + ] + }, + "then": { + "properties": { + "params": { + "$ref": "#/$defs/dragAndDropParams" + }, + "source": { + "$ref": "#/$defs/dragAndDropSelector" + }, + "target": { + "$ref": "#/$defs/dragAndDropSelector" + }, + "pressDurationMs": { + "type": "number", + "description": "Press duration ms (min: 600, max: 3000, default: 600)", + "minimum": 600, + "maximum": 3000 + }, + "dragDurationMs": { + "type": "number", + "description": "Drag duration ms (min: 300, max: 1000, default: 300)", + "minimum": 300, + "maximum": 1000 + }, + "holdDurationMs": { + "type": "number", + "description": "Hold duration ms (min: 100, max: 3000, default: 100)", + "minimum": 100, + "maximum": 3000 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + } + }, + "allOf": [ + { + "anyOf": [ + { + "required": [ + "source" + ] + }, + { + "properties": { + "params": { + "required": [ + "source" + ] + } + }, + "required": [ + "params" + ] + } + ] + }, + { + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "properties": { + "params": { + "required": [ + "target" + ] + } + }, + "required": [ + "params" + ] + } + ] + } + ] + } + }, + { + "if": { + "properties": { + "tool": { + "const": "highlight" + } + }, + "required": [ + "tool" + ] + }, + "then": { + "properties": { + "params": { + "$ref": "#/$defs/highlightParams" + }, + "description": { + "type": "string", + "description": "Optional highlight description" + }, + "id": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "text": { + "type": "string", + "description": "Element text content to match" + }, + "shape": { + "$ref": "#/$defs/highlightShape" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "timeoutMs": { + "type": "number", + "description": "Highlight request timeout ms (default: 5000)" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + } + }, + "allOf": [ + { + "anyOf": [ + { + "required": [ + "shape" + ] + }, + { + "required": [ + "id" + ] + }, + { + "required": [ + "text" + ] + }, + { + "properties": { + "params": { + "required": [ + "shape" + ] + } + }, + "required": [ + "params" + ] + }, + { + "properties": { + "params": { + "required": [ + "id" + ] + } + }, + "required": [ + "params" + ] + }, + { + "properties": { + "params": { + "required": [ + "text" + ] + } + }, + "required": [ + "params" + ] + } + ] + } + ] + } + } + ] + }, + "dragAndDropSelector": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Element text", + "minLength": 1 + }, + "elementId": { + "type": "string", + "description": "Element ID", + "minLength": 1 + } + }, + "additionalProperties": false, + "description": "Selector for dragAndDrop elements", + "oneOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "elementId" + ] + } + ] + }, + "dragAndDropParams": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/dragAndDropSelector", + "description": "Source element" + }, + "target": { + "$ref": "#/$defs/dragAndDropSelector", + "description": "Target element" + }, + "pressDurationMs": { + "type": "number", + "description": "Press duration ms (min: 600, max: 3000, default: 600)", + "minimum": 600, + "maximum": 3000 + }, + "dragDurationMs": { + "type": "number", + "description": "Drag duration ms (min: 300, max: 1000, default: 300)", + "minimum": 300, + "maximum": 1000 + }, + "holdDurationMs": { + "type": "number", + "description": "Hold duration ms (min: 100, max: 3000, default: 100)", + "minimum": 100, + "maximum": 3000 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + }, + "device": { + "type": "string", + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")" + } + }, + "additionalProperties": true, + "description": "Parameters for dragAndDrop" + }, + "highlightBounds": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Bounds x-coordinate" + }, + "y": { + "type": "number", + "description": "Bounds y-coordinate" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Bounds width" + }, + "height": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Bounds height" + }, + "sourceWidth": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "description": "Optional source width for coordinate normalization" + }, + "sourceHeight": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "description": "Optional source height for coordinate normalization" + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false, + "description": "Highlight bounds" + }, + "highlightStyle": { + "type": [ + "object", + "null" + ], + "properties": { + "strokeColor": { + "type": "string", + "description": "Stroke color (hex or CSS color)" + }, + "strokeWidth": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Stroke width" + }, + "fillColor": { + "type": "string", + "description": "Fill color (hex or CSS color)" + }, + "dashPattern": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "description": "Dash pattern lengths" + } + }, + "additionalProperties": false, + "description": "Highlight style" + }, + "highlightShape": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "box", + "circle" + ], + "description": "Highlight shape type" + }, + "bounds": { + "$ref": "#/$defs/highlightBounds" + }, + "style": { + "$ref": "#/$defs/highlightStyle" + } + }, + "required": [ + "type", + "bounds" + ], + "additionalProperties": false, + "description": "Highlight shape definition" + }, + "highlightParams": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Optional highlight description" + }, + "id": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "text": { + "type": "string", + "description": "Element text content to match" + }, + "shape": { + "$ref": "#/$defs/highlightShape" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "timeoutMs": { + "type": "number", + "description": "Highlight request timeout ms (default: 5000)" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + }, + "device": { + "type": "string", + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")" + }, + "deviceId": { + "type": "string", + "description": "Device ID override" + } + }, + "additionalProperties": true, + "description": "Parameters for highlight" + }, + "expectation": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "description": "Type of assertion to perform" + }, + "selector": { + "type": "object", + "description": "Selector for the element to check", + "properties": { + "testTag": { + "type": "string" + }, + "text": { + "type": "string" + }, + "contentDescription": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "className": { + "type": "string" + } + } + }, + "text": { + "type": "string", + "description": "Text to check for presence/absence" + } + } + }, + "criticalSectionParams": { + "type": "object", + "required": ["lock", "steps", "deviceCount"], + "additionalProperties": false, + "properties": { + "lock": { + "type": "string", + "description": "Global lock identifier for synchronization across devices", + "minLength": 1 + }, + "steps": { + "type": "array", + "description": "Steps to execute serially within the critical section", + "minItems": 1, + "items": { + "$ref": "#/$defs/planStep" + } + }, + "deviceCount": { + "type": "integer", + "description": "Expected number of devices that must reach the barrier before execution proceeds", + "minimum": 1 + }, + "timeout": { + "type": "integer", + "description": "Barrier timeout in milliseconds (default: 30000ms)", + "minimum": 0, + "default": 30000 + } + }, + "description": "Parameters for criticalSection tool that provides barrier synchronization and mutex-based serial execution across multiple devices" + } + } +} diff --git a/android/test-plan-validation/src/test/kotlin/dev/jasonpearson/automobile/validation/TestPlanValidatorTest.kt b/android/test-plan-validation/src/test/kotlin/dev/jasonpearson/automobile/validation/TestPlanValidatorTest.kt new file mode 100644 index 000000000..38a067a23 --- /dev/null +++ b/android/test-plan-validation/src/test/kotlin/dev/jasonpearson/automobile/validation/TestPlanValidatorTest.kt @@ -0,0 +1,676 @@ +package dev.jasonpearson.automobile.validation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TestPlanValidatorTest { + + // ========== Valid Plan Tests ========== + + @Test + fun `validates minimal valid plan`() { + val yaml = + """ + name: test-plan + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Plan should be valid") + assertTrue(result.errors.isEmpty(), "Should have no errors") + } + + @Test + fun `validates complete plan with all fields`() { + val yaml = + """ + name: complete-plan + description: A complete test plan + devices: + - A + - B + steps: + - tool: launchApp + params: + appId: com.example.app + device: A + label: Launch app on device A + - tool: observe + params: + device: A + metadata: + createdAt: "2026-01-08T00:00:00Z" + version: "1.0.0" + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Complete plan should be valid") + } + + @Test + fun `validates plan with YAML anchors`() { + val yaml = + """ + name: anchors-test + description: Test with YAML anchors + steps: + - tool: launchApp + params: &launch-params + appId: com.example.app + coldBoot: false + label: First launch + - tool: launchApp + params: + <<: *launch-params + coldBoot: true + label: Second launch with cold boot + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Plan with YAML anchors should be valid") + } + + @Test + fun `validates plan with merge keys`() { + val yaml = + """ + name: merge-keys-test + devices: + - A + - B + steps: + - tool: observe + params: &observe-base + includeScreenshot: true + includeHierarchy: true + device: A + - tool: observe + params: + <<: *observe-base + device: B + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Plan with merge keys should be valid") + } + + @Test + fun `validates critical section parameters`() { + val yaml = + """ + name: critical-section-test + devices: + - A + - B + steps: + - tool: criticalSection + params: + lock: sync-point + deviceCount: 2 + steps: + - tool: tapOn + params: + device: A + text: Button + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Critical section plan should be valid") + } + + @Test + fun `validates expectations array`() { + val yaml = + """ + name: expectations-test + steps: + - tool: observe + expectations: + - type: elementExists + selector: + text: "Hello" + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Plan with expectations should be valid") + } + + @Test + fun `validates metadata fields`() { + val yaml = + """ + name: metadata-test + steps: + - tool: observe + metadata: + createdAt: "2026-01-08T00:00:00Z" + version: "1.0.0" + appId: com.example.app + sessionId: "session-123" + toolCallCount: 10 + duration: 1500.5 + generatedFromToolCalls: true + experiments: ["exp-1", "exp-2"] + treatments: + exp-1: "variant-a" + featureFlags: + darkMode: true + beta: false + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "Plan with metadata should be valid") + } + + // ========== YAML Parsing Tests ========== + + @Test + fun `reports YAML parse errors`() { + val yaml = + """ + name: test + steps: [invalid + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "Invalid YAML should not be valid") + assertTrue(result.errors.isNotEmpty(), "Should have errors") + assertEquals("root", result.errors[0].field) + assertTrue(result.errors[0].message.contains("YAML parsing failed")) + } + + // ========== Required Field Tests ========== + + @Test + fun `reports missing required name field`() { + val yaml = + """ + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.isNotEmpty()) + val nameError = result.errors.find { it.message.contains("name") } + assertNotNull(nameError, "Should have error about missing name") + assertTrue(nameError.message.contains("Missing required property")) + } + + @Test + fun `reports missing required steps field`() { + val yaml = + """ + name: test-plan + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + val stepsError = result.errors.find { it.message.contains("steps") } + assertNotNull(stepsError, "Should have error about missing steps") + assertTrue(stepsError.message.contains("Missing required property")) + } + + @Test + fun `reports empty name`() { + val yaml = + """ + name: "" + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.any { it.field.contains("name") }) + } + + @Test + fun `reports empty steps array`() { + val yaml = + """ + name: test-plan + steps: [] + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.any { it.message.contains("at least 1") }) + } + + @Test + fun `reports missing tool in step`() { + val yaml = + """ + name: test-plan + steps: + - params: + foo: bar + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + val toolError = result.errors.find { it.message.contains("tool") } + assertNotNull(toolError, "Should have error about missing tool") + assertTrue(toolError.message.contains("Missing required property")) + } + + @Test + fun `reports empty tool name`() { + val yaml = + """ + name: test-plan + steps: + - tool: "" + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.any { it.field.contains("tool") }) + } + + // ========== Type Validation Tests ========== + + @Test + fun `reports wrong type for steps`() { + val yaml = + """ + name: test-plan + steps: "not an array" + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "Plan with wrong type for steps should be invalid") + assertTrue(result.errors.isNotEmpty(), "Should have at least one error") + val hasStepsError = + result.errors.any { error -> + error.field.contains("steps", ignoreCase = true) || + error.message.contains("steps", ignoreCase = true) || + error.message.contains("array", ignoreCase = true) + } + assertTrue( + hasStepsError, + "Should have error related to steps being wrong type. Errors: ${result.errors}", + ) + } + + // ========== Field Validation Tests ========== + + @Test + fun `reports invalid mcpVersion format`() { + val yaml = + """ + name: test-plan + mcpVersion: invalid-version + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.any { it.field.contains("mcpVersion") }) + } + + @Test + fun `reports duplicate devices`() { + val yaml = + """ + name: test-plan + devices: + - A + - A + steps: + - tool: observe + params: + device: A + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.any { it.field.contains("devices") }) + } + + @Test + fun `reports empty device label`() { + val yaml = + """ + name: test-plan + devices: + - "" + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + assertTrue(result.errors.any { it.field.contains("devices") }) + } + + @Test + fun `detects unknown property as error`() { + val yaml = + """ + name: test-plan + steps: + - tool: observe + unknownField: value + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "YAML with unknown property should fail validation") + val unknownError = result.errors.find { it.message.contains("unknownField") } + assertNotNull(unknownError, "Should report unknown property") + } + + // ========== Deprecated Field Tests ========== + + @Test + fun `allows deprecated generated field with warning`() { + val yaml = + """ + name: legacy-plan + generated: "2026-01-08T00:00:00Z" + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + // Deprecated fields should still validate but may have warnings + val warningErrors = result.errors.filter { it.severity == ValidationSeverity.WARNING } + val hasDeprecatedWarning = + warningErrors.any { it.message.contains("generated") || it.message.contains("deprecated") } + assertTrue( + hasDeprecatedWarning || result.valid, + "Plan with deprecated 'generated' field should be valid or have warning", + ) + } + + @Test + fun `allows deprecated appId field with warning`() { + val yaml = + """ + name: legacy-plan + appId: com.example.app + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + // Deprecated fields should still validate but may have warnings + val warningErrors = result.errors.filter { it.severity == ValidationSeverity.WARNING } + val hasDeprecatedWarning = + warningErrors.any { it.message.contains("appId") || it.message.contains("deprecated") } + assertTrue( + hasDeprecatedWarning || result.valid, + "Plan with deprecated 'appId' field should be valid or have warning", + ) + } + + @Test + fun `allows deprecated parameters field with warning`() { + val yaml = + """ + name: legacy-plan + parameters: + key1: value1 + key2: value2 + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + // Deprecated fields should still validate but may have warnings + val warningErrors = result.errors.filter { it.severity == ValidationSeverity.WARNING } + val hasDeprecatedWarning = + warningErrors.any { it.message.contains("parameters") || it.message.contains("deprecated") } + assertTrue( + hasDeprecatedWarning || result.valid, + "Plan with deprecated 'parameters' field should be valid or have warning", + ) + } + + @Test + fun `allows deprecated description in steps with warning`() { + val yaml = + """ + name: legacy-plan + steps: + - tool: observe + description: Old-style description + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + // Deprecated fields should still validate but may have warnings + val warningErrors = result.errors.filter { it.severity == ValidationSeverity.WARNING } + val hasDeprecatedWarning = + warningErrors.any { + it.message.contains("description") || it.message.contains("deprecated") + } + assertTrue( + hasDeprecatedWarning || result.valid, + "Plan with deprecated step 'description' should be valid or have warning", + ) + } + + // ========== Tool Name Validation Tests ========== + + @Test + fun `detects invalid tool name`() { + val yaml = + """ + name: test-plan + steps: + - tool: invalidTool + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "YAML with invalid tool should fail validation") + val toolError = + result.errors.find { + it.message.contains("Unknown tool") && it.message.contains("invalidTool") + } + assertNotNull(toolError, "Should report unknown tool") + } + + @Test + fun `accepts valid tool names`() { + val yaml = + """ + name: test-plan + steps: + - tool: observe + - tool: tapOn + params: + text: button + - tool: launchApp + params: + appId: com.example.app + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "YAML with valid tools should pass validation: ${result.errors}") + } + + @Test + fun `detects multiple invalid tool names`() { + val yaml = + """ + name: test-plan + steps: + - tool: invalidTool1 + - tool: observe + - tool: invalidTool2 + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "YAML with invalid tools should fail validation") + val tool1Error = result.errors.find { it.message.contains("invalidTool1") } + val tool2Error = result.errors.find { it.message.contains("invalidTool2") } + assertNotNull(tool1Error, "Should report first invalid tool") + assertNotNull(tool2Error, "Should report second invalid tool") + } + + // ========== Tool Parameter Validation Tests ========== + + @Test + fun `validates dragAndDrop params in params object`() { + val yaml = + """ + name: drag-and-drop + steps: + - tool: dragAndDrop + params: + source: + text: Source + target: + elementId: target-id + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue(result.valid, "dragAndDrop with valid params should be valid: ${result.errors}") + } + + @Test + fun `reports missing dragAndDrop target`() { + val yaml = + """ + name: drag-and-drop + steps: + - tool: dragAndDrop + params: + source: + text: Source + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "dragAndDrop without target should be invalid") + assertTrue( + result.errors.any { error -> + error.field.contains("target", ignoreCase = true) || + error.message.contains("target", ignoreCase = true) + }, + "Should report missing target: ${result.errors}", + ) + } + + @Test + fun `reports dragAndDrop source with both text and elementId`() { + val yaml = + """ + name: drag-and-drop + steps: + - tool: dragAndDrop + params: + source: + text: Source + elementId: source-id + target: + text: Target + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid, "dragAndDrop source should require exactly one selector") + assertTrue( + result.errors.any { error -> + error.field.contains("source", ignoreCase = true) || + error.message.contains("source", ignoreCase = true) + }, + "Should report invalid source selector: ${result.errors}", + ) + } + + @Test + fun `validates dragAndDrop with top-level selectors and param overrides`() { + val yaml = + """ + name: drag-and-drop + steps: + - tool: dragAndDrop + source: + text: Source + target: + text: Target + params: + dragDurationMs: 800 + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertTrue( + result.valid, + "dragAndDrop with top-level selectors should be valid: ${result.errors}", + ) + } + + // ========== Error Reporting Tests ========== + + @Test + fun `provides line numbers when possible`() { + val invalidYaml = + """ + steps: + - tool: observe + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(invalidYaml) + assertFalse(result.valid) + // Line numbers are best-effort, so we just verify the structure is correct + result.errors.forEach { error -> + assertNotNull(error.field) + assertNotNull(error.message) + // line and column may be null, which is acceptable + } + } + + @Test + fun `formats field paths nicely`() { + val yaml = + """ + name: test-plan + steps: + - tool: observe + - {} + """ + .trimIndent() + + val result = TestPlanValidator.validateYaml(yaml) + assertFalse(result.valid) + // Should have error for steps[1] missing tool + val error = result.errors.find { it.field.contains("steps") } + assertNotNull(error, "Should have error about steps") + } +} diff --git a/android/test-plan-validation/src/test/kotlin/dev/jasonpearson/automobile/validation/ToolResultParserTest.kt b/android/test-plan-validation/src/test/kotlin/dev/jasonpearson/automobile/validation/ToolResultParserTest.kt new file mode 100644 index 000000000..145bd9b24 --- /dev/null +++ b/android/test-plan-validation/src/test/kotlin/dev/jasonpearson/automobile/validation/ToolResultParserTest.kt @@ -0,0 +1,356 @@ +package dev.jasonpearson.automobile.validation + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class ToolResultParserTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `parse tapOn response with selectedElement`() { + val payload = + """ + { + "success": true, + "action": "tap", + "element": { + "text": "Test Channel", + "resource-id": "com.example:id/item", + "content-desc": "Item", + "bounds": { "left": 0, "top": 0, "right": 100, "bottom": 100 } + }, + "selectedElement": { + "text": "Test Channel", + "resourceId": "com.example:id/item", + "bounds": { + "left": 0, "top": 0, "right": 100, "bottom": 100, + "centerX": 50, "centerY": 50 + }, + "indexInMatches": 2, + "totalMatches": 5, + "selectionStrategy": "random" + }, + "observation": { + "selectedElements": [ + { + "text": "Test Channel", + "resourceId": "com.example:id/item", + "selectedState": { "method": "visual", "confidence": 0.8 } + } + ] + } + } + """ + .trimIndent() + + val response = ToolResultParser.parseTapOnResponse(payload) + + assertEquals("tap", response.action) + assertEquals("Test Channel", response.selectedElement?.text) + assertEquals(2, response.selectedElement?.indexInMatches) + assertEquals("random", response.selectedElement?.selectionStrategy) + assertEquals("Test Channel", response.observation?.selectedElements?.firstOrNull()?.text) + } + + @Test + fun `parse tapOn response without selectedElement is backwards compatible`() { + val payload = + """ + { + "success": true, + "action": "tap" + } + """ + .trimIndent() + + val response = ToolResultParser.parseTapOnResponse(payload) + + assertTrue(response.success) + assertNull(response.selectedElement) + } + + @Test + fun `parse tapOn response without action for focus noop`() { + val payload = + """ + { + "success": true, + "wasAlreadyFocused": true, + "focusChanged": false + } + """ + .trimIndent() + + val response = ToolResultParser.parseTapOnResponse(payload) + + assertTrue(response.success) + assertNull(response.action) + } + + @Test + fun `parse tool result for unknown tool returns generic response`() { + val payload = + """ + { + "success": true, + "customField": "value" + } + """ + .trimIndent() + + val result = ToolResultParser.parseToolResult(0, "unknownTool", payload) + + assertTrue(result.success) + assertTrue(result.response is GenericToolResponse) + } + + @Test + fun `parse MCP response wrapper`() { + val payload = + """ + { + "content": [ + { "type": "text", "text": "{\"success\":true,\"action\":\"tap\"}" } + ] + } + """ + .trimIndent() + + val element = json.parseToJsonElement(payload) + val result = ToolResultParser.parseToolResultFromMcpResponse(1, "tapOn", element) + + assertTrue(result.success) + assertEquals(1, result.stepIndex) + } + + @Test + fun `malformed JSON throws serialization exception`() { + assertFailsWith { ToolResultParser.parseTapOnResponse("{") } + } + + @Test + fun `validate tapOn output schema from tool-definitions`() { + val definitions = DiskToolDefinitionsSource(json, ToolDefinitionsLocator.findPath()).load() + val tapOn = definitions.firstOrNull { it.name == "tapOn" } + assertNotNull(tapOn, "tapOn tool definition missing") + val schema = tapOn.outputSchema + assertNotNull(schema, "tapOn outputSchema missing") + + val sample = SchemaSampleGenerator.generate(schema) + val response = + ToolResultParser.parseTapOnResponse(json.encodeToString(JsonElement.serializer(), sample)) + + assertTrue(response.success) + } + + @Test + fun `validate executePlan output schema from tool-definitions`() { + val definitions = DiskToolDefinitionsSource(json, ToolDefinitionsLocator.findPath()).load() + val executePlan = definitions.firstOrNull { it.name == "executePlan" } + assertNotNull(executePlan, "executePlan tool definition missing") + val schema = executePlan.outputSchema + assertNotNull(schema, "executePlan outputSchema missing") + + val sample = SchemaSampleGenerator.generate(schema) + val response = + ToolResultParser.parseExecutePlanResponse( + json.encodeToString(JsonElement.serializer(), sample) + ) + + assertTrue(response.success) + } + + @Test + fun `generate sample payload from fake schema`() { + val fakeSchema = + JsonObject( + mapOf( + "type" to JsonPrimitive("object"), + "properties" to + JsonObject( + mapOf("success" to JsonObject(mapOf("type" to JsonPrimitive("boolean")))) + ), + "required" to JsonArray(listOf(JsonPrimitive("success"))), + ) + ) + + val sample = SchemaSampleGenerator.generate(fakeSchema).jsonObject + + assertEquals(true, sample["success"]?.jsonPrimitive?.content?.toBooleanStrictOrNull()) + } + + @Test + fun `load fake tool definitions without disk IO`() { + val fakeDefinitions = + """ + [ + { + "name": "tapOn", + "outputSchema": { + "type": "object", + "properties": { + "success": { "type": "boolean" } + }, + "required": ["success"] + } + } + ] + """ + .trimIndent() + + val definitions = FakeToolDefinitionsSource(json, fakeDefinitions).load() + + assertEquals(1, definitions.size) + assertEquals("tapOn", definitions.first().name) + } +} + +@Serializable +private data class ToolDefinitionSnapshot( + val name: String, + val outputSchema: JsonObject? = null, +) + +private interface ToolDefinitionsSource { + fun load(): List +} + +private class DiskToolDefinitionsSource( + private val json: Json, + private val path: Path, +) : ToolDefinitionsSource { + override fun load(): List { + val content = Files.readString(path) + return json.decodeFromString(content) + } +} + +private class FakeToolDefinitionsSource( + private val json: Json, + private val content: String, +) : ToolDefinitionsSource { + override fun load(): List { + return json.decodeFromString(content) + } +} + +private object ToolDefinitionsLocator { + fun findPath(): Path { + var current = Paths.get("").toAbsolutePath() + repeat(6) { + val candidate = current.resolve("schemas/tool-definitions.json") + if (Files.exists(candidate)) { + return candidate + } + current = current.parent ?: return@repeat + } + throw IllegalStateException("Unable to locate schemas/tool-definitions.json") + } +} + +private object SchemaSampleGenerator { + fun generate(schema: JsonObject): JsonElement { + return generate(schema, schema) + } + + private fun generate(schema: JsonObject, root: JsonObject): JsonElement { + val ref = schema["\$ref"]?.jsonPrimitive?.content + if (ref != null) { + val resolved = resolveRef(root, ref) ?: return JsonNull + return generate(resolved, root) + } + + schema["anyOf"]?.jsonArray?.firstOrNull()?.jsonObject?.let { + return generate(it, root) + } + schema["oneOf"]?.jsonArray?.firstOrNull()?.jsonObject?.let { + return generate(it, root) + } + schema["allOf"]?.jsonArray?.firstOrNull()?.jsonObject?.let { + return generate(it, root) + } + + val enumValues = schema["enum"]?.jsonArray + if (enumValues != null && enumValues.isNotEmpty()) { + return enumValues.first() + } + + return when (resolveType(schema)) { + "object" -> generateObject(schema, root) + "array" -> generateArray(schema, root) + "string" -> JsonPrimitive("value") + "integer" -> JsonPrimitive(1) + "number" -> JsonPrimitive(1.0) + "boolean" -> JsonPrimitive(true) + else -> JsonNull + } + } + + private fun resolveType(schema: JsonObject): String? { + val typeElement = schema["type"] ?: return null + if (typeElement is JsonPrimitive) { + return typeElement.content + } + if (typeElement is JsonArray) { + for (entry in typeElement) { + val content = (entry as? JsonPrimitive)?.content ?: continue + if (content != "null") { + return content + } + } + } + return null + } + + private fun generateObject(schema: JsonObject, root: JsonObject): JsonElement { + val properties = schema["properties"]?.jsonObject ?: JsonObject(emptyMap()) + val required = + schema["required"]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.content }?.toSet() + ?: emptySet() + + val values = mutableMapOf() + for (key in required) { + val propertySchema = properties[key] as? JsonObject ?: continue + values[key] = generate(propertySchema, root) + } + + return JsonObject(values) + } + + private fun generateArray(schema: JsonObject, root: JsonObject): JsonElement { + val items = schema["items"] as? JsonObject ?: return JsonArray(emptyList()) + return JsonArray(listOf(generate(items, root))) + } + + private fun resolveRef(root: JsonObject, ref: String): JsonObject? { + if (!ref.startsWith("#/")) { + return null + } + val parts = ref.removePrefix("#/").split("/") + var current: JsonElement = root + for (rawPart in parts) { + val part = rawPart.replace("~1", "/").replace("~0", "~") + current = (current as? JsonObject)?.get(part) ?: return null + } + return current as? JsonObject + } +} diff --git a/android/video-server/build.gradle.kts b/android/video-server/build.gradle.kts new file mode 100644 index 000000000..8b0cbc377 --- /dev/null +++ b/android/video-server/build.gradle.kts @@ -0,0 +1,88 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import javax.inject.Inject + +plugins { + kotlin("jvm") + `java-library` +} + +java { + toolchain { languageVersion.set(JavaLanguageVersion.of(libs.versions.build.java.target.get())) } +} + +// Android SDK android.jar as compileOnly dependency for Android APIs +val androidSdkPath: String = + System.getenv("ANDROID_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: "${System.getProperty("user.home")}/Library/Android/sdk" + +val compileSdk: String = libs.versions.build.android.compileSdk.get() +val buildToolsVersion: String = libs.versions.build.android.buildTools.get() +val minSdk: String = libs.versions.build.android.minSdk.get() + +dependencies { + compileOnly(files("$androidSdkPath/platforms/android-$compileSdk/android.jar")) +} + +// Configure Kotlin compilation options +tasks.withType().configureEach { + compilerOptions { + languageVersion.set( + KotlinVersion.valueOf( + "KOTLIN_${libs.versions.build.kotlin.language.get().replace(".", "_")}" + ) + ) + } +} + +// Custom task to compile JAR to DEX using d8 +abstract class D8DexTask +@Inject +constructor(private val execOperations: ExecOperations) : DefaultTask() { + + @get:InputFile abstract val inputJar: RegularFileProperty + + @get:OutputFile abstract val outputDex: RegularFileProperty + + @get:Input abstract val d8Path: Property + + @get:Input abstract val minSdkVersion: Property + + @TaskAction + fun execute() { + val outputDir = outputDex.get().asFile.parentFile + outputDir.mkdirs() + + // Run d8 + execOperations.exec { + commandLine( + d8Path.get(), + "--output", + outputDir.absolutePath, + "--min-api", + minSdkVersion.get(), + inputJar.get().asFile.absolutePath, + ) + } + + // d8 outputs classes.dex, rename to automobile-video.dex + val classesFile = File(outputDir, "classes.dex") + val targetFile = outputDex.get().asFile + if (classesFile.exists() && classesFile != targetFile) { + classesFile.renameTo(targetFile) + } + } +} + +tasks.register("d8Dex") { + group = "build" + description = "Compile JAR to DEX using d8" + + dependsOn(tasks.jar) + + inputJar.set(tasks.jar.flatMap { it.archiveFile }) + outputDex.set(layout.buildDirectory.file("libs/automobile-video.dex")) + d8Path.set("$androidSdkPath/build-tools/$buildToolsVersion/d8") + minSdkVersion.set(minSdk) +} diff --git a/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/QualityPreset.kt b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/QualityPreset.kt new file mode 100644 index 000000000..446ad3814 --- /dev/null +++ b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/QualityPreset.kt @@ -0,0 +1,33 @@ +package dev.jasonpearson.automobile.video + +/** + * Hardcoded quality presets for video streaming. + * + * @property maxHeight Maximum height in pixels (width scales proportionally) + * @property bitrate Target bitrate in bits per second + * @property fps Target frame rate + */ +enum class QualityPreset( + val maxHeight: Int, + val bitrate: Int, + val fps: Int, +) { + /** 540p @ 2 Mbps @ 30fps - Good for slow USB connections */ + LOW(maxHeight = 540, bitrate = 2_000_000, fps = 30), + + /** 720p @ 4 Mbps @ 60fps - Balanced quality and performance */ + MEDIUM(maxHeight = 720, bitrate = 4_000_000, fps = 60), + + /** 1080p @ 8 Mbps @ 60fps - Full HD streaming */ + HIGH(maxHeight = 1080, bitrate = 8_000_000, fps = 60); + + companion object { + fun fromString(value: String): QualityPreset = + when (value.lowercase()) { + "low" -> LOW + "medium" -> MEDIUM + "high" -> HIGH + else -> throw IllegalArgumentException("Unknown quality preset: $value") + } + } +} diff --git a/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/ScreenCapture.kt b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/ScreenCapture.kt new file mode 100644 index 000000000..ea0a5a10b --- /dev/null +++ b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/ScreenCapture.kt @@ -0,0 +1,45 @@ +package dev.jasonpearson.automobile.video + +import android.hardware.display.VirtualDisplay +import android.view.Surface +import dev.jasonpearson.automobile.video.wrappers.DisplayControl + +/** + * Manages VirtualDisplay creation for screen mirroring. + * + * Uses hidden DisplayManagerGlobal APIs via [DisplayControl] to create a VirtualDisplay that + * mirrors the main display. This only works when running as shell user (UID 2000). + */ +class ScreenCapture( + private val width: Int, + private val height: Int, + private val densityDpi: Int, +) { + private var virtualDisplay: VirtualDisplay? = null + + /** + * Create a VirtualDisplay that mirrors the main display. + * + * @param surface The surface to render to (typically from MediaCodec) + * @return The created VirtualDisplay + */ + fun start(surface: Surface): VirtualDisplay { + val display = + DisplayControl.createVirtualDisplay( + name = "automobile-mirror", + width = width, + height = height, + densityDpi = densityDpi, + surface = surface, + displayIdToMirror = 0, // Mirror the main display + ) + virtualDisplay = display + return display + } + + /** Release the VirtualDisplay. */ + fun stop() { + virtualDisplay?.release() + virtualDisplay = null + } +} diff --git a/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoEncoder.kt b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoEncoder.kt new file mode 100644 index 000000000..c4e3ffc37 --- /dev/null +++ b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoEncoder.kt @@ -0,0 +1,122 @@ +package dev.jasonpearson.automobile.video + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.os.Build +import android.view.Surface + +/** + * MediaCodec wrapper for H.264 encoding with Surface input. + * + * Configures the encoder for low-latency streaming with: + * - H.264 Baseline profile for maximum compatibility + * - Surface input for zero-copy GPU rendering + * - CBR bitrate mode for consistent bandwidth + * - Frame repeat for idle screen optimization + */ +class VideoEncoder( + private val width: Int, + private val height: Int, + private val bitrate: Int, + private val fps: Int, +) { + private var codec: MediaCodec? = null + + /** Input surface for the VirtualDisplay to render to. Available after [start]. */ + var inputSurface: Surface? = null + private set + + /** + * Configure and start the encoder. + * + * @return The input Surface for the VirtualDisplay + */ + fun start(): Surface { + val format = + MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply { + // Bitrate + setInteger(MediaFormat.KEY_BIT_RATE, bitrate) + + // Frame rate hint (actual rate is variable based on display updates) + setInteger(MediaFormat.KEY_FRAME_RATE, fps) + + // I-frame interval: 10 seconds + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + // Surface input (zero-copy from GPU) + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface, + ) + + // Repeat frame after 100ms of no changes (reduces idle bandwidth) + setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000) + + // H.264 Baseline profile for maximum compatibility + // Note: We don't set KEY_LEVEL - let the codec choose the appropriate level + // based on resolution/fps. Level 3.1 can't handle 720p@60fps or 1080p@60fps. + setInteger( + MediaFormat.KEY_PROFILE, + MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, + ) + + // CBR for consistent bitrate + setInteger( + MediaFormat.KEY_BITRATE_MODE, + MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR, + ) + + // Request low latency mode on Android 11+ (API 30) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setInteger(MediaFormat.KEY_LOW_LATENCY, 1) + } + } + + val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + + val surface = encoder.createInputSurface() + inputSurface = surface + + encoder.start() + codec = encoder + + return surface + } + + /** + * Dequeue an output buffer from the encoder. + * + * @param bufferInfo Buffer info to be populated + * @param timeoutUs Timeout in microseconds (-1 for infinite) + * @return Buffer index, or negative value on error/timeout + */ + fun dequeueOutputBuffer(bufferInfo: MediaCodec.BufferInfo, timeoutUs: Long): Int { + return codec?.dequeueOutputBuffer(bufferInfo, timeoutUs) ?: -1 + } + + /** Get the output buffer at the given index. */ + fun getOutputBuffer(index: Int): java.nio.ByteBuffer? { + return codec?.getOutputBuffer(index) + } + + /** Release the output buffer at the given index. */ + fun releaseOutputBuffer(index: Int) { + codec?.releaseOutputBuffer(index, false) + } + + /** Stop and release the encoder. */ + fun stop() { + codec?.let { encoder -> + try { + encoder.stop() + } catch (_: IllegalStateException) { + // Already stopped + } + encoder.release() + } + codec = null + inputSurface = null + } +} diff --git a/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoServer.kt b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoServer.kt new file mode 100644 index 000000000..0b85c2464 --- /dev/null +++ b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoServer.kt @@ -0,0 +1,207 @@ +package dev.jasonpearson.automobile.video + +import android.media.MediaCodec +import dev.jasonpearson.automobile.video.wrappers.DisplayControl + +/** + * Main entry point for the video streaming server. + * + * This server captures the device screen using VirtualDisplay, encodes it as H.264 using + * MediaCodec, and streams the encoded video over a LocalSocket. + * + * ## Usage + * + * ```bash + * # Push DEX to device + * adb push android/video-server/build/libs/automobile-video.dex /data/local/tmp/ + * + * # Run server + * adb shell CLASSPATH=/data/local/tmp/automobile-video.dex \ + * app_process / dev.jasonpearson.automobile.video.VideoServer --quality medium + * ``` + * + * ## Quality presets + * - `low`: 540p @ 2 Mbps @ 30fps + * - `medium`: 720p @ 4 Mbps @ 60fps (default) + * - `high`: 1080p @ 8 Mbps @ 60fps + */ +object VideoServer { + private const val SOCKET_NAME = "automobile_video" + + @Volatile private var running = true + + private var encoder: VideoEncoder? = null + private var capture: ScreenCapture? = null + private var streamWriter: VideoStreamWriter? = null + + @JvmStatic + fun main(args: Array) { + // Parse arguments + val quality = parseQuality(args) + + println("AutoMobile Video Server") + println("Quality preset: ${quality.name}") + + // Get display info + val displayInfo = DisplayControl.getDisplayInfo() + println("Display: ${displayInfo.width}x${displayInfo.height} @ ${displayInfo.densityDpi}dpi") + + // Calculate output dimensions based on quality preset + val (outputWidth, outputHeight) = calculateOutputDimensions(displayInfo, quality) + println( + "Output: ${outputWidth}x${outputHeight} @ ${quality.bitrate / 1_000_000}Mbps @ ${quality.fps}fps" + ) + + // Install shutdown hook for clean termination + Runtime.getRuntime() + .addShutdownHook( + Thread { + println("\nShutting down...") + running = false + shutdown() + } + ) + + try { + run(outputWidth, outputHeight, displayInfo.densityDpi, quality) + } catch (e: Exception) { + System.err.println("Error: ${e.message}") + e.printStackTrace() + shutdown() + } + } + + private fun parseQuality(args: Array): QualityPreset { + var i = 0 + while (i < args.size) { + when (args[i]) { + "--quality", + "-q" -> { + if (i + 1 < args.size) { + return try { + QualityPreset.fromString(args[i + 1]) + } catch (e: IllegalArgumentException) { + System.err.println("Invalid quality preset: ${args[i + 1]}") + System.err.println("Valid values: low, medium, high") + QualityPreset.MEDIUM + } + } + } + "--help", + "-h" -> { + printUsage() + System.exit(0) + } + } + i++ + } + return QualityPreset.MEDIUM + } + + private fun printUsage() { + println( + """ + Usage: VideoServer [options] + + Options: + --quality, -q Quality preset: low, medium, high (default: medium) + --help, -h Show this help message + + Quality presets: + low 540p @ 2 Mbps @ 30fps + medium 720p @ 4 Mbps @ 60fps + high 1080p @ 8 Mbps @ 60fps + """ + .trimIndent() + ) + } + + private fun calculateOutputDimensions( + displayInfo: DisplayControl.DisplayInfo, + quality: QualityPreset, + ): Pair { + val displayWidth = displayInfo.width + val displayHeight = displayInfo.height + + // Portrait: height is the larger dimension + // Landscape: width is the larger dimension + val isPortrait = displayHeight > displayWidth + + if (isPortrait) { + // Scale based on height + if (displayHeight <= quality.maxHeight) { + return displayWidth to displayHeight + } + val scale = quality.maxHeight.toFloat() / displayHeight.toFloat() + val scaledWidth = (displayWidth * scale).toInt() and 0xFFFE // Round to even + return scaledWidth to quality.maxHeight + } else { + // Scale based on width (landscape) + if (displayWidth <= quality.maxHeight) { + return displayWidth to displayHeight + } + val scale = quality.maxHeight.toFloat() / displayWidth.toFloat() + val scaledHeight = (displayHeight * scale).toInt() and 0xFFFE // Round to even + return quality.maxHeight to scaledHeight + } + } + + private fun run(width: Int, height: Int, densityDpi: Int, quality: QualityPreset) { + // Create encoder + encoder = + VideoEncoder( + width = width, + height = height, + bitrate = quality.bitrate, + fps = quality.fps, + ) + val surface = encoder!!.start() + + // Create screen capture + capture = ScreenCapture(width, height, densityDpi) + capture!!.start(surface) + + // Create stream writer + streamWriter = VideoStreamWriter(SOCKET_NAME, width, height) + streamWriter!!.start() + + println("Streaming started") + + // Encoding loop + val bufferInfo = MediaCodec.BufferInfo() + while (running) { + val index = encoder!!.dequeueOutputBuffer(bufferInfo, 100_000) // 100ms timeout + if (index >= 0) { + val buffer = encoder!!.getOutputBuffer(index) + if (buffer != null) { + val success = streamWriter!!.writePacket(buffer, bufferInfo) + if (!success) { + println("Client disconnected") + break + } + } + encoder!!.releaseOutputBuffer(index) + + // Check for end of stream + if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + println("End of stream") + break + } + } + } + + shutdown() + } + + private fun shutdown() { + streamWriter?.stop() + capture?.stop() + encoder?.stop() + + streamWriter = null + capture = null + encoder = null + + println("Shutdown complete") + } +} diff --git a/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoStreamWriter.kt b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoStreamWriter.kt new file mode 100644 index 000000000..91aa676c2 --- /dev/null +++ b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/VideoStreamWriter.kt @@ -0,0 +1,163 @@ +package dev.jasonpearson.automobile.video + +import android.media.MediaCodec +import android.net.LocalServerSocket +import android.net.LocalSocket +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +/** + * Writes encoded video packets to a LocalSocket using a binary protocol. + * + * ## Protocol + * + * ### Stream Header (12 bytes) + * + * ``` + * ┌─────────────────┬─────────────────┬─────────────────┐ + * │ codec_id (4) │ width (4) │ height (4) │ + * │ big-endian │ big-endian │ big-endian │ + * └─────────────────┴─────────────────┴─────────────────┘ + * ``` + * + * codec_id values: + * - 0x68323634 = "h264" (H.264/AVC) + * + * ### Packet Header (12 bytes per packet) + * + * ``` + * ┌─────────────────────────────────────┬─────────────────┐ + * │ pts_and_flags (8) │ size (4) │ + * │ big-endian │ big-endian │ + * └─────────────────────────────────────┴─────────────────┘ + * ``` + * + * pts_and_flags bit layout: + * - bit 63: CONFIG flag (codec config data, not a frame) + * - bit 62: KEY_FRAME flag (I-frame) + * - bits 0-61: presentation timestamp in microseconds + * + * Followed by `size` bytes of encoded frame data. + */ +class VideoStreamWriter( + private val socketName: String, + private val width: Int, + private val height: Int, +) { + private var serverSocket: LocalServerSocket? = null + private var clientSocket: LocalSocket? = null + private var outputStream: OutputStream? = null + + @Volatile private var stopped = false + + companion object { + /** "h264" as big-endian int: 0x68323634 */ + const val CODEC_ID_H264 = 0x68323634 + + /** Bit 63: codec configuration data */ + const val PACKET_FLAG_CONFIG = 1L shl 63 + + /** Bit 62: key frame (I-frame) */ + const val PACKET_FLAG_KEY_FRAME = 1L shl 62 + + /** Mask for PTS (bits 0-61) */ + const val PTS_MASK = (1L shl 62) - 1 + } + + /** + * Start the server and wait for a client connection. + * + * This method blocks until a client connects. + * + * @throws IOException if the socket cannot be created or written to + */ + fun start() { + // Create LocalServerSocket in abstract namespace + serverSocket = LocalServerSocket(socketName) + println("Waiting for client connection on localabstract:$socketName") + + // Accept a single client connection (blocking) + val client = serverSocket!!.accept() + clientSocket = client + outputStream = client.outputStream + + println("Client connected, writing stream header") + + // Write stream header + writeHeader() + } + + private fun writeHeader() { + val header = ByteBuffer.allocate(12) + header.putInt(CODEC_ID_H264) + header.putInt(width) + header.putInt(height) + outputStream!!.write(header.array()) + outputStream!!.flush() + } + + /** + * Write an encoded packet to the stream. + * + * @param buffer The encoded data buffer + * @param bufferInfo The buffer info from MediaCodec + * @return true if the packet was written successfully, false if the stream was closed + */ + fun writePacket(buffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo): Boolean { + if (stopped) return false + + val output = outputStream ?: return false + + try { + // Build pts_and_flags + var ptsAndFlags = bufferInfo.presentationTimeUs and PTS_MASK + + if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + ptsAndFlags = ptsAndFlags or PACKET_FLAG_CONFIG + } + + if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { + ptsAndFlags = ptsAndFlags or PACKET_FLAG_KEY_FRAME + } + + // Write packet header (12 bytes) + val packetHeader = ByteBuffer.allocate(12) + packetHeader.putLong(ptsAndFlags) + packetHeader.putInt(bufferInfo.size) + output.write(packetHeader.array()) + + // Write packet data + val data = ByteArray(bufferInfo.size) + buffer.position(bufferInfo.offset) + buffer.get(data, 0, bufferInfo.size) + output.write(data) + + return true + } catch (e: IOException) { + println("Error writing packet: ${e.message}") + return false + } + } + + /** Stop the stream writer and close all sockets. */ + fun stop() { + stopped = true + + try { + outputStream?.close() + } catch (_: IOException) {} + + try { + clientSocket?.close() + } catch (_: IOException) {} + + try { + serverSocket?.close() + } catch (_: IOException) {} + + outputStream = null + clientSocket = null + serverSocket = null + } +} diff --git a/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/wrappers/DisplayControl.kt b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/wrappers/DisplayControl.kt new file mode 100644 index 000000000..9fa4151d2 --- /dev/null +++ b/android/video-server/src/main/kotlin/dev/jasonpearson/automobile/video/wrappers/DisplayControl.kt @@ -0,0 +1,116 @@ +package dev.jasonpearson.automobile.video.wrappers + +import android.hardware.display.VirtualDisplay +import android.view.Surface +import java.lang.reflect.Method + +/** + * Reflection wrapper for accessing hidden Android display APIs. + * + * This class provides access to `DisplayManagerGlobal.createVirtualDisplay()` with the + * `displayIdToMirror` parameter, which is required for screen mirroring without MediaProjection. + * + * Only works when running as shell user (UID 2000) via `adb shell app_process`. + */ +object DisplayControl { + + private val displayManagerGlobalClass: Class<*> by lazy { + Class.forName("android.hardware.display.DisplayManagerGlobal") + } + + private val displayManagerGlobal: Any by lazy { + val getInstanceMethod = displayManagerGlobalClass.getMethod("getInstance") + getInstanceMethod.invoke(null) + ?: throw IllegalStateException("DisplayManagerGlobal.getInstance() returned null") + } + + private val createVirtualDisplayMethod: Method by lazy { + // Parameters: String name, int width, int height, int densityDpi, + // Surface surface, int flags, VirtualDisplay.Callback callback, + // Handler handler, String uniqueId, int displayIdToMirror + displayManagerGlobalClass.getMethod( + "createVirtualDisplay", + String::class.java, // name + Int::class.javaPrimitiveType, // width + Int::class.javaPrimitiveType, // height + Int::class.javaPrimitiveType, // densityDpi + Surface::class.java, // surface + Int::class.javaPrimitiveType, // flags + Class.forName("android.hardware.display.VirtualDisplay\$Callback"), // callback + android.os.Handler::class.java, // handler + String::class.java, // uniqueId + Int::class.javaPrimitiveType, // displayIdToMirror + ) + } + + /** Get display information for the default display. */ + fun getDisplayInfo(displayId: Int = 0): DisplayInfo { + val displayInfoClass = Class.forName("android.view.DisplayInfo") + val displayInfo = displayInfoClass.getDeclaredConstructor().newInstance() + + val getDisplayInfoMethod = + displayManagerGlobalClass.getMethod( + "getDisplayInfo", + Int::class.javaPrimitiveType, + displayInfoClass, + ) + + getDisplayInfoMethod.invoke(displayManagerGlobal, displayId, displayInfo) + + val logicalWidth = displayInfoClass.getField("logicalWidth").getInt(displayInfo) + val logicalHeight = displayInfoClass.getField("logicalHeight").getInt(displayInfo) + val logicalDensityDpi = displayInfoClass.getField("logicalDensityDpi").getInt(displayInfo) + val rotation = displayInfoClass.getField("rotation").getInt(displayInfo) + + return DisplayInfo( + width = logicalWidth, + height = logicalHeight, + densityDpi = logicalDensityDpi, + rotation = rotation, + ) + } + + /** + * Create a VirtualDisplay that mirrors the specified display. + * + * @param name The name of the virtual display + * @param width The width of the virtual display + * @param height The height of the virtual display + * @param densityDpi The density of the virtual display + * @param surface The surface to render to + * @param displayIdToMirror The display ID to mirror (typically 0 for the main display) + * @return The created VirtualDisplay + */ + fun createVirtualDisplay( + name: String, + width: Int, + height: Int, + densityDpi: Int, + surface: Surface, + displayIdToMirror: Int = 0, + ): VirtualDisplay { + // Flags for mirroring: VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR (1 << 4) = 16 + val flags = 1 shl 4 + + return createVirtualDisplayMethod.invoke( + displayManagerGlobal, + name, + width, + height, + densityDpi, + surface, + flags, + null, // callback + null, // handler + null, // uniqueId + displayIdToMirror, + ) as VirtualDisplay + } + + data class DisplayInfo( + val width: Int, + val height: Int, + val densityDpi: Int, + val rotation: Int, + ) +} diff --git a/baseline/memory-leak-baseline.json b/baseline/memory-leak-baseline.json new file mode 100644 index 000000000..56dd64257 --- /dev/null +++ b/baseline/memory-leak-baseline.json @@ -0,0 +1,35 @@ +{ + "timestamp": "2026-01-10T06:19:04.512Z", + "passed": true, + "config": { + "iterations": 1000, + "opsPerSecond": 10, + "operations": [ + "observe", + "tapOn", + "swipeOn", + "inputText" + ], + "heapGrowthLimitMb": 50, + "warmupIterations": 100, + "gcEvery": 100 + }, + "results": { + "durationMs": 100127.662833, + "operationCounts": { + "observe": 250, + "tapOn": 250, + "swipeOn": 250, + "inputText": 250 + }, + "heapUsedStart": 24333376, + "heapUsedEnd": 24724544, + "heapGrowthBytes": 391168, + "heapDiffBytes": 0, + "effectiveGrowthBytes": 391168 + }, + "memwatch": { + "leakDetected": false, + "leakEventCount": 0 + } +} \ No newline at end of file diff --git a/benchmark/contrast-performance.ts b/benchmark/contrast-performance.ts new file mode 100644 index 000000000..5cdedf8aa --- /dev/null +++ b/benchmark/contrast-performance.ts @@ -0,0 +1,287 @@ +/** + * Performance benchmark for contrast checking optimizations + * Tests the impact of caching on accessibility audit performance + */ + +import { ContrastChecker } from "../src/features/accessibility/ContrastChecker"; +import { Element } from "../src/models/Element"; +import path from "path"; + +interface BenchmarkResult { + name: string; + duration: number; + cacheStats?: ReturnType; +} + +/** + * Generate test elements with varying positions + */ +function generateTestElements(count: number): Element[] { + const elements: Element[] = []; + + for (let i = 0; i < count; i++) { + const x = (i % 5) * 100 + 50; + const y = Math.floor(i / 5) * 100 + 50; + + elements.push({ + "index": i, + "text": `Text Element ${i}`, + "resource-id": `com.example:id/text_${i}`, + "class": "android.widget.TextView", + "package": "com.example", + "content-desc": "", + "checkable": false, + "checked": false, + "clickable": false, + "enabled": true, + "focusable": false, + "focused": false, + "scrollable": false, + "long-clickable": false, + "password": false, + "selected": false, + "visible": true, + "bounds": { + left: x, + top: y, + right: x + 80, + bottom: y + 30, + }, + }); + } + + return elements; +} + +/** + * Run a benchmark scenario + */ +async function runBenchmark( + name: string, + checker: ContrastChecker, + screenshotPath: string, + elements: Element[], + useBatch: boolean = false +): Promise { + const startTime = performance.now(); + + if (useBatch) { + await checker.checkContrastBatch(screenshotPath, elements, "AA"); + } else { + for (const element of elements) { + await checker.checkContrast(screenshotPath, element, "AA"); + } + } + + const duration = performance.now() - startTime; + const cacheStats = checker.getCacheStats(); + + return { + name, + duration, + cacheStats, + }; +} + +/** + * Format cache hit rate as percentage + */ +function hitRate(hits: number, misses: number): string { + const total = hits + misses; + if (total === 0) {return "N/A";} + return `${((hits / total) * 100).toFixed(1)}%`; +} + +/** + * Print benchmark results + */ +function printResults(results: BenchmarkResult[]): void { + console.log("\n=== Contrast Checking Performance Benchmark ===\n"); + + // Print duration comparison + console.log("Duration Results:"); + console.log("─".repeat(60)); + for (const result of results) { + console.log(`${result.name.padEnd(35)} ${result.duration.toFixed(0).padStart(6)} ms`); + } + + // Calculate improvements + if (results.length >= 2) { + console.log("\nPerformance Improvements:"); + console.log("─".repeat(60)); + const baseline = results[0].duration; + for (let i = 1; i < results.length; i++) { + const improvement = ((baseline - results[i].duration) / baseline) * 100; + const speedup = baseline / results[i].duration; + console.log( + `${results[i].name.padEnd(35)} ${improvement.toFixed(1)}% faster (${speedup.toFixed(1)}x)` + ); + } + } + + // Print cache statistics + console.log("\nCache Statistics:"); + console.log("─".repeat(60)); + for (const result of results) { + if (result.cacheStats) { + const stats = result.cacheStats; + console.log(`\n${result.name}:`); + console.log( + ` Screenshots: ${stats.screenshots.size.toString().padStart(3)} cached, ` + + `${hitRate(stats.screenshots.hits, stats.screenshots.misses)} hit rate` + ); + console.log( + ` Color Pairs: ${stats.colorPairs.size.toString().padStart(3)} cached, ` + + `${hitRate(stats.colorPairs.hits, stats.colorPairs.misses)} hit rate` + ); + console.log( + ` Elements: ${stats.elements.size.toString().padStart(3)} cached, ` + + `${hitRate(stats.elements.hits, stats.elements.misses)} hit rate` + ); + console.log( + ` Backgrounds: ${stats.backgrounds.size.toString().padStart(3)} cached, ` + + `${hitRate(stats.backgrounds.hits, stats.backgrounds.misses)} hit rate` + ); + } + } + + console.log("\n" + "=".repeat(60) + "\n"); +} + +/** + * Main benchmark execution + */ +async function main() { + // Use test fixture screenshot + const screenshotPath = path.join(__dirname, "../test/fixtures/screenshots/wcag-aa-minimum.png"); + + // Generate test elements + const elementCount = 50; + const elements = generateTestElements(elementCount); + + console.log(`\nBenchmarking with ${elementCount} text elements...\n`); + + const results: BenchmarkResult[] = []; + + // Scenario 1: Cold cache (all caching disabled) + console.log("Running: Cold cache (no caching)..."); + const noCacheChecker = new ContrastChecker({ + enableScreenshotCache: false, + enableColorPairCache: false, + enableElementCache: false, + enableBackgroundCache: false, + }); + results.push( + await runBenchmark("1. Cold cache (no caching)", noCacheChecker, screenshotPath, elements) + ); + + // Scenario 2: Screenshot caching only + console.log("Running: Screenshot caching only..."); + const screenshotCacheChecker = new ContrastChecker({ + enableScreenshotCache: true, + enableColorPairCache: false, + enableElementCache: false, + enableBackgroundCache: false, + }); + results.push( + await runBenchmark( + "2. Screenshot caching only", + screenshotCacheChecker, + screenshotPath, + elements + ) + ); + + // Scenario 3: All caches enabled (first run) + console.log("Running: All caches enabled (first run)..."); + const fullCacheChecker = new ContrastChecker(); + results.push( + await runBenchmark("3. All caches enabled (first run)", fullCacheChecker, screenshotPath, elements) + ); + + // Scenario 4: All caches enabled (warm cache - same elements) + console.log("Running: All caches enabled (warm cache)..."); + results.push( + await runBenchmark("4. All caches enabled (warm cache)", fullCacheChecker, screenshotPath, elements) + ); + + // Scenario 5: Batch processing with cold cache + console.log("Running: Batch processing (cold cache)..."); + const batchChecker = new ContrastChecker(); + batchChecker.clearCaches(); + results.push( + await runBenchmark( + "5. Batch processing (cold cache)", + batchChecker, + screenshotPath, + elements, + true + ) + ); + + // Scenario 6: Batch processing with warm cache + console.log("Running: Batch processing (warm cache)..."); + results.push( + await runBenchmark( + "6. Batch processing (warm cache)", + batchChecker, + screenshotPath, + elements, + true + ) + ); + + // Print all results + printResults(results); + + // Verify target improvements + console.log("Target Performance Goals:"); + console.log("─".repeat(60)); + + const baseline = results[0].duration; + const warmCache = results[3].duration; + const batchWarm = results[5].duration; + + const warmCacheImprovement = baseline / warmCache; + const batchWarmImprovement = baseline / batchWarm; + + console.log(`First audit (50 elements): ${baseline.toFixed(0)} ms`); + console.log( + `Repeated audit (warm cache): ${warmCache.toFixed(0)} ms (${warmCacheImprovement.toFixed(1)}x faster)` + ); + console.log( + `Batch + warm cache: ${batchWarm.toFixed(0)} ms (${batchWarmImprovement.toFixed(1)}x faster)` + ); + + console.log("\nTarget Goals from Issue #105:"); + console.log(" ✓ Screenshot loaded once per audit (not per element)"); + console.log( + ` ${warmCacheImprovement >= 10 ? "✓" : "✗"} Repeated audits ≥10x faster: ${warmCacheImprovement.toFixed(1)}x` + ); + console.log( + ` ${batchWarmImprovement >= 10 ? "✓" : "✗"} Batch processing ≥10x faster: ${batchWarmImprovement.toFixed(1)}x` + ); + + const screenshotStats = results[3].cacheStats?.screenshots; + const colorPairStats = results[3].cacheStats?.colorPairs; + + if (screenshotStats) { + const screenshotHitRate = + screenshotStats.hits / (screenshotStats.hits + screenshotStats.misses); + console.log( + ` ${screenshotHitRate >= 0.95 ? "✓" : "✗"} Screenshot cache hit rate: ${(screenshotHitRate * 100).toFixed(1)}%` + ); + } + + if (colorPairStats) { + const colorHitRate = colorPairStats.hits / (colorPairStats.hits + colorPairStats.misses); + console.log( + ` ${colorHitRate >= 0.8 ? "✓" : "✗"} Color pair cache hit rate >80%: ${(colorHitRate * 100).toFixed(1)}%` + ); + } + + console.log(); +} + +// Run benchmark +main().catch(console.error); diff --git a/benchmark/startup-baseline.json b/benchmark/startup-baseline.json new file mode 100644 index 000000000..98db7a3e5 --- /dev/null +++ b/benchmark/startup-baseline.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0", + "thresholdMultiplier": 1.3, + "metrics": { + "mcpServer.cold.timeToReadyMs": 500, + "mcpServer.warm.timeToReadyMs": 200, + "mcpServer.cold.memory.heapUsedBytes": 104857600, + "daemon.cold.timeToReadyMs": 2000, + "daemon.warm.timeToReadyMs": 1000, + "daemon.cold.timeToResponsiveMs": 2500, + "daemon.warm.timeToResponsiveMs": 1500, + "daemon.cold.memory.heapUsedBytes": 157286400 + }, + "metadata": { + "generatedAt": "2025-01-01T00:00:00.000Z", + "description": "Initial startup performance baselines (targets + buffer)" + } +} diff --git a/build.ts b/build.ts new file mode 100644 index 000000000..dcf3b127a --- /dev/null +++ b/build.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun + +/** + * Build script using Bun's built-in TypeScript transpiler + * Replaces the previous tsc-based build process + */ + +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { spawnSync } from "child_process"; + +// Clean dist directory +const distPath = join(import.meta.dir, "dist"); +if (existsSync(distPath)) { + console.log("Cleaning dist directory..."); + rmSync(distPath, { recursive: true, force: true }); +} + +// Build with Bun - transpile TypeScript to JavaScript +console.log("Building with Bun..."); +const result = await Bun.build({ + entrypoints: ["./src/index.ts"], + outdir: "./dist/src", + target: "bun", + format: "esm", + sourcemap: "external", + minify: true, + splitting: false, +}); + +if (!result.success) { + console.error("Build failed:"); + for (const log of result.logs) { + console.error(log); + } + process.exit(1); +} + +console.log(`✓ Built ${result.outputs.length} files`); + +const sourcemapPath = join(import.meta.dir, "dist", "src", "index.js.map"); +if (existsSync(sourcemapPath)) { + try { + const includeDependencySources = process.env.AUTOMOBILE_SOURCEMAP_INCLUDE_DEPS === "true"; + const rawMap = readFileSync(sourcemapPath, "utf8"); + const map = JSON.parse(rawMap); + let trimmedCount = 0; + + if (!includeDependencySources && Array.isArray(map.sources) && Array.isArray(map.sourcesContent)) { + map.sourcesContent = map.sourcesContent.map((content: string | null, index: number) => { + const source = String(map.sources[index] ?? ""); + if (source.includes("node_modules") || source.includes("__bun")) { + if (content) { + trimmedCount += 1; + } + return null; + } + return content; + }); + } + + writeFileSync(sourcemapPath, JSON.stringify(map)); + if (includeDependencySources) { + console.log("✓ Minified sourcemap"); + } else { + console.log(`✓ Minified sourcemap (trimmed ${trimmedCount} dependency sources)`); + } + } catch (error) { + console.warn("Failed to optimize sourcemap:", error); + } +} + +// Copy migrations for runtime usage (FileMigrationProvider reads from disk) +const migrationsSource = join(import.meta.dir, "src", "db", "migrations"); +const migrationsDest = join(import.meta.dir, "dist", "src", "db", "migrations"); +if (existsSync(migrationsSource)) { + mkdirSync(migrationsDest, { recursive: true }); + cpSync(migrationsSource, migrationsDest, { recursive: true }); + console.log("✓ Copied database migrations"); +} else { + console.warn(`Database migrations not found at ${migrationsSource}`); +} + +// Copy schemas for runtime validation (PlanSchemaValidator reads from disk) +const schemasSource = join(import.meta.dir, "schemas"); +const schemasDest = join(import.meta.dir, "dist", "schemas"); +if (existsSync(schemasSource)) { + mkdirSync(schemasDest, { recursive: true }); + cpSync(schemasSource, schemasDest, { recursive: true }); + console.log("✓ Copied validation schemas"); +} else { + console.warn(`Validation schemas not found at ${schemasSource}`); +} + +// Build iOS assets using the same bun executable that's running this script +console.log("Building iOS assets..."); +const proc = spawnSync(Bun.which("bun") || process.execPath, ["scripts/build-ios-assets.js"], { + stdio: "inherit", +}); + +if (proc.status !== 0) { + console.error("iOS assets build failed"); + process.exit(1); +} + +console.log("Build completed successfully!"); diff --git a/bun.lock b/bun.lock new file mode 100644 index 000000000..11cacd575 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1182 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "@kaeawc/auto-mobile", + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/js-yaml": "^4.0.9", + "@types/ws": "^8.18.1", + "adm-zip": "^0.5.16", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "async-mutex": "^0.5.0", + "fs-extra": "^11.3.3", + "glob": "^13.0.1", + "jimp": "^1.6.0", + "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", + "kysely": "^0.28.9", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0", + "sharp": "^0.34.5", + "ws": "^8.19.0", + "xml2js": "^0.6.2", + "zod": "^4.3.5", + }, + "devDependencies": { + "@faker-js/faker": "^10.2.0", + "@stylistic/eslint-plugin": "^5.7.0", + "@types/fs-extra": "^11.0.4", + "@types/node": "^25.0.9", + "@types/pixelmatch": "^5.2.6", + "@types/pngjs": "^6.0.5", + "@types/xml2js": "^0.4.14", + "@typescript-eslint/eslint-plugin": "^8.53.0", + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^10.0.0", + "eslint-plugin-import": "^2.31.0", + "heapdump": "^0.3.15", + "knip": "^5.81.0", + "memwatch-next": "^0.3.0", + "ts-prune": "^0.10.3", + "tsx": "^4.21.0", + "turbo": "^2.8.10", + "typescript": "^5.9.2", + }, + }, + }, + "overrides": { + "lodash": "^4.17.23", + "tar": "7.5.7", + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="], + + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.2", "", { "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" } }, "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.2", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="], + + "@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.2", "", {}, "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="], + + "@faker-js/faker": ["@faker-js/faker@10.3.0", "", {}, "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw=="], + + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.16.2", "", { "os": "android", "cpu": "arm" }, "sha512-lVJbvydLQIDZHKUb6Zs9Rq80QVTQ9xdCQE30eC9/cjg4wsMoEOg65QZPymUAIVJotpUAWJD0XYcwE7ugfxx5kQ=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.16.2", "", { "os": "android", "cpu": "arm64" }, "sha512-fEk+g/g2rJ6LnBVPqeLcx+/alWZ/Db1UlXG+ZVivip0NdrnOzRL48PAmnxTMGOrLwsH1UDJkwY3wOjrrQltCqg=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.16.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pkbp1qi7kdUX6k3Fk1PvAg6p7ruwaWKg1AhOlDgrg2vLXjtv9ZHo7IAQN6kLj0W771dPJZWqNxoqTPacp2oYWA=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.16.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-FYCGcU1iSoPkADGLfQbuj0HWzS+0ItjDCt9PKtu2Hzy6T0dxO4Y1enKeCOxCweOlmLEkSxUlW5UPT4wvT3LnAg=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.16.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1zHCoK6fMcBjE54P2EG/z70rTjcRxvyKfvk4E/QVrWLxNahuGDFZIxoEoo4kGnnEcmPj41F0c2PkrQbqlpja5g=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.16.2", "", { "os": "linux", "cpu": "arm" }, "sha512-+ucLYz8EO5FDp6kZ4o1uDmhoP+M98ysqiUW4hI3NmfiOJQWLrAzQjqaTdPfIOzlCXBU9IHp5Cgxu6wPjVb8dbA=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.16.2", "", { "os": "linux", "cpu": "arm" }, "sha512-qq+TpNXyw1odDgoONRpMLzH4hzhwnEw55398dL8rhKGvvYbio71WrJ00jE+hGlEi7H1Gkl11KoPJRaPlRAVGPw=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-xlMh4gNtplNQEwuF5icm69udC7un0WyzT5ywOeHrPMEsghKnLjXok2wZgAA7ocTm9+JsI+nVXIQa5XO1x+HPQg=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OZs33QTMi0xmHv/4P0+RAKXJTBk7UcMH5tpTaCytWRXls/DGaJ48jOHmriQGK2YwUqXl+oneuNyPOUO0obJ+Hg=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.16.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UVyuhaV32dJGtF6fDofOcBstg9JwB2Jfnjfb8jGlu3xcG+TsubHRhuTwQ6JZ1sColNT1nMxBiu7zdKUEZi1kwg=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.16.2", "", { "os": "linux", "cpu": "none" }, "sha512-YZZS0yv2q5nE1uL/Fk4Y7m9018DSEmDNSG8oJzy1TJjA1jx5HL52hEPxi98XhU6OYhSO/vC1jdkJeE8TIHugug=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.16.2", "", { "os": "linux", "cpu": "none" }, "sha512-9VYuypwtx4kt1lUcwJAH4dPmgJySh4/KxtAPdRoX2BTaZxVm/yEXHq0mnl/8SEarjzMvXKbf7Cm6UBgptm3DZw=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.16.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3gbwQ+xlL5gpyzgSDdC8B4qIM4mZaPDLaFOi3c/GV7CqIdVJc5EZXW4V3T6xwtPBOpXPXfqQLbhTnUD4SqwJtA=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0WcK0j54tSwWa+hQaJMScZdWneqE7xixp/vpFqlkbhuKW9dRHykPAFvSYg1YJ3MJgu9ZzVNpYHhPKJiEQq57Q=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjUm3w96P2t47nWywGwj1A2mAVBI/8IoS7XHhcogWCfXnEI3M6NPIRQPYAZW4s5/u3u6w1uPtgOwffj2XIOb/g=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.16.2", "", { "os": "none", "cpu": "arm64" }, "sha512-OFVQ2x3VenTp13nIl6HcQ/7dmhFmM9dg2EjKfHcOtYfrVLQdNR6THFU7GkMdmc8DdY1zLUeilHwBIsyxv5hkwQ=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.16.2", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-+O1sY3RrGyA2AqDnd3yaDCsqZqCblSTEpY7TbbaOaw0X7iIbGjjRLdrQk9StG3QSiZuBy9FdFwotIiSXtwvbAQ=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.16.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-jMrMJL+fkx6xoSMFPOeyQ1ctTFjavWPOSZEKUY5PebDwQmC9cqEr4LhdTnGsOtFrWYLXlEU4xWeMdBoc/XKkOA=="], + + "@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.16.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-tl0xDA5dcQplG2yg2ZhgVT578dhRFafaCfyqMEAXq8KNpor85nJ53C3PLpfxD2NKzPioFgWEexNsjqRi+kW2Mg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.16.2", "", { "os": "win32", "cpu": "x64" }, "sha512-M7z0xjYQq1HdJk2DxTSLMvRMyBSI4wn4FXGcVQBsbAihgXevAReqwMdb593nmCK/OiFwSNcOaGIzUvzyzQ+95w=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@ts-morph/common": ["@ts-morph/common@0.12.3", "", { "dependencies": { "fast-glob": "^3.2.7", "minimatch": "^3.0.4", "mkdirp": "^1.0.4", "path-browserify": "^1.0.1" } }, "sha512-4tUmeLyXJnJWvTFOKtcNJ1yh0a3SsTLi2MUoyj8iUNznFRN1ZquaNe7Oukqrnki2FzZkm0J9adCNLDZxUzvj+w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], + + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + + "@types/pixelmatch": ["@types/pixelmatch@5.2.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg=="], + + "@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.56.0", "", {}, "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + + "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "code-block-writer": ["code-block-writer@11.0.3", "", {}, "sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw=="], + + "commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.0.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.2", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.1", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-scope": ["eslint-scope@9.1.1", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "heapdump": ["heapdump@0.3.15", "", { "dependencies": { "nan": "^2.13.2" } }, "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA=="], + + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jiti": ["jiti@2.6.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "knip": ["knip@5.85.0", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "js-yaml": "^4.1.1", "minimist": "^1.2.8", "oxc-resolver": "^11.15.0", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-V2kyON+DZiYdNNdY6GALseiNCwX7dYdpz9Pv85AUn69Gk0UKCts+glOKWfe5KmaMByRjM9q17Mzj/KinTVOyxg=="], + + "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "memwatch-next": ["memwatch-next@0.3.0", "", { "dependencies": { "bindings": "^1.2.1", "nan": "^2.3.2" } }, "sha512-DgDzV5H/tA+ZvA5XSyz+XC5sF/m6S88qDI8Z9/XM62J6eHbiTvZzsG7+vPqwhyWJ9iOdC9zKeHwEety2EMJx5g=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@3.0.0", "", { "bin": "cli.js" }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": "bin/cmd.js" }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nan": ["nan@2.24.0", "", {}, "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "oxc-resolver": ["oxc-resolver@11.16.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.16.2", "@oxc-resolver/binding-android-arm64": "11.16.2", "@oxc-resolver/binding-darwin-arm64": "11.16.2", "@oxc-resolver/binding-darwin-x64": "11.16.2", "@oxc-resolver/binding-freebsd-x64": "11.16.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.16.2", "@oxc-resolver/binding-linux-arm64-musl": "11.16.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.16.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.16.2", "@oxc-resolver/binding-linux-x64-gnu": "11.16.2", "@oxc-resolver/binding-linux-x64-musl": "11.16.2", "@oxc-resolver/binding-openharmony-arm64": "11.16.2", "@oxc-resolver/binding-wasm32-wasi": "11.16.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.16.2", "@oxc-resolver/binding-win32-ia32-msvc": "11.16.2", "@oxc-resolver/binding-win32-x64-msvc": "11.16.2" } }, "sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": "bin/pixelmatch" }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], + + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + + "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "true-myth": ["true-myth@4.1.1", "", {}, "sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "ts-morph": ["ts-morph@13.0.3", "", { "dependencies": { "@ts-morph/common": "~0.12.3", "code-block-writer": "^11.0.0" } }, "sha512-pSOfUMx8Ld/WUreoSzvMFQG5i9uEiWIsBYjpU9+TTASOeUa89j5HykomeqVULm1oqWtBdleI3KEFRLrlA3zGIw=="], + + "ts-prune": ["ts-prune@0.10.3", "", { "dependencies": { "commander": "^6.2.1", "cosmiconfig": "^7.0.1", "json5": "^2.1.3", "lodash": "^4.17.21", "true-myth": "^4.1.0", "ts-morph": "^13.0.1" }, "bin": { "ts-prune": "lib/index.js" } }, "sha512-iS47YTbdIcvN8Nh/1BFyziyUqmjXz7GVzWu02RaZXqb+e/3Qe1B7IQ4860krOeCGUeJmterAlaM2FRH0Ue0hjw=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="], + + "turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@jimp/diff/pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": "bin/pixelmatch" }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@types/fs-extra/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + + "@types/jsonfile/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + + "@types/pixelmatch/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + + "@types/pngjs/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + + "@types/ws/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + + "@types/xml2js/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + + "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "body-parser/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "eslint/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint/espree": ["espree@11.1.1", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "sharp/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "@jimp/diff/pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@types/fs-extra/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/jsonfile/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/pixelmatch/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/pngjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/xml2js/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "eslint/espree/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..c0436fbc8 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[test] +coverageReporter = ["text", "lcov"] +coverageDir = "./coverage" diff --git a/dead-code-allowlist.json b/dead-code-allowlist.json new file mode 100644 index 000000000..ae21080ef --- /dev/null +++ b/dead-code-allowlist.json @@ -0,0 +1,46 @@ +{ + "ignorePaths": [ + "src/daemon/socketServer/index.ts", + "src/db/index.ts", + "src/features/action/Fling.ts", + "src/features/action/SwipeOnBounds.ts", + "src/features/action/SwipeOnScreen.ts", + "src/features/action/UninstallApp.ts", + "src/features/action/swipeon/index.ts", + "src/features/appearance/index.ts", + "src/features/observe/android/index.ts", + "src/features/observe/interfaces/index.ts", + "src/features/observe/ios/index.ts", + "src/features/snapshot/index.ts", + "src/features/video/index.ts", + "src/models/index.ts", + "src/utils/factories/DeviceSessionManagerFactory.ts", + "src/utils/hostControlClient.ts", + "src/utils/interfaces/PlanExecutor.ts", + "src/utils/interfaces/PlanSerializer.ts", + "src/utils/interfaces/index.ts", + "src/utils/screenshot-utils.ts", + "src/vision/index.ts" + ], + "ignorePathPatterns": [ + "src/daemon/", + "scripts/memory/" + ], + "ignoreEntries": [ + { + "file": "src/features/action/UninstallApp.ts", + "name": "UninstallApp", + "reason": "TODO: planned usage in future PR" + }, + { + "file": "src/utils/android-cmdline-tools/avdmanager.ts", + "name": "COMMON_SYSTEM_IMAGES", + "reason": "Used via namespace import in tests (import * as avdmanager)" + }, + { + "file": "src/utils/android-cmdline-tools/avdmanager.ts", + "name": "COMMON_DEVICES", + "reason": "Used via namespace import in tests (import * as avdmanager)" + } + ] +} diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 000000000..9b1960e71 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1 @@ +output/ \ No newline at end of file diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 000000000..402187cee --- /dev/null +++ b/demo/README.md @@ -0,0 +1,392 @@ +# AutoMobile Demo Recording + +Tools and scripts for creating side-by-side demo videos showing CLI + Android emulator interactions. + +## Overview + +This demo setup records: +- **Terminal**: Claude Code interacting with AutoMobile MCP server (asciicinema) +- **Device**: Android emulator screen during automation (AutoMobile videoRecording) +- **Output**: Side-by-side merged video showing both simultaneously + +## Quick Start + +```bash +# Make scripts executable +chmod +x demo/scripts/*.sh + +# Record a demo (requires running emulator) +./demo/scripts/record-demo.sh my-demo + +# Output files will be in demo/output/ +``` + +## Workflow Options + +### Option 1: Automated Recording (Recommended) + +**Tools**: asciicinema, AutoMobile videoRecording, agg, ffmpeg + +```bash +# One command to record everything +./demo/scripts/record-demo.sh clock-demo +``` + +**Process**: +1. Starts AutoMobile device screen recording +2. Records terminal with asciicinema +3. Stops device recording +4. Converts terminal recording (cast → gif → mp4) +5. Merges videos side-by-side with ffmpeg + +**Pros**: +- Fully automated workflow +- Synchronized recordings +- High quality output + +**Cons**: +- Requires all tools installed +- Two-step conversion for terminal (cast → gif → mp4) + +### Option 2: Manual Recording + Merge + +**Record separately**, then merge: + +```bash +# 1. Record terminal +asciicinema rec terminal.cast + +# 2. Record device (in another terminal) +# Start recording +bun run src/index.ts --cli videoRecording --action start --platform android --outputName demo +# ... perform actions ... +# Stop recording +bun run src/index.ts --cli videoRecording --action stop --platform android --recordingId + +# 3. Convert terminal to video +agg terminal.cast terminal.gif +ffmpeg -i terminal.gif -pix_fmt yuv420p terminal.mp4 + +# 4. Merge +./demo/scripts/merge-videos.sh terminal.mp4 device.mp4 final.mp4 +``` + +**Pros**: +- Full control over timing +- Can re-record either side independently + +**Cons**: +- Manual synchronization required +- More steps + +### Option 3: Direct Terminal Video Recording + +**Tools**: ttyd, ffmpeg (skip asciicinema) + +```bash +# Record terminal directly as video +ffmpeg -video_size 1920x1080 -framerate 30 -f avfoundation -i "1" terminal.mp4 + +# Record device with AutoMobile +# ... same as above ... + +# Merge +./demo/scripts/merge-videos.sh terminal.mp4 device.mp4 final.mp4 +``` + +**Pros**: +- Skip cast → gif → mp4 conversion +- Native video quality + +**Cons**: +- Larger file sizes +- Platform-specific screen capture + +### Option 4: GIF-Only Output + +**For lightweight demos**: + +```bash +# Record with asciicinema +asciicinema rec demo.cast + +# Convert to GIF only +agg demo.cast demo.gif + +# Record device +# ... device recording ... + +# Convert device to GIF +ffmpeg -i device.mp4 -vf "fps=10,scale=540:-1:flags=lanczos" device.gif + +# Merge GIFs (if needed) +ffmpeg -i demo.gif -i device.gif -filter_complex "[0][1]hstack" output.gif +``` + +**Pros**: +- Smaller file sizes +- Easy to embed in docs + +**Cons**: +- Quality loss +- Limited frame rate + +## Tool Alternatives + +### Terminal Recording + +| Tool | Output | Pros | Cons | +|------|--------|------|------| +| **asciicinema** | .cast (JSON) | Editable, small size | Requires conversion | +| **termtosvg** | .svg | Vector graphics | Limited playback | +| **asciinema-automation** | .cast | Scriptable | Extra dependency | +| **vhs** (Charm) | .mp4, .gif | Direct video output | Requires Go | +| **ttyd + ffmpeg** | .mp4 | Native video | Platform-specific | + +### Video Conversion + +| Tool | Purpose | Notes | +|------|---------|-------| +| **agg** | cast → gif | Fast, good quality | +| **asciicast2gif** | cast → gif | Alternative to agg | +| **ffmpeg** | Universal converter | Swiss army knife | +| **ImageMagick** | gif manipulation | For optimization | + +### Video Merging + +| Method | Command | Use Case | +|--------|---------|----------| +| **Horizontal stack** | `hstack` | Side-by-side | +| **Vertical stack** | `vstack` | Top/bottom | +| **Picture-in-picture** | `overlay` | Device in corner | +| **Grid layout** | `xstack` | Multiple devices | + +## Example Layouts + +### Side-by-Side (Current) + +``` +┌─────────────────┬─────────┐ +│ │ │ +│ Terminal │ Device │ +│ (CLI) │ Screen │ +│ │ │ +└─────────────────┴─────────┘ + 2/3 width 1/3 width +``` + +### Picture-in-Picture + +``` +┌─────────────────────────┐ +│ │ +│ Terminal │ +│ (CLI) ┌───┐ │ +│ │Dev│ │ +│ └───┘ │ +└─────────────────────────┘ +``` + +### Vertical Stack + +``` +┌─────────────────────────┐ +│ Terminal │ +│ (CLI) │ +├─────────────────────────┤ +│ Device │ +│ Screen │ +└─────────────────────────┘ +``` + +## Scripts + +### `record-demo.sh` + +Main orchestration script. Records both terminal and device, then merges. + +```bash +./demo/scripts/record-demo.sh [demo-name] +``` + +**Output**: +- `{demo-name}.cast` - Terminal recording +- `{demo-name}.gif` - Terminal as GIF +- `{demo-name}-cli.mp4` - Terminal as video +- `{demo-name}-device.mp4` - Device recording +- `{demo-name}-final.mp4` - Merged video + +### `simulate-claude-code.sh` + +Simulates Claude Code interface for terminal recording. + +**Features**: +- Animated typing +- MCP tool call display +- Progress indicators +- Color-coded output + +### `merge-videos.sh` + +Standalone video merger. + +```bash +./demo/scripts/merge-videos.sh [output] +``` + +## Requirements + +### Essential +- **Bun** - Run AutoMobile +- **ffmpeg** - Video processing +- **asciicinema** - Terminal recording +- **agg** - Cast to GIF conversion + +### Installation + +```bash +# macOS +brew install ffmpeg asciicinema agg + +# Linux +apt install ffmpeg asciicinema +cargo install agg + +# Bun (if not installed) +curl -fsSL https://bun.sh/install | bash +``` + +## Configuration + +### Video Quality + +Edit `record-demo.sh` to adjust quality: + +```bash +# Device recording quality +--qualityPreset high # Options: low, medium, high +--maxDuration 120 # Max seconds + +# Terminal GIF quality +--font-size 14 # Terminal font size +--theme monokai # Color theme + +# Final video encoding +-crf 23 # Quality (lower = better, 18-28 range) +-preset medium # Speed (ultrafast to veryslow) +``` + +### Layout Dimensions + +Edit `merge-videos.sh` to change layout: + +```bash +TARGET_HEIGHT=1080 # Final height +CLI_TARGET_WIDTH=1280 # Terminal width +DEVICE_TARGET_WIDTH=640 # Device width +``` + +## Advanced Workflows + +### Multi-Device Recording + +Record multiple devices side-by-side: + +```bash +# Start recordings for device A and B +# ... record actions ... +# Merge with xstack filter +ffmpeg -i cli.mp4 -i deviceA.mp4 -i deviceB.mp4 \ + -filter_complex "[1][2]hstack[devices];[0][devices]vstack" \ + output.mp4 +``` + +### Add Narration + +Record audio separately and combine: + +```bash +# Record video (muted) +./demo/scripts/record-demo.sh demo + +# Record audio +arecord -f cd narration.wav + +# Combine +ffmpeg -i demo-final.mp4 -i narration.wav \ + -c:v copy -c:a aac -shortest \ + demo-with-audio.mp4 +``` + +### Live Demo Recording + +Use OBS Studio for live recording with multiple sources: + +```bash +# Install OBS Studio +brew install obs + +# Add sources: +# 1. Terminal window capture +# 2. Android emulator window capture +# 3. Webcam (optional) + +# Record directly to MP4 +``` + +## Troubleshooting + +### "Command not found: agg" + +```bash +# Install agg +cargo install --git https://github.com/asciinema/agg +``` + +### Device video not synchronized + +Adjust timing in `simulate-claude-code.sh`: + +```bash +# Increase sleep delays between actions +sleep 2 # Wait 2 seconds between interactions +``` + +### Video quality issues + +Increase bitrate and quality: + +```bash +# In merge-videos.sh +-crf 18 # Better quality +-preset slower # Better compression +``` + +### Videos have different lengths + +Use `-shortest` flag (already default) or trim: + +```bash +# Trim longer video +ffmpeg -i long.mp4 -t 60 -c copy trimmed.mp4 +``` + +## Examples + +See `demo/examples/` for sample outputs: +- `clock-demo-final.mp4` - Full Clock app exploration +- `timer-demo-final.mp4` - Timer interaction +- `alarm-demo-final.mp4` - Alarm setup + +## Contributing + +To create a new demo: + +1. Create scenario script in `demo/scripts/scenarios/` +2. Update `simulate-claude-code.sh` to use scenario +3. Run `record-demo.sh` with scenario name +4. Add output to `demo/examples/` + +## License + +Part of AutoMobile project. See main repository for license. diff --git a/demo/WORKFLOWS.md b/demo/WORKFLOWS.md new file mode 100644 index 000000000..87691864b --- /dev/null +++ b/demo/WORKFLOWS.md @@ -0,0 +1,404 @@ +# Demo Recording Workflows + +Quick reference for all possible recording and merging workflows. + +## Recording Phase + +### Terminal (CLI) + +```bash +# Option A: asciicinema (recommended) +asciicinema rec demo.cast --command "./simulate-claude-code.sh" + +# Option B: termtosvg (SVG output) +termtosvg demo.svg + +# Option C: vhs (direct to video) +vhs demo.tape # Creates demo.mp4 + +# Option D: ttyd + ffmpeg (live video) +ttyd bash & +ffmpeg -f avfoundation -i "1" terminal.mp4 +``` + +### Device (Android) + +```bash +# Option A: AutoMobile videoRecording (recommended) +bun run src/index.ts --cli videoRecording \ + --action start --platform android --outputName demo +# ... perform actions ... +bun run src/index.ts --cli videoRecording \ + --action stop --platform android --recordingId + +# Option B: Direct ADB screen recording +adb shell screenrecord /sdcard/demo.mp4 +# ... perform actions ... +adb shell pkill -2 screenrecord +adb pull /sdcard/demo.mp4 + +# Option C: scrcpy + screen capture +scrcpy & +ffmpeg -f avfoundation -i "2" device.mp4 +``` + +## Conversion Phase + +### Terminal Formats + +```bash +# cast → gif +agg demo.cast demo.gif + +# cast → gif (alternative) +asciicast2gif -s 2 demo.cast demo.gif + +# cast → svg +svg-term --in demo.cast --out demo.svg + +# gif → mp4 +ffmpeg -i demo.gif -movflags faststart -pix_fmt yuv420p demo.mp4 + +# svg → mp4 +# (requires rendering svg frames first) +``` + +### Device Formats + +```bash +# mp4 → gif +ffmpeg -i device.mp4 -vf "fps=10,scale=540:-1:flags=lanczos" device.gif + +# mp4 optimization +ffmpeg -i device.mp4 -c:v libx264 -crf 23 device-optimized.mp4 + +# Extract frames +ffmpeg -i device.mp4 frames/frame_%04d.png +``` + +## Merging Phase + +### Timing Options + +| When to Merge | Method | +|---------------|--------| +| **Before conversion** | Merge .cast + device video, convert together | +| **After conversion** | Convert separately, merge MP4s (recommended) | +| **During recording** | Use OBS with multiple sources | + +### Layout Options + +#### 1. Side-by-Side (Horizontal) + +```bash +ffmpeg -i cli.mp4 -i device.mp4 \ + -filter_complex "[0:v]scale=960:-1[left];[1:v]scale=960:-1[right];[left][right]hstack" \ + -c:v libx264 -crf 23 output.mp4 +``` + +#### 2. Vertical Stack + +```bash +ffmpeg -i cli.mp4 -i device.mp4 \ + -filter_complex "[0:v]scale=-1:540[top];[1:v]scale=-1:540[bottom];[top][bottom]vstack" \ + -c:v libx264 -crf 23 output.mp4 +``` + +#### 3. Picture-in-Picture + +```bash +ffmpeg -i cli.mp4 -i device.mp4 \ + -filter_complex "[0:v][1:v]overlay=W-w-10:H-h-10" \ + -c:v libx264 -crf 23 output.mp4 +``` + +#### 4. Grid (4 videos) + +```bash +ffmpeg -i v1.mp4 -i v2.mp4 -i v3.mp4 -i v4.mp4 \ + -filter_complex "\ + [0:v]scale=960:540[v0];\ + [1:v]scale=960:540[v1];\ + [2:v]scale=960:540[v2];\ + [3:v]scale=960:540[v3];\ + [v0][v1]hstack[top];\ + [v2][v3]hstack[bottom];\ + [top][bottom]vstack" \ + -c:v libx264 -crf 23 output.mp4 +``` + +## Complete Workflows + +### Workflow 1: Full Auto (Recommended) + +```bash +./demo/scripts/record-demo.sh my-demo +``` + +**Process**: +1. Start device recording (AutoMobile) +2. Record terminal (asciicinema) +3. Stop device recording +4. Convert: cast → gif → mp4 +5. Merge: cli.mp4 + device.mp4 → final.mp4 + +**Pros**: One command, synchronized +**Cons**: Two-step terminal conversion + +--- + +### Workflow 2: Direct Video Recording + +```bash +# Start device recording +RECORDING_ID=$(bun run src/index.ts --cli videoRecording \ + --action start --platform android --outputName demo | jq -r '.recordingId') + +# Record terminal as video (macOS) +ffmpeg -f avfoundation -i "1" -t 60 terminal.mp4 & +FFMPEG_PID=$! + +# Run demo +./simulate-claude-code.sh + +# Stop both +kill $FFMPEG_PID +bun run src/index.ts --cli videoRecording --action stop --recordingId $RECORDING_ID + +# Merge +./demo/scripts/merge-videos.sh terminal.mp4 device.mp4 final.mp4 +``` + +**Pros**: Skip cast conversion +**Cons**: Platform-specific, larger files + +--- + +### Workflow 3: GIF-Only Output + +```bash +# Record terminal +asciicinema rec demo.cast --command "./simulate-claude-code.sh" + +# Record device +adb shell screenrecord /sdcard/demo.mp4 +adb pull /sdcard/demo.mp4 + +# Convert both to GIF +agg demo.cast cli.gif +ffmpeg -i demo.mp4 -vf "fps=10,scale=540:-1:flags=lanczos" device.gif + +# Merge GIFs +ffmpeg -i cli.gif -i device.gif -filter_complex "[0][1]hstack" final.gif +``` + +**Pros**: Small file size +**Cons**: Quality loss, limited FPS + +--- + +### Workflow 4: OBS Live Recording + +```bash +# Setup OBS with sources: +# - Terminal window capture +# - Emulator window capture +# - Audio input (optional) + +# Start OBS recording +obs --startrecording + +# Run demo +./simulate-claude-code.sh + +# Stop OBS recording +obs --stoprecording + +# Output is ready (no merging needed) +``` + +**Pros**: Real-time, professional quality +**Cons**: Requires OBS setup, manual control + +--- + +### Workflow 5: Post-Production Editing + +```bash +# Record everything separately +asciicinema rec terminal.cast +# ... device recording ... + +# Convert with timestamps for sync +agg terminal.cast terminal.gif +ffmpeg -i terminal.gif -pix_fmt yuv420p terminal.mp4 + +# Edit in video editor (iMovie, Final Cut, Premiere) +# - Import both videos +# - Align timing manually +# - Add transitions, text, audio +# - Export final video +``` + +**Pros**: Full control, professional edits +**Cons**: Manual work, requires video editor + +--- + +## Tool Comparison Matrix + +| Workflow | Tools | Output | Quality | Effort | Use Case | +|----------|-------|--------|---------|--------|----------| +| **Full Auto** | asciicinema, agg, ffmpeg | MP4 | High | Low | Quick demos | +| **Direct Video** | ffmpeg, AutoMobile | MP4 | High | Medium | Skip conversion | +| **GIF Only** | asciicinema, agg | GIF | Medium | Low | Documentation | +| **OBS Live** | OBS Studio | MP4 | Highest | High | Presentations | +| **Post-Production** | Video editor | MP4 | Highest | Highest | Marketing | + +## Timing Considerations + +### Synchronization Strategies + +**Option A: Start Both Simultaneously** +```bash +device_recording_start & +asciicinema rec ... & +wait +``` +- Pros: Natural sync +- Cons: Hard to coordinate + +**Option B: Device First, Then Terminal** +```bash +# Start device (captures everything) +start_device_recording +sleep 2 +# Run terminal (shorter duration) +asciicinema rec ... +# Device continues recording +stop_device_recording +# Trim device video to match terminal +``` +- Pros: Easier coordination +- Cons: Need to trim device video + +**Option C: Add Delays in Terminal** +```bash +# In simulate-claude-code.sh +show_mcp_call "launchApp" ... +sleep 3 # Wait for device to catch up +``` +- Pros: Visual sync in output +- Cons: Artificial delays + +### Trimming Videos + +```bash +# Trim device video to match terminal +ffmpeg -i device.mp4 -ss 00:00:02 -t 00:01:00 -c copy device-trimmed.mp4 + +# Trim terminal video +ffmpeg -i terminal.mp4 -ss 00:00:00 -t 00:01:00 -c copy terminal-trimmed.mp4 +``` + +## Quality Settings + +### Terminal Recording + +```bash +# asciicinema +asciicinema rec demo.cast \ + --idle-time-limit 2 # Limit idle time + --command "./script.sh" # Run specific script + +# agg (cast → gif) +agg demo.cast demo.gif \ + --cols 120 # Terminal width + --rows 30 # Terminal height + --font-size 14 # Font size + --theme monokai # Color theme + --speed 1.5 # Playback speed +``` + +### Device Recording + +```bash +# AutoMobile +--qualityPreset high # low, medium, high +--targetBitrateKbps 4000 # Custom bitrate +--fps 30 # Frame rate +--maxDuration 120 # Max seconds +``` + +### Video Encoding + +```bash +# ffmpeg quality settings +-crf 18 # Very high quality +-crf 23 # High quality (default) +-crf 28 # Lower quality +-preset ultrafast # Fast, larger files +-preset medium # Balanced (default) +-preset veryslow # Slow, smaller files +``` + +## Troubleshooting + +### Audio Out of Sync +```bash +# Add audio offset +ffmpeg -i video.mp4 -itsoffset 0.5 -i audio.mp3 \ + -c:v copy -c:a aac output.mp4 +``` + +### Different Frame Rates +```bash +# Force consistent frame rate +ffmpeg -i input.mp4 -r 30 -c:v libx264 output.mp4 +``` + +### Different Resolutions +```bash +# Scale to same height before merging +-filter_complex "[0:v]scale=-1:1080[v0];[1:v]scale=-1:1080[v1];[v0][v1]hstack" +``` + +### Videos Different Lengths +```bash +# Use shortest video duration +ffmpeg -i v1.mp4 -i v2.mp4 -filter_complex "[0][1]hstack" -shortest output.mp4 + +# Or loop shorter video +ffmpeg -stream_loop -1 -i short.mp4 -i long.mp4 \ + -filter_complex "[0][1]hstack" -shortest output.mp4 +``` + +## Advanced Techniques + +### Add Timestamp Overlay +```bash +ffmpeg -i input.mp4 \ + -vf "drawtext=text='%{pts\\:hms}':x=10:y=10:fontsize=24:fontcolor=white" \ + output.mp4 +``` + +### Add Border Between Videos +```bash +-filter_complex "[0:v]pad=iw+10:ih[left];[left][1:v]hstack" +``` + +### Fade In/Out +```bash +ffmpeg -i input.mp4 \ + -vf "fade=in:0:30,fade=out:870:30" \ + output.mp4 +``` + +### Speed Up/Slow Down +```bash +# 2x speed +ffmpeg -i input.mp4 -filter:v "setpts=0.5*PTS" output.mp4 + +# 0.5x speed (slow motion) +ffmpeg -i input.mp4 -filter:v "setpts=2.0*PTS" output.mp4 +``` diff --git a/demo/scripts/check-deps.sh b/demo/scripts/check-deps.sh new file mode 100755 index 000000000..7d756e8b0 --- /dev/null +++ b/demo/scripts/check-deps.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Dependency checker for AutoMobile demo recording + +echo "🔍 Checking Demo Recording Dependencies" +echo "========================================" +echo "" + +MISSING_DEPS=() +OPTIONAL_DEPS=() + +# Check essential tools +check_required() { + local cmd="$1" + local name="$2" + local install="$3" + + if command -v "$cmd" &> /dev/null; then + echo "✅ $name: $(command -v "$cmd")" + else + echo "❌ $name: NOT FOUND" + echo " Install: $install" + MISSING_DEPS+=("$name") + fi +} + +check_optional() { + local cmd="$1" + local name="$2" + local install="$3" + + if command -v "$cmd" &> /dev/null; then + echo "✅ $name: $(command -v "$cmd")" + else + echo "⚠️ $name: NOT FOUND (optional)" + echo " Install: $install" + OPTIONAL_DEPS+=("$name") + fi +} + +echo "Required Dependencies:" +echo "---------------------" +check_required "bun" "Bun" "curl -fsSL https://bun.sh/install | bash" +check_required "ffmpeg" "FFmpeg" "brew install ffmpeg" +check_required "ffprobe" "FFprobe" "brew install ffmpeg" +check_required "asciinema" "Asciinema" "brew install asciinema" +check_required "agg" "agg" "cargo install --git https://github.com/asciinema/agg" + +echo "" +echo "Optional Tools:" +echo "---------------" +check_optional "jq" "jq (JSON processor)" "brew install jq" +check_optional "obs" "OBS Studio" "brew install obs" + +echo "" +echo "========================================" +echo "" + +if [ ${#MISSING_DEPS[@]} -eq 0 ]; then + echo "✅ All required dependencies installed!" + echo "" + echo "Ready to record demos:" + echo " ./demo/scripts/record-demo.sh my-demo" + exit 0 +else + echo "❌ Missing ${#MISSING_DEPS[@]} required dependencies:" + printf ' - %s\n' "${MISSING_DEPS[@]}" + echo "" + echo "Install missing dependencies before recording demos." + exit 1 +fi diff --git a/demo/scripts/claude-code-simulation.sh b/demo/scripts/claude-code-simulation.sh new file mode 100755 index 000000000..b2005e092 --- /dev/null +++ b/demo/scripts/claude-code-simulation.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Shared functions for simulating Claude Code interface with AutoMobile MCP + +# Get project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" + +# Colors for terminal output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +GRAY='\033[0;90m' +RESET='\033[0m' +BOLD='\033[1m' + +# Helper function to call AutoMobile MCP tools +call_mcp_tool() { + local tool_name="$1" + shift + # Run the tool silently in the background, discarding output + bun run "$PROJECT_ROOT/src/index.ts" --cli "$tool_name" "$@" > /dev/null 2>&1 || true +} + +# Helper function for iOS simulator actions using direct simctl commands. +# This produces identical visual results to call_mcp_tool but uses direct +# simctl calls which generate display frames needed by simctl video recording. +call_ios_tool() { + local tool_name="$1" + shift + + case "$tool_name" in + launchApp) + local app_id="" + while [[ $# -gt 0 ]]; do + case "$1" in + --appId) app_id="$2"; shift 2 ;; + --platform|--clearAppData) shift 2 ;; + *) shift ;; + esac + done + xcrun simctl launch booted "$app_id" > /dev/null 2>&1 || true + ;; + terminateApp) + local app_id="" + while [[ $# -gt 0 ]]; do + case "$1" in + --appId) app_id="$2"; shift 2 ;; + --platform) shift 2 ;; + *) shift ;; + esac + done + xcrun simctl terminate booted "$app_id" > /dev/null 2>&1 || true + ;; + tapOn|swipeOn|inputText|pressButton|pressKey|observe) + # Fall back to MCP tool for interaction commands + bun run "$PROJECT_ROOT/src/index.ts" --cli "$tool_name" "$@" > /dev/null 2>&1 || true + ;; + *) + # Default: use MCP tool + bun run "$PROJECT_ROOT/src/index.ts" --cli "$tool_name" "$@" > /dev/null 2>&1 || true + ;; + esac +} + +# Helper function to show MCP tool call +show_mcp_call() { + local tool_name="$1" + local params="$2" + echo -e "${GREEN}●${RESET}${BOLD} auto-mobile - ${tool_name} (MCP)${RESET}${GRAY}${params}${RESET}" +} + +# Helper function to show thinking/progress +show_progress() { + local message="$1" + echo -e "${GRAY}● ${message}…${RESET}" +} + +# Helper function to add content (just echo for now) +add_line() { + sleep 0.1 + local line="$1" + echo -e "$line" +} + +# Helper function to replace the previous line +replace_prev_line() { + local line="$1" + # Move cursor up one line, clear it, then print new content + echo -e "\033[1A\033[2K$line" +} + +# Show header with user prompt +show_header() { + local prompt="$1" + clear + echo "──────────────────────────────────────────────────────────────────────────────────" + echo -e "${YELLOW}❯${RESET} ${prompt}" + echo "──────────────────────────────────────────────────────────────────────────────────" + echo "" +} + +# Show thinking animation +show_thinking() { + echo "" + echo -e "${GRAY}● Demoing… (ctrl+c to interrupt)${RESET}" + echo "" + sleep 0.5 +} + +# Show final ready prompt +show_footer() { + echo "" + echo "──────────────────────────────────────────────────────────────────────────────────" + echo -e "${YELLOW}❯${RESET}" + echo "──────────────────────────────────────────────────────────────────────────────────" +} diff --git a/demo/scripts/diagnose-video.sh b/demo/scripts/diagnose-video.sh new file mode 100755 index 000000000..96b7778d2 --- /dev/null +++ b/demo/scripts/diagnose-video.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Video file diagnostic tool +# Helps debug MP4 structure issues + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + echo "" + echo "Diagnoses video file issues (moov atom, codecs, etc.)" + exit 1 +fi + +VIDEO_FILE="$1" + +if [ ! -f "$VIDEO_FILE" ]; then + echo "❌ File not found: $VIDEO_FILE" + exit 1 +fi + +echo "🔍 Video File Diagnostics" +echo "========================" +echo "File: $VIDEO_FILE" +echo "" + +# File size +FILE_SIZE=$(stat -f%z "$VIDEO_FILE" 2>/dev/null || stat -c%s "$VIDEO_FILE" 2>/dev/null || echo "unknown") +echo "📦 File Size: $FILE_SIZE bytes" +echo "" + +# ffprobe analysis +echo "📊 File Analysis (ffprobe):" +echo "----------------------------" +if ffprobe -v error -show_format -show_streams "$VIDEO_FILE" 2>&1; then + echo "" + echo "✅ File is readable by ffprobe" +else + echo "" + echo "❌ File cannot be read by ffprobe" +fi +echo "" + +# Check for moov atom +echo "🔎 Checking for moov atom..." +if ffmpeg -v error -i "$VIDEO_FILE" -f null - 2>&1 | grep -q "moov atom not found"; then + echo "❌ MOOV ATOM NOT FOUND - file needs fixing" + echo "" + echo "💡 To fix:" + echo " ffmpeg -i $VIDEO_FILE -c copy -movflags +faststart fixed.mp4" +else + if ffmpeg -v error -i "$VIDEO_FILE" -t 0.1 -f null - > /dev/null 2>&1; then + echo "✅ File structure is OK" + else + echo "⚠️ File may have issues (see errors above)" + fi +fi +echo "" + +# Codec info +echo "🎬 Codec Information:" +echo "--------------------" +ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,r_frame_rate -of default=noprint_wrappers=1 "$VIDEO_FILE" 2>&1 || echo "Cannot read codec info" +echo "" diff --git a/demo/scripts/merge-videos.sh b/demo/scripts/merge-videos.sh new file mode 100755 index 000000000..6256cf1fc --- /dev/null +++ b/demo/scripts/merge-videos.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Standalone video merger for AutoMobile demos +# Merges CLI terminal recording with Android device screen recording + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_DIR="$DEMO_DIR/output" + +# Parse arguments +if [ $# -lt 2 ]; then + echo "Usage: $0 [output-video.mp4]" + echo "" + echo "Merges CLI and device videos side-by-side" + echo "" + echo "Example:" + echo " $0 cli-demo.mp4 device-demo.mp4 final-demo.mp4" + exit 1 +fi + +CLI_VIDEO="$1" +DEVICE_VIDEO="$2" +OUTPUT_VIDEO="${3:-$OUTPUT_DIR/merged-demo.mp4}" + +if [ ! -f "$CLI_VIDEO" ]; then + echo "❌ CLI video not found: $CLI_VIDEO" + exit 1 +fi + +if [ ! -f "$DEVICE_VIDEO" ]; then + echo "❌ Device video not found: $DEVICE_VIDEO" + exit 1 +fi + +echo "🎞️ Merging Videos" +echo "================================" +echo "CLI video: $CLI_VIDEO" +echo "Device video: $DEVICE_VIDEO" +echo "Output: $OUTPUT_VIDEO" +echo "" + +# Get video info +echo "📊 Analyzing videos..." +CLI_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$CLI_VIDEO") +CLI_WIDTH=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "$CLI_VIDEO") +CLI_HEIGHT=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$CLI_VIDEO") + +DEVICE_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$DEVICE_VIDEO") +DEVICE_WIDTH=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "$DEVICE_VIDEO") +DEVICE_HEIGHT=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$DEVICE_VIDEO") + +echo " CLI: ${CLI_WIDTH}x${CLI_HEIGHT} (${CLI_DURATION}s)" +echo " Device: ${DEVICE_WIDTH}x${DEVICE_HEIGHT} (${DEVICE_DURATION}s)" +echo "" + +# Calculate target dimensions +# Target: 1920x1080 total (16:9) +# CLI on left (2/3 width), device on right (1/3 width) +TARGET_HEIGHT=1080 +CLI_TARGET_WIDTH=1280 +DEVICE_TARGET_WIDTH=640 + +echo "🎬 Merging with ffmpeg..." +echo " Target layout: ${CLI_TARGET_WIDTH}x${TARGET_HEIGHT} + ${DEVICE_TARGET_WIDTH}x${TARGET_HEIGHT}" +echo "" + +# Create side-by-side video +# CLI on left (scaled to fit), device on right (scaled to fit) +# Use force_original_aspect_ratio=decrease to ensure videos fit within target dimensions +ffmpeg \ + -i "$CLI_VIDEO" \ + -i "$DEVICE_VIDEO" \ + -filter_complex "\ + [0:v]scale=${CLI_TARGET_WIDTH}:${TARGET_HEIGHT}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${CLI_TARGET_WIDTH}:${TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1[left];\ + [1:v]scale=${DEVICE_TARGET_WIDTH}:${TARGET_HEIGHT}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${DEVICE_TARGET_WIDTH}:${TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1[right];\ + [left][right]hstack=inputs=2[v]" \ + -map "[v]" \ + -c:v libx264 \ + -crf 23 \ + -preset medium \ + -pix_fmt yuv420p \ + -shortest \ + -y "$OUTPUT_VIDEO" + +echo "" +echo "✅ Merge complete!" +echo "" +echo "📁 Output: $OUTPUT_VIDEO" +echo "" +echo "🎥 Play video:" +echo " open '$OUTPUT_VIDEO'" +echo "" diff --git a/demo/scripts/record-demo-ios.sh b/demo/scripts/record-demo-ios.sh new file mode 100755 index 000000000..59ddbbdd7 --- /dev/null +++ b/demo/scripts/record-demo-ios.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +# AutoMobile + CLI Demo Recording Script (iOS) +# Records both terminal interaction and iOS simulator screen simultaneously +# Uses xcrun simctl io directly for more reliable iOS simulator recording + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_DIR="$DEMO_DIR/output" + +# Configuration +DEMO_NAME="${1:-reminders-ios}" +SCENARIO_SCRIPT="$SCRIPT_DIR/scenarios/${DEMO_NAME}.sh" +RAW_DEVICE_VIDEO="$OUTPUT_DIR/${DEMO_NAME}-device-raw.mov" +DEVICE_VIDEO="$OUTPUT_DIR/${DEMO_NAME}-device.mp4" +CLI_VIDEO="$OUTPUT_DIR/${DEMO_NAME}-cli.mp4" +FINAL_VIDEO="$OUTPUT_DIR/${DEMO_NAME}.mp4" + +# Get the booted iOS simulator device ID +DEVICE_ID=$(xcrun simctl list devices booted -j | jq -r '[.devices[][] | select(.state == "Booted")] | .[0].udid') + +echo "🎬 AutoMobile iOS Demo Recorder" +echo "================================" +echo "Demo name: $DEMO_NAME" +echo "Scenario: $SCENARIO_SCRIPT" +echo "Device: $DEVICE_ID" +echo "Output: $OUTPUT_DIR" +echo "" + +if [ "$DEVICE_ID" = "null" ] || [ -z "$DEVICE_ID" ]; then + echo "❌ Error: No booted iOS simulator found" + echo " Start one with: xcrun simctl boot " + exit 1 +fi + +# Check if scenario exists +if [ ! -f "$SCENARIO_SCRIPT" ]; then + echo "❌ Error: Scenario not found: $SCENARIO_SCRIPT" + echo "" + echo "Available scenarios:" + find "$SCRIPT_DIR/scenarios" -maxdepth 1 -name "*.sh" -type f -exec basename {} .sh \; | sed 's/^/ - /' | sort + exit 1 +fi + +# Ensure output directory exists +mkdir -p "$OUTPUT_DIR" + +# Clean previous outputs +rm -f "$RAW_DEVICE_VIDEO" "$DEVICE_VIDEO" "$CLI_VIDEO" "$FINAL_VIDEO" + +# Track the simctl recording PID so we can clean it up on exit +SIMCTL_PID="" +cleanup() { + if [ -n "$SIMCTL_PID" ] && kill -0 "$SIMCTL_PID" 2>/dev/null; then + echo "" + echo "🧹 Stopping simctl recording (PID $SIMCTL_PID)..." + kill -INT "$SIMCTL_PID" 2>/dev/null || true + wait "$SIMCTL_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# Step 1: Start iOS simulator screen recording in background +echo "📱 Starting iOS simulator screen recording..." +xcrun simctl io "$DEVICE_ID" recordVideo --codec h264 --force "$RAW_DEVICE_VIDEO" & +SIMCTL_PID=$! +# Give simctl a moment to initialize the recording +sleep 2 +echo " Recording PID: $SIMCTL_PID" +echo "" + +# Step 2: Record terminal interaction with asciinema +echo "🖥️ Recording terminal interaction..." +CAST_FILE="$OUTPUT_DIR/${DEMO_NAME}.cast" +asciinema rec "$CAST_FILE" \ + --command "$SCENARIO_SCRIPT" \ + --overwrite \ + --title "Claude Code + AutoMobile iOS Demo: ${DEMO_NAME}" \ + --idle-time-limit 2 + +echo "" + +# Step 3: Stop the iOS simulator recording (send SIGINT to simctl) +echo "📱 Stopping iOS simulator screen recording..." +kill -INT "$SIMCTL_PID" 2>/dev/null || true +wait "$SIMCTL_PID" 2>/dev/null || true +SIMCTL_PID="" # Clear so EXIT trap doesn't double-stop +sleep 1 +echo " ✓ Recording stopped" +echo "" + +# Step 4: Verify raw device video +if [ ! -f "$RAW_DEVICE_VIDEO" ]; then + echo " ❌ Error: Device video file not found at: $RAW_DEVICE_VIDEO" + exit 1 +fi + +FILE_SIZE=$(stat -f%z "$RAW_DEVICE_VIDEO" 2>/dev/null || echo "0") +echo " Raw device video: $RAW_DEVICE_VIDEO ($FILE_SIZE bytes)" + +if [ "$FILE_SIZE" -lt 1000 ]; then + echo " ❌ Error: Device video file is too small or empty!" + exit 1 +fi + +# Convert raw MOV to MP4 with proper structure +echo " Converting to MP4..." +if ffmpeg -i "$RAW_DEVICE_VIDEO" -c:v libx264 -crf 23 -preset fast -movflags +faststart -pix_fmt yuv420p -y "$DEVICE_VIDEO" > /dev/null 2>&1; then + echo " ✓ Device video ready: $DEVICE_VIDEO" +else + echo " ⚠️ H.264 encode failed, trying copy..." + ffmpeg -i "$RAW_DEVICE_VIDEO" -c copy -movflags +faststart -y "$DEVICE_VIDEO" > /dev/null 2>&1 +fi +echo "" + +# Step 5: Convert terminal recording to video +echo "📹 Converting terminal recording to video..." +CLI_GIF="$OUTPUT_DIR/${DEMO_NAME}-cli.gif" +agg "$CAST_FILE" "$CLI_GIF" --cols 100 --rows 48 --font-size 24 --theme dracula --font-family "Menlo" --renderer fontdue > /dev/null 2>&1 + +# Convert GIF to MP4 +ffmpeg -i "$CLI_GIF" -movflags faststart -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -y "$CLI_VIDEO" > /dev/null 2>&1 + +if [ ! -f "$CLI_VIDEO" ]; then + echo " ❌ Error: CLI video file not found at $CLI_VIDEO" + exit 1 +fi +echo " ✓ CLI video ready: $CLI_VIDEO" +echo "" + +# Step 6: Merge videos side-by-side +echo "🎞️ Merging videos side-by-side..." + +# Get video durations and use the shorter one +CLI_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$CLI_VIDEO") +DEVICE_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$DEVICE_VIDEO") + +# Use the shorter duration +SHORTEST_DURATION=$(python3 -c "print(min($CLI_DURATION, $DEVICE_DURATION))") +echo " CLI duration: ${CLI_DURATION}s" +echo " Device duration: ${DEVICE_DURATION}s" +echo " Using duration: ${SHORTEST_DURATION}s" + +# Terminal on left (960x1080), iOS simulator on right (540x1080) +ffmpeg \ + -i "$CLI_VIDEO" \ + -i "$DEVICE_VIDEO" \ + -filter_complex "\ + [0:v]trim=start=0:duration=${SHORTEST_DURATION},setpts=PTS-STARTPTS,scale=960:1080:force_original_aspect_ratio=decrease:flags=lanczos,pad=960:1080:(ow-iw)/2:(oh-ih)/2,setsar=1[left];\ + [1:v]trim=start=0:duration=${SHORTEST_DURATION},setpts=PTS-STARTPTS,scale=540:1080:force_original_aspect_ratio=decrease:flags=lanczos,pad=540:1080:(ow-iw)/2:(oh-ih)/2,setsar=1[right];\ + [left][right]hstack=inputs=2[v]" \ + -map "[v]" \ + -c:v libx264 \ + -crf 23 \ + -preset medium \ + -pix_fmt yuv420p \ + -y "$FINAL_VIDEO" > /dev/null 2>&1 + +echo "" +echo "✅ Demo recording complete!" +echo "" + +# Step 7: Create GIF from combined video +echo "🖼️ Creating GIF from combined video..." +FINAL_GIF="$OUTPUT_DIR/${DEMO_NAME}.gif" +ffmpeg -i "$FINAL_VIDEO" \ + -vf "fps=10,scale=1500:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \ + -loop 0 \ + -y "$FINAL_GIF" > /dev/null 2>&1 + +echo "" +echo "🎥 Play final video:" +echo " open $FINAL_VIDEO" +echo "" +echo "🎨 GIF created:" +echo " $FINAL_GIF" diff --git a/demo/scripts/record-demo.sh b/demo/scripts/record-demo.sh new file mode 100755 index 000000000..a36621bfc --- /dev/null +++ b/demo/scripts/record-demo.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +# AutoMobile + CLI Demo Recording Script +# Records both terminal interaction and Android device screen simultaneously + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_DIR="$DEMO_DIR/output" +PROJECT_ROOT="$(dirname "$DEMO_DIR")" + +# Configuration +DEMO_NAME="${1:-clock-app}" +SCENARIO_SCRIPT="$SCRIPT_DIR/scenarios/${DEMO_NAME}.sh" +DEVICE_VIDEO="$OUTPUT_DIR/${DEMO_NAME}-device.mp4" +CLI_VIDEO="$OUTPUT_DIR/${DEMO_NAME}-cli.mp4" +FINAL_VIDEO="$OUTPUT_DIR/${DEMO_NAME}.mp4" + +echo "🎬 AutoMobile Demo Recorder" +echo "================================" +echo "Demo name: $DEMO_NAME" +echo "Scenario: $SCENARIO_SCRIPT" +echo "Output dir: $OUTPUT_DIR" +echo "" + +# Check if scenario exists +if [ ! -f "$SCENARIO_SCRIPT" ]; then + echo "❌ Error: Scenario not found: $SCENARIO_SCRIPT" + echo "" + echo "Available scenarios:" + find "$SCRIPT_DIR/scenarios" -maxdepth 1 -name "*.sh" -type f -exec basename {} .sh \; | sed 's/^/ - /' | sort + exit 1 +fi + +# Clean previous outputs +rm -f "$DEVICE_VIDEO" "$CLI_VIDEO" "$FINAL_VIDEO" + +# Step 0: Stop any existing device recordings +echo "🧹 Cleaning up any existing recordings..." +bun run "$PROJECT_ROOT/src/index.ts" --cli videoRecording \ + --action stop \ + --platform android > /dev/null 2>&1 || true || echo " No existing recordings" +echo "" + +# Step 1: Start device video recording +echo "📱 Starting device screen recording..." +RECORDING_ID=$(bun run "$PROJECT_ROOT/src/index.ts" --cli videoRecording \ + --action start \ + --platform android \ + --outputName "$DEMO_NAME" \ + --qualityPreset high \ + --maxDuration 120 | jq -r '.content[0].text | fromjson | .recordings[0].recordingId') + +echo " Recording ID: $RECORDING_ID" +echo "" + +# Step 2: Record terminal interaction with asciinema +echo "🖥️ Recording terminal interaction..." +CAST_FILE="$OUTPUT_DIR/${DEMO_NAME}.cast" +asciinema rec "$CAST_FILE" \ + --command "$SCENARIO_SCRIPT" \ + --overwrite \ + --title "Claude Code + AutoMobile Demo: ${DEMO_NAME}" \ + --idle-time-limit 2 + +# Convert cast to gif with agg +# Reduced cols and increased font-size for a more square, readable output +CLI_GIF="$OUTPUT_DIR/${DEMO_NAME}-cli.gif" +agg "$CAST_FILE" "$CLI_GIF" --cols 100 --rows 48 --font-size 24 --theme dracula --font-family "Menlo" --renderer fontdue > /dev/null 2>&1 || true + +# Convert GIF to MP4 +ffmpeg -i "$CLI_GIF" -movflags faststart -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -y "$CLI_VIDEO" > /dev/null 2>&1 || true || true + +# Step 3: Stop device recording +echo "📱 Stopping device screen recording..." +STOP_RESULT=$(bun run "$PROJECT_ROOT/src/index.ts" --cli videoRecording \ + --action stop \ + --platform android \ + --recordingId "$RECORDING_ID") + +# Extract device video path +DEVICE_VIDEO_PATH=$(echo "$STOP_RESULT" | jq -r '.content[0].text | fromjson | .recordings[0].filePath') + +# Wait for file to be fully written and finalized +sleep 1 + +# Verify the file exists and is valid +if [ ! -f "$DEVICE_VIDEO_PATH" ]; then + echo " ❌ Error: Device video file not found!" + exit 1 +fi + +# Check if file is readable +FILE_SIZE=$(stat -f%z "$DEVICE_VIDEO_PATH" 2>/dev/null || echo "0") + +if [ "$FILE_SIZE" -lt 1000 ]; then + echo " ❌ Error: Device video file is too small or empty!" + exit 1 +fi + +# Fix moov atom by re-encoding (ensuring proper MP4 structure) +TEMP_VIDEO="${DEVICE_VIDEO}.temp.mp4" + +# Try to fix the MP4 structure +if ffmpeg -i "$DEVICE_VIDEO_PATH" -c copy -movflags +faststart "$TEMP_VIDEO" -y > /dev/null 2>&1; then + + mv "$TEMP_VIDEO" "$DEVICE_VIDEO" +else + + # Full re-encode as fallback + if ffmpeg -i "$DEVICE_VIDEO_PATH" -c:v libx264 -crf 23 -preset fast -movflags +faststart "$TEMP_VIDEO" -y > /dev/null 2>&1; then + + mv "$TEMP_VIDEO" "$DEVICE_VIDEO" + else + + cp "$DEVICE_VIDEO_PATH" "$DEVICE_VIDEO" + fi +fi +echo "" + +# Step 4: VHS already output MP4 directly, verify it exists +echo "📹 Verifying CLI video..." +if [ ! -f "$CLI_VIDEO" ]; then + echo " ❌ Error: CLI video file not found at $CLI_VIDEO" + exit 1 +fi +echo " ✓ CLI video ready: $CLI_VIDEO" +echo "" + +# Step 5: Merge videos side-by-side +echo "🎞️ Merging videos side-by-side..." + +# Get video durations +CLI_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$CLI_VIDEO") + +# Determine shortest duration (use CLI duration since it's typically shorter) +SHORTEST_DURATION=$CLI_DURATION + +# Scale both videos to fit within target dimensions, place side-by-side +# Use trim filter to skip initial idle time in device video and sync both videos +ffmpeg \ + -i "$CLI_VIDEO" \ + -i "$DEVICE_VIDEO" \ + -filter_complex "\ + [0:v]trim=start=0:duration=${SHORTEST_DURATION},setpts=PTS-STARTPTS,scale=960:1080:force_original_aspect_ratio=decrease:flags=lanczos,pad=960:1080:(ow-iw)/2:(oh-ih)/2,setsar=1[left];\ + [1:v]trim=start=0:duration=${SHORTEST_DURATION},setpts=PTS-STARTPTS,scale=540:1080:force_original_aspect_ratio=decrease:flags=lanczos,pad=540:1080:(ow-iw)/2:(oh-ih)/2,setsar=1[right];\ + [left][right]hstack=inputs=2[v]" \ + -map "[v]" \ + -c:v libx264 \ + -crf 23 \ + -preset medium \ + -pix_fmt yuv420p \ + -y "$FINAL_VIDEO" > /dev/null 2>&1 || true || true + +echo "" +echo "✅ Demo recording complete!" +echo "" + +# Step 6: Create GIF from combined video +echo "🖼️ Creating GIF from combined video..." +FINAL_GIF="$OUTPUT_DIR/${DEMO_NAME}.gif" +ffmpeg -i "$FINAL_VIDEO" \ + -vf "fps=10,scale=1500:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \ + -loop 0 \ + -y "$FINAL_GIF" > /dev/null 2>&1 + +echo "" +echo "🎥 Play final video:" +echo " $FINAL_VIDEO" +echo "" +echo "🎨 GIF created:" +echo " $FINAL_GIF" diff --git a/demo/scripts/record-install.sh b/demo/scripts/record-install.sh new file mode 100755 index 000000000..3d966217f --- /dev/null +++ b/demo/scripts/record-install.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Interactive Install Demo Recording Script +# Records the interactive installer using asciinema + agg + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_DIR="$DEMO_DIR/output" +PROJECT_ROOT="$(dirname "$DEMO_DIR")" + +DEMO_NAME="install" +DEMO_HOME="$DEMO_DIR/tmp-home/$DEMO_NAME" +CAST_FILE="$OUTPUT_DIR/${DEMO_NAME}.cast" +GIF_OUTPUT="$OUTPUT_DIR/${DEMO_NAME}.gif" +DOC_GIF="$PROJECT_ROOT/docs/img/${DEMO_NAME}.gif" + +echo "Recording interactive install demo" +echo "==================================" + +# Check dependencies +missing_deps=() +if ! command -v asciinema >/dev/null 2>&1; then + missing_deps+=("asciinema") +fi +if ! command -v agg >/dev/null 2>&1; then + missing_deps+=("agg") +fi + +if [[ ${#missing_deps[@]} -gt 0 ]]; then + echo "Missing dependencies: ${missing_deps[*]}" + echo "Install with: brew install ${missing_deps[*]}" + exit 1 +fi + +# Setup clean demo home directory +mkdir -p "$OUTPUT_DIR" "$DEMO_HOME" +rm -rf "${DEMO_HOME:?}"/* + +# Clean previous outputs +rm -f "$CAST_FILE" "$GIF_OUTPUT" "$DOC_GIF" + +echo "Demo home: $DEMO_HOME" +echo "Output: $GIF_OUTPUT" +echo "" + +# Record terminal with asciinema +# Use --record-mode to auto-select defaults and run the actual installation +# Use --dry-run instead to show what would happen without making changes +# Add 3 second pause at end so GIF shows completion message before looping +echo "Recording terminal session..." +asciinema rec "$CAST_FILE" \ + --overwrite \ + --title "AutoMobile Interactive Install" \ + --idle-time-limit 2 \ + --command "HOME='$DEMO_HOME' TERM=xterm-256color '$PROJECT_ROOT/scripts/install.sh' --record-mode && sleep 3" + +echo "" +echo "Converting to GIF..." + +# Convert cast to GIF with agg +# Use fontdue renderer - works better on macOS for font detection (renders monochrome emoji) +# Reduced cols and increased font-size for a more square, readable output +agg "$CAST_FILE" "$GIF_OUTPUT" \ + --cols 100 \ + --rows 34 \ + --font-size 24 \ + --theme dracula \ + --font-family "Menlo" \ + --renderer fontdue + +# Copy to docs +mkdir -p "$(dirname "$DOC_GIF")" +cp "$GIF_OUTPUT" "$DOC_GIF" + +echo "" +echo "Recording complete:" +echo " Cast: $CAST_FILE" +echo " GIF: $GIF_OUTPUT" +echo " Docs: $DOC_GIF" diff --git a/demo/scripts/record-uninstall.sh b/demo/scripts/record-uninstall.sh new file mode 100755 index 000000000..59bcfcb67 --- /dev/null +++ b/demo/scripts/record-uninstall.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Uninstall Demo Recording Script +# Records the uninstaller using asciinema + agg + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +OUTPUT_DIR="$DEMO_DIR/output" +PROJECT_ROOT="$(dirname "$DEMO_DIR")" + +DEMO_NAME="uninstall" +DEMO_HOME="$DEMO_DIR/tmp-home/$DEMO_NAME" +CAST_FILE="$OUTPUT_DIR/${DEMO_NAME}.cast" +GIF_OUTPUT="$OUTPUT_DIR/${DEMO_NAME}.gif" +DOC_GIF="$PROJECT_ROOT/docs/img/${DEMO_NAME}.gif" + +echo "Recording uninstall demo" +echo "========================" + +# Check dependencies +missing_deps=() +if ! command -v asciinema >/dev/null 2>&1; then + missing_deps+=("asciinema") +fi +if ! command -v agg >/dev/null 2>&1; then + missing_deps+=("agg") +fi + +if [[ ${#missing_deps[@]} -gt 0 ]]; then + echo "Missing dependencies: ${missing_deps[*]}" + echo "Install with: brew install ${missing_deps[*]}" + exit 1 +fi + +# Setup clean demo home directory +mkdir -p "$OUTPUT_DIR" "$DEMO_HOME" +rm -rf "${DEMO_HOME:?}"/* + +# Clean previous outputs +rm -f "$CAST_FILE" "$GIF_OUTPUT" "$DOC_GIF" + +echo "Demo home: $DEMO_HOME" +echo "Output: $GIF_OUTPUT" +echo "" + +# Record terminal with asciinema +# Use --record-mode to auto-select all components and skip confirmations +# Use --dry-run to show what would be removed without making changes +# Add 3 second pause at end so GIF shows completion message before looping +echo "Recording terminal session..." +asciinema rec "$CAST_FILE" \ + --overwrite \ + --title "AutoMobile Uninstall" \ + --idle-time-limit 2 \ + --command "HOME='$DEMO_HOME' TERM=xterm-256color '$PROJECT_ROOT/scripts/uninstall.sh' --record-mode --dry-run && sleep 3" + +echo "" +echo "Converting to GIF..." + +# Convert cast to GIF with agg +# Use fontdue renderer - works better on macOS for font detection (renders monochrome emoji) +# Reduced cols and increased font-size for a more square, readable output +agg "$CAST_FILE" "$GIF_OUTPUT" \ + --cols 100 \ + --rows 34 \ + --font-size 24 \ + --theme dracula \ + --font-family "Menlo" \ + --renderer fontdue + +# Copy to docs +mkdir -p "$(dirname "$DOC_GIF")" +cp "$GIF_OUTPUT" "$DOC_GIF" + +echo "" +echo "Recording complete:" +echo " Cast: $CAST_FILE" +echo " GIF: $GIF_OUTPUT" +echo " Docs: $DOC_GIF" diff --git a/demo/scripts/scenarios/README.md b/demo/scripts/scenarios/README.md new file mode 100644 index 000000000..d15743a03 --- /dev/null +++ b/demo/scripts/scenarios/README.md @@ -0,0 +1,93 @@ +# Demo Scenarios + +This directory contains demo scenario scripts that simulate Claude Code interacting with AutoMobile MCP. + +## Creating a New Scenario + +1. Create a new file: `.sh` +2. Source the shared functions: `source "$SCRIPT_DIR/../claude-code-simulation.sh"` +3. Use the helper functions to simulate the demo +4. Make it executable: `chmod +x .sh` + +## Example Structure + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Your demo prompt here" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll help you with..." + +# Step 1: Launch app +echo "" +add_line "●${GRAY} Launching app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.example.app\")${RESET}" +call_mcp_tool "launchApp" --appId "com.example.app" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.example.app\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App launched${RESET}" + +# More steps... + +# Summary +add_line "" +add_line "Successfully completed the task!" + +# Show footer +show_footer +``` + +## Available Helper Functions + +### `show_header ` +Shows the user prompt header at the top of the demo. + +### `show_thinking` +Shows the "Demoing..." thinking animation. + +### `show_footer` +Shows the final ready prompt at the bottom. + +### `add_line ` +Adds a line of text with a small delay for animation. + +### `replace_prev_line ` +Replaces the previous line (useful for showing completion status). + +### `call_mcp_tool [args...]` +Calls an AutoMobile MCP tool silently in the background. + +## Color Variables + +- `$BLUE` - Blue text +- `$GREEN` - Green text (success) +- `$YELLOW` - Yellow text (prompts) +- `$GRAY` - Gray text (secondary info) +- `$RESET` - Reset to default +- `$BOLD` - Bold text + +## Running a Scenario + +```bash +# Run the demo recorder with your scenario +./demo/scripts/record-demo.sh + +# Example: +./demo/scripts/record-demo.sh clock-app +``` + +## Available Scenarios + +- **clock-app** - Set a 6:30 AM alarm in the Clock app +- **slack-status** - Set Slack status to "Out sick" with expiration date (January 17th) +- **youtube-search** - Search for a video on YouTube mobile web and open it diff --git a/demo/scripts/scenarios/bug-repro.sh b/demo/scripts/scenarios/bug-repro.sh new file mode 100755 index 000000000..02290043b --- /dev/null +++ b/demo/scripts/scenarios/bug-repro.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bug Reproduction Demo - Test UI state update bug detection + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Test bug reproduction demo with UI state update detection" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll demonstrate the bug reproduction flow with state update detection." + +# Step 1: Launch app via deeplink +echo "" +add_line "●${GRAY} Opening Bug Reproduction Demo via deeplink…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - openLink (MCP)${RESET}${GRAY} (url: \"automobile://playground/demos/bugs/repro\")${RESET}" +call_mcp_tool "openLink" --url "automobile://playground/demos/bugs/repro" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - openLink (MCP)${RESET}${GRAY} (url: \"automobile://playground/demos/bugs/repro\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Bug Reproduction Demo opened${RESET}" + +# Step 2: Test normal behavior (bug disabled) +echo "" +add_line "●${GRAY} Testing normal behavior (bug disabled)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Expected: 1, Displayed: 1 (synced)${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Expected: 2, Displayed: 2 (synced)${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Expected: 3, Displayed: 3 (synced) - Normal behavior confirmed${RESET}" + +# Step 3: Enable the bug +echo "" +add_line "●${GRAY} Enabling bug toggle…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_toggle\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_toggle" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_toggle\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Bug enabled${RESET}" + +# Step 4: Reproduce the bug +echo "" +add_line "●${GRAY} Reproducing bug (expected count increments, displayed freezes)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${YELLOW}⚠${RESET}${GRAY} Expected: 4, Displayed: 3 (bug reproduced - UI frozen)${RESET}" + +# Step 4: Reproduce the bug +echo "" +add_line "●${GRAY} Found issue${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - highlight (MCP)${RESET}${GRAY} (text: \"Displayed count: 3\")${RESET}" +call_mcp_tool "highlight" --action "tap" --text "Displayed" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - highlight (MCP)${RESET}${GRAY} (text: \"Displayed count: 3\")${RESET}" +add_line "${GRAY} └ ${YELLOW}⚠${RESET}${GRAY} Expected: 4, Displayed: 3 (bug reproduced - UI frozen)${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${YELLOW}⚠${RESET}${GRAY} Expected: 5, Displayed: 3 (gap widening)${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${YELLOW}⚠${RESET}${GRAY} Expected: 6, Displayed: 3 (delta: 3)${RESET}" + +# Step 5: Reset +echo "" +add_line "●${GRAY} Testing reset functionality…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_reset\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_reset" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_reset\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Both counts reset to 0, bug toggle remains enabled${RESET}" + +# Step 6: Disable bug and verify +echo "" +add_line "●${GRAY} Disabling bug and verifying normal behavior restored…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_toggle\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_toggle" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_toggle\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Bug disabled${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "bug_repro_add" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"bug_repro_add\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Expected: 1, Displayed: 1 (normal behavior restored)${RESET}" + +# Step 7: Terminate app +echo "" +add_line "●${GRAY} Closing Playground app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +call_mcp_tool "terminateApp" --appId "dev.jasonpearson.automobile.playground" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App terminated${RESET}" + +# Summary +add_line "" +add_line "Bug reproduction verified:" +add_line " • ${GREEN}Normal mode: Counts synced (3 taps → 3 updates)${RESET}" +add_line " • ${YELLOW}Bug enabled: Counts diverged (6 expected vs 3 displayed)${RESET}" +add_line " • ${GREEN}Reset: Cleared all counts successfully${RESET}" +add_line " • ${GREEN}Post-fix: Normal behavior restored${RESET}" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/camera-gallery.sh b/demo/scripts/scenarios/camera-gallery.sh new file mode 100755 index 000000000..73205717e --- /dev/null +++ b/demo/scripts/scenarios/camera-gallery.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Camera + Gallery Demo - Take a photo, edit it, and share via Quick Share + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Take a photo, edit it, and share via Quick Share" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll help you explore the Camera app, take a picture, edit it, and share via Quick Share." + +# Step 1: Launch Camera app +echo "" +add_line "●${GRAY} Launching Camera app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.android.camera2\", coldBoot: true)${RESET}" +call_mcp_tool "launchApp" --appId "com.android.camera2" --coldBoot true --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.android.camera2\", coldBoot: true)${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Camera app launched${RESET}" + +# Step 2: Explore camera settings +echo "" +add_line "●${GRAY} Opening camera settings…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"mode_options_toggle\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.camera2:id/mode_options_toggle" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"mode_options_toggle\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Settings menu opened (showing countdown, grid lines, HDR, flash options)${RESET}" + +# Step 3: Take a picture +echo "" +add_line "●${GRAY} Taking a picture…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"shutter_button\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.camera2:id/shutter_button" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"shutter_button\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Picture captured${RESET}" + +# Step 4: Open gallery +echo "" +add_line "●${GRAY} Opening gallery to view photo…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"rounded_thumbnail_view\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.camera2:id/rounded_thumbnail_view" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"rounded_thumbnail_view\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Gallery opened with photo${RESET}" + +# Step 5: Edit photo +echo "" +add_line "●${GRAY} Opening photo editor…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"filmstrip_bottom_control_edit\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.camera2:id/filmstrip_bottom_control_edit" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"filmstrip_bottom_control_edit\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Intent chooser appeared${RESET}" + +# Step 6: Select Markup editor +echo "" +add_line "●${GRAY} Selecting Markup editor…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Markup\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Markup"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Markup\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Markup selected${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"android:id/button_once\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "android:id/button_once" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"android:id/button_once\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Markup editor opened with Crop, Text, Pen, Highlighter, Eraser tools${RESET}" + +# Step 7: Interact with crop tool +echo "" +add_line "●${GRAY} Using crop tool…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Top boundary 0 percent\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Top boundary 0 percent"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Top boundary 0 percent\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Crop boundaries adjusted${RESET}" + +# Step 8: Save edited photo +echo "" +add_line "●${GRAY} Saving edited photo…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.google.android.markup:id/save\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.google.android.markup:id/save" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.google.android.markup:id/save\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Photo saved and returned to gallery${RESET}" + +# Step 9: Share photo +echo "" +add_line "●${GRAY} Opening share menu…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.android.camera2:id/filmstrip_bottom_control_share\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.camera2:id/filmstrip_bottom_control_share" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.android.camera2:id/filmstrip_bottom_control_share\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Share sheet opened with Quick Share, Maps, Messages, Print, Drive options${RESET}" + +# Step 10: Observe Quick Share (navigation to Quick Share would require coordinate tap workaround) +echo "" +add_line "●${GRAY} Observing share options…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - observe (MCP)${RESET}${GRAY} (platform: \"android\")${RESET}" +call_mcp_tool "observe" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - observe (MCP)${RESET}${GRAY} (platform: \"android\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Quick Share available in share menu${RESET}" + +# Step 11: Go back to home +echo "" +add_line "●${GRAY} Returning to home screen…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - pressButton (MCP)${RESET}${GRAY} (button: \"back\")${RESET}" +call_mcp_tool "pressButton" --button "back" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - pressButton (MCP)${RESET}${GRAY} (button: \"back\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Back to gallery${RESET}" + +# Step 12: Terminate Camera app +echo "" +add_line "●${GRAY} Closing Camera app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.android.camera2\")${RESET}" +call_mcp_tool "terminateApp" --appId "com.android.camera2" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.android.camera2\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Camera app terminated${RESET}" + +# Summary +add_line "" +add_line "Successfully explored Camera app features:" +add_line " • Opened camera settings (countdown, grid, HDR, flash)" +add_line " • Captured a photo" +add_line " • Viewed photo in gallery" +add_line " • Edited photo using Markup (crop tool)" +add_line " • Accessed share menu with Quick Share option" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/clock-app.sh b/demo/scripts/scenarios/clock-app.sh new file mode 100755 index 000000000..87ed2cf3c --- /dev/null +++ b/demo/scripts/scenarios/clock-app.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Clock App Demo - Set a 6:30 AM alarm + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Set a 6:30 AM alarm in the Clock app" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll help you set a 6:30 AM alarm in the Clock app." + +# Step 1: Launch Clock app +echo "" +add_line "●${GRAY} Launching Clock app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.deskclock\", clearAppData: true)${RESET}" +call_mcp_tool "launchApp" --appId "com.google.android.deskclock" --clearAppData true --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.deskclock\", clearAppData: true)${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App launched${RESET}" + +# Step 2: Tap Alarm tab +echo "" +add_line "●${RESET}${GRAY} Opening Alarm section…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Alarm\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Alarm"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Alarm\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Alarm tab opened${RESET}" + +# Step 3: Tap add alarm button +echo "" +add_line "●${GRAY} Creating new alarm…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.google.android.deskclock:id/fab\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.google.android.deskclock:id/fab" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.google.android.deskclock:id/fab\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Alarm creation dialog opened${RESET}" + +# Step 4: Tap 6 for hours +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"6\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"6"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"6\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Selected 6${RESET}" + +# Step 5: Tap 30 minutes +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"30\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"30"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"30\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Time set to 6:30 AM${RESET}" + +# Step 6: Confirm alarm +echo "" +add_line "●${GRAY} Confirming alarm…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"OK\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"OK"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"OK\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Alarm saved${RESET}" + +# Step 7: Terminate app +echo "" +add_line "●${GRAY} Closing Clock app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.deskclock\")${RESET}" +call_mcp_tool "terminateApp" --appId "com.google.android.deskclock" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.deskclock\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App terminated${RESET}" + +# Summary +add_line "" +add_line "Successfully created a 6:30 AM alarm in the Clock app!" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/deeplink-startup.sh b/demo/scripts/scenarios/deeplink-startup.sh new file mode 100755 index 000000000..e5a0b29e8 --- /dev/null +++ b/demo/scripts/scenarios/deeplink-startup.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deep Link Startup Demo - Launch app via deeplink and measure startup performance + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Launch Playground app via deeplink to Startup Demo and measure performance" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll terminate the app, launch via deeplink, and measure startup performance." + +# Step 1: Terminate app for cold start +echo "" +add_line "●${GRAY} Terminating app for cold start…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +call_mcp_tool "terminateApp" --appId "dev.jasonpearson.automobile.playground" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App terminated${RESET}" + +# Step 2: Open deeplink to startup demo +echo "" +add_line "●${GRAY} Opening deeplink to Startup Demo…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - openLink (MCP)${RESET}${GRAY} (url: \"automobile://playground/demos/perf/startup\")${RESET}" +call_mcp_tool "openLink" --url "automobile://playground/demos/perf/startup" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - openLink (MCP)${RESET}${GRAY} (url: \"automobile://playground/demos/perf/startup\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Deeplink opened (28ms intent, 65ms stability, 378ms observe)${RESET}" + +# Step 3: Observe app state (workaround for issue #537) +echo "" +add_line "●${GRAY} Observing app state after launch…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - observe (MCP)${RESET}${GRAY} (platform: \"android\")${RESET}" +call_mcp_tool "observe" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - observe (MCP)${RESET}${GRAY} (platform: \"android\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App at DemoStartupDestination with 'Ready' signal${RESET}" + +# Step 4: Terminate app +echo "" +add_line "●${GRAY} Closing Playground app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +call_mcp_tool "terminateApp" --appId "dev.jasonpearson.automobile.playground" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App terminated${RESET}" + +# Summary +add_line "" +add_line "Startup performance measured:" +add_line " • ${GREEN}Cold start to Ready state: ~100ms${RESET}" +add_line " • ${GREEN}60 FPS rendering: 16ms median frame time${RESET}" +add_line " • ${GREEN}Deep link navigation working correctly${RESET}" +add_line " • ${YELLOW}Note: openLink observation may be stale (issue #537)${RESET}" +add_line " • ${GREEN}Workaround: Use observe() after openLink${RESET}" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/google-maps.sh b/demo/scripts/scenarios/google-maps.sh new file mode 100755 index 000000000..518350f82 --- /dev/null +++ b/demo/scripts/scenarios/google-maps.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Google Maps Demo - Find NYC and zoom to Manhattan + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Open Google Maps, find New York City, and zoom in until only Manhattan is visible" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll help you navigate to New York City in Google Maps and zoom to Manhattan." + + + +# ⏺ auto-mobile - launchApp (MCP)(appId: "com.google.android.apps.maps") + +# ⏺ auto-mobile - tapOn (MCP)(platform: "android", action: "tap", id: +# "com.google.android.apps.maps:id/search_omnibox_text_box") + +# ⏺ auto-mobile - inputText (MCP)(platform: "android", text: "New York City", imeAction: "search") + +# ⏺ auto-mobile - tapOn (MCP)(platform: "android", action: "tap", text: "Search") + +# ⏺ auto-mobile - pinchOn (MCP)(platform: "android", direction: "out", autoTarget: true, +# duration: 500) + +# ⏺ auto-mobile - pinchOn (MCP)(platform: "android", direction: "out", autoTarget: true, duration: 500) + +# ⏺ auto-mobile - pinchOn (MCP)(platform: "android", direction: "out", autoTarget: true, +# duration: 500) + +# Step 1: Launch Google Maps +echo "" +add_line "●${GRAY} Launching Google Maps…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.apps.maps\")${RESET}" +call_mcp_tool "launchApp" --appId "com.google.android.apps.maps" --coldBoot true --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.apps.maps\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Google Maps launched${RESET}" + +# Step 2: Pinch out to zoom out and see more area +echo "" +add_line "●${GRAY} Zooming out to find New York City…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - pinchOn (MCP)${RESET}${GRAY} (direction: \"in\", scale: 0.3)${RESET}" +call_mcp_tool "pinchOn" --direction "in" --scale '"0.3"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - pinchOn (MCP)${RESET}${GRAY} (direction: \"in\", scale: 0.3)${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Zoomed out${RESET}" + +# Step 3: Search for New York City +echo "" +add_line "●${GRAY} Searching for New York City…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Search here\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Search here"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Search here\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Search activated${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"New York City\")${RESET}" +call_mcp_tool "inputText" --text '"New York City"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"New York City\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Text entered${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - imeAction (MCP)${RESET}${GRAY} (action: \"search\")${RESET}" +call_mcp_tool "imeAction" --action "search" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - imeAction (MCP)${RESET}${GRAY} (action: \"search\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Search submitted${RESET}" + +# Step 4: Zoom in to Manhattan +echo "" +add_line "●${GRAY} Zooming in to Manhattan…${RESET}" + +sleep 3 + +echo "" +add_line "${BOLD}● auto-mobile - pinchOn (MCP)${RESET}${GRAY} (direction: \"out\", scale: 2.0)${RESET}" +call_mcp_tool "pinchOn" --direction "out" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - pinchOn (MCP)${RESET}${GRAY} (direction: \"out\", scale: 2.0)${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Manhattan is now visible${RESET}" + +# Summary +add_line "" +add_line "Successfully navigated to New York City and zoomed to Manhattan view!" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/reminders-ios.sh b/demo/scripts/scenarios/reminders-ios.sh new file mode 100755 index 000000000..f5c60c03d --- /dev/null +++ b/demo/scripts/scenarios/reminders-ios.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reminders iOS Demo - Open and close the Reminders app + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Open the Reminders app on iOS and then close it" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll open the Reminders app on the iOS simulator and then close it." + +# Step 1: Launch Reminders app +echo "" +add_line "●${GRAY} Launching Reminders app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.apple.reminders\")${RESET}" +call_ios_tool "launchApp" --appId "com.apple.reminders" --platform "ios" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.apple.reminders\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App launched${RESET}" + +# Pause to let the user see the app +sleep 2 + +# Step 2: Terminate Reminders app +echo "" +add_line "●${GRAY} Closing Reminders app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.apple.reminders\")${RESET}" +call_ios_tool "terminateApp" --appId "com.apple.reminders" --platform "ios" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.apple.reminders\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App terminated${RESET}" + +# Summary +add_line "" +add_line "Successfully opened and closed the Reminders app on iOS!" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/scroll-transition-perf.sh b/demo/scripts/scenarios/scroll-transition-perf.sh new file mode 100755 index 000000000..1f4f47d7b --- /dev/null +++ b/demo/scripts/scenarios/scroll-transition-perf.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Scroll & Transition Performance Demo - Test scroll performance and transitions + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Test scroll performance and list-to-detail transitions in Performance List Demo" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll test scroll performance with alternating directions and rapid transitions." + +# Step 1: Launch app via deeplink +echo "" +add_line "●${GRAY} Opening Performance List Demo via deeplink…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - openLink (MCP)${RESET}${GRAY} (url: \"automobile://playground/demos/perf/list\")${RESET}" +call_mcp_tool "openLink" --url "automobile://playground/demos/perf/list" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - openLink (MCP)${RESET}${GRAY} (url: \"automobile://playground/demos/perf/list\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Performance List Demo opened${RESET}" + +# Step 2: Swipe UP (fast) +echo "" +add_line "●${GRAY} Testing scroll performance (UP direction)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"fast\", container.elementId: \"performance_list\")${RESET}" +call_mcp_tool "swipeOn" --direction "up" --speed "fast" --container '{"elementId":"performance_list"}' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"fast\", container.elementId: \"performance_list\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Scrolled up - 16ms median frame time (60 FPS)${RESET}" + +# Step 3: Swipe DOWN (fast) +echo "" +add_line "●${GRAY} Testing scroll performance (DOWN direction)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"down\", speed: \"fast\", container.elementId: \"performance_list\")${RESET}" +call_mcp_tool "swipeOn" --direction "down" --speed "fast" --container '{"elementId":"performance_list"}' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"down\", speed: \"fast\", container.elementId: \"performance_list\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Scrolled down - 4950ms frame time (measurement ceiling)${RESET}" + +# Step 4: Swipe UP again +echo "" +add_line "●${GRAY} Testing scroll performance (UP direction)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"fast\")${RESET}" +call_mcp_tool "swipeOn" --direction "up" --speed "fast" --container '{"elementId":"performance_list"}' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"fast\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Scrolled up - 16ms median frame time (60 FPS)${RESET}" + +# Step 5: Swipe DOWN again +echo "" +add_line "●${GRAY} Testing scroll performance (DOWN direction)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"down\", speed: \"fast\")${RESET}" +call_mcp_tool "swipeOn" --direction "down" --speed "fast" --container '{"elementId":"performance_list"}' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"down\", speed: \"fast\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Scrolled down - 4950ms frame time (measurement ceiling)${RESET}" + +# Step 6: Swipe UP final +echo "" +add_line "●${GRAY} Testing scroll performance (UP direction)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"fast\")${RESET}" +call_mcp_tool "swipeOn" --direction "up" --speed "fast" --container '{"elementId":"performance_list"}' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"fast\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Scrolled up - 16ms median frame time (60 FPS)${RESET}" + +# Step 11: Scroll and tap another item +echo "" +add_line "●${GRAY} Scrolling to find Product 87…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"normal\", lookFor.text: \"Product 80\")${RESET}" +call_mcp_tool "swipeOn" --direction "up" --speed "normal" --lookFor '{"text":"Product 80"}' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - swipeOn (MCP)${RESET}${GRAY} (direction: \"up\", speed: \"normal\", lookFor.text: \"Product 80\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Found Product 80${RESET}" + +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"performance_item_80_action\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "performance_item_80_action" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"performance_item_80_action\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Navigated to Product 80 detail${RESET}" + +# Step 12: Terminate app +echo "" +add_line "●${GRAY} Closing Playground app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +call_mcp_tool "terminateApp" --appId "dev.jasonpearson.automobile.playground" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"dev.jasonpearson.automobile.playground\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App terminated${RESET}" + +# Summary +add_line "" +add_line "Performance findings:" +add_line " • ${GREEN}Forward scrolling (UP): 16ms median (60 FPS), stable${RESET}" +add_line " • ${YELLOW}Backward scrolling (DOWN): 4950ms (measurement ceiling)${RESET}" +add_line " • ${GREEN}List-to-detail transitions: ~20ms frames, ~550ms total${RESET}" +add_line " • ${GREEN}Rapid navigation maintains performance${RESET}" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/slack-status.sh b/demo/scripts/scenarios/slack-status.sh new file mode 100755 index 000000000..9811e48ae --- /dev/null +++ b/demo/scripts/scenarios/slack-status.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Slack Status Demo - Set status to "🔥 I'm awesome" + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Set my Slack status to :fire: I'm awesome" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll help you set your Slack status to \"🔥 I'm awesome\"." + +# Step 1: Launch Slack +echo "" +add_line "●${GRAY} Opening Slack…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.Slack.internal.debug\", coldBoot: true)${RESET}" +call_mcp_tool "launchApp" --appId "com.Slack.internal.debug" --coldBoot true --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.Slack.internal.debug\", coldBoot: true)${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Slack launched${RESET}" + +# Step 2: Navigate to profile/You screen +# Note: Profile avatar shows "Online" or "Away" depending on user status - try both +echo "" +add_line "●${GRAY} Opening profile screen…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", profile avatar)${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Online"' --platform "android" +call_mcp_tool "tapOn" --action "tap" --text '"Away"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", profile avatar)${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Profile screen opened${RESET}" + +# Step 3: Navigate to status screen +echo "" +add_line "●${GRAY} Opening status settings…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Update your status\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Update your status"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Update your status\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Status screen opened${RESET}" + +# Step 4: Open emoji picker +echo "" +add_line "●${GRAY} Opening emoji picker…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.Slack.internal.debug:id/status_emoji_picker_btn\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.Slack.internal.debug:id/status_emoji_picker_btn" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.Slack.internal.debug:id/status_emoji_picker_btn\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Emoji picker opened${RESET}" + +# Step 5: Select fire emoji +echo "" +add_line "●${GRAY} Selecting fire emoji…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"fire\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"fire"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"fire\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Fire emoji selected${RESET}" + +# Step 6: Focus on status text field +echo "" +add_line "●${GRAY} Focusing on status text field…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.Slack.internal.debug:id/set_status_field\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.Slack.internal.debug:id/set_status_field" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", elementId: \"com.Slack.internal.debug:id/set_status_field\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Text field focused${RESET}" + +# Step 7: Enter status text +echo "" +add_line "●${GRAY} Entering status text…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"I'm awesome\")${RESET}" +call_mcp_tool "inputText" --text '"I'"'"'m awesome"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"I'm awesome\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Status text entered${RESET}" + +# Step 8: Close keyboard +echo "" +add_line "●${GRAY} Closing keyboard…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - keyboard (MCP)${RESET}${GRAY} (action: \"close\")${RESET}" +call_mcp_tool "keyboard" --action "close" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - keyboard (MCP)${RESET}${GRAY} (action: \"close\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Keyboard closed${RESET}" + +# Step 9: Save status +echo "" +add_line "●${GRAY} Saving status…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Save\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Save"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Save\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Status saved${RESET}" + +# Summary +add_line "" +add_line "Successfully set Slack status:" +add_line " • ${GREEN}Status: 🔥 I'm awesome${RESET}" +add_line " • ${GREEN}Emoji selected via Slack's emoji picker${RESET}" +add_line " • ${GREEN}Custom text entered${RESET}" + +# Show footer +show_footer diff --git a/demo/scripts/scenarios/youtube-search.sh b/demo/scripts/scenarios/youtube-search.sh new file mode 100755 index 000000000..0b646b3fb --- /dev/null +++ b/demo/scripts/scenarios/youtube-search.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +# YouTube Search Demo - Search for a video on YouTube mobile web + +# Source shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../claude-code-simulation.sh" + +# Show header +show_header "Search for 'Droidcon NYC 2025 AutoMobile' on YouTube mobile web and open the video" + +# Show thinking animation +show_thinking + +# AI response start +add_line "I'll navigate to YouTube mobile web, search for the video, and open it." + +# Step 12: Terminate Chrome +echo "" +add_line "●${GRAY} Closing Chrome…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.android.chrome\")${RESET}" +call_mcp_tool "terminateApp" --appId "com.android.chrome" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - terminateApp (MCP)${RESET}${GRAY} (appId: \"com.android.chrome\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Chrome terminated${RESET}" + +# Step 1: Launch Chrome +echo "" +add_line "●${GRAY} Launching Chrome browser…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.android.chrome\")${RESET}" +call_mcp_tool "launchApp" --appId "com.android.chrome" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.android.chrome\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Chrome launched${RESET}" + +# Step 2: Open tab switcher +echo "" +add_line "●${GRAY} Opening tab switcher to clean up…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/tab_switcher_button\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.chrome:id/tab_switcher_button" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/tab_switcher_button\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Tab switcher opened${RESET}" + +# Step 3: Open menu +echo "" +add_line "●${GRAY} Opening menu…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/menu_button\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.chrome:id/menu_button" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/menu_button\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Menu opened${RESET}" + +# Step 4: Close all tabs +echo "" +add_line "●${GRAY} Closing all tabs…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/close_all_tabs_menu_id\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.chrome:id/close_all_tabs_menu_id" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/close_all_tabs_menu_id\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Close confirmation dialog appeared${RESET}" + +# Step 5: Confirm closing all tabs +echo "" +add_line "●${GRAY} Confirming close all tabs…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/positive_button\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.chrome:id/positive_button" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/positive_button\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} All tabs closed${RESET}" + +# Step 6: Create new tab +echo "" +add_line "●${GRAY} Creating new tab…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/new_tab_view\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.chrome:id/new_tab_view" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/new_tab_view\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} New tab created with clean state${RESET}" + +# Step 7: Navigate to YouTube +echo "" +add_line "●${GRAY} Tapping search box text…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/search_box_text\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.android.chrome:id/search_box_text" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.android.chrome:id/search_box_text\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Search box focused${RESET}" + +sleep 3 + +echo "" +add_line "${BOLD}● auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"https://m.youtube.com\", imeAction: \"go\")${RESET}" +call_mcp_tool "inputText" --text '"https://m.youtube.com"' --imeAction "go" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"m.youtube.com\", imeAction: \"go\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Navigated to YouTube${RESET}" + +sleep 3 + +# Step 8: Search on YouTube +echo "" +add_line "●${GRAY} Searching for video…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Search YouTube\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Search YouTube"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Search YouTube\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Search activated${RESET}" + +sleep 3 + +echo "" +add_line "${BOLD}● auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"Droidcon NYC 2025 AutoMobile\", imeAction: \"go\")${RESET}" +call_mcp_tool "inputText" --text '"Droidcon NYC 2025 AutoMobile"' --imeAction "go" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - inputText (MCP)${RESET}${GRAY} (text: \"Droidcon NYC 2025 AutoMobile\", imeAction: \"go\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Search query entered${RESET}" + +sleep 4 + +# Step 11: Open the video +echo "" +add_line "●${GRAY} Opening video…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"AutoMobile - Jason Pearson...\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"AutoMobile - Jason Pearson | droidcon New York 2025"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"AutoMobile - Jason Pearson...\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Video opened${RESET}" + +sleep 6 + +# Step 11: Open the video +echo "" +add_line "●${GRAY} Skipping ad (if any)…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"Skip\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"Skip"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"AutoMobile - Jason Pearson...\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Skipped ads${RESET}" + +sleep 1 + +# Summary +add_line "" +add_line "Successfully demonstrated:" +add_line " • ${GREEN}Chrome tab cleanup for consistent runs${RESET}" +add_line " • ${GREEN}WebView accessibility tree interaction${RESET}" +add_line " • ${GREEN}YouTube mobile web navigation and search${RESET}" +add_line " • ${GREEN}Video search results parsing and selection${RESET}" +add_line " • ${GREEN}Consistent 60 FPS performance (17ms frames)${RESET}" + +# Show footer +show_footer diff --git a/demo/scripts/simulate-claude-code.sh b/demo/scripts/simulate-claude-code.sh new file mode 100755 index 000000000..9f5dec021 --- /dev/null +++ b/demo/scripts/simulate-claude-code.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Simulates AI CLI interacting with AutoMobile MCP server + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" + +# Colors for terminal output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +GRAY='\033[0;90m' +RESET='\033[0m' +BOLD='\033[1m' + +# Helper function to call AutoMobile MCP tools +call_mcp_tool() { + local tool_name="$1" + shift + # Run the tool silently in the background, discarding output + # echo "bun run $PROJECT_ROOT/src/index.ts --cli $tool_name $@" + bun run "$PROJECT_ROOT/src/index.ts" --cli "$tool_name" "$@" > /dev/null 2>&1 || true +} + +# Helper function to show MCP tool call +show_mcp_call() { + local tool_name="$1" + local params="$2" + echo -e "${GREEN}●${RESET}${BOLD} auto-mobile - ${tool_name} (MCP)${RESET}${GRAY}${params}${RESET}" +} + +# Helper function to show thinking/progress +show_progress() { + local message="$1" + echo -e "${GRAY}● ${message}…${RESET}" +} + +# Helper function to add content (just echo for now) +add_line() { + sleep 0.1 + local line="$1" + echo -e "$line" +} + +# Helper function to replace the previous line +replace_prev_line() { + local line="$1" + # Move cursor up one line, clear it, then print new content + echo -e "\033[1A\033[2K$line" +} + +# Clear screen and show header +clear +echo "──────────────────────────────────────────────────────────────────────────────────" +echo -e "${YELLOW}❯${RESET} Set a 6:30 AM alarm in the Clock app" +echo "──────────────────────────────────────────────────────────────────────────────────" +echo "" + +# Show thinking animation +echo "" +echo -e "${GRAY}● Demoing… (ctrl+c to interrupt)${RESET}" +echo "" +sleep 0.5 + +# AI response start +add_line "I'll help you set a 6:30 AM alarm in the Clock app." + +# Step 4: Tap Alarm tab +echo "" +add_line "●${GRAY} Launching Clock app…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.deskclock:id/tab_menu_alarm\")${RESET}" +call_mcp_tool "launchApp" --appId "com.google.android.deskclock" --clearAppData true --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - launchApp (MCP)${RESET}${GRAY} (appId: \"com.google.android.deskclock:id/tab_menu_alarm\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} App launched${RESET}" + +# Step 4: Tap Alarm tab +echo "" +add_line "●${RESET}${GRAY} Opening Alarm section…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.google.android.deskclock:id/tab_menu_alarm\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.google.android.deskclock:id/tab_menu_alarm" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.google.android.deskclock:id/tab_menu_alarm\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Alarm tab opened${RESET}" + +# Step 5: Tap add alarm button +echo "" +add_line "●${GRAY} Creating new alarm…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.google.android.deskclock:id/fab\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --id "com.google.android.deskclock:id/fab" --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", id: \"com.google.android.deskclock:id/fab\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Alarm creation dialog opened${RESET}" + +# Step 6: Select hour (6) +echo "" +add_line "●${GRAY} Setting hour to 6…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"6\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"6"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"6\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Hour set to 6${RESET}" + +# Step 8: Select minutes (30) +echo "" +add_line "●${GRAY} Setting minutes to 30…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"30\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"30"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"30\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Minutes set to 30${RESET}" + +# Step 8: Select minutes (30) +echo "" +add_line "●${GRAY} Setting PM…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"PM\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"PM"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"PM\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Set PM${RESET}" + +# Step 9: Confirm alarm +echo "" +add_line "●${GRAY} Confirming alarm time…${RESET}" +echo "" +add_line "${BOLD}● auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"OK\")${RESET}" +call_mcp_tool "tapOn" --action "tap" --text '"OK"' --platform "android" +replace_prev_line "${GREEN}●${RESET}${BOLD} auto-mobile - tapOn (MCP)${RESET}${GRAY} (action: \"tap\", text: \"OK\")${RESET}" +add_line "${GRAY} └ ${GREEN}✓${RESET}${GRAY} Alarm saved${RESET}" + +# Summary +add_line "" +add_line "Successfully created a 6:30 AM alarm in the Clock app!" + +# Show final ready prompt +echo "" +echo "──────────────────────────────────────────────────────────────────────────────────" +echo -e "${YELLOW}❯${RESET}" +echo "──────────────────────────────────────────────────────────────────────────────────" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..6ad65afad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +services: + auto-mobile: + build: + context: . + dockerfile: Dockerfile + image: auto-mobile:latest + container_name: auto-mobile + + # Security: Use specific capabilities instead of privileged mode + # ADB USB device access requires the ability to create device nodes (mknod) + # Docker provides CAP_MKNOD by default, but we explicitly add it for clarity + cap_add: + - MKNOD # Required for creating device nodes in /dev + + # Grant access to USB devices without privileged mode + # Major number 189 is for USB devices + # 'c' = character device, '*' = all minor numbers, 'rwm' = read/write/mknod + device_cgroup_rules: + - 'c 189:* rwm' + + # Environment variables + environment: + - ANDROID_HOME=/opt/android-sdk + - ANDROID_SDK_ROOT=/opt/android-sdk + - NODE_ENV=development + + # Mount volumes + volumes: + # Mount source code for development (comment out for production) + - .:/workspace + # Persist Android SDK + - android-sdk:/opt/android-sdk + # Persist node modules + - node-modules:/workspace/node_modules + # Mount ADB keys + - ~/.android:/home/automobile/.android + # Mount AutoMobile database directory + - ~/.auto-mobile:/home/automobile/.auto-mobile + # Mount scratch directory for outputs + - ./scratch:/workspace/scratch + # Mount USB device directory for ADB access to physical devices + - /dev/bus/usb:/dev/bus/usb + + # Security Note: host networking bypasses Docker's network isolation + # Required for ADB to communicate with devices on the local network + # Alternative: Use bridge networking with appropriate port mappings if only + # connecting to network-attached devices via TCP/IP (adb connect) + network_mode: host + + # Keep container running + stdin_open: true + tty: true + + # Working directory + working_dir: /workspace + + # Override default command for different transport mode + # Default Dockerfile CMD is: node dist/src/index.js (stdio mode) + # Override here for streamable mode + command: ["npm", "start"] + + # Development service with auto-reload + auto-mobile-dev: + build: + context: . + dockerfile: Dockerfile + image: auto-mobile:latest + container_name: auto-mobile-dev + + # Security: Use specific capabilities instead of privileged mode + # ADB USB device access requires the ability to create device nodes (mknod) + # Docker provides CAP_MKNOD by default, but we explicitly add it for clarity + cap_add: + - MKNOD # Required for creating device nodes in /dev + + # Grant access to USB devices without privileged mode + # Major number 189 is for USB devices + # 'c' = character device, '*' = all minor numbers, 'rwm' = read/write/mknod + device_cgroup_rules: + - 'c 189:* rwm' + + environment: + - ANDROID_HOME=/opt/android-sdk + - ANDROID_SDK_ROOT=/opt/android-sdk + - NODE_ENV=development + + volumes: + - .:/workspace + - android-sdk:/opt/android-sdk + - node-modules:/workspace/node_modules + - ~/.android:/home/automobile/.android + - ~/.auto-mobile:/home/automobile/.auto-mobile + - ./scratch:/workspace/scratch + # Mount USB device directory for ADB access to physical devices + - /dev/bus/usb:/dev/bus/usb + + # Security Note: host networking bypasses Docker's network isolation + # Required for ADB to communicate with devices on the local network + # Alternative: Use bridge networking with appropriate port mappings if only + # connecting to network-attached devices via TCP/IP (adb connect) + network_mode: host + stdin_open: true + tty: true + working_dir: /workspace + + # Run in development mode with auto-reload + command: ["npm", "run", "dev"] + +volumes: + android-sdk: + name: auto-mobile-android-sdk + node-modules: + name: auto-mobile-node-modules diff --git a/docs/ai/validation.md b/docs/ai/validation.md deleted file mode 100644 index 796292cca..000000000 --- a/docs/ai/validation.md +++ /dev/null @@ -1,22 +0,0 @@ -# Project Validation - -This document provides instructions for AI agents to validate the Node TypeScript AutoMobile project -builds correctly and all tests pass. After writing some implementation you should select the most relevant checks given -the changes made. At no point should we be writing any JavaScript. - -```bash -# Compile main source code -npm run build - -# Run lint with automatic fixes - do this first before attempting to fix lint errors via editing -npm run lint - -# Run all tests -npm run test - -# Run specific tests -npm run test -- --grep "Name of the test suite or test case" - -# Reinstall MCP server -npm install -``` diff --git a/docs/contributing/local-development.md b/docs/contributing/local-development.md deleted file mode 100644 index 007fabeef..000000000 --- a/docs/contributing/local-development.md +++ /dev/null @@ -1,51 +0,0 @@ -# Running from Source - -Whether you want to contribute to AutoMobile or just want to run the MCP directly from source, this guide will set you -up for the development environment maintainers use. - -## Build from Source - -If you're about to build AutoMobile from source for the very first time after cloning you should do the following: - -```shell -npm install -npm run build -npm install -g -``` - -## Hot Reload - -AutoMobile supports multiple transport modes but the only supported use case right now is streamable transport over the -`mcp-remote` npm package. This allows decoupling of the MCP server process from the STDIO interface to continually -recompile code as changes are made. - -### Options - -**Streamable HTTP (Recommended)** - Modern MCP transport with full streaming support: -```shell -# Start with hot reloading (ts-node-dev), streamable is the default -npm run dev -npm run dev:streamable - -# Custom port -npm run dev:port 8080 -``` - -Configuration for your favorite MCP client: - -```json -{ - "mcpServers": { - "AutoMobile": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "http://localhost:9000/auto-mobile/streamable" - ] - } - } -} -``` - -![firebender-mcp-server-setup.png](../img/firebender-mcp-server-setup-dev.png) diff --git a/docs/contributing/overview.md b/docs/contributing/overview.md deleted file mode 100644 index 2d28a1ade..000000000 --- a/docs/contributing/overview.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing - -We're currently working on the process to document accepting outside contributions. diff --git a/docs/design-docs/index.md b/docs/design-docs/index.md new file mode 100644 index 000000000..29df9e475 --- /dev/null +++ b/docs/design-docs/index.md @@ -0,0 +1,111 @@ +# Design Documentation + +Technical architecture and design details for AutoMobile. + +## Overview + +AutoMobile is a mobile UI automation framework built on the Model Context Protocol (MCP). It enables AI agents to interact with Android and iOS devices for testing, exploration, and automation. + +```mermaid +flowchart TB + subgraph Clients + Agent["🤖 AI Agent"] + IDE["💻 IDE Plugin"] + CLI["🧪 Test Runner"] + end + + subgraph MCP["MCP Server"] + Transport["Transport Layer
(STDIO / Daemon Socket)"] + Registry["Tool & Resource
Registry"] + + subgraph Features["Feature Composition"] + Actions["Actions
(tap, swipe, input)"] + Observe["Observation
(hierarchy, screenshot)"] + AppMgmt["App Management
(launch, terminate)"] + DeviceMgmt["Device Management
(list, start, kill)"] + end + end + + subgraph External["External Interfaces"] + WS["📡 WebSocket
Clients"] + Socket["🔌 Unix Socket
Commands"] + CLITools["🛠️ CLI Tools
(adb, xcrun)"] + end + + subgraph Devices["Devices"] + Android["📱 Android"] + iOS["📱 iOS"] + end + + Agent --> Transport + IDE --> Transport + CLI --> Transport + Transport --> Registry + Registry --> Features + + Actions --> WS + Observe --> WS + AppMgmt --> CLITools + DeviceMgmt --> CLITools + DeviceMgmt --> Socket + + WS --> Android + WS --> iOS + CLITools --> Android + CLITools --> iOS + + classDef client fill:#CC2200,stroke-width:0px,color:white; + classDef mcp fill:#525FE1,stroke-width:0px,color:white; + classDef external fill:#007A3D,stroke-width:0px,color:white; + classDef device fill:#666666,stroke-width:0px,color:white; + + class Agent,IDE,CLI client; + class Transport,Registry,Actions,Observe,AppMgmt,DeviceMgmt mcp; + class WS,Socket,CLITools external; + class Android,iOS device; +``` + +## Core Components + +| Component | Description | +|-----------|-------------| +| [MCP Server](mcp/index.md) | Protocol server enabling AI agent interaction | +| [Interaction Loop](mcp/interaction-loop.md) | Observe-act-observe cycle with idle detection | +| [Observation](mcp/observe/index.md) | Real-time UI hierarchy and screen capture | +| [Actions](mcp/tools.md) | Touch, swipe, input, and app management | +| [Navigation Graph](mcp/nav/index.md) | Automatic screen flow mapping | +| [Daemon](mcp/daemon/index.md) | Device pooling and test execution | + +## Design Principles + +High quality, performant, and accessible workflows are necessary to creating good software. + +1. **Fast & Accurate** - Mobile UX can change instantly so slow observations translate to lower quality analysis. +2. **Reliable Element Search** - Multiple strategies with integrated vision fallback. +3. **Autonomous Operation** - AI agents explore without human guidance or intervention. +4. **CI/CD Ready** - Built for automated testing pipelines, dogfooding the tool to test itself. +5. **Accessible** - You shouldn't need to be a build engineer or AI expert to use AutoMobile. + +## Status + +Feature implementation status is indicated throughout these docs using chips. See the [Status Glossary](status-glossary.md) for chip definitions and the master list of unimplemented and partially-implemented features. + +## Platform Support + +### Android + +| Component | Purpose | Status | +|-----------|---------|--------| +| [Accessibility Service](plat/android/control-proxy.md) | Real-time view hierarchy access | ✅ Implemented 🧪 Tested | +| [JUnitRunner](plat/android/junitrunner.md) | Test execution framework | ✅ Implemented 🧪 Tested | +| [IDE Plugin](plat/android/ide-plugin/overview.md) | Android Studio integration | ✅ Implemented | +| [Work Profiles](plat/android/work-profiles.md) | Enterprise device support | ✅ Implemented 🧪 Tested | + +### iOS + +| Component | Purpose | Status | +|-----------|---------|--------| +| [iOS Overview](plat/ios/index.md) | Architecture and status | 📱 Simulator Only | +| [XCTestService](plat/ios/xctestservice.md) | WebSocket automation server | ✅ Implemented 🧪 Tested | +| [XCTestRunner](plat/ios/xctestrunner.md) | Test execution framework | ✅ Implemented 🧪 Tested | +| [Xcode Integration](plat/ios/ide-plugin/overview.md) | Companion app + editor extension | ⚠️ Partial | diff --git a/docs/design-docs/mcp/a11y/index.md b/docs/design-docs/mcp/a11y/index.md new file mode 100644 index 000000000..d4095dedc --- /dev/null +++ b/docs/design-docs/mcp/a11y/index.md @@ -0,0 +1,71 @@ +# Overview + +⚠️ Partial + +> **Current state:** TalkBack/VoiceOver auto-detection is implemented (via ADB secure settings query, cached with 60s TTL). Tool adaptations for TalkBack mode (ACTION_CLICK, two-finger scroll, etc.) are designed but not yet fully built — see [TalkBack/VoiceOver design](talkback-voiceover.md). iOS VoiceOver support is planned. The `auditAccessibility` MCP tool for contrast/tap target checks is ✅ Implemented separately. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile supports accessibility testing by detecting and adapting to screen readers like TalkBack (Android) and VoiceOver (iOS). + +```mermaid +flowchart LR + subgraph Detection + Auto["🔍 Auto-detect
TalkBack/VoiceOver"] + Cache["💾 Cache State
(60s TTL)"] + end + + subgraph Adaptation + Tap["👆 tapOn
ACTION_CLICK"] + Scroll["👉 swipeOn
Two-finger / Scroll Action"] + Input["⌨️ inputText
ACTION_SET_TEXT"] + end + + subgraph Result + Agent["🤖 Agent
(No changes needed)"] + end + + Auto --> Cache + Cache --> Tap + Cache --> Scroll + Cache --> Input + Tap --> Agent + Scroll --> Agent + Input --> Agent + + classDef detect fill:#CC2200,stroke-width:0px,color:white; + classDef adapt fill:#525FE1,stroke-width:0px,color:white; + classDef result fill:#007A3D,stroke-width:0px,color:white; + + class Auto,Cache detect; + class Tap,Scroll,Input adapt; + class Agent result; +``` + +## Design Principles + +1. **Auto-detect and adapt** - Tools automatically detect screen readers and adjust behavior +2. **Backward compatible** - No changes to existing tool interfaces or automation scripts +3. **Transparent** - Behavior adaptations invisible to MCP consumers (agents) +4. **Performance** - Detection cached with <50ms overhead + +## Key Adaptations + +| Tool | Standard Mode | Screen Reader Mode | +|------|---------------|-------------------| +| `tapOn` | Coordinate tap | `ACTION_CLICK` on element | +| `swipeOn` | Single-finger swipe | Two-finger swipe or `ACTION_SCROLL_*` | +| `inputText` | `ACTION_SET_TEXT` | No change (already accessible) | +| `pressButton` | Hardware keyevent | Optional `GLOBAL_ACTION_BACK` | + +## Topics + +| Document | Description | +|----------|-------------| +| [TalkBack/VoiceOver Adaptation](talkback-voiceover.md) | Complete design for screen reader support | + +## Platform Support + +| Platform | Screen Reader | Status | +|----------|---------------|--------| +| Android | TalkBack | ⚠️ Partial — detection implemented, tool adaptations in progress | +| iOS | VoiceOver | 🚧 Design Only — planned | + diff --git a/docs/design-docs/mcp/a11y/talkback-voiceover.md b/docs/design-docs/mcp/a11y/talkback-voiceover.md new file mode 100644 index 000000000..f53bc5581 --- /dev/null +++ b/docs/design-docs/mcp/a11y/talkback-voiceover.md @@ -0,0 +1,203 @@ +# TalkBack/VoiceOver + +🚧 Design Only + +> **Current state:** This document describes the full 4-phase implementation plan. Phase 1 detection infrastructure (TalkBack state via ADB secure settings) is partially implemented. Phases 2–4 (tool adaptations, focus tracking, advanced features) are **not yet implemented**. iOS VoiceOver support is planned. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Overview + +When TalkBack (Android) or VoiceOver (iOS) is enabled, mobile UX fundamentally changes: + +- **Navigation Model**: Linear swipe-based navigation through accessibility nodes instead of visual/spatial navigation +- **Interaction Model**: Focus-based actions (e.g., double-tap to activate focused element) instead of direct coordinate-based taps +- **View Hierarchy**: Accessibility tree may differ from visual hierarchy due to content grouping, virtual nodes, hidden decorative elements, and alternative text +- **Gestures**: System reserves gestures (e.g., two-finger swipe for scrolling, single swipe for next/previous item) + +**Strategy**: Auto-detect and adapt. MCP tools automatically detect when TalkBack/VoiceOver is enabled and adjust behavior accordingly, without requiring explicit mode parameters from agents. + +### Design Principles + +1. **Transparency**: Behavior adaptations are invisible to MCP tool consumers (agents) +2. **Backward Compatibility**: All existing tool interfaces remain unchanged +3. **Graceful Degradation**: If detection fails, fall back to standard behavior with appropriate warnings +4. **Performance**: Detection is cached (<50ms overhead) and does not impact tool execution latency +5. **Explicit Override**: Force accessibility mode via feature flags when needed + +--- + +## Accessibility Mode Detection + +### Detection Methods + +**Android TalkBack** can be detected via multiple approaches: + +| Method | Mechanism | Latency | Notes | +|--------|-----------|---------|-------| +| AccessibilityManager (preferred) | `settings get secure enabled_accessibility_services` via ADB | ~20-40ms | Fast, reliable, cacheable | +| AccessibilityService query | In-process `getEnabledAccessibilityServiceList()` | Instant | Requires AutoMobile AccessibilityService context | +| `dumpsys accessibility` (fallback) | Full accessibility configuration dump | ~100-200ms | Useful for debugging, not production | + +**iOS VoiceOver** is detected via `UIAccessibility.isVoiceOverRunning` (native iOS API, requires XCTestService integration). + +See [Android TalkBack](../../plat/android/talkback.md) for platform-specific ADB commands and simulation details. + +### Caching Strategy + +- **Tool Initialization**: Check once when device session starts +- **Periodic Refresh**: Re-check every 60 seconds (configurable TTL) +- **Explicit Invalidation**: After `setTalkBackEnabled()` tool calls +- **Feature Flag Override**: Allow manual force-enable for testing + +--- + +## View Hierarchy Differences + +The accessibility tree exposed by `AccessibilityNodeInfo` (Android) or `AXUIElement` (iOS) differs from the visual view hierarchy: + +### Element Merging + +TalkBack merges child text into parent for logical reading units: + +```text +Before (Visual Hierarchy): + LinearLayout (clickable) + ImageView (icon) + TextView "Settings" + TextView "Manage app preferences" + +After (Accessibility Tree): + LinearLayout (clickable, focusable) + content-desc: "Settings, Manage app preferences" + [Children marked importantForAccessibility=NO] +``` + +**Impact**: `tapOn` with `text: "Settings"` may not find the TextView directly. Must search for parent with merged content-desc using substring matching. + +### Virtual Nodes + +Some accessibility nodes (e.g., slider controls) don't correspond to actual views. Standard coordinate-based taps fail on virtual nodes; must use accessibility actions (`ACTION_SCROLL_FORWARD`, `ACTION_SCROLL_BACKWARD`). + +### Hidden Decorative Elements + +Elements marked `importantForAccessibility="no"` are excluded from the accessibility tree. `observe` returns fewer elements, and visual selectors may fail. Use semantic selectors (text, content-desc, role) instead. + +### Content Description Priority + +When both `text` and `contentDescription` exist, TalkBack prioritizes `contentDescription`. Search logic must check both fields, with content-desc taking priority. + +### Hierarchy Extraction + +AutoMobile's `ViewHierarchyExtractor.kt` already uses `AccessibilityNodeInfo` APIs and captures `text`, `contentDescription`, `isFocusable`, and `isFocused`. No changes needed for basic TalkBack support. + +--- + +## Focus Management + +Android has two types of focus: + +| Aspect | Input Focus | Accessibility Focus | +|--------|-------------|---------------------| +| Purpose | Text input target | Screen reader cursor position | +| Visibility | Cursor/highlight | TalkBack announces, green outline | +| Movement | Via keyboard (Tab) or touch | Via TalkBack swipe gestures | + +**During scrolling**, TalkBack focus may move off-screen, stay on a now-invisible element, or jump to the first visible focusable. The `swipeOn` tool clears accessibility focus before scrolling to avoid focus-follow issues. + +--- + +## Gesture Adaptations + +### TalkBack Gesture Conflicts + +When TalkBack is active, Android reserves certain gestures: + +| Standard Gesture | TalkBack Behavior | Impact on Automation | +|------------------|-------------------|---------------------| +| Single tap | Announces element | Does NOT activate element | +| Double tap (anywhere) | Activates focused element | Alternative to direct tap | +| Single swipe right/left | Next/previous element | Does NOT scroll content | +| Two-finger swipe | Scroll content | Required for scrolling | +| Three-finger swipe | System navigation | Reserved gesture | + +### Per-Tool Adaptations + +**tapOn**: Use `ACTION_CLICK` on the target element instead of coordinate-based tap. Optionally set accessibility focus first to mimic user behavior and trigger TalkBack announcement. Long press uses `ACTION_LONG_CLICK`. + +**swipeOn / scroll**: Three approaches in priority order: +1. **Accessibility scroll actions** (preferred for known scrollable containers) - uses `ACTION_SCROLL_FORWARD`/`ACTION_SCROLL_BACKWARD` +2. **Two-finger swipe** (general-purpose scrolling) - dispatches parallel two-finger gesture via `GestureDescription` +3. **Temporarily suspend TalkBack** (advanced, avoid) - requires extra permissions + +For scroll-until-visible (`lookFor`), clear accessibility focus before scrolling, use accessibility scroll actions in a loop, and optionally set focus on the target when found. + +**inputText / clearText**: No change needed. Already uses `ACTION_SET_TEXT`, which TalkBack handles correctly. + +**pressButton**: Hardware keycodes work the same. Back button may exit TalkBack local context menu instead of navigating back; use `GLOBAL_ACTION_BACK` to bypass when needed. + +**launchApp / terminateApp / installApp / startDevice / killDevice**: No change needed. App lifecycle and device management are unaffected by TalkBack state. + +--- + +## Use Cases + +### Login Flow + +Standard automation script works unchanged with TalkBack enabled: + +```typescript +await tapOn({ text: "Username" }); // Uses ACTION_CLICK (not coordinate tap) +await inputText({ text: "user@example.com" }); // Uses ACTION_SET_TEXT (works in both modes) +await tapOn({ text: "Password" }); +await inputText({ text: "password123" }); +await tapOn({ text: "Log in" }); // ACTION_CLICK on button +``` + +**Edge case**: If "Username" is a label (not the EditText), search logic checks nearby EditText with matching `content-desc` or `hint`. + +### List Scrolling + +```typescript +await swipeOn({ + container: { elementId: "item_list" }, + direction: "up", + lookFor: { text: "Item 50" }, + // Internally uses ACTION_SCROLL_FORWARD or two-finger swipe +}); +await tapOn({ text: "Item 50" }); // ACTION_CLICK +``` + +Scroll-until-visible detects list end by checking if hierarchy changes after scroll. Accessibility focus is cleared before each scroll to prevent focus-follow issues. + +--- + +## Implementation Strategy + +- **Phase 1**: Detection infrastructure - `AccessibilityDetector` class with caching, expose TalkBack state in observation results, feature flag override +- **Phase 2**: Core tool adaptations - `tapOn` uses `ACTION_CLICK`, `swipeOn` uses two-finger swipe or scroll actions, multi-touch gesture support +- **Phase 3**: Advanced features - accessibility focus tracking in observations, scroll-until-visible with focus management, optional explicit focus control tools +- **Phase 4**: Documentation and polish - user-facing docs, example scripts, performance benchmarks + +### iOS VoiceOver + +iOS VoiceOver follows the same phased approach. Key differences: + +| Aspect | Android TalkBack | iOS VoiceOver | +|--------|------------------|---------------| +| Detection | `AccessibilityManager` / settings query | `UIAccessibility.isVoiceOverRunning` | +| Scroll Gesture | Two-finger swipe | Three-finger swipe | +| Focus API | `FOCUS_ACCESSIBILITY` | `UIAccessibilityFocus` | +| Rotor | No equivalent | Two-finger rotate for navigation modes | + +iOS is secondary priority; initial focus is Android TalkBack validation. + +--- + +## Future Enhancement Ideas + +- **Explicit focus control tools**: `setAccessibilityFocus`, `getAccessibilityFocus`, `navigateFocus` +- **Announcement control**: Trigger screen reader announcements for user testing +- **Enhanced scroll-until-visible**: Smart loop detection, bi-directional search, focus tracking +- **Accessibility tree export**: Full node hierarchy with actions for debugging +- **Complex gesture simulation**: TalkBack local/global context menus, rotor navigation +- **Accessibility auditing**: Combine TalkBack support with WCAG auditing +- **iOS VoiceOver parity**: Three-finger swipe, rotor, Magic Tap support diff --git a/docs/design-docs/mcp/context-thresholds.md b/docs/design-docs/mcp/context-thresholds.md new file mode 100644 index 000000000..3398a5c02 --- /dev/null +++ b/docs/design-docs/mcp/context-thresholds.md @@ -0,0 +1,223 @@ +# Context Thresholds + +✅ Implemented 🧪 Tested + +> **Current state:** Context threshold benchmarking is fully implemented with CI integration. Runs on every PR/push to main. See the [Status Glossary](../status-glossary.md) for chip definitions. + +## Overview + +The MCP context threshold system enforces limits on the token count of tool definitions, resources, and resource templates to prevent context bloat and ensure the MCP server remains efficient. + +### Local Development + +```bash +# Check current context usage (estimation only) +bun run estimate-context + +# Run threshold benchmark (pass/fail check) +bun run benchmark-context + +# Output benchmark report to file +bun run benchmark-context --output reports/context-benchmark.json + +# Use custom threshold configuration +bun run benchmark-context --config custom-thresholds.json +``` + +### Threshold Configuration + +The configuration file (`scripts/context-thresholds.json`) has the following structure: + +```json +{ + "version": "1.0.0", + "metadata": { + "generatedAt": "2026-01-13", + "description": "MCP context usage thresholds with manual headroom for resource/template growth", + "baseline": { + "tools": 10382, + "resources": 431, + "resourceTemplates": 1412, + "total": 12225 + }, + "buffer": "custom" + }, + "thresholds": { + "tools": 14000, + "resources": 1000, + "resourceTemplates": 2000, + "total": 17000 + } +} +``` + +### Current Baselines (as of 2026-01-13) + +| Category | Baseline | Threshold (current) | Usage | +|----------|----------|------------------------|-------| +| Tools | 10,382 tokens | 14,000 tokens | 74% | +| Resources | 431 tokens | 1,000 tokens | 43% | +| Resource Templates | 1,412 tokens | 2,000 tokens | 71% | +| **Total** | **12,225 tokens** | **17,000 tokens** | **72%** | + +## Benchmark Report Format + +### Terminal Output + +```text +================================================================================ +MCP CONTEXT THRESHOLD BENCHMARK REPORT +================================================================================ + +Category Actual / Threshold Usage Status +-------------------------------------------------------------------------------- + Tools 10382 / 14000 ( 74%) ✓ PASS + Resources 431 / 1000 ( 43%) ✓ PASS + Resource Templates 1412 / 2000 ( 71%) ✓ PASS +-------------------------------------------------------------------------------- + TOTAL 12225 / 17000 ( 72%) ✓ PASS +================================================================================ + +Overall Status: ✓ PASSED +``` + +### JSON Report + +```json +{ + "timestamp": "2026-01-13T02:54:55.664Z", + "passed": true, + "results": { + "tools": { + "actual": 10382, + "threshold": 14000, + "passed": true, + "usage": 74 + }, + "resources": { + "actual": 431, + "threshold": 1000, + "passed": true, + "usage": 43 + }, + "resourceTemplates": { + "actual": 1412, + "threshold": 2000, + "passed": true, + "usage": 71 + }, + "total": { + "actual": 12225, + "threshold": 17000, + "passed": true, + "usage": 72 + } + }, + "thresholds": { + "tools": 14000, + "resources": 1000, + "resourceTemplates": 2000, + "total": 17000 + }, + "violations": [] +} +``` + +## CI Integration + +The GitHub Actions workflow runs automatically on: +- All pull requests +- Pushes to main branch + +### Workflow Behavior + +```mermaid +flowchart LR + A["Run benchmark
with JSON output"] --> B["Upload report
as artifact (90 days)"]; + B --> C["Post or update
PR comment"]; + C --> D{"Thresholds exceeded?"}; + D -->|"yes"| E["Fail workflow"]; + D -->|"no"| F["Complete workflow"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,B,C logic; + class D decision; + class E,F result; +``` + +### PR Comment Format + +| Category | Actual | Threshold | Usage | Status | +|----------|--------|-----------|-------|--------| +| Tools | 10,382 | 14,000 | 74% | ✅ | +| Resources | 431 | 1,000 | 43% | ✅ | +| Resource Templates | 1,412 | 2,000 | 71% | ✅ | +| **Total** | **12,225** | **17,000** | **72%** | ✅ | + + +## Updating Thresholds + +When legitimate changes require increasing thresholds: + +```mermaid +flowchart LR + A["Need higher thresholds"] --> B["Run estimation
(bun run estimate-context)"]; + B --> C["Update scripts/context-thresholds.json"]; + C --> D["Commit changes
with PR justification"]; + D --> E["Ensure CI passes
with new thresholds"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,B,C logic; + class D,E result; +``` + +Estimation command: +```bash +bun run estimate-context +``` + +Update guidance: +- Set headroom based on expected growth +- Update metadata section with rationale + +## Rationale + +### Why Manual Headroom? + +Manual headroom allows for: +- Larger shifts between tools, resources, and templates +- Planned growth without constant threshold churn +- Guardrails against unexpected regressions + +### Category Tracking + +Separate thresholds for tools, resources, and templates enable: +- Identifying which category is growing fastest +- Making informed decisions about optimization targets +- Understanding context distribution across MCP components + +## Performance Impact + +Token estimation is fast and suitable for CI: +- Full estimation: ~1-2 seconds +- Memory usage: minimal (< 100MB) +- No external dependencies beyond js-tiktoken + +## Future Enhancements + +Potential improvements to consider: + +1. **Per-Item Thresholds**: Limit individual tool/resource token counts +2. **Historical Tracking**: Trend analysis over time via artifact reports +3. **Automatic Threshold Suggestions**: Calculate optimal thresholds from baseline +4. **Cost Estimation**: Convert token counts to API cost estimates +5. **Optimization Recommendations**: Identify tools that could be simplified +6. **Integration with Performance Budgets**: Link to broader performance goals + +## Related Documentation + +- [MCP Resources](resources.md) - Resource system design +- [MCP Server](index.md) - Overall MCP architecture +- [Validation Commands](https://github.com/kaeawc/auto-mobile/blob/main/CLAUDE.md) - Development validation workflows diff --git a/docs/design-docs/mcp/daemon/critical-section.md b/docs/design-docs/mcp/daemon/critical-section.md new file mode 100644 index 000000000..3f4b1b540 --- /dev/null +++ b/docs/design-docs/mcp/daemon/critical-section.md @@ -0,0 +1,197 @@ +# Critical Section + +✅ Implemented 🧪 Tested + +> **Current state:** `criticalSection` MCP tool is fully implemented in daemon mode. Barrier synchronization, serial execution, timeout/nesting/abort handling are all active. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Overview + +The 🔒 [`criticalSection`](../tools.md) tool provides multi-device synchronization for serialized execution of steps. It implements a barrier synchronization pattern where all devices must arrive at the critical section before any can proceed, and then executes steps one device at a time. + +## Availability + +The 🔒 `criticalSection` tool is available only when AutoMobile runs as an [MCP Daemon](index.md). It is not registered in standalone MCP mode. + +## Use Cases + +Critical sections are useful when you need to: + +1. **Serialize resource access**: Ensure only one device at a time accesses a shared resource (e.g., payment processing, database writes) +2. **Prevent race conditions**: Coordinate state changes across devices that must not interleave +3. **Synchronize multi-device tests**: Ensure devices reach specific test milestones together before proceeding +4. **Order-dependent operations**: Guarantee specific execution order for operations that affect shared state + +## How It Works + +### Barrier Synchronization + +1. **Registration**: Each device registers the expected number of devices for the lock +2. **Arrival**: Devices arrive at the critical section and wait at a barrier +3. **Release**: Once ALL expected devices arrive, they are released from the barrier +4. **Serial Execution**: Devices acquire a mutex and execute their steps one at a time +5. **Cleanup**: After execution, the lock is released and resources are cleaned up + +### Execution Flow + +#### High-Level Flow (No Lock Details) + +```mermaid +sequenceDiagram + participant A as Device A + participant C as Coordinator + participant B as Device B + + C-->>A: Start parallel steps + C-->>B: Start parallel steps + Note over C,B: Reached criticalSection + Note over C,A: Reached criticalSection + C->>A: Critical Step 1 for Device A + C->>B: Critical Step 1 for Device B + C->>A: Critical Step 2 for Device A + C->>B: Critical Step 2 for Device B + Note over C,A: Ended criticalSection + Note over C,B: Ended criticalSection + C-->>A: Continue parallel steps + C-->>B: Continue parallel steps +``` + +#### Device-Level Flow (Parallel + Critical Section with Locking) + +```mermaid +sequenceDiagram + participant C as Coordinator + participant D as Device (track A) + + Note over C,D: Parallel steps (no lock) + C->>D: Execute step + D-->>C: Step result + C->>D: Execute next step + D-->>C: Step result + + Note over C,D: Critical section (lock = shared-resource) + C-->>D: Wait at barrier + D->>C: Arrived at barrier + C-->>D: Barrier released (all devices arrived) + C->>D: Lock granted + execute one critical step + D-->>C: Step results + C->>D: Lock granted + execute one critical step + D-->>C: Step results + D->>C: Release lock + C-->>D: Continue parallel steps +``` + +## Usage Examples + +### Two-Device Synchronization Check + +```yaml +steps: + - tool: navigateTo + params: + device: A + text: Edit Profile + - tool: tapOn + params: + device: A + text: Clear Status + - tool: navigateTo + params: + device: A + text: Search + - tool: inputText + params: + device: B + text: Test User A + - tool: imeAction + params: + device: B + text: done + - tool: criticalSection + params: + lock: profile-sync + deviceCount: 2 + steps: + - tool: tapOn + params: + device: A + text: Edit Profile + - tool: tapOn + params: + device: A + text: Status + - tool: inputText + params: + device: A + text: Pretty awesome + - tool: tapOn + params: + device: A + text: Clear Status + - tool: observe + params: + device: B + await: + - text: No Status + - tool: tapOn + params: + device: A + text: Save profile + - tool: observe + params: + device: B + await: + - text: Pretty awesome +``` + +## Error Handling + +### Timeout Errors + +If not all devices reach the barrier within the timeout period: + +```yaml +Error: Timeout waiting for critical section "payment-lock". +2/3 devices arrived after 30000ms. +Missing devices may have failed or not reached the critical section. +``` + +**Resolution**: +- Check that all devices are reaching the critical section +- Increase timeout if devices need more time +- Verify deviceCount is correct + +### Nesting Errors + +If a critical section step contains another critical section: + +```yaml +Error: Nested critical sections are not supported. +Found criticalSection step inside critical section "outer-lock". +``` + +**Resolution**: +- Remove nested critical sections +- Use different lock names for sequential synchronization points + +### Step Execution Failure + +If a step fails inside the critical section: + +```yaml +Error: Critical section "payment-lock" failed for device device-A: +Failed at step 2/3 (observe): Element not found +``` + +**Behavior**: +- Execution stops immediately (fail-fast) +- Lock is released +- Other waiting devices will timeout +- Resources are cleaned up + +## Limitations + +1. **No Nesting**: Critical sections cannot be nested. Each lock must be distinct. + +4. **Fail-Fast**: If any device fails inside the critical section, all devices fail. There is no partial success mode. + +5. **Static Device Count**: The device count must be known upfront and cannot change during execution. diff --git a/docs/design-docs/mcp/daemon/index.md b/docs/design-docs/mcp/daemon/index.md new file mode 100644 index 000000000..a7c6c2053 --- /dev/null +++ b/docs/design-docs/mcp/daemon/index.md @@ -0,0 +1,64 @@ +# Overview + +✅ Implemented 🧪 Tested + +> **Current state:** Daemon mode is fully implemented. Device pool management, session allocation, Unix socket communication, and test timing tracking are all active. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Background daemon service for device pooling and parallel test execution. +The AutoMobile daemon: + +1. Maintains a pool of available devices specifically for running tests +2. Allocates devices to test sessions on demand +3. Tracks test execution history and performance to automatically optimize test distribution +4. Provides session management APIs + +## Architecture + +With Android in mind, JUnitRunner uses the MCP Daemon to orchestrate and control N devices under test. The daemon is a long-lived Node process with the same MCP capabilities, so it can replay the same interactions an AI agent would run. This architecture also enables multi-device features like [critical section](critical-section.md). + +```mermaid +stateDiagram-v2 + JUnitRunner: JUnitRunner + Daemon: MCP Daemon + Device1: Device 1 + Device2: Device 2 + DeviceDots: Device ... + DeviceN: Device N + InteractionLoop: Interaction Loop + + JUnitRunner --> Daemon + Daemon --> JUnitRunner + Daemon --> DeviceSessionManager + InteractionLoop --> Daemon: 🖼️ Processed Results + DeviceSessionManager --> InteractionLoop: 📱 + + InteractionLoop --> Device1 + InteractionLoop --> Device2 + InteractionLoop --> DeviceDots + InteractionLoop --> DeviceN +``` + +## Socket Communication + +The daemon listens on a Unix socket at: +```typescript +/tmp/auto-mobile-daemon-.sock +``` + +## Socket API + +The daemon exposes a full [Unix Socket API](unix-socket-api.md) for IDE plugins and the CLI. Endpoints cover: + +- **IDE operations** — feature flags, service updates, SharedPreferences access +- **MCP proxy** — `tools/list`, `tools/call`, `resources/list`, `resources/read` +- **Daemon management** — device pool queries, session lifecycle + +## Implementation + +The daemon is implemented in the main AutoMobile MCP server and can run: + +- **Standalone** - As a background service +- **Embedded** - Within the MCP server process +- **CI Mode** - Temporary pools for CI environments + +See [MCP Server](index.md) for integration details. diff --git a/docs/design-docs/mcp/daemon/unix-socket-api.md b/docs/design-docs/mcp/daemon/unix-socket-api.md new file mode 100644 index 000000000..db4eb9adf --- /dev/null +++ b/docs/design-docs/mcp/daemon/unix-socket-api.md @@ -0,0 +1,407 @@ +# Unix Socket API + +✅ Implemented 🧪 Tested + +The AutoMobile daemon exposes a Unix socket for IDE plugins and CLI clients to communicate with the daemon without going through MCP. See [Daemon Overview](index.md) for architecture context. + +## Socket Path + +``` +/tmp/auto-mobile-daemon-.sock +``` + +The path can be overridden via the `AUTOMOBILE_DAEMON_SOCKET_PATH` or `AUTO_MOBILE_DAEMON_SOCKET_PATH` environment variables. + +## Protocol + +All messages are newline-delimited JSON sent over the Unix socket. Each request receives exactly one response. + +**Request** + +```json +{ + "id": "unique-request-id", + "type": "mcp_request", + "method": "ide/ping", + "params": {}, + "timeoutMs": 30000 +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | Yes | Caller-assigned ID echoed back in the response | +| `type` | `"mcp_request" \| "daemon_request"` | Yes | Request category | +| `method` | `string` | Yes | Endpoint name (e.g. `ide/ping`, `daemon/availableDevices`) | +| `params` | `object` | Yes | Method-specific parameters; pass `{}` when none are needed | +| `timeoutMs` | `number` | No | Per-request timeout in milliseconds (default: 30 000) | + +**Response** + +```json +{ + "id": "unique-request-id", + "type": "mcp_response", + "success": true, + "result": { } +} +``` + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Echoed from the request | +| `type` | `"mcp_response"` | Always this value | +| `success` | `boolean` | `true` on success, `false` on error | +| `result` | `object` | Present when `success` is `true` | +| `error` | `string` | Present when `success` is `false` | + +--- + +## IDE Endpoints + +These are handled directly by the daemon process without forwarding to the MCP server. + +### `ide/ping` + +Liveness check. Returns immediately. + +**Params:** none + +**Result** + +```json +{ "ok": true, "timestamp": 1718000000000 } +``` + +--- + +### `ide/status` + +Returns daemon version and bundled service artifact information. + +**Params:** none + +**Result** + +```json +{ + "version": "1.2.3", + "releaseVersion": "1.2.3", + "android": { + "accessibilityService": { + "expectedSha256": "abc123...", + "url": "https://..." + } + }, + "ios": { + "xcTestService": { + "expectedSha256": "def456...", + "expectedAppHash": "ghi789...", + "url": "https://..." + } + } +} +``` + +--- + +### `ide/listFeatureFlags` + +Lists all available feature flags and their current state. See [Feature Flags](../feature-flags.md) for the full list of flags. + +**Params:** none + +**Result** + +```json +{ + "flags": [ + { "key": "debugMode", "enabled": false, "config": null } + ] +} +``` + +--- + +### `ide/setFeatureFlag` + +Enables or disables a feature flag, with optional configuration. + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `key` | `string` | Yes | Feature flag key | +| `enabled` | `boolean` | Yes | Enable or disable the flag | +| `config` | `object \| null` | No | Optional flag-specific configuration | + +**Result:** the updated feature flag object. + +--- + +### `ide/updateService` + +Updates the Android accessibility service APK or restarts the iOS XCTestService on the target device. + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `deviceId` | `string` | Yes | ADB device ID or simulator UDID | +| `platform` | `"android" \| "ios"` | Yes | Target platform | + +**Result** + +```json +{ + "success": true, + "message": "Accessibility service upgraded", + "status": { "status": "upgraded" } +} +``` + +For iOS, `status` is omitted and `message` is `"XCTestService restarted"`. + +--- + +### `ide/setKeyValue` + +Writes a value into an Android app's SharedPreferences file via the accessibility service. 🤖 Android Only + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `deviceId` | `string` | Yes | ADB device ID | +| `appId` | `string` | Yes | Application package name | +| `fileName` | `string` | Yes | SharedPreferences file name (without `.xml`) | +| `key` | `string` | Yes | Preference key | +| `value` | `string \| null` | Yes | Value to write; `null` removes the key | +| `type` | `"STRING" \| "INT" \| "LONG" \| "FLOAT" \| "BOOLEAN" \| "STRING_SET"` | Yes | Preference type | + +**Result** + +```json +{ "success": true } +``` + +--- + +### `ide/removeKeyValue` + +Removes a single key from an Android app's SharedPreferences file. 🤖 Android Only + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `deviceId` | `string` | Yes | ADB device ID | +| `appId` | `string` | Yes | Application package name | +| `fileName` | `string` | Yes | SharedPreferences file name | +| `key` | `string` | Yes | Preference key to remove | + +**Result** + +```json +{ "success": true } +``` + +--- + +### `ide/clearKeyValueFile` + +Clears all keys from an Android app's SharedPreferences file. 🤖 Android Only + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `deviceId` | `string` | Yes | ADB device ID | +| `appId` | `string` | Yes | Application package name | +| `fileName` | `string` | Yes | SharedPreferences file name | + +**Result** + +```json +{ "success": true } +``` + +--- + +## MCP Proxy Endpoints + +These are forwarded to the daemon's internal MCP server. The response wraps whatever the MCP server returns. + +### `tools/list` + +Lists all registered MCP tools. Equivalent to the MCP `tools/list` protocol message. + +**Params:** none + +**Result:** standard MCP `ListToolsResult`. + +--- + +### `tools/call` + +Calls a registered MCP tool by name. + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | `string` | Yes | MCP tool name (e.g. `observe`, `tapOn`) | +| `arguments` | `object` | Yes | Tool-specific arguments | + +**Result:** standard MCP `CallToolResult`. + +--- + +### `resources/list` + +Lists all registered MCP resources. + +**Params:** none + +**Result:** standard MCP `ListResourcesResult`. + +--- + +### `resources/read` + +Reads a single MCP resource by URI. + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `uri` | `string` | Yes | Resource URI (e.g. `automobile:devices/booted`) | + +**Result:** standard MCP `ReadResourceResult`. + +--- + +### `resources/list-templates` + +Lists available MCP resource templates. + +**Params:** none + +**Result:** standard MCP `ListResourceTemplatesResult`. + +--- + +### `ide/getNavigationGraph` + +Convenience wrapper that calls the `getNavigationGraph` MCP tool and returns its result directly. Accepts the same arguments as the MCP tool. + +**Params:** same as the `getNavigationGraph` MCP tool (all optional). + +**Result:** navigation graph tool result. + +--- + +## Daemon Management Endpoints + +These manage the device pool and session lifecycle. See [Daemon Overview](index.md) for pool architecture details. + +### `daemon/availableDevices` + +Returns current device pool statistics. + +**Params:** none + +**Result** + +```json +{ + "availableDevices": 3, + "totalDevices": 4, + "assignedDevices": 1, + "errorDevices": 0, + "stats": { + "total": 4, + "idle": 3, + "assigned": 1, + "error": 0 + } +} +``` + +--- + +### `daemon/refreshDevices` + +Re-discovers connected devices and updates the pool. + +**Params:** none + +**Result** + +```json +{ + "addedDevices": 1, + "totalDevices": 4, + "availableDevices": 3, + "stats": { "total": 4, "idle": 3, "assigned": 1, "error": 0 } +} +``` + +--- + +### `daemon/sessionInfo` + +Returns metadata for an active session. + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `sessionId` | `string` | Yes | Session ID to query | + +**Result** + +```json +{ + "sessionId": "abc-123", + "assignedDevice": "emulator-5554", + "platform": "android", + "createdAt": 1718000000000, + "lastUsedAt": 1718000010000, + "expiresAt": 1718003600000, + "cacheSize": 4096 +} +``` + +Returns an error if the session does not exist. + +--- + +### `daemon/releaseSession` + +Releases a session and returns its device to the idle pool. Idempotent — safe to call even if the session was already released. + +**Params** + +| Field | Type | Required | Description | +|---|---|---|---| +| `sessionId` | `string` | Yes | Session to release | + +**Result** + +```json +{ + "message": "Session abc-123 released", + "device": "emulator-5554", + "alreadyReleased": false +} +``` + +When the session was already released (or never existed): + +```json +{ + "message": "Session abc-123 already released or never existed", + "alreadyReleased": true +} +``` diff --git a/docs/design-docs/mcp/feature-flags.md b/docs/design-docs/mcp/feature-flags.md new file mode 100644 index 000000000..e40aa49b9 --- /dev/null +++ b/docs/design-docs/mcp/feature-flags.md @@ -0,0 +1,29 @@ +# Feature Flags + +✅ Implemented + +> **Current state:** Feature flags are implemented as CLI args (e.g., `--debug`, `--accessibility-audit`, `--ui-perf-mode`). IDE integration for runtime flag toggling is described in linked docs but is `🚧 Design Only` for Android Studio and Xcode. See the [Status Glossary](../status-glossary.md) for chip definitions. + +Runtime configuration system for experimental features, performance tuning, and debugging AutoMobile. At +present these flags can only be set on MCP startup as CLI args. The plan is to have them configurable via IDE integrations for +[Android Studio](../plat/android/ide-plugin/feature-flags.md) & [XCode](../plat/ios/ide-plugin/feature-flags.md) + +### Debug Flags + +**`--debug`** - Enable debug logging + +**`--debug-perf`** - Enable performance debug output + +### Performance Flags + +**`--ui-perf-mode`** - Enable UI performance monitoring + +**`--ui-perf-debug`** - Detailed performance logging + +**`--mem-perf-audit`** - Memory performance auditing + +### Behavior Flags + +**`--accessibility-audit`** - Enable accessibility checks + +**`--predictive-ui`** - AI-powered UI prediction diff --git a/docs/design-docs/mcp/index.md b/docs/design-docs/mcp/index.md new file mode 100644 index 000000000..e2bd7ee21 --- /dev/null +++ b/docs/design-docs/mcp/index.md @@ -0,0 +1,29 @@ +# Overview + +✅ Implemented 🧪 Tested + +> **Current state:** All capabilities described below are implemented and tested. See the [Status Glossary](../status-glossary.md) for chip definitions. + +The MCP server exposes AutoMobile's capabilities as tool calls, resources, and real-time observations. + +## Core Capabilities + +- 🤖 **Fast UX Inspection** Kotlin [Accessibility Service](../plat/android/control-proxy.md) and Swift [XCTestService](../plat/ios/xctestservice.md) to enable fast, accurate observations. 10x faster than the next fastest observation toolkit. +- 🦾 **Full Touch Injection** Tap, Swipe, Pinch, Drag & Drop, Shake with automatic element targeting. +- ♻️ **Tool Feedback** [Observations](observe/index.md) drive the [interaction loop](interaction-loop.md) for all [tool calls](tools.md). +- 🧪 **Test Execution** [Kotlin JUnitRunner](../plat/android/junitrunner.md) & [Swift XCTestRunner](../plat/ios/xctestrunner.md) execute tests natively handling device pooling, multi-device tests, and automatically optimizing test timing. + +## Additional Features + +- 📹 **[Video recording](observe/video-recording.md)** Low-overhead capture for CI artifacts +- 💄 **[Visual Highlighting](observe/visual-highlighting.md)** Overlays for calling out important elements or regressions +- 📱 **[Device Snapshots](storage/snapshots.md)** Emulator Snapshots & Simulator App Containers +- 🗺️ **[Navigation graph](nav/index.md)** Automatic screen flow mapping +- ⚙️ **[Feature flags](feature-flags.md)** to gate debug or advanced features to be toggled at runtime in IDE integrations. +- 🦆 **[Migrations](storage/migrations.md)** Database & test plan schema management + +## Transport + +The MCP server uses **STDIO** transport (default). For hot reload development and IDE plugin integration, a background daemon process accepts connections via a Unix socket. + +See [installation](../../install.md) for detailed configuration options. diff --git a/docs/design-docs/mcp/interaction-loop.md b/docs/design-docs/mcp/interaction-loop.md new file mode 100644 index 000000000..cdb439239 --- /dev/null +++ b/docs/design-docs/mcp/interaction-loop.md @@ -0,0 +1,29 @@ +# Interaction Loop + +✅ Implemented 🧪 Tested + +> **Current state:** The full interaction loop (observe → execute → observe) is implemented with gfxinfo-based idle detection. See the [Status Glossary](../status-glossary.md) for chip definitions. + +![Interaction loop demo - setting an alarm](../../img/clock-app.gif) + +This interaction loop is supported by comprehensive [observation](observe/index.md) of UI state and UI stability checks +(Android uses `dumpsys gfxinfo`-based idle detection) before and after action execution. Together, that allows for +accurate and precise exploration with the [action tool calls](tools.md). + +```mermaid +sequenceDiagram + participant Agent as AI Agent + participant MCP as MCP Server + participant Device as Device + + Agent->>MCP: 🤖 Interaction Request + MCP->>Device: 👀 Observe + Device-->>MCP: 📱 UI State/Data (Cached) + + MCP->>Device: ⚡ Execute Actions + Device-->>MCP: ✅ Result + + MCP->>Device: 👀 Observe + Device-->>MCP: 📱 UI State/Data + MCP-->>Agent: 🔄 Interaction Response with UI State +``` \ No newline at end of file diff --git a/docs/design-docs/mcp/multi-device.md b/docs/design-docs/mcp/multi-device.md new file mode 100644 index 000000000..b73b88e22 --- /dev/null +++ b/docs/design-docs/mcp/multi-device.md @@ -0,0 +1,149 @@ +# Multi-device + +✅ Implemented 🧪 Tested + +> **Current state:** Fully implemented. Parallel device execution, `criticalSection` synchronization, per-device abort strategies, and YAML anchor support are all active. See the [Status Glossary](../status-glossary.md) for chip definitions. + +## Goal + +Enable true parallel steps in `executePlan`, while supporting critical +sections where only one device can proceed at a time. Keep the plan format +close to the existing single-device test plans by adding a `device` key per +step and a simple `devices` list. Parallelism is implicit: top-level steps +for different devices run concurrently unless synchronized via +`criticalSection`. + +## YAML Syntax + +Simple device labels, allocated by JUnitRunner/Daemon: + +```yaml +devices: ["A", "B"] + +steps: + - tool: launchApp + device: A + appId: com.chat.app + label: Launch chat app (sender) + + - tool: launchApp + device: B + appId: com.chat.app + label: Launch chat app (receiver) +``` + +Top-level steps remain consistent; parallelism is implicit: + +```yaml +devices: ["A", "B"] + +steps: + - tool: launchApp + params: + device: A + appId: com.chat.app + label: Launch sender + + - tool: launchApp + params: + device: B + appId: com.chat.app + label: Launch receiver + + - tool: criticalSection + params: + lock: "chat-room" + deviceCount: 2 + steps: + - tool: inputText + params: + device: A + text: "Hello" + label: Type message + - tool: imeAction + params: + device: A + action: send + label: Send message + + - tool: systemTray + params: + device: B + action: find + notification: { body: "Hello" } + awaitTimeout: 5000 + label: Verify notification +``` + +YAML anchors (merge keys) are supported for reuse and validation: + +```yaml +devices: ["A", "B"] + +tap: &tap + tool: tapOn + action: tap + +steps: + - <<: *tap + device: A + id: "com.google.android.deskclock:id/tab_menu_alarm" + label: Tap Alarm tab + - <<: *tap + device: B + id: "com.google.android.deskclock:id/tab_menu_alarm" + label: Tap Alarm tab +``` + +Semantics: + +- `devices` is a list of labels to allocate sessions for; JUnitRunner requests + the required number of devices from the MCP Daemon and maps them to labels. +- `device` on a step selects the label for routing. +- Steps targeting different devices run concurrently by default. +- `criticalSection` is a mutex; all devices must reach it, then steps execute + one device at a time within the section. See [Critical Section](daemon/critical-section.md) for details. +- Optional `barrier` tool can synchronize devices without serializing actions. + +## Implementation + +### Plan Validation + +Plans are validated at parse time: +- If `devices` field is present, all non-`criticalSection` steps must have a `device` parameter +- Device labels must be unique and non-empty strings +- Steps cannot reference undeclared device labels +- If any step uses device labels or criticalSection, the plan must declare `devices` + +### Execution Model + +**Sequential Mode (Single Device):** +- Plans without `devices` field execute sequentially as before +- Backward compatible with all existing plans + +**Parallel Mode (Multi-Device):** +- Plan is partitioned into device tracks based on device labels +- Each device track executes independently in parallel +- Steps within a device maintain their relative order +- Both plan position and device track position are tracked for debugging + +### Abort Strategy + +Configurable behavior when a device fails: +- `immediate` (default): Abort all devices immediately +- `finish-current-step`: Let other devices finish their current step before aborting + +### Per-Device Timing + +Debug mode or failures log per-device execution timing: +```text +[PARALLEL_EXEC] A: SUCCESS - 5/5 steps (1234ms) +[PARALLEL_EXEC] B: FAILED - 3/5 steps (987ms) +[PARALLEL_EXEC] B: Failed at plan step 7 (track step 2): Timeout waiting for element +``` + +## Known Limitations + +- YAML anchors (`<<` merge keys) work but device labels must still be explicitly specified (not merged from anchors). +- Parallel actions can cause ordering hazards without explicit locks - use critical sections to synchronize. +- Each device's abort signal is checked between steps, not mid-step. diff --git a/docs/design-docs/mcp/nav/explore.md b/docs/design-docs/mcp/nav/explore.md new file mode 100644 index 000000000..5caa573d6 --- /dev/null +++ b/docs/design-docs/mcp/nav/explore.md @@ -0,0 +1,67 @@ +# Explore + +✅ Implemented 🧪 Tested + +> **Current state:** All three modes (discover, validate, hybrid) are implemented. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Exploration Modes + +The `explore` tool supports three modes for different use cases: + +### Discovery Mode (`mode: "discover"`) + +**Purpose**: Build the navigation graph from scratch by discovering new screens and transitions. + +**Behavior**: +- Heavily favors novel elements and unexplored areas +- Prioritizes coverage over validation +- Records new screens and transitions as they're discovered +- Best for initial app exploration + +### Validate Mode (`mode: "validate"`) + +**Purpose**: Navigate through a known navigation graph to verify it matches current app behavior. + +**Behavior**: +- Requires an existing navigation graph +- Systematically traverses all known edges in the graph +- Validates that each navigation transition still works as recorded +- Fails with detailed error if app diverges from known graph +- Records edge validation results (success/failure, confidence scores) +- Provides graph traversal metrics (edges traversed, nodes visited, coverage %) + +**Use Cases**: +- **Regression Testing**: Verify navigation paths still work after code changes +- **State Verification**: Navigate to specific screens to verify UI/functionality +- **Performance Testing**: Measure navigation performance across known routes +- **Graph Quality Assessment**: Validate graph accuracy and identify stale edges + +**Validation Results**: +```typescript +{ + graphTraversal: { + nodesVisited: number, + totalNodes: number, + edgesTraversed: number, + totalEdges: number, + edgeValidationResults: EdgeValidationResult[], + coveragePercentage: number + } +} +``` + +**Edge Validation**: +Each edge traversal records: +- Success/failure of the navigation +- Expected vs actual destination +- Element matching confidence +- Error details if validation failed + +### Hybrid Mode (`mode: "hybrid"`) + +**Purpose**: Balance between discovery and validation. + +**Behavior**: +- Uses known graph when available but allows discovery +- Balances navigation score, novelty, and coverage equally +- Suitable for general exploration of partially-known apps diff --git a/docs/design-docs/mcp/nav/fingerprinting.md b/docs/design-docs/mcp/nav/fingerprinting.md new file mode 100644 index 000000000..16aa98a60 --- /dev/null +++ b/docs/design-docs/mcp/nav/fingerprinting.md @@ -0,0 +1,297 @@ +# Fingerprinting + +✅ Implemented 🧪 Tested + +> **Current state:** 4-tier fingerprinting strategy fully implemented and benchmarked. 100% success rate for non-keyboard scenarios. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Screen Fingerprinting + +Screen fingerprinting generates stable identifiers for UI screens that remain consistent despite dynamic content changes, scrolling, keyboard appearance, and user interactions. + +This strategy is critical for reliably identifying screens and building accurate navigation graphs across diverse scenarios. + +### Research Foundation + +The implementation is based on extensive research testing multiple strategies across real-world scenarios: + +- **4 screen types** tested (discover-tap, discover-swipe, discover-chat, discover-text) +- **11 observations** captured with varying states +- **6 strategies** evaluated +- **100% success rate** for non-keyboard scenarios achieved with shallow scrollable markers + +The tiered strategy was validated across 4 screen types and 11 observations with a 100% success rate for non-keyboard scenarios. + +--- + +### Tiered Fingerprinting Strategy + +AutoMobile uses a tiered fallback approach with confidence levels: + +#### Tier 1: Navigation Resource-ID (95% confidence) + +**When**: SDK-instrumented apps with `navigation.*` resource-ids + +**How**: Extract and hash the navigation resource-id + +**Example**: +```typescript +// Hierarchy contains: +{ "resource-id": "navigation.HomeDestination" } + +// Fingerprint: +{ + hash: "abc123...", + method: "navigation-id", + confidence: 95, + navigationId: "navigation.HomeDestination" +} +``` + +**Advantages**: +- Perfect identifier for SDK apps +- Immune to content changes +- Very stable + +**Limitations**: +- Only works with AutoMobile SDK +- Disappears when keyboard occludes app + +--- + +#### Tier 2: Cached Navigation ID (85% confidence) + +**When**: Keyboard detected + navigation ID was recently cached + +**How**: Use cached navigation ID from previous observation (within TTL) + +**Example**: +```typescript +// Before keyboard: navigation.TextScreen visible +// Keyboard appears: only keyboard elements visible +// Use cached navigation.TextScreen (within 10 second TTL) + +compute(hierarchyWithKeyboard, { + cachedNavigationId: "navigation.TextScreen", + cachedNavigationIdTimestamp: previousTimestamp +}) +``` + +**Advantages**: +- Handles keyboard occlusion gracefully +- Maintains high confidence +- Prevents false screen changes + +**Limitations**: +- Requires temporal tracking +- Cache expires after TTL (default: 10 seconds) + +--- + +#### Tier 3: Shallow Scrollable (75% confidence) + +**When**: No navigation ID available, no keyboard detected + +**How**: Enhanced hierarchy filtering with shallow scrollable markers + +**Strategy**: +1. **Shallow Scrollable Markers**: Keep container metadata, drop all children +2. **Selected State Preservation**: Extract and preserve `selected="true"` items +3. **Dynamic Content Filtering**: Remove time, numbers, system UI +4. **Static Text Inclusion**: Keep labels and titles for differentiation + +**Example**: +```json +// Before filtering: +{ + "scrollable": "true", + "resource-id": "tab_row", + "node": [ + { "selected": "true", "node": { "text": "Home" } }, + { "selected": "false", "node": { "text": "Profile" } }, + { "selected": "false", "node": { "text": "Settings" } } + ] +} + +// After filtering (shallow marker + selected): +{ + "_scrollable": true, + "resource-id": "tab_row", + "_selected": [ + { "selected": "true", "text": "Home" } + ] +} +``` + +**Advantages**: +- Handles scrolling perfectly +- Prevents tab collision (different screens with same structure) +- Works for non-SDK apps +- Reduces noise from dynamic content + +**Limitations**: +- Lower confidence than navigation ID +- May struggle with very similar screens + +--- + +#### Tier 4: Shallow Scrollable + Keyboard (60% confidence) + +**When**: Keyboard detected, no cached navigation ID, no current navigation ID + +**How**: Same as Tier 3 but with keyboard element filtering + +**Additional Filtering**: +- Remove nodes with keyboard indicators (Delete, Enter, emoji) +- Filter `keyboard` and `inputmethod` resource-ids + +**Advantages**: +- Best effort for keyboard scenarios without cache +- Still provides reasonable differentiation + +**Limitations**: +- Lowest confidence +- May miss subtle screen differences + +--- + +### Key Features + +#### 1. Shallow Scrollable Markers + +**Problem**: Scrolling changes visible content completely + +**Solution**: Keep container, drop children + +```typescript +// Same screen, different scroll positions produce SAME fingerprint +Before scroll: button_regular, button_elevated, press_duration_tracker +After scroll: filter_chip_1, icon_button_delete, slider_control + +Both fingerprint to: hash(scrollable container metadata) +``` + +**Impact**: 100% success for scrolling scenarios + +--- + +#### 2. Selected State Preservation + +**Problem**: Different tabs/screens have same structure but different selected state + +**Critical Fix**: Preserve selected items even in scrollable containers + +**Example of Collision Prevention**: +```typescript +// Without selected state preservation - COLLISION +Home Screen: scrollable tab_row → hash(container) +Settings Screen: scrollable tab_row → hash(container) +// Both get SAME fingerprint! ❌ + +// With selected state preservation - NO COLLISION +Home Screen: scrollable + _selected: ["Home"] → hash1 +Settings Screen: scrollable + _selected: ["Settings"] → hash2 +// Different fingerprints! ✅ +``` + +**Impact**: Prevents false positives in tab-based navigation + +--- + +#### 3. Keyboard Detection & Filtering + +**Indicators**: +- `content-desc` containing: Delete, Enter, keyboard, emoji, Shift +- `resource-id` containing: keyboard, inputmethod + +**Actions**: +```mermaid +flowchart LR + A["Keyboard detected"] --> B["Set keyboardDetected = true"]; + B --> C["Filter keyboard elements
from hierarchy"]; + C --> D["Attempt cached
navigation ID"]; + D --> E["Degrade confidence level"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,E result; + class B,C,D logic; +``` + +**Impact**: Graceful handling of keyboard occlusion + +--- + +#### 4. Editable Text Filtering + +**Detection**: +- `className` contains EditText +- `text-entry-mode="true"` +- `editable="true"` +- `resource-id` contains: edit, input, text_field, search + +**Action**: Omit text content from editable fields + +**Rationale**: User input is dynamic and shouldn't affect screen identity + +**Impact**: Same screen despite different user input + +--- + +#### 5. Dynamic Content Filtering + +**Time Patterns**: `8:55`, `8:55 AM`, `9:00 PM` +**Number Patterns**: `42`, `100`, `0` +**Percentage Patterns**: `45%`, `90%` + +**System UI**: +- `com.android.systemui:id/*` resource-ids +- `android:id/*` resource-ids +- Battery/signal content-descriptions + +**Impact**: Stable fingerprints despite constantly changing data + +--- + +### Computing a Fingerprint + +```typescript +import { ScreenFingerprint } from './features/navigation/ScreenFingerprint'; + +const result = ScreenFingerprint.compute(hierarchy, { + cachedNavigationId: previousResult?.navigationId, + cachedNavigationIdTimestamp: previousResult?.timestamp, + cacheTTL: 10000 // optional, defaults to 10s +}); + +console.log(result.hash); // SHA-256 fingerprint +console.log(result.confidence); // 95, 85, 75, or 60 +console.log(result.method); // navigation-id, cached-navigation-id, etc. +console.log(result.keyboardDetected); +``` + +### Stateful Tracking Pattern + +```typescript +class NavigationTracker { + private lastFingerprint: FingerprintResult | null = null; + + async onHierarchyChange(hierarchy: AccessibilityHierarchy) { + // Compute with cache + const fingerprint = ScreenFingerprint.compute(hierarchy, { + cachedNavigationId: this.lastFingerprint?.navigationId, + cachedNavigationIdTimestamp: this.lastFingerprint?.timestamp, + }); + + // Check if screen changed + if (!this.lastFingerprint || fingerprint.hash !== this.lastFingerprint.hash) { + console.log('Screen changed!'); + this.onScreenChange(fingerprint); + } + + // Cache for next observation + if (fingerprint.navigationId) { + this.lastFingerprint = fingerprint; + } + } +} +``` diff --git a/docs/design-docs/mcp/nav/graph-structure.md b/docs/design-docs/mcp/nav/graph-structure.md new file mode 100644 index 000000000..f40e00ca9 --- /dev/null +++ b/docs/design-docs/mcp/nav/graph-structure.md @@ -0,0 +1,43 @@ +# Graph Structure + +✅ Implemented 🧪 Tested + +> **Current state:** Nodes, edges, and history are all persisted in SQLite. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +The navigation graph captures: + +- **Nodes**: Unique UI states identified by AutoMobile SDK navigation events + view hierarchy hashing +- **Edges**: Tool calls that cause navigation +- **History**: Sequence of screens visited + +## Graph Structure + +### Nodes + +Each node is identified by: +```typescript +{ + screenId: string, // Unique identifier + screenName: string, // Screen name + title: string, // Screen title/label + signature: string, // View hierarchy fingerprint + timestamp: number // First seen time +} +``` + +### Edges + +Edges record the method of navigation in terms of UI interaction: +```typescript +{ + from: string, // Source screen ID + to: string, // Destination screen ID + trigger: { + action: string, // "tap", "swipe", etc. + element: string, // Element that triggered transition + text: string // Element text/description + }, + count: number, // Times this transition occurred + avgDuration: number // Average transition time +} +``` diff --git a/docs/design-docs/mcp/nav/index.md b/docs/design-docs/mcp/nav/index.md new file mode 100644 index 000000000..f877a6017 --- /dev/null +++ b/docs/design-docs/mcp/nav/index.md @@ -0,0 +1,122 @@ +# Overview + +✅ Implemented 🧪 Tested + +> **Current state:** Navigation graph is fully implemented. Screen fingerprinting (4-tier strategy), `navigateTo` smart routing, `explore` (discover/validate/hybrid modes), and graph persistence in SQLite are all active. Benchmarked at ≤1ms. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +As AutoMobile explores an app it automatically maps what it observes into a [navigation graph](graph-structure.md). + +```mermaid +flowchart TD + subgraph Navigation Graph + Home["🏠 Home Screen"] + Profile["👤 Profile Screen"] + Settings["⚙️ Settings Screen"] + EditProfile["✏️ Edit Profile"] + Notifications["🔔 Notifications"] + Privacy["🔒 Privacy Settings"] + end + + Home -->|"👆 tapOn 'Profile'"| Profile + Home -->|"👆 tapOn 'Settings'"| Settings + Home -->|"👆 tapOn 'Notifications'"| Notifications + Profile -->|"👆 tapOn 'Edit'"| EditProfile + Profile -->|"🔘 pressButton 'back'"| Home + EditProfile -->|"🔘 pressButton 'back'"| Profile + Settings -->|"👆 tapOn 'Privacy'"| Privacy + Settings -->|"🔘 pressButton 'back'"| Home + Privacy -->|"🔘 pressButton 'back'"| Settings + Notifications -->|"🔘 pressButton 'back'"| Home + + classDef screen fill:#525FE1,stroke-width:0px,color:white; + class Home,Profile,Settings,EditProfile,Notifications,Privacy screen; +``` + +Upon every observation after a screen has reached UI stability: + +1. Create unique screen signature by [fingerprinting the observation](fingerprinting.md) paired with AutoMobile SDK navigation events +2. Compare current vs previous screen +3. If we're on a different unique navigation fingerprint, record the tool call as the edge in the graph. + +This process has been [benchmarked to take at most 1ms](performance.md) and it is a project goal to keep it within the limit. The graph is [persisted](../storage/index.md) as exploration takes place whether by the user or AI. As its built you can take advantage of it: + +### Navigate to Screen + +The 🗺️ [`navigateTo`](../tools.md) tool uses the graph to find paths: + +1. Finds target screen in graph +2. Calculates shortest path from current node to the target +3. Executes recorded actions to reach target +4. Verifies arrival at destination + +### Explore Efficiently + +The 🔍 [`explore`](../tools.md) tool uses the graph to: + +- Avoid revisiting known screens +- Prioritize unexplored branches +- Track coverage of app features + +Read more about [how to use the 🔍 `explore` tool's modes](explore.md). + +## Edge Cases & Limitations + +#### Known Limitations + +1. **Multiple similar screens without navigation IDs** + - Risk: May produce same fingerprint + - Mitigation: Include static text for differentiation + +2. **Cache expiration during long keyboard sessions** + - Risk: Lost navigation ID reference + - Mitigation: Adjust cacheTTL based on use case + +3. **Screens with identical structure and no selected state** + - Risk: Cannot differentiate + - Mitigation: Encourage SDK integration for perfect identification + +#### Handled Edge Cases + +- ✅ Nested scrollable containers +- ✅ Scrollable tab rows (critical fix) +- ✅ Keyboard show/hide transitions +- ✅ Empty hierarchies +- ✅ Deeply nested structures + +--- + +## Best Practices + +#### For SDK-Instrumented Apps + +✅ **Do**: +- Use unique navigation resource-ids for each screen +- Follow `navigation.*` naming convention +- Ensure navigation IDs persist during keyboard + +✅ **Consider**: +- Add navigation IDs even to modal/overlay screens +- Use descriptive names: `navigation.ProfileEditScreen` + +#### For Non-SDK Apps + +✅ **Do**: +- Rely on Tier 3 shallow scrollable strategy +- Ensure screens have distinguishing static text or selected states +- Test fingerprinting across different app states + +⚠️ **Watch for**: +- Screens with identical layout but different data +- Heavy use of dynamic content without static labels + +#### For All Apps + +✅ **Do**: +- Cache previous fingerprint results for stateful tracking +- Monitor confidence levels +- Log fingerprint method for debugging + +❌ **Don't**: +- Assume 100% accuracy without navigation IDs +- Ignore confidence levels in decision-making +- Skip validation on critical navigation paths diff --git a/docs/design-docs/mcp/nav/performance.md b/docs/design-docs/mcp/nav/performance.md new file mode 100644 index 000000000..a8bd7f612 --- /dev/null +++ b/docs/design-docs/mcp/nav/performance.md @@ -0,0 +1,29 @@ +# Performance + +✅ Implemented 🧪 Tested + +> **Current state:** Benchmarked. Navigation ID path ~1ms, Shallow Scrollable ~5–10ms. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +### Performance + +- **Navigation ID**: ~1ms (simple extraction + hash) +- **Shallow Scrollable**: ~5-10ms (filtering + hash) +- **Cached ID**: ~1ms (no hierarchy processing) + +--- + +### Success Rates by Scenario + +| Scenario | Strategy | Success Rate | Notes | +|----------|----------|--------------|-------| +| SDK app, no keyboard | Navigation ID | **100%** | Perfect identifier | +| SDK app, with keyboard | Cached Nav ID | **100%** | Keyboard occlusion handled | +| Scrolling content | Shallow Scrollable | **100%** | Container stays stable | +| Tab navigation | Shallow Scrollable | **100%** | Selected state preserved | +| Non-SDK app | Shallow Scrollable | **75-85%** | Depends on hierarchy distinctiveness | + +#### Overall Performance + +- **Non-keyboard scenarios**: 100% success +- **Keyboard scenarios**: Depends on cache availability +- **No false positives**: Collision prevention through selected state diff --git a/docs/design-docs/mcp/observe/appearance.md b/docs/design-docs/mcp/observe/appearance.md new file mode 100644 index 000000000..dde185bcc --- /dev/null +++ b/docs/design-docs/mcp/observe/appearance.md @@ -0,0 +1,55 @@ +# Appearance Sync + +✅ Implemented 🧪 Tested + +> **Current state:** Fully implemented via Unix socket at `~/.auto-mobile/appearance.sock`. Supports Android and iOS simulator. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile can align device appearance (light/dark mode) with the host system. +Configuration is managed through a Unix socket (not MCP tools). + +## Unix Socket + +- Path: `~/.auto-mobile/appearance.sock` +- Protocol: newline-delimited JSON + +### Commands + +```json +{"id":"1","command":"set_appearance_sync","enabled":true} +{"id":"2","command":"set_appearance_sync","enabled":false} +{"id":"3","command":"set_appearance","mode":"light"} +{"id":"4","command":"set_appearance","mode":"dark"} +{"id":"5","command":"set_appearance","mode":"auto"} +{"id":"6","command":"get_appearance_config"} +``` + +### Responses + +```json +{"id":"6","type":"appearance_response","success":true,"result":{"config":{"syncWithHost":true,"defaultMode":"auto","applyOnConnect":true}}} +``` + +When a command applies an appearance change immediately, the response includes +`appliedMode` (`light` or `dark`). + +## Configuration Shape + +```json +{ + "appearance": { + "syncWithHost": true, + "defaultMode": "auto", + "applyOnConnect": true + } +} +``` + +## Host Detection + +- macOS: `defaults read -g AppleInterfaceStyle` +- Linux: GNOME `gsettings` (`color-scheme` / `gtk-theme`) and KDE `kreadconfig*` + +## Device Control + +- Android: `adb shell cmd uimode night yes|no` +- iOS Simulator: `xcrun simctl ui appearance light|dark` diff --git a/docs/design-docs/mcp/observe/index.md b/docs/design-docs/mcp/observe/index.md new file mode 100644 index 000000000..22eba02ae --- /dev/null +++ b/docs/design-docs/mcp/observe/index.md @@ -0,0 +1,40 @@ +# Overview + +✅ Implemented 🧪 Tested + +> **Current state:** Fully implemented. All described fields (`viewHierarchy`, `screenSize`, `systemInsets`, `rotation`, `activeWindow`, `accessibilityAudit`, `performanceAudit`, etc.) are collected during observation. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Each observation captures a snapshot of the current state of a device's screen and UI. When executed, it +collects multiple data points in parallel to minimize observation latency. These operations are incredibly platform +specific and will likely require a different ordering of steps per platform. All of this is to drive the +[interaction loop](../interaction-loop.md). + +All collected data is assembled into an object containing (fields may be omitted when unavailable): + +- `updatedAt`: device timestamp (or server timestamp fallback) +- `screenSize`: current screen dimensions (rotation-aware) +- `systemInsets`: UI insets for all screen edges +- `rotation`: current device rotation value +- `activeWindow`: current app/activity information when resolved +- `viewHierarchy`: complete UI hierarchy (if available) +- `focusedElement`: currently focused UI element (if any) +- `intentChooserDetected`: whether a system intent chooser is visible +- `wakefulness` and `backStack`: Android-specific state +- `perfTiming`, `displayedTimeMetrics` (Android launchApp "Displayed" startup timings), `performanceAudit`, and `accessibilityAudit`: present when the relevant modes are enabled +- `error`: error messages encountered during observation + +The observation gracefully handles various error conditions: + +- Screen off or device locked states +- Missing accessibility service +- Network timeouts or ADB connection issues +- Partial failures (returns available data even if some operations fail) + +Each error is captured in the result object without causing the entire observation to fail, ensuring maximum data +availability for automation workflows. + +## See Also + +- [Video Recording](video-recording.md) for setting up screen recording for later analysis. +- [Vision Fallback](vision-fallback.md) for how we fall back to LLM vision analysis when view hierarchy observation fails. +- [Visual Highlighting](visual-highlighting.md) for how we can draw on top of the observed app. \ No newline at end of file diff --git a/docs/design-docs/mcp/observe/screen-streaming.md b/docs/design-docs/mcp/observe/screen-streaming.md new file mode 100644 index 000000000..7ab9d1cae --- /dev/null +++ b/docs/design-docs/mcp/observe/screen-streaming.md @@ -0,0 +1,159 @@ +# Real-Time Screen Streaming Architecture + +⚠️ Partial + +> **Note:** This document covers the **live IDE screen mirroring** feature (continuous streaming to the Android Studio / Xcode plugin). This is distinct from the `videoRecording` MCP tool (which records a clip to a file) — that tool is ✅ Implemented 🧪 Tested. +> +> The Android `video-server` JAR (H.264, VirtualDisplay) is fully built and used by `videoRecording`. The end-to-end live mirroring pipeline (MCP relay → IDE DeviceScreenView) is in progress. +> iOS live streaming is 🚧 Design Only — see [iOS Screen Streaming](../../plat/ios/screen-streaming.md). +> +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Real-time screen streaming from mobile devices to the IDE plugin, enabling interactive device mirroring at up to 60fps with <100ms latency. + +## Goals + +- Continuous live streaming for device mirroring in the IDE +- Up to 60fps frame rate +- <100ms end-to-end latency for interactive use +- Support USB-connected physical devices and emulators/simulators +- Include audio streaming for complete mirroring +- Integrate with existing observation architecture +- Single device streaming at a time (no multi-device simultaneous streams) + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Mobile Device │ +│ │ +│ Platform-specific capture mechanism │ +│ (see platform docs for details) │ +│ │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ MCP Server (Node.js) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Existing sockets: New socket: │ +│ ├─ auto-mobile.sock (MCP proxy) └─ video-stream.sock │ +│ ├─ observation-stream.sock (binary frame data) │ +│ └─ performance-push.sock │ +│ │ +│ VideoStreamManager │ +│ ├─ Platform detection │ +│ ├─ Capture process lifecycle │ +│ ├─ Frame forwarding to clients │ +│ └─ Fallback to screenshot mode │ +│ │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ IDE Plugin (Kotlin/JVM) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ VideoStreamClient │ +│ ├─ Unix socket connection to video-stream.sock │ +│ ├─ Platform-specific frame decoding │ +│ └─ Frame → ImageBitmap conversion │ +│ │ +│ DeviceScreenView (Compose Desktop) │ +│ ├─ Live frame display │ +│ ├─ Overlay support (hierarchy highlights, selection) │ +│ ├─ FPS indicator │ +│ └─ Fallback to static screenshots │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Platform-Specific Capture + +The capture mechanism differs significantly between platforms: + +| Platform | Capture Location | Frame Format | Decoder Needed | +|----------|-----------------|--------------|----------------| +| Android | On device | H.264 encoded | Yes (Klarity) | +| iOS | On Mac | Raw BGRA | No | + +See platform-specific documentation for implementation details: +- **[Android Screen Streaming](../../plat/android/screen-streaming.md)** - VirtualDisplay + MediaCodec via shell-user JAR +- **[iOS Screen Streaming](../../plat/ios/screen-streaming.md)** - AVFoundation + ScreenCaptureKit on macOS + +## Video Stream Socket Protocol + +New Unix socket: `~/.auto-mobile/video-stream.sock` + +### Connection Handshake + +``` +Client → Server: { "command": "subscribe", "deviceId": "" } +Server → Client: { "type": "stream_started", "deviceId": "...", "platform": "android|ios" } +``` + +### Frame Data + +Binary frames with platform-specific headers: + +**Android (H.264):** +``` +┌─────────────────┬─────────────────┬─────────────────┐ +│ codec_id (4) │ width (4) │ height (4) │ +└─────────────────┴─────────────────┴─────────────────┘ +Then per-packet: pts_flags (8) + size (4) + H.264 data +``` + +**iOS (Raw BGRA):** +``` +┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐ +│ width (4) │ height (4) │ bytesPerRow (4) │ timestamp (4) │ +└─────────────────┴─────────────────┴─────────────────┴─────────────────┘ +Then: height * bytesPerRow bytes of BGRA pixel data +``` + +### Stream Control + +``` +Client → Server: { "command": "set_quality", "quality": "low|medium|high" } +Client → Server: { "command": "unsubscribe" } +Server → Client: { "type": "stream_stopped", "reason": "..." } +``` + +## Quality Presets + +| Quality | Android Bitrate | Resolution | Target FPS | +|---------|-----------------|------------|------------| +| Low | 2 Mbps | 540p | 30 | +| Medium | 4 Mbps | 720p | 60 | +| High | 8 Mbps | 1080p | 60 | + +iOS streams raw frames, so quality is controlled by resolution scaling only. + +## Fallback Behavior + +When video streaming is unavailable: +1. Detect stream failure or unsupported device +2. Automatically switch to existing screenshot-based observation +3. Display indicator in UI showing "Screenshot mode" +4. Retry video streaming on user request or device reconnection + +## Decisions + +| Question | Decision | +|----------|----------| +| Audio streaming | Include audio for complete mirroring | +| Touch input | Plan for it, implement later | +| Quality auto-adjustment | Automatically lower quality on frame drops | +| Multiple devices | Single device streaming at a time | +| Android decoder | Klarity only, no FFmpeg subprocess fallback | +| iOS Swift integration | Swift-to-Node bridge | +| macOS permissions | User handles permission prompts | +| macOS entitlements | No special entitlements needed for iOS capture | + +## References + +- [Android Screen Streaming](../../plat/android/screen-streaming.md) +- [iOS Screen Streaming](../../plat/ios/screen-streaming.md) +- [Video Recording Design](./video-recording.md) diff --git a/docs/design-docs/mcp/observe/video-recording.md b/docs/design-docs/mcp/observe/video-recording.md new file mode 100644 index 000000000..8059ec49b --- /dev/null +++ b/docs/design-docs/mcp/observe/video-recording.md @@ -0,0 +1,118 @@ +# Video Recording + +✅ Implemented 🧪 Tested + +> **Current state:** `videoRecording` MCP tool is fully implemented. Supports Android (via `automobile-video.dex` VirtualDisplay + MediaCodec H.264) and iOS simulator (via `simctl io recordVideo`). Highlights, archive management, and Unix socket config are all implemented. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Optional screen recording for debugging, performance analysis, and CI artifacts. Recording is off by default +and optimized for low overhead with a low-quality default preset. + +## Goals + +- Provide on-demand device/simulator video recordings via MCP tools. +- Default to low quality to minimize CPU, GPU, and IO overhead. +- Allow explicit configuration of target bitrate and max throughput. +- Enforce a maximum total archive size with automatic eviction. +- Prefer the highest-performance libraries available on both macOS and Linux. + +## Non-goals + +- Continuous always-on recording. +- High-quality marketing or demo capture (use external tools instead). + +## Configuration + +Defaults should be conservative and low-quality: + +- `qualityPreset`: `low` (default) +- `targetBitrateKbps`: 1000 +- `maxThroughputMbps`: 5 +- `fps`: 15 +- `maxArchiveSizeMb`: 100 +- `format`: `mp4` (H.264 baseline) + +Example config payload: + +```json +{ + "qualityPreset": "low", + "targetBitrateKbps": 1000, + "maxThroughputMbps": 5, + "fps": 15, + "maxArchiveSizeMb": 100, + "format": "mp4" +} +``` + +`maxThroughputMbps` caps encoded throughput (bitrate * fps * resolution) by adjusting capture settings. + +## MCP Tools + +- `videoRecording` + - Params: + - `action`: `start` or `stop`. + - `platform`: `android` or `ios`. + - `deviceId`/`sessionUuid`/`device`: optional device targeting. If omitted, the action applies to all devices on the platform. + - `recordingId`: optional (stop only). + - `highlights`: optional list of highlight entries (Android only) to show during recording. Each entry includes optional `description`, `shape`, and optional `timing` (`startTimeMs`). + - Optional overrides for `targetBitrateKbps`, `fps`, `resolution`, `qualityPreset`, `format`, + `maxDuration` (seconds, default 30, max 300), and `outputName`. + - Returns: per-device recording metadata and any evictions. + +Recording metadata now includes `highlights` entries with appearance/disappearance timestamps in seconds (millisecond precision). + +## MCP Resources + +- `automobile:video/latest` (metadata + blob) +- `automobile:video/archive` (metadata list) +- `automobile:video/archive/{recordingId}` (single video blob + metadata) + +## Architecture + +Introduce a `VideoRecorderService` with a pluggable backend interface: + +```kotlin +interface VideoCaptureBackend { + start(config): Promise; + stop(handle): Promise; +} +``` + +### Backend selection + +Prefer FFmpeg/libav across macOS and Linux for best cross-platform performance and hardware acceleration: + +- macOS: `ffmpeg` + VideoToolbox (H.264 hardware encode) +- Linux: `ffmpeg` + VAAPI/NVENC when available + +Platform-specific capture sources: + +- Android: + - Physical devices: `adb exec-out screenrecord` (pipe to ffmpeg when transcoding or resizing). + - Emulators: FFmpeg screen/window capture for higher throughput when ADB capture is slow. +- iOS (simulator only, macOS): + - Prefer `simctl io recordVideo` for simulator-native capture. + - Fallback to FFmpeg capture when available and needed for cross-platform parity. + +## Storage and retention + +- Archive directory: `~/.auto-mobile/video-archive`. +- Store recording metadata in SQLite (`~/.auto-mobile/auto-mobile.db`). +- Enforce `maxArchiveSizeMb` with LRU eviction (oldest first). +- Provide stable filenames (`recordingId` + timestamp). + +## Video recording configuration socket + +- Unix socket: `~/.auto-mobile/video-recording.sock`. +- Supports `config/get` and `config/set` requests for live video recording defaults. + +## Performance considerations + +- Default to low-quality preset to reduce overhead. +- Hardware-accelerated encoding by default when supported. +- Avoid blocking tool calls; stop/start should be asynchronous and cancellable. + +## Security and privacy + +- Recording is opt-in only (explicit tool call or CLI flag). +- Sensitive metadata must be scrubbed from filenames. diff --git a/docs/design-docs/mcp/observe/vision-fallback.md b/docs/design-docs/mcp/observe/vision-fallback.md new file mode 100644 index 000000000..7494b7d0b --- /dev/null +++ b/docs/design-docs/mcp/observe/vision-fallback.md @@ -0,0 +1,225 @@ +# Vision Fallback + +⚠️ Partial 🔒 Internal 🤖 Android Only + +Vision fallback uses Claude's vision API to help locate UI elements when traditional element finding methods fail. + +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Current Implementation + +### Status + +Vision fallback is an **internal feature** that is: + +- **Disabled by default** to avoid unexpected API costs +- Only available when constructing `TapOnElement` with custom vision configuration +- Not exposed via MCP server or CLI by default +- Currently integrated into `tapOn` only (invoked after polling times out) +- Android screenshots only (iOS not yet implemented) + +### How It Works + +When element finding fails after retries, `TapOnElement` follows this flow: + +```mermaid +flowchart LR + A["Element finding retries
exhausted"] --> B["Screenshot capture
(~100-200ms)"]; + B --> C["Claude vision analysis
(~2-5s)"]; + C --> D{"Confidence high?"}; + D -->|"yes"| E["Return alternative selectors
or navigation instructions"]; + D -->|"no"| F["Return detailed error
with screen context"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,E,F result; + class B,C logic; + class D decision; +``` + +### Configuration + +```typescript +const tapTool = new TapOnElement( + device, + adb, + axe, + { + enabled: true, // Enable vision fallback + provider: 'claude', // Only Claude supported currently + confidenceThreshold: 'high', // Reserved for future gating + maxCostUsd: 1.0, // Warning threshold (does not block) + cacheResults: true, // Cache to avoid repeated calls + cacheTtlMinutes: 60 // Cache for 60 minutes + } +); +``` + +**Note**: MCP server constructs `TapOnElement` with default config (enabled: false), so vision fallback is not available through MCP unless you modify the server code. + +### Example Scenarios + +**Element Text Changed**: +```yaml +Input: tapOn({ text: "Login" }) + +Traditional Error: +Element not found with provided text 'Login' + +With Vision Fallback: +Element not found. AI suggests trying: +- text: "Sign In" (Text label changed from 'Login' to 'Sign In') +(Cost: $0.0234, Confidence: high) +``` + +**Element Requires Navigation**: +```yaml +Input: tapOn({ text: "Advanced Settings" }) + +With Vision Fallback: +Element not found, but AI suggests these steps: +1. Scroll down in the settings menu to reveal more options +2. Look for "Advanced Settings" in the newly visible section +(Cost: $0.0312, Confidence: high) +``` + +**Element Doesn't Exist**: +```yaml +Input: tapOn({ text: "Nonexistent Button" }) + +With Vision Fallback: +Element not found. The current screen shows a login form with +'Username', 'Password', and 'Sign In' elements. The requested +'Nonexistent Button' is not visible on this screen. +(Cost: $0.0198, Confidence: high) +``` + +### Cost and Performance + +**Typical costs per failed search**: +- Input tokens: Screenshot + view hierarchy + prompt (~5,000-10,000 tokens) +- Output tokens: Analysis response (~500-1,000 tokens) +- **Cost**: $0.02-0.05 per vision fallback call + +**Performance**: +- Screenshot capture: ~100-200ms +- Claude API call: ~2-5 seconds +- **Total**: ~2-5 seconds (only when traditional methods fail) + +**Caching**: +- Cache key: Screenshot path + search criteria (text/resourceId) +- TTL: 60 minutes (configurable) +- Benefit: Instant response for repeated failures + +### API Key Setup + +Vision fallback requires an Anthropic API key: + +```bash +export ANTHROPIC_API_KEY=sk-ant-xxxxx +``` + +Get an API key at: https://console.anthropic.com/ + +### Limitations + +**Current Limitations**: +1. **tapOn only**: Not integrated into other tools (swipeOn, scrollUntil, etc.) +2. **Android only**: iOS screenshot capture not implemented +3. **No auto-retry**: Suggestions are informational - user must manually retry with suggested selectors +4. **Not in MCP**: Requires custom TapOnElement construction, not available via MCP server by default + +**When Vision Fallback Won't Help**: +- Element truly doesn't exist on screen +- Screenshot quality is poor +- Custom/non-standard UI elements +- Dynamic content that changes rapidly + +## Proposed Future Architecture + +🚧 Design Only + +The following hybrid vision approach is **not implemented** - it is a design proposal for future enhancement. + +### Design Principles + +1. **Last Resort**: Only activate after all existing fallback mechanisms exhausted +2. **Cost Conscious**: Prefer local models (80% cases), escalate to Claude only when needed +3. **High Confidence**: Only suggest navigation steps when confidence is high +4. **Transparent**: Clear error messages when fallback cannot help +5. **Fast & Offline**: Local models provide <500ms responses without internet + +### Proposed Hybrid Approach + +When implemented, add a **Tier 1** local model layer before Claude: + +- **Tier 1**: Fast, free local models (Florence-2, PaddleOCR) for common cases (~80%) + - OCR + object detection + element descriptions + - <500ms response time, $0 cost + - Handles text extraction and simple element matching + +- **Tier 2**: Claude vision API for complex cases (~15%) + - Advanced navigation and spatial reasoning + - 2-5s response time, $0.02-0.05 cost + - Optional Set-of-Mark preprocessing + +**Expected Distribution**: +- 80% resolved by Tier 1 (local models find alternative selectors) +- 15% resolved by Tier 2 (Claude provides navigation) +- 5% genuine failures (element truly doesn't exist) + +### Proposed Component Structure + +```typescript +export interface VisionFallbackConfig { + enabled: boolean; + + // Tier 1: Local models + tier1: { + enabled: boolean; + models: Array<'florence2' | 'paddleocr'>; + confidenceThreshold: number; // 0-1 + timeoutMs: number; + }; + + // Tier 2: Claude vision API + tier2: { + enabled: boolean; + useSoM: boolean; // Set-of-Mark preprocessing + confidenceThreshold: "high" | "medium" | "low"; + maxCostUsd: number; + }; + + cacheResults: boolean; + cacheTtlMinutes: number; +} +``` + +### Local Model Integration + +**Florence-2** for OCR + object detection: +- Extract all text with bounding boxes +- Detect UI elements (buttons, inputs, menus) +- Generate element descriptions +- ONNX runtime with CUDA/CPU execution + +**PaddleOCR** as fallback: +- Deep text extraction for complex/multi-language cases +- Layout analysis (text, title, list, table, figure) +- Used when Florence-2 confidence < 0.7 + +### Future Enhancements + +Planned improvements: + +1. **Auto-retry**: Automatically retry with suggested selectors +2. **More tools**: Integrate into `swipeOn`, `scrollUntil`, etc. +3. **Set-of-Mark**: Enhanced spatial understanding with visual markers +4. **Learning**: Track corrections to improve suggestions over time +5. **Multi-screenshot analysis**: Compare before/after states +6. **Visual regression detection**: Alert when UI changed significantly + +## See Also + +- [MCP tool reference](../tools.md) - Tool implementation details +- [Feature Flags](../feature-flags.md) - Runtime configuration diff --git a/docs/design-docs/mcp/observe/visual-highlighting.md b/docs/design-docs/mcp/observe/visual-highlighting.md new file mode 100644 index 000000000..12875aaf4 --- /dev/null +++ b/docs/design-docs/mcp/observe/visual-highlighting.md @@ -0,0 +1,83 @@ +# Visual Highlighting + +✅ Implemented 🧪 Tested 🤖 Android Only + +Expose visual highlight overlays (box or circle) as an MCP tool for debugging UI layout and state. + +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## MCP Tool + +- `highlight` + - Params: + - `shape`: required highlight shape definition + - Optional `description` + - `platform`: `android` | `ios` + - Optional device targeting: `deviceId`, `device`, `sessionUuid` + - Optional `timeoutMs` override + - Returns: success flag and optional error message. + - iOS: returns an unsupported error (Android only for now). + - Highlights auto-remove after their animation completes. + +## Examples + +### Highlight element during bug report +```javascript +await highlight({ + shape: { + type: "box", + bounds: { x: 100, y: 200, width: 300, height: 150 }, + style: { strokeColor: "#FF0000", strokeWidth: 3 } + }, + platform: "android" +}); +``` + +### Generate bug report with highlights +```javascript +await bugReport({ + platform: "android", + includeScreenshot: true, + highlights: [ + { + description: "Expected button location", + shape: { + type: "box", + bounds: { x: 120, y: 280, width: 220, height: 90 }, + style: { strokeColor: "#FF0000", strokeWidth: 6 } + } + } + ], + includeHighlightsInScreenshot: true +}); +``` + +### Multiple highlights for comparison +```javascript +await highlight({ + shape: { + type: "circle", + bounds: { x: 200, y: 300, width: 50, height: 50 }, + style: { strokeColor: "#00FF00", strokeWidth: 3 } + }, + platform: "android" +}); + +await highlight({ + shape: { + type: "circle", + bounds: { x: 210, y: 310, width: 50, height: 50 }, + style: { strokeColor: "#FF0000", strokeWidth: 3 } + }, + platform: "android" +}); +``` + +## Response Format + +```typescript +{ + success: boolean; + error?: string; +} +``` diff --git a/docs/design-docs/mcp/resources.md b/docs/design-docs/mcp/resources.md new file mode 100644 index 000000000..059363ff3 --- /dev/null +++ b/docs/design-docs/mcp/resources.md @@ -0,0 +1,119 @@ +# Resources + +✅ Implemented 🧪 Tested + +> **Current state:** All resources listed here are implemented. See the [Status Glossary](../status-glossary.md) for chip definitions. + +AutoMobile exposes resources through the Model Context Protocol for AI agents to access. + + +MCP Resources provide read-only access to: + +- Navigation graph data +- Test execution history +- Performance metrics +- Device information + +## Available Resources + +### Navigation Graph + +**URI**: `automobile:navigation/graph` + +Returns the current navigation graph showing: + +- Known screens and their IDs +- Screen transitions and triggers +- UI elements that cause navigation + +See [Navigation Graph](nav/index.md) for details. + +### Booted Devices + +**URI**: `automobile:devices/booted` + +Returns booted device inventory across Android and iOS, including: + +- Total, per-platform, virtual, and physical device counts +- Optional daemon pool status (idle/assigned/error) when the daemon is active +- Per-device pool assignment (status + session UUID) when available + +**URI Template**: `automobile:devices/booted/{platform}` + +This resource replaces the removed `listDevices` and `daemon_available_devices` tools. + +### Installed Apps + +**URI**: `automobile:apps` + +Returns installed apps for booted devices. `deviceId` is required. Supports query parameters for filtering: + +- `platform` (`android` or `ios`) +- `search` (case-insensitive partial match on package name or display name when available) +- `type` (`user` or `system`) +- `profile` (Android user ID, e.g. `0` or `10`) +- `deviceId` (booted device ID, required) + +Example URIs: + +- `automobile:apps?deviceId=emulator-5554&platform=android&search=slack&type=user` +- `automobile:apps?deviceId=YOUR_IOS_DEVICE_ID&platform=ios&search=calendar` + +Clients can subscribe to specific `automobile:apps?deviceId=...` URIs for change notifications and re-read filtered URIs after updates. + +### Test Timing History + +**URI**: ` +automobile:test-timings` + +Returns historical test execution data: + +- Test class and method names +- Average execution duration +- Success/failure rates +- Device information +- Supports query parameters for filtering and sorting (e.g., lookbackDays, limit, minSamples, orderBy, sessionUuid). + +See [Daemon](daemon/index.md) for test timing aggregation. + +### Performance Results + +**URI**: ` +automobile:performance-results` + +Returns recent UI performance audit results: + +- Scroll framerate measurements +- Frame drop counts +- Render time statistics + +### Localization Settings + +**URI**: `automobile:devices/{deviceId}/localization` + +Returns current localization settings for a device: + +- Locale tag +- Time zone +- Text direction +- Time format +- Calendar system + +## Using Resources + +AI agents can request resources via MCP: + +```json +{ + "method": "resources/read", + "params": { + "uri": "automobile:navigation/graph" + } +} +``` + +The agent receives structured data that it can analyze and use to inform decisions. + +## Implementation + +See [MCP Server](index.md) for technical implementation details of resource providers. diff --git a/docs/design-docs/mcp/storage/index.md b/docs/design-docs/mcp/storage/index.md new file mode 100644 index 000000000..7e9996c24 --- /dev/null +++ b/docs/design-docs/mcp/storage/index.md @@ -0,0 +1,86 @@ +# Overview + +✅ Implemented 🧪 Tested + +> **Current state:** SQLite persistence with Drizzle/Kysely, 32+ migrations, snapshot storage, and Unix socket configuration are all implemented. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile persists state across sessions using SQLite for metadata and the filesystem for larger payloads. + +```mermaid +flowchart TB + subgraph Runtime["Runtime Data"] + NavGraph["🗺️ Navigation Graph"] + Sessions["📱 Device Sessions"] + Config["⚙️ Configuration"] + end + + subgraph Storage["Persistent Storage"] + SQLite["🗄️ SQLite
~/.auto-mobile/auto-mobile.db"] + Snapshots["📸 Snapshots
~/.automobile/snapshots/"] + Migrations["🔄 Migrations
src/db/migrations/"] + end + + NavGraph --> SQLite + Sessions --> SQLite + Config --> SQLite + SQLite -.->|"schema updates"| Migrations + SQLite -.->|"metadata"| Snapshots + + classDef runtime fill:#CC2200,stroke-width:0px,color:white; + classDef storage fill:#525FE1,stroke-width:0px,color:white; + + class NavGraph,Sessions,Config runtime; + class SQLite,Snapshots,Migrations storage; +``` + +## Storage Locations + +| Path | Purpose | +|------|---------| +| `~/.auto-mobile/auto-mobile.db` | SQLite database for metadata | +| `~/.automobile/snapshots/` | Device state snapshot payloads | +| `~/.auto-mobile/*.sock` | Unix sockets for configuration | + +## Topics + +| Document | Description | +|----------|-------------| +| [Database Migrations](migrations.md) | Schema management with Kysely | +| [Device Snapshots](snapshots.md) | Capture and restore device state | +| [Appearance Sync](../observe/appearance.md) | Host/device appearance configuration | + +## Database Schema + +The SQLite database stores: + +- **Navigation graph** - Screens, edges, and fingerprints +- **Device sessions** - Active device connections +- **Snapshot metadata** - Index of captured snapshots +- **Configuration** - Feature flags and settings + +## Migration System + +Migrations run automatically on server startup: + +```mermaid +flowchart LR + Start["Server Start"] --> Check["Check Schema"]; + Check --> Run["Run Pending
Migrations"]; + Run --> Ready["Ready"]; + + classDef step fill:#525FE1,stroke-width:0px,color:white; + class Start,Check,Run,Ready step; +``` + +See [Database Migrations](migrations.md) for details on adding new migrations. + +## Snapshot Storage + +Device snapshots use a hybrid approach: + +| Snapshot Type | Metadata | Payload | +|---------------|----------|---------| +| VM Snapshot | SQLite | Emulator AVD directory | +| ADB Snapshot | SQLite | `~/.automobile/snapshots/` | + +See [Device Snapshots](snapshots.md) for capture/restore workflows. diff --git a/docs/design-docs/mcp/storage/migrations.md b/docs/design-docs/mcp/storage/migrations.md new file mode 100644 index 000000000..a3c239f16 --- /dev/null +++ b/docs/design-docs/mcp/storage/migrations.md @@ -0,0 +1,46 @@ +# Migrations + +✅ Implemented 🧪 Tested + +> **Current state:** Migration system with Kysely `FileMigrationProvider` is fully implemented. 32+ migrations run automatically on server startup. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile uses SQLite migrations to keep the MCP server schema up to date across releases. +Migrations run on server startup and are managed with Kysely's `Migrator` + `FileMigrationProvider`. + +## Layout + +- Source migrations live in `src/db/migrations` as TypeScript files. +- Build output copies them to `dist/src/db/migrations` so the runtime can load them from disk. + +## Resolution rules + +The migration directory is resolved in this order: + +```mermaid +flowchart LR + A["Resolve migrations directory"] --> B{"AUTOMOBILE_MIGRATIONS_DIR set?"}; + B -->|"yes"| C["Use AUTOMOBILE_MIGRATIONS_DIR path"]; + B -->|"no"| D{"dist/src/db/migrations exists?"}; + D -->|"yes"| E["Use dist/src/db/migrations
(bundled server)"]; + D -->|"no"| F{"src/db/migrations exists?"}; + F -->|"yes"| G["Use src/db/migrations
(running from source)"]; + F -->|"no"| H["Throw error with checked paths"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,H result; + class B,D,F decision; + class C,E,G logic; +``` + +If no folder is found, the server throws an error describing the checked paths. + +## Docker notes + +The Docker image runs the bundled server from `dist/src/index.js`, so migrations must be present +in `dist/src/db/migrations`. The build pipeline copies migrations into `dist` to satisfy this. + +## Related code + +- `src/db/migrator.ts` resolves the migration folder and runs migrations. +- `build.ts` copies migrations into `dist` during `bun run build`. diff --git a/docs/design-docs/mcp/storage/snapshots.md b/docs/design-docs/mcp/storage/snapshots.md new file mode 100644 index 000000000..0ade27e16 --- /dev/null +++ b/docs/design-docs/mcp/storage/snapshots.md @@ -0,0 +1,405 @@ +# Device State Snapshots + +✅ Implemented 🧪 Tested + +> **Current state:** `deviceSnapshot` MCP tool is fully implemented. VM snapshots (emulators), ADB snapshots (all Android devices), and iOS simulator app container backups are all supported. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Overview + +The snapshot feature provides deterministic device state management for mobile testing. It supports Android device/emulator snapshots and iOS simulator app container backups to enable reproducible test environments and efficient parallel testing. + +## Features + +- **VM Snapshots for Emulators**: Instant snapshot/restore using Android emulator's built-in snapshot feature +- **ADB-based Snapshots**: Portable snapshots for both emulators and physical devices +- **iOS App Container Backups**: Portable app-scoped snapshots for iOS simulators +- **Auto-generated Naming**: Automatic timestamp-based snapshot names with optional custom naming +- **Comprehensive State Capture**: Includes app data, system settings, package list, and foreground app state +- **Host-based Storage**: Snapshots stored in `~/.automobile/snapshots/` for fast access and easy management + +## MCP Tool + +### deviceSnapshot + +Capture or restore device snapshots. + +**Parameters:** +- `action` (required): `"capture"` or `"restore"` +- `snapshotName` (capture: optional, restore: required): Name for the snapshot +- `includeAppData` (capture only): Include app data directories in snapshot +- `includeSettings` (capture only): Include system settings (global/secure/system) +- `useVmSnapshot` (capture/restore): Use emulator VM snapshot if available (faster for emulators) +- `vmSnapshotTimeoutMs` (capture/restore): Timeout in milliseconds for emulator VM snapshot commands +- `strictBackupMode` (capture only): If true, fail entire snapshot if app data backup fails or times out +- `backupTimeoutMs` (capture only): Timeout in milliseconds for adb backup user confirmation +- `userApps` (capture only): Which apps to backup - `"current"` (foreground app only) or `"all"` (all user-installed apps) +- `appBundleIds` (capture only): iOS bundle IDs to include in app container backups +- `sessionUuid` (optional): Session UUID for multi-device targeting +- `device` (optional): Device label for multi-device control + +**Capture response:** +```json +{ + "message": "Snapshot 'Pixel_5_2026-01-08_12-30-45' captured successfully", + "snapshotName": "Pixel_5_2026-01-08_12-30-45", + "snapshotType": "vm", + "timestamp": "2026-01-08T12:30:45.123Z", + "deviceId": "emulator-5554", + "deviceName": "Pixel_5", + "manifest": { ... }, + "evictedSnapshotNames": ["older-snapshot"] +} +``` + +**Restore response:** +```json +{ + "message": "Snapshot 'clean-state-before-login-test' restored successfully", + "snapshotName": "clean-state-before-login-test", + "snapshotType": "vm", + "restoredAt": "2026-01-08T12:35:00.456Z", + "deviceId": "emulator-5554", + "deviceName": "Pixel_5" +} +``` + +**Examples:** +```javascript +// Capture with auto-generated name +await deviceSnapshot({ action: "capture" }); + +// Capture with custom name +await deviceSnapshot({ + action: "capture", + snapshotName: "clean-state-before-login-test" +}); + +// Restore a snapshot +await deviceSnapshot({ + action: "restore", + snapshotName: "clean-state-before-login-test" +}); +``` + +## MCP Resources + +### automobile:deviceSnapshots/archive + +List archived device snapshots. + +**Returns:** +```json +{ + "snapshots": [ + { + "snapshotName": "Pixel_5_2026-01-08_12-30-45", + "deviceId": "emulator-5554", + "deviceName": "Pixel_5", + "platform": "android", + "snapshotType": "vm", + "includeAppData": true, + "includeSettings": true, + "createdAt": "2026-01-08T12:30:45.123Z", + "lastAccessedAt": "2026-01-08T12:30:45.123Z", + "sizeBytes": 47448064, + "sizeLabel": "45.23 MB" + } + ], + "count": 1, + "totalSizeBytes": 47448064, + "maxArchiveSizeMb": 100 +} +```bash + +## Configuration + +Device snapshot defaults can be read or updated via the Unix socket at `~/.auto-mobile/device-snapshot.sock`. + +**Defaults:** +- `includeAppData`: `true` +- `includeSettings`: `true` +- `useVmSnapshot`: `true` +- `strictBackupMode`: `false` +- `backupTimeoutMs`: `30000` +- `userApps`: `"current"` +- `vmSnapshotTimeoutMs`: `30000` +- `maxArchiveSizeMb`: `100` + +## Snapshot Types + +### VM Snapshots (Emulators Only) + +**Pros:** +- Instant snapshot capture and restoration +- Complete system state including RAM +- No need to clear app data individually + +**Cons:** +- Only works with Android emulators +- Requires emulator console access + +**Technical Details:** +- Uses `adb emu avd snapshot save/load` commands +- Emulator replies with `OK` or `KO: ` (missing `OK` is treated as failure) +- Commands time out after 30000ms by default (configurable via `vmSnapshotTimeoutMs`) +- Snapshots stored in emulator's AVD directory (typically `~/.android/avd/.avd/snapshots/`) +- Metadata stored in `~/.automobile/snapshots/` for management + +### ADB Snapshots (All Devices) + +**Pros:** +- Works with both emulators and physical devices +- Portable across device types +- Fine-grained control over what gets captured + +**Cons:** +- Slower than VM snapshots +- Requires clearing app data individually +- App data backup requires root access or user confirmation + +**What Gets Captured:** +- Package list (`pm list packages`) +- System settings (global/secure/system via `settings list`) +- Foreground app state +- App data via `adb backup`: + - Only user-installed apps (system apps excluded) + - Only apps that allow backup (`android:allowBackup="true"`) + - Defaults to current foreground app only (`userApps: "current"`) + - Can backup all user apps with `userApps: "all"` + - Requires user confirmation on device (30s timeout by default) + - Apps with `android:allowBackup="false"` are automatically skipped + +**What Gets Restored:** +- Clears app data for all packages via `pm clear` +- Restores system settings via `settings put` +- Restores app data via `adb restore` (if backup was successful) + - Requires user confirmation on device (30s timeout by default) + - Only restores apps that were successfully backed up +- Relaunches foreground app + +### iOS App Container Backups (Current) + +**Pros:** +- Portable between dev machines +- Captures only the target app's container for focused reproduction + +**Cons:** +- Does not include system settings, keychain, or other app state +- Requires explicit bundle IDs for the target app(s) + +**Technical Details:** +- Uses `xcrun simctl get_app_container data` +- Copies `Documents/`, `Library/`, and `tmp/` for each bundle ID +- Snapshot type is `app_data` +- Simulator-wide `simctl snapshot` is intentionally not used for portability + +## Storage Location + +Snapshot payloads are stored in `~/.automobile/snapshots/` (ADB snapshots), and metadata is tracked in SQLite at `~/.auto-mobile/auto-mobile.db`: + +```text +~/.automobile/snapshots/ +├── Pixel_5_2026-01-08_12-30-45/ +│ ├── settings.json # Device settings (ADB snapshots only) +│ └── app_data/ # App data directory (ADB snapshots only) +│ ├── packages.txt # List of installed packages +│ └── backup.ab # ADB backup file (if backup succeeded) +└── another-snapshot/ + └── ... +``` + +VM snapshots themselves are stored in the emulator AVD directory and persist across emulator restarts. Automatic cleanup removes AutoMobile metadata and host snapshot payloads, but does not delete the emulator's VM snapshot. + +iOS app container backups are stored per simulator device ID: + +```text +~/.automobile/snapshots/ios/ +└── / + └── / + ├── metadata.json + └── app-data/ + └── / + ├── Documents/ + ├── Library/ + └── tmp/ +``` + +## Use Cases + +### 1. Deterministic Testing + +Eliminate state pollution between test runs by starting each test from an identical snapshot: + +```javascript +// Setup: Capture clean state +await deviceSnapshot({ action: "capture", snapshotName: "clean-base-state" }); + +// Before each test +await deviceSnapshot({ action: "restore", snapshotName: "clean-base-state" }); + +// Run test... +``` + +### 2. Parallel Testing + +Run multiple tests in parallel with each starting from the same snapshot: + +```javascript +// Create base snapshot once +await deviceSnapshot({ action: "capture", snapshotName: "test-base" }); + +// In parallel test runners +await Promise.all([ + runTest1(() => deviceSnapshot({ action: "restore", snapshotName: "test-base" })), + runTest2(() => deviceSnapshot({ action: "restore", snapshotName: "test-base" })), + runTest3(() => deviceSnapshot({ action: "restore", snapshotName: "test-base" })) +]); +``` + +### 3. Debugging + +Save device state before a failure occurs, then restore and debug: + +```javascript +try { + // Test code that might fail + await runComplexTest(); +} catch (error) { + // Capture state at failure point + await deviceSnapshot({ action: "capture", snapshotName: "failure-state" }); + throw error; +} + +// Later, restore and debug +await deviceSnapshot({ action: "restore", snapshotName: "failure-state" }); +``` + +### 4. Regression Detection + +Compare snapshots across app versions to detect unintended changes: + +```javascript +// Version 1.0 +await deviceSnapshot({ action: "capture", snapshotName: "v1.0-baseline" }); + +// Version 1.1 +await deviceSnapshot({ action: "capture", snapshotName: "v1.1-baseline" }); + +// Compare manifests programmatically +const v1 = await loadManifest("v1.0-baseline"); +const v2 = await loadManifest("v1.1-baseline"); +```bash + +## App Data Backup Details + +### How It Works + +The ADB snapshot feature uses Android's native `adb backup` and `adb restore` commands to capture and restore app data: + +1. **Filtering**: Only user-installed apps are backed up (system apps are excluded) +2. **Backup Eligibility**: Apps must have `android:allowBackup="true"` in their manifest +3. **Scope**: By default, only the current foreground app is backed up (`userApps: "current"`) +4. **User Confirmation**: The device will prompt the user to confirm the backup/restore operation +5. **Timeout**: If the user doesn't confirm within 30 seconds (configurable), the backup continues without app data + +### Backup Metadata + +The snapshot manifest includes detailed backup information: + +```json +{ + "appDataBackup": { + "backupFile": "backup.ab", + "backupMethod": "adb_backup", + "totalPackages": 150, + "backedUpPackages": ["com.example.app"], + "skippedPackages": ["com.example.nobackup"], + "failedPackages": [], + "backupTimedOut": false + } +} +``` + +### Backup Modes + +**Current App Only (default)**: +```javascript +await deviceSnapshot({ + action: "capture", + userApps: "current", // Only backup foreground app + includeAppData: true +}); +``` + +**All User Apps**: +```javascript +await deviceSnapshot({ + action: "capture", + userApps: "all", // Backup all user-installed apps + includeAppData: true +}); +``` + +**Strict Mode** (fail if backup times out): +```javascript +await deviceSnapshot({ + action: "capture", + strictBackupMode: true, // Fail entire snapshot if backup fails + backupTimeoutMs: 60000 // Wait 60 seconds for user confirmation +}); +``` + +### Limitations + +- **User Confirmation Required**: Cannot automate without user interaction +- **allowBackup Flag**: Apps with `android:allowBackup="false"` cannot be backed up +- **APKs Not Included**: Only app data is backed up, not the APK files themselves +- **Timeout**: If user doesn't confirm, snapshot continues without app data (unless strictBackupMode is enabled) + +## Limitations + +- **Android + iOS Simulator Only**: iOS snapshots are app container backups for simulators +- **App Data Backup**: Requires user confirmation on device for each backup/restore operation +- **VM Snapshots**: Only available for emulators, not physical devices +- **Storage Space**: Snapshots can be large (especially VM snapshots), manage storage accordingly +- **Backup Scope**: By default, only current app is backed up (set `userApps: "all"` for all apps) +- **iOS Simulator Snapshot**: `simctl snapshot` is intentionally not used; app container backups are the current choice + +## Performance + +- **VM Snapshot Capture**: ~2-5 seconds +- **VM Snapshot Restore**: ~3-8 seconds (includes emulator stabilization) +- **ADB Snapshot Capture**: ~10-30 seconds (depends on number of apps and settings) +- **ADB Snapshot Restore**: ~15-45 seconds (depends on number of apps to clear) +- **iOS App Container Backup**: Varies with app data size + +## Best Practices + +1. **Use VM Snapshots for Emulators**: Significantly faster than ADB snapshots +2. **Manage Archive Size**: Automatic cleanup enforces `maxArchiveSizeMb` (adjust via the device snapshot socket config) +3. **Descriptive Names**: Use meaningful snapshot names for easier management +4. **Base Snapshots**: Create a "golden" base snapshot and restore from it +5. **Device Matching**: Ensure snapshots are restored to compatible devices (same platform) + +## Troubleshooting + +### Snapshot Capture Fails + +- Verify device is connected and responsive +- For VM snapshots, ensure emulator console is accessible +- If VM snapshot commands time out, increase `vmSnapshotTimeoutMs` or restart the emulator +- If the emulator reports an unknown command, update the emulator to a version that supports snapshots +- Check available disk space in `~/.automobile/snapshots/` + +### Snapshot Restore Fails + +- Verify snapshot exists using the `automobile:deviceSnapshots/archive` resource +- Check platform compatibility (snapshot vs device) +- For VM snapshots, ensure emulator is running +- If the emulator reports device offline, reconnect or restart the emulator + +### Snapshot Too Large + +- Disable `includeAppData` for smaller snapshots +- Use ADB snapshots instead of VM snapshots +- Adjust `maxArchiveSizeMb` to control archive size diff --git a/docs/design-docs/mcp/test-plan-validation.md b/docs/design-docs/mcp/test-plan-validation.md new file mode 100644 index 000000000..786a59442 --- /dev/null +++ b/docs/design-docs/mcp/test-plan-validation.md @@ -0,0 +1,416 @@ +# Test Plan Validation + +✅ Implemented 🧪 Tested + +> **Current state:** YAML test plan validation is fully implemented in TypeScript (AJV) and Kotlin (networknt json-schema-validator). CI runs validation on every PR. YAML anchors and merge keys are supported. See the [Status Glossary](../status-glossary.md) for chip definitions. + +AutoMobile includes comprehensive YAML validation for test plans to catch syntax errors and schema violations early in the development process. + +## Overview + +All test plan YAML files are validated against a JSON schema that defines: + +- Required fields (name, steps) +- Allowed tool names +- Step structure and parameters +- Metadata format +- Legacy field support for backwards compatibility + +## Validation Levels + +### 1. Parse Validation +All YAML files must be syntactically valid and parseable by the YAML parser. Parse errors include line and column numbers to help locate issues. + +**YAML Features Supported:** + +- ✅ YAML anchors (`&anchor-name`) +- ✅ Anchor references (`*anchor-name`) +- ✅ Merge keys (`<<: *anchor`) +- ✅ Multi-document YAML (though plans should use single documents) + +### 2. Schema Validation +Test plans must conform to the AutoMobile test plan schema: + +- `schemas/test-plan.schema.json` - JSON Schema Draft 7 format + +The schema validates: + +- Required fields (`name`, `steps`) +- Field types (strings, arrays, objects, etc.) +- Array constraints (minItems, uniqueItems) +- String constraints (minLength, pattern) +- Nested structures (expectations, metadata, critical sections) + +**Note:** Tool-specific parameters (e.g., `appId` for `launchApp`, `id`/`text` for `tapOn`) are validated at runtime by individual tool handlers, not by the schema. + +### 3. Execution-Time Validation + +Validation happens automatically in two places: + +**Daemon-backed MCP Server (`executePlan` tool, daemon mode only):** + +- Validates YAML before parsing +- Runs in TypeScript using AJV (Another JSON Schema Validator) +- Reports errors with line/column numbers when possible + +**Kotlin JUnit Runner (`AutoMobilePlanExecutor`):** + +- Validates YAML after parameter substitution but before sending to daemon +- Runs in Kotlin using networknt json-schema-validator +- Reports errors with line/column numbers when possible +- Ensures test plans fail fast in Android tests with clear error messages + +## Running Validation Locally + +### Validate All Test Plans +```bash +bun run validate:yaml +``` + +This scans all `**/test-plans/**/*.yaml` files in the repository. + +### Validate Specific File or Pattern +```bash +bun scripts/validate-yaml.ts "path/to/your/plan.yaml" +bun scripts/validate-yaml.ts "test/resources/**/*.yaml" +``` + +### Example Output + +**Success:** +```text +AutoMobile Test Plan YAML Validation +==================================== + +Found 20 test plan file(s) to validate + +✓ test/resources/test-plans/playground/ux-exploration/navigate-demo-index.yaml +✓ test/resources/test-plans/playground/accessibility/combined-a11y-audit.yaml +... + +Validation Summary +================== +Total files: 20 +Valid files: 20 +Invalid files: 0 + +✅ All test plans are valid! +``` + +**Failure:** +```text +✗ test/resources/test-plans/my-plan.yaml + root: Missing required property 'name' + steps[0].tool: Must be one of: observe, tapOn, swipeOn, launchApp, ... + steps[2]: Unknown property 'invalidField'. This might be a legacy field - check the migration guide. + +❌ Validation failed - see errors above +``` + +## CI Integration + +YAML validation runs automatically in GitHub Actions on every pull request: + +- Job name: **Validate YAML Test Plans** +- Workflow: `.github/workflows/pull_request.yml` +- Runs alongside other validation steps (XML, shell scripts, MkDocs navigation) + +If validation fails, the PR check will fail and block merging. + +## Test Plan Schema + +### Required Fields + +```yaml +name: my-test-plan # Required: unique plan identifier +steps: # Required: at least one step + - tool: observe # Required: tool name + label: Wait for app # Optional: human-readable description +``` + +### Complete Example + +```yaml +name: complete-example +description: Comprehensive test plan example +mcpVersion: 0.0.7 +metadata: + createdAt: "2025-01-08T00:00:00.000Z" + version: "1.0.0" + appId: com.example.app + +steps: + - tool: launchApp + appId: com.example.app + clearAppData: true + label: Launch app with clean state + + - tool: observe + label: Wait for home screen + + - tool: tapOn + id: login_button + label: Tap login button + expectations: + - type: elementVisible + selector: + testTag: welcome_message +``` + +### Supported Tools + +The schema validates the following tool names: + +- `observe` +- `tapOn` +- `swipeOn` +- `launchApp` +- `terminateApp` +- `installApp` +- `inputText` +- `clearText` +- `pressButton` +- `highlight` +- `auditAccessibility` +- `openLink` +- `postNotification` +- `criticalSection` + +### Parameter Formats + +Step parameters can be specified in two ways: + +**1. Inside `params` object (recommended for new plans):** +```yaml +steps: + - tool: tapOn + params: + id: my_button + label: Tap button +``` + +**2. As top-level step properties (also valid):** +```yaml +steps: + - tool: tapOn + id: my_button + label: Tap button +``` + +Both formats are valid. The `PlanNormalizer` converts top-level properties into the `params` object at runtime. + +### YAML Anchors and Merge Keys + +Test plans support YAML anchors and merge keys to reduce repetition and make plans more maintainable: + +**Example: Reusing common parameters** +```yaml +name: anchor-example +steps: + # Define anchor for common launch params + - tool: launchApp + params: &launch-params + appId: com.example.app + coldBoot: false + clearAppData: false + label: First launch + + # Reuse anchor with merge and override + - tool: launchApp + params: + <<: *launch-params # Merge all properties from anchor + coldBoot: true # Override specific property + label: Second launch with cold boot + + # Use same params again + - tool: launchApp + params: *launch-params + label: Third launch (same as first) +``` + +**Example: Multi-device plans with anchors** +```yaml +name: multi-device-anchor-example +devices: + - A + - B +steps: + - tool: observe + params: &observe-common + includeScreenshot: true + includeHierarchy: true + device: A + + - tool: tapOn + params: + device: A + text: Sync + + - tool: observe + params: + <<: *observe-common + device: B # Override device while keeping other params +``` + +The validation system fully supports YAML anchors and merge keys - they are resolved during YAML parsing before schema validation occurs. + +### Legacy Field Support + +The schema allows legacy fields for backwards compatibility: + +**Plan level:** +- `generated` → Use `metadata.createdAt` +- `appId` → Use `metadata.appId` +- `parameters` → Deprecated + +**Step level:** +- `description` → Use `label` + +These fields are marked as deprecated but won't fail validation. The migration system handles conversion at runtime. + +## IDE Integration + +### VS Code YAML Extension + +Add to your workspace settings (`.vscode/settings.json`): + +```json +{ + "yaml.schemas": { + "./schemas/test-plan.schema.json": "**/test-plans/**/*.yaml" + } +} +``` + +This enables: + +- Autocomplete for test plan fields +- Inline validation errors +- Hover documentation +- Schema-aware formatting + +### IntelliJ IDEA / Android Studio + +1. Open Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings +2. Add new mapping: + - Name: `AutoMobile Test Plans` + - Schema file: `schemas/test-plan.schema.json` + - Schema version: `JSON Schema version 7` + - File path pattern: `**/test-plans/**/*.yaml` + +## Programmatic Usage + +### In TypeScript/JavaScript + +```typescript +import { PlanSchemaValidator } from './src/utils/plan/PlanSchemaValidator'; + +const validator = new PlanSchemaValidator(); +await validator.loadSchema(); + +// Validate YAML content +const result = validator.validateYaml(yamlContent); +if (!result.valid) { + console.error('Validation errors:'); + result.errors?.forEach(err => { + console.error(` ${err.field}: ${err.message}`); + }); +} + +// Validate file +const fileResult = await validator.validateFile('path/to/plan.yaml'); +``` + +### In Kotlin/Java (JUnit Runner) + +```kotlin +import dev.jasonpearson.automobile.junit.PlanSchemaValidator + +// Validate YAML content +val result = PlanSchemaValidator.validateYaml(yamlContent) +if (!result.valid) { + result.errors.forEach { err -> + val location = err.line?.let { " (line $it)" } ?: "" + println("${err.field}: ${err.message}$location") + } +} +``` + +Validation is automatic in `AutoMobilePlanExecutor.loadAndProcessPlan()`. If validation fails, you'll get an `IllegalArgumentException`: + +```text +java.lang.IllegalArgumentException: Plan YAML validation failed: +steps[0].tool: Missing required property 'tool' (line 5) +steps[2]: Unknown property 'invalidField'. This might be a legacy field - check the migration guide. + +The plan does not conform to the AutoMobile test plan schema. +Check schemas/test-plan.schema.json for details. +``` + +### In executePlan Tool + +Validation is automatic when using the `executePlan` MCP tool. If a plan fails validation, you'll receive an `ActionableError` with details: + +```text +Plan YAML validation failed: +steps[0].tool: Must be one of: observe, tapOn, swipeOn, ... (line 5) +steps[2]: Missing required property 'tool' (line 12) + +The plan does not conform to the AutoMobile test plan schema. +Check the schema at schemas/test-plan.schema.json for details. +``` + +## Troubleshooting + +### "Unknown property" errors for valid parameters + +If you see errors like: +```text +steps[0]: Unknown property 'auditType'. This might be a legacy field - check the migration guide. +``` + +This means the property should be inside the `params` object or is a tool-specific parameter. Since `additionalProperties: true` is set for steps, this shouldn't fail validation, but indicates the field might be better placed in `params`. + +### "Must be one of" tool name errors + +If you see: +```text +steps[0].tool: Must be one of: observe, tapOn, swipeOn, ... +``` + +The tool name is not recognized. Check: +1. Is the tool name spelled correctly? +2. Is it a custom/legacy tool that needs to be added to the schema? +3. Has the tool been deprecated or renamed? + +### YAML parsing errors + +If you see: +```yaml +root: YAML parsing failed: bad indentation of a mapping entry at line 10, column 3 +``` + +This is a syntax error in your YAML. Common causes: + +- Incorrect indentation (use 2 spaces, not tabs) +- Missing colons after keys +- Unquoted strings with special characters +- Mismatched brackets/braces + +## Future Enhancements + +Planned improvements for YAML validation: + +1. **Tool Parameter Validation** - Validate tool-specific parameters against Zod schemas at the schema level (currently validated at runtime) +2. **Schema Documentation** - Auto-generate schema docs from JSON schema with examples +3. **Custom Rules** - Add custom validation rules (e.g., required labels, naming conventions, accessibility requirements) +4. **Pre-commit Hook** - Automatically validate changed YAML files before commit +5. **Better Error Recovery** - Suggest fixes for common validation errors +6. **Performance Profiling** - Validate that plans don't contain known performance anti-patterns + +## Related Documentation + +- [ExecutePlan Assertions Design](../plat/android/executeplan-assertions.md) - Design doc for assertion expectations +- [MCP Migrations](storage/migrations.md) - Migration system design +- [UI Tests Guide](../../using/ui-tests.md) - Guide for using AutoMobile for UI testing +- [JSON Schema](https://github.com/kaeawc/auto-mobile/blob/main/schemas/test-plan.schema.json) - Full schema definition diff --git a/docs/design-docs/mcp/tools.md b/docs/design-docs/mcp/tools.md new file mode 100644 index 000000000..d5294088e --- /dev/null +++ b/docs/design-docs/mcp/tools.md @@ -0,0 +1,74 @@ +# Tools + +> All tools listed here are ✅ Implemented and 🧪 Tested unless noted otherwise. See the [Status Glossary](../status-glossary.md) for chip definitions. + +#### Observe + +Almost all other tool calls have built-in observation via the [interaction loop](interaction-loop.md), but we also have a standalone [observe](observe/index.md) tool that specifically performs just that action to get the AI agent up to speed. + +#### Interactions + +- 👆 `tapOn` supports tap, double-tap, long press, and long-press drag actions. +- 👉 `swipeOn` handles directional swipes and scrolling within container bounds. +- ↔️ `dragAndDrop` for element-to-element moves. +- 🔍 `pinchOn` for zoom in/out gestures. +- 📳 `shake` for accelerometer simulation. + +#### App Management + +- 📱 Installed apps are exposed via the `automobile:apps` resource with query filters. +- 🚀 `launchApp` starts apps by package name (with optional clear-app-data support). +- ❌ `terminateApp` force-stops an app by package name. +- 📦 `installApp` installs an APK. +- 🔗 `getDeepLinks` reads registered deep links/intent filters for an Android package. + +#### Input Methods + +- ⌨️ `inputText` and `imeAction` for typing and IME actions. +- 🗑️ `clearText` and `selectAllText` act on the focused field. +- 🔘 `pressButton` or `pressKey` for back/home/recent/power/volume. + +#### Device Configuration + +- 🔄 `rotate` sets portrait or landscape. +- 🌐 `openLink` launches URLs or deep links. +- 🧰 `systemTray`, `homeScreen`, and `recentApps` control system surfaces. +- 🔔 `postNotification` posts notifications from the app-under-test when SDK hooks are installed. +- 🌍 `changeLocalization` sets locale, time zone, text direction, and time format in one call. + +#### Navigation & Exploration + +- 🗺️ `navigateTo` navigates to a specific screen using learned paths from the navigation graph. +- 🔍 [`explore`](nav/explore.md) automatically explores the app and builds the navigation graph by intelligently selecting and interacting with UI elements. +- 📊 `getNavigationGraph` retrieves the current navigation graph for debugging and analysis. + +#### Advanced Device Management + +- 📋 Device inventory and pool status are exposed via the `automobile:devices/booted` resource. +- 🚀 `startDevice` starts a device with the specified device image. +- ❌ `killDevice` terminates a running device. +- 🔧 `setActiveDevice` sets the active device for subsequent operations. + +#### Testing & Debugging {#testing-debugging} + +- 🧪 `executePlan` (daemon mode only) executes a series of tool calls from a YAML plan content, stopping if any step fails. +- 🔒 `criticalSection` (daemon mode only) coordinates multiple devices at a synchronization barrier for serialized steps. +- 🩺 `doctor` runs diagnostic checks to verify AutoMobile setup and environment configuration. +- 🐛 `bugReport` generates a comprehensive bug report including screen state, view hierarchy, logcat, screenshot, and optional highlight metadata. +- 🔍 `debugSearch` debugs element search operations to understand why elements aren't found or wrong elements are selected. +- 📸 `rawViewHierarchy` gets raw view hierarchy data (XML/JSON) without parsing for debugging. +- 🖍️ `highlight` draws visual overlays to highlight areas of the screen during debugging. 🤖 Android Only +- 🔗 `identifyInteractions` suggests likely interactions with ready-to-use tool calls (debug-only; enable the debug feature flag). + +#### Network & Connectivity + +- `setNetworkState` — Wi-Fi, cellular, and airplane mode control. ADB commands validated on API 35. ❌ Not Implemented *(MCP tool not yet built; see [network-state.md](../plat/android/network-state.md))* + +#### Accessibility + +- `setTalkBackEnabled`, `setA11yFocus`, `announce` — TalkBack simulation and enablement. ADB commands validated. ❌ Not Implemented *(MCP tools not yet built; see [talkback.md](../plat/android/talkback.md))* + +#### Daemon & Session Management + +- 📋 Device pool status is exposed via the `automobile:devices/booted` resource. +- Daemon management and IDE operations are exposed via the [Unix Socket API](daemon/unix-socket-api.md) (not MCP tools). diff --git a/docs/design-docs/plat/android/accessibility-testing.md b/docs/design-docs/plat/android/accessibility-testing.md new file mode 100644 index 000000000..3cb74ae5d --- /dev/null +++ b/docs/design-docs/plat/android/accessibility-testing.md @@ -0,0 +1,59 @@ +# Accessibility testing + +✅ Implemented 🧪 Tested + +> **Current state:** Implemented as the `auditAccessibility` MCP tool (not `runA11yChecks` as originally proposed). Supports `auditType: contrast` (WCAG contrast ratio checks) and `auditType: tapTargets` (minimum touch target size checks). ATF integration is not implemented. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Expose an MCP tool that runs fast a11y checks using the existing +AccessibilityService, with optional deeper checks via ATF. + +## MCP tool (`auditAccessibility`) + +```typescript +runA11yChecks({ + scope: "visible" | "screen", + includeContrast: boolean, + includeFocusOrder: boolean, + minTapTargetDp: 48 +}) +``` + +Return structure should include: + +- `violations`: array of rule IDs, node references, and summaries +- `supported`: per-rule capability flags + +## Android implementation + +Baseline checks (fast, AccessibilityService): + +- Content description on image-only controls +- Minimum tap target size (dp -> px from density) +- Clickable without label +- Duplicate or ambiguous labels +- Focusability issues for editable text + +Contrast checks: + +- Use a recent screenshot and view bounds from AccessibilityNodeInfo. +- Sample foreground and background colors and compute WCAG contrast ratio. +- Flag nodes under a threshold (e.g., 4.5:1). + +Optional ATF integration: + +- Dependency: `com.google.android.apps.common.testing.accessibility:accessibility-test-framework` +- Use `AccessibilityCheckRunner.runChecks()` on the current node tree. +- Map ATF results into the MCP violation schema. + +## Plan + +1. Implement baseline heuristic rules using accessibility nodes. +2. Add contrast checks with screenshot sampling. +3. Add optional ATF integration behind a feature flag. + +## Risks + +- Contrast sampling can be noisy on transparent backgrounds. +- ATF may increase build size and dependency surface. diff --git a/docs/design-docs/plat/android/auto-mobile-sdk.md b/docs/design-docs/plat/android/auto-mobile-sdk.md new file mode 100644 index 000000000..c9b32e5ea --- /dev/null +++ b/docs/design-docs/plat/android/auto-mobile-sdk.md @@ -0,0 +1,22 @@ +# AutoMobile SDK + +✅ Implemented 🧪 Tested + +> **Current state:** `android/auto-mobile-sdk/` is a published Android library. Includes navigation tracking (Navigation3, Circuit adapters), crash/ANR/handled exception capture, Compose recomposition tracking, notification triggering, SQLite database inspection, and SharedPreferences inspection. Published to Maven Central. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +The AutoMobile SDK provides Android-specific components for integrating AutoMobile into your Android applications and test suites. + +The SDK consists of: + +- [JUnitRunner](junitrunner.md) - Test execution framework with AI self-healing capabilities +- [Accessibility Service](control-proxy.md) - Real-time view hierarchy access and UI monitoring +- **Gradle Integration** - Build configuration and dependency management + +## Setup + +- Follow [JUnitRunner](junitrunner.md) for dependency installation and runner configuration. +- Enable the [Accessibility Service](control-proxy.md) on test devices to access the view hierarchy. + +## See Also + +- [MCP Server](../../mcp/index.md) - MCP Server integration diff --git a/docs/design-docs/plat/android/biometrics.md b/docs/design-docs/plat/android/biometrics.md new file mode 100644 index 000000000..3ab346ea7 --- /dev/null +++ b/docs/design-docs/plat/android/biometrics.md @@ -0,0 +1,129 @@ +# Biometrics Stubbing + +✅ Implemented 🧪 Tested 🤖 Emulator Only + +> **Current state:** The `biometricAuth` MCP tool is fully implemented and tested for Android emulators via `adb emu finger touch/remove`. Physical Android devices and face/iris modalities are not supported. The optional SDK hook (`AutoMobileBiometrics.overrideResult()`) is ❌ Not Implemented. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Allow tests and agents to trigger biometric success/failure/cancel on +emulators (API 29/35) and provide a path for app-under-test integration +when emulator support is not sufficient. + +## MCP tool + +```typescript +biometricAuth({ + action: "match" | "fail" | "cancel", + modality: "any" | "fingerprint" | "face", + fingerprintId?: number +}) +``` + +Behavior: + +- `modality: any` prefers fingerprint on emulator (highest support). +- `action: match` should succeed if enrollment exists. +- `action: fail` should simulate a non-matching biometric. + +## Emulator implementation (API 29/35) + +Primary mechanism (fingerprint): + +- Enroll a fingerprint in Settings (one-time per emulator snapshot). +- Trigger match: `adb -s emu finger touch ` +- Release sensor: `adb -s emu finger remove ` + +Failure simulation: + +- Use a non-enrolled `` to generate a mismatch when the prompt is active. +- If emulator does not differentiate ids, treat `fail` as `cancel` and return + `supported: partial` with a reason. + +Capability probing: + +- `adb -s shell getprop ro.kernel.qemu` (1 indicates emulator) +- `adb -s emu help` to confirm `finger` commands are available + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Enrollment + auth steps (confirmed): + +1. Set a device PIN (required for enrollment). + - `adb -s shell locksettings set-pin 1234` +2. Launch fingerprint enrollment. + - `adb -s shell am start -a android.settings.FINGERPRINT_ENROLL` +3. Enter PIN to continue. + - `adb -s shell input text 1234` + - `adb -s shell input keyevent 66` +4. Accept the consent screen ("I AGREE") via UI automation. + - `adb -s shell uiautomator dump /sdcard/fp_enroll.xml` + - `adb -s shell input tap ` +5. At "Touch the sensor", simulate enrollment. + - `adb -s emu finger touch 1` + - `adb -s emu finger remove 1` + - repeat until the UI shows "Fingerprint added" +6. Verify enrollment. + - `adb -s shell dumpsys fingerprint` + - `adb -s shell cmd fingerprint sync` +7. Validate unlock behavior on the lock screen. + - `adb -s shell input keyevent 26` + - `adb -s shell input keyevent 26` + - `adb -s emu finger touch 1` + - `adb -s emu finger remove 1` + - `adb -s shell dumpsys window | rg -n "isKeyguardShowing"` + - `adb -s shell input keyevent 26` + - `adb -s shell input keyevent 26` + - `adb -s emu finger touch 2` + - `adb -s emu finger remove 2` + - `adb -s shell dumpsys window | rg -n "isKeyguardShowing"` + +Observed results: + +- Enrollment completes after repeated touch/remove cycles and UI shows + "Fingerprint added." +- `dumpsys fingerprint` reports one enrolled print: + `prints:[{"id":0,"count":1,...}]`. +- `touch 1` unlocks the lock screen (`isKeyguardShowing=false`). +- `touch 2` does not unlock (`isKeyguardShowing=true`) when only print 1 + is enrolled. + +Notes: + +- `adb shell cmd biometric` has no shell implementation on API 35. +- `adb shell cmd fingerprint` exposes sync/fingerdown/notification only; + enrollment still requires the Settings UI. + +## App-under-test integration (AutoMobile SDK) + +When emulator-only support is not enough, add a debug-only SDK hook to +bypass or simulate biometric callbacks within the app-under-test. + +Suggested SDK entrypoint: + +```text +AutoMobileBiometrics.overrideResult( + result = SUCCESS | FAILURE | CANCEL, + ttlMs = 5000 +) +``` + +Notes: + +- This is a build-time opt-in for apps we can modify. +- It can bypass the system prompt to make tests deterministic. + +## Plan + +1. Implement emulator fingerprint support via `adb emu finger`. +2. Add capability detection and clear error messages for unsupported devices. +3. Add optional SDK hook for deterministic app-under-test flows. + +## Risks + +- Emulator support is primarily fingerprint; face/iris is not consistent. +- Physical devices may not allow simulation without device-owner privileges. diff --git a/docs/design-docs/plat/android/clipboard.md b/docs/design-docs/plat/android/clipboard.md new file mode 100644 index 000000000..680b0a848 --- /dev/null +++ b/docs/design-docs/plat/android/clipboard.md @@ -0,0 +1,75 @@ +# Clipboard tool + +✅ Implemented 🧪 Tested + +> **Current state:** `clipboard` MCP tool is fully implemented. On API 35, `cmd clipboard` returns "No shell command implementation", so the implementation routes through the AutoMobile Accessibility Service. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Provide clipboard copy/paste/clear/get for Android 29/35 emulators and +best-effort support on devices. + +## MCP tool + +```typescript +clipboard({ + action: "copy" | "paste" | "clear" | "get", + text?: string +}) +``` + +## Android implementation + +Primary path (API 29/35 if supported): + +- `adb -s shell cmd clipboard set ""` +- `adb -s shell cmd clipboard get` +- `adb -s shell cmd clipboard clear` + +Capability probe: + +- `adb -s shell cmd clipboard help` + +Fallback path (helper APK): + +- Helper app exposes a broadcast receiver or bound service to set/read + the clipboard via `ClipboardManager`. +- AutoMobile reads results via file or socket for `get`. + +Notes: + +- Newer Android versions restrict clipboard reads for background apps. + The helper should run in the foreground when possible. + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Attempted commands: + +- `adb -s shell cmd clipboard set "Hello AutoMobile"` +- `adb -s shell cmd clipboard get` +- `adb -s shell cmd clipboard clear` +- `adb -s shell cmd clipboard get` + +Observed results: + +- `cmd clipboard` returns "No shell command implementation" on API 35. +- `dumpsys clipboard` returns empty output. + +Notes: + +- ADB-only clipboard manipulation appears unsupported on this emulator/API + level; a helper APK fallback is likely required. + +## Plan + +1. Implement adb `cmd clipboard` support with capability detection. +2. Add helper APK fallback for consistent behavior. + +## Risks + +- Clipboard reads may be blocked on physical devices without foreground UI. +- Service call clipboard APIs are unstable across OEMs. diff --git a/docs/design-docs/plat/android/control-proxy.md b/docs/design-docs/plat/android/control-proxy.md new file mode 100644 index 000000000..9b7e66306 --- /dev/null +++ b/docs/design-docs/plat/android/control-proxy.md @@ -0,0 +1,227 @@ +# Accessibility Service + +✅ Implemented 🧪 Tested + +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +The Android Accessibility Service provides real-time access to view hierarchy data and user interface +elements without requiring device rooting or special permissions beyond accessibility service enablement. +This service acts as a bridge between the Android system's accessibility framework and AutoMobile's +automation capabilities. When enabled, the accessibility service continuously monitors UI changes and +provides detailed information about view hierarchies. It writes the latest hierarchy to app-private +storage and can stream updates over WebSocket for the MCP Server/Daemon to consume. + +## Setup and Enablement + +AutoMobile uses a **settings-based approach** to enable the accessibility service programmatically via ADB commands. This method is fast, reliable, and eliminates the need for UI-based navigation through Android Settings. + +### Toggle Methods (Settings vs Manual UI) + +AutoMobile has one automated toggle path and one manual fallback: + +- **Settings-based (ADB secure settings)**: Default when the device allows programmatic changes. +- **Manual UI enablement**: Used only when settings-based commands are not supported or are denied. + +AutoMobile does not automate the Settings UI. If the settings-based toggle fails or is unsupported, the setup flow returns an error and you must enable the service manually. + +### Settings-Based Toggle + +The accessibility service is enabled by modifying Android secure settings via ADB: + +1. **Reading current services**: Query `enabled_accessibility_services` to preserve existing services +2. **Appending AutoMobile service**: Add the AutoMobile service component to the colon-separated list +3. **Enabling globally**: Set `accessibility_enabled` to `1` + +This approach: +- Preserves other enabled accessibility services +- Works on emulators without special permissions +- Completes in milliseconds by avoiding Settings UI and animations +- Avoids flaky UI automation and Settings screen layout changes + +### Manual UI Enablement (Fallback) + +If settings-based toggling is unavailable or denied: + +1. Open Settings > Accessibility +2. Select AutoMobile Accessibility Service +3. Toggle On and confirm prompts + +This is the only fallback path; AutoMobile does not drive these UI steps programmatically. + +## Overlay Permissions + +The accessibility service can draw overlays for debugging and inspection. When the +`SYSTEM_ALERT_WINDOW` permission is granted, AutoMobile uses `TYPE_APPLICATION_OVERLAY`. +If the permission is missing, it falls back to `TYPE_ACCESSIBILITY_OVERLAY`, which only +requires the accessibility service permission. + +### Granting SYSTEM_ALERT_WINDOW + +- Settings: Open Settings > Apps > Special access > Display over other apps, then enable + AutoMobile Accessibility Service. +- ADB (automated test environments): + +```bash +adb shell appops set dev.jasonpearson.automobile.ctrlproxy \ + SYSTEM_ALERT_WINDOW allow +``` + +The accessibility service can also launch the overlay permission settings screen via +`ACTION_MANAGE_OVERLAY_PERMISSION` when requested over WebSocket (`get_permission`). + +### Capability Detection + +AutoMobile automatically detects whether settings-based toggling is supported on a device: + +**Supported devices:** +- Android emulators (API 16+) +- Physical devices with root access +- Physical devices configured as device owner +- Devices with appropriate shell permissions + +**Unsupported devices:** +- Standard physical devices without special permissions +- Devices below API level 16 + +The capability detection caches results during the session to avoid redundant device queries. + +### Physical Device Limitations + +Most physical devices block `settings put secure` unless the device is rooted, configured as device owner, or grants special shell permissions. When those privileges are missing, settings-based toggling fails with permission errors and manual enablement is required. + +## Device Owner Mode (Optional) + +AutoMobile bundles a `DeviceAdminReceiver` in the accessibility service APK to support provisioning as a device owner. When the app is configured as device owner, it can use `DevicePolicyManager` APIs that are otherwise blocked on physical devices, including CA certificate management. + +### Setup (Factory Reset Required) + +1. Factory reset the device and keep a single user profile. +2. Ensure the AutoMobile accessibility service APK is installed. +3. Provision device owner via ADB: + +```bash +adb shell dpm set-device-owner dev.jasonpearson.automobile.ctrlproxy/.AutoMobileDeviceAdminReceiver +``` + +If provisioning succeeds, `DevicePolicyManager.isDeviceOwnerApp` will return true for the AutoMobile package. + +### CA Certificate Management + +The accessibility service WebSocket interface supports CA certificate operations when device owner is active: + +- `install_ca_cert` with a PEM or base64-encoded DER payload. Returns an alias (SHA-256 fingerprint). +- `install_ca_cert_from_path` with a device file path (e.g., `/sdcard/Download/automobile/ca_certs/...`). +- `remove_ca_cert` with the alias (or certificate payload) to uninstall. +- `get_device_owner_status` to report device owner and admin activation state. + +These operations are currently available via the accessibility service WebSocket client (not exposed as MCP tools). + +### Error Handling + +The setup process provides categorized error messages for common failure scenarios: + +- **Permission errors**: Indicates the device requires root, device owner status, or special shell permissions +- **Connection errors**: Device is offline, not found, or ADB is unresponsive +- **Timeout errors**: Device is taking too long to respond +- **Network errors**: Unable to download the accessibility service APK +- **Installation errors**: APK installation failed +- **Unsupported device**: Settings-based toggle not available on this device + +Each error category includes the original error details for debugging while providing user-friendly context about what went wrong and potential remediation steps. + +## API Reference (TypeScript) + +The settings-toggle API lives in `src/utils/CtrlProxyManager.ts` and is implemented by `AndroidCtrlProxyManager`. + +### ToggleCapabilities + +`ToggleCapabilities` describes whether programmatic toggling is supported: + +```ts +export type ToggleCapabilities = { + supportsSettingsToggle: boolean; + deviceType: "emulator" | "physical"; + apiLevel: number | null; + reason?: string; +}; +```typescript + +### `canUseSettingsToggle(): Promise` + +Returns `true` when settings-based toggling is supported. Returns `false` when capability detection fails or the device does not allow secure settings updates. This method does not throw. + +### `getToggleCapabilities(): Promise` + +Returns detailed capability information, including a human-readable `reason` when the toggle is unsupported. This method does not throw; detection errors are reflected in the returned payload. + +### `enableViaSettings(): Promise` + +Enables the service via `settings put secure`. Throws `Error` with categorized messages for: + +- Permission denied (root/device owner/shell permissions required) +- Device connection loss or offline device +- Timeouts +- Other ADB or device-state failures + +### `disableViaSettings(): Promise` + +Disables the service via `settings put secure`. Throws the same error categories as `enableViaSettings()`. It preserves other enabled services and only clears `accessibility_enabled` when no services remain. + +### `enable(): Promise` + +Alias for settings-based enablement. There is no UI automation fallback. + +### Example + +```ts +// Inside the AutoMobile codebase. +const manager = AndroidCtrlProxyManager.getInstance(device, adb); +const capabilities = await manager.getToggleCapabilities(); + +if (!capabilities.supportsSettingsToggle) { + throw new Error(capabilities.reason ?? "Settings-based toggle not supported"); +} + +await manager.enableViaSettings(); +``` + +## Architecture + +The accessibility service runs as a standard Android accessibility service that: + +1. Monitors UI hierarchy changes in real-time +2. Extracts view properties and accessibility metadata +3. Writes hierarchy data to app-private storage for fast local access +4. Optionally streams updates via WebSocket for real-time observation +5. Provides accessibility identifiers and semantic information for reliable element targeting + +## Version Management + +AutoMobile manages accessibility service versions automatically: + +- Compares installed APK checksum against expected release version +- Upgrades when version mismatch detected +- Falls back to reinstallation if upgrade fails +- Validates downloaded APKs via SHA256 checksum +- Supports local APK overrides for development + +When device setup uses `skipAccessibilityDownload`, AutoMobile still validates the installed service checksum. +If the version is incompatible, it surfaces a warning/error advising you to rerun without +`skipAccessibilityDownload` to upgrade or install the matching APK manually. + +## Troubleshooting + +- **Settings toggle fails with permission denied**: Physical devices often require root, device owner status, or special shell permissions. Enable the service manually in Settings if you cannot grant those permissions. +- **Settings toggle fails with connection errors**: Verify `adb devices` shows the device as online and retry. +- **Force UI-based toggle**: AutoMobile does not automate UI toggling. Use manual Settings enablement as described above. +- **Service appears enabled but not detected**: Confirm the service component appears in `adb shell settings get secure enabled_accessibility_services`. + +## Environment Variables + +- `AUTOMOBILE_ACCESSIBILITY_APK_PATH`: Override APK source with a local file path. +- `AUTOMOBILE_SKIP_ACCESSIBILITY_CHECKSUM`: Skip checksum validation (development mode). +- `AUTO_MOBILE_ACCESSIBILITY_SERVICE_SHA_SKIP_CHECK` (deprecated): Legacy alias for skipping checksum validation. +- `AUTOMOBILE_SKIP_ACCESSIBILITY_DOWNLOAD_IF_INSTALLED`: Skip version check if the service is already installed. +- `AUTOMOBILE_ACCESSIBILITY_TOGGLE_METHOD`: Not supported; settings-based toggling is the only automated path. + +See [GitHub Issue #483](https://github.com/kaeawc/auto-mobile/issues/483) for ongoing work to standardize environment variable naming. diff --git a/docs/design-docs/plat/android/executeplan-assertions.md b/docs/design-docs/plat/android/executeplan-assertions.md new file mode 100644 index 000000000..db0acc17a --- /dev/null +++ b/docs/design-docs/plat/android/executeplan-assertions.md @@ -0,0 +1,73 @@ +# executePlan assertions + +⚠️ Partial + +> **Current state:** `await` behavior exists in the `observe` tool (via `waitFor` parameters) and `swipeOn` (via `lookFor`). The standalone `await` YAML step type and `assert` step type described below are **not implemented**. In-plan assertions are available via `expectations` in step params. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Add native assertion and await steps to `executePlan`, with fail-fast +behavior by default and idling support from accessibility events. + +## Proposed YAML extensions 🚧 Design Only + +```text +- await: + lookFor: + text: "Logged in" + timeoutMs: 5000 + idle: "a11y" + +- assert: + exists: + resourceId: "com.app:id/error" + shouldBe: false +``` + +Semantics: + +- `await` polls until the selector matches or timeout occurs. +- `assert` fails immediately when the condition is not met. +- Default failure mode is hard fail (stop plan). JUnitRunner can wrap + failures into test assertions if needed. + +## Android implementation + +Idle detection: + +- Prefer AccessibilityService events (e.g., `WINDOW_CONTENT_CHANGED`). +- If no events arrive, fall back to polling `observe` at a low interval. + +Selector resolution: + +- Reuse existing element finding logic (text/resourceId/regex/class). +- The tool should return `lastObservationId` for debugging. + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Confirmed commands: + +- Open Settings and dump UI: + - `adb -s shell am start -n com.android.settings/.Settings` + - `adb -s shell uiautomator dump /sdcard/settings_dump.xml` + - `adb -s shell cat /sdcard/settings_dump.xml` + +Observed results: + +- The UI dump contains stable text ("Settings") suitable for await/assert + selectors. + +## Plan + +1. Extend executePlan schema to include `await` and `assert` steps. +2. Implement a11y-event idling with polling fallback. +3. Map failures to JUnitRunner assertions when invoked under JUnit. + +## Risks + +- Event-driven idling may miss transient states; use a min-hold duration. +- Polling too aggressively can increase device load. diff --git a/docs/design-docs/plat/android/feature-ideas.md b/docs/design-docs/plat/android/feature-ideas.md new file mode 100644 index 000000000..38d018739 --- /dev/null +++ b/docs/design-docs/plat/android/feature-ideas.md @@ -0,0 +1,23 @@ +# Overview + +> This page links to design docs with status chips indicating whether each feature is ✅ Implemented, ⚠️ Partial, or 🚧 Design Only. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +> This page links to design docs for features that are unimplemented or only partially implemented. Each linked doc has its own status chip. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +This page summarizes Android-specific MCP feature ideas and links to the +per-feature design notes. + +## Current scope decisions + +- Emulator images: API 29 and API 35. +- Helper APKs are allowed and preferred when faster or more reliable. +- Use the AccessibilityService whenever it is the lowest-latency option. +- Network controls should include all toggles plus shaping profiles. +- executePlan failures should halt immediately (JUnitRunner may override). + +## Remaining feature docs + +- [Network state control](network-state.md) +- [Accessibility testing](accessibility-testing.md) +- [executePlan assertions and await](executeplan-assertions.md) +- [TalkBack simulation/enablement](talkback.md) diff --git a/docs/design-docs/plat/android/ide-plugin/feature-flags.md b/docs/design-docs/plat/android/ide-plugin/feature-flags.md new file mode 100644 index 000000000..97f3daaa7 --- /dev/null +++ b/docs/design-docs/plat/android/ide-plugin/feature-flags.md @@ -0,0 +1,47 @@ +# Control Feature Flags + +⚠️ Partial + +> **Current state:** Feature flag data is available via the daemon Unix socket. The IDE plugin tab UI for viewing and toggling flags is implemented in the plugin infrastructure but may not be fully feature-complete. See the [Status Glossary](../../../status-glossary.md) for chip definitions. + +## Goal + +Provide a dedicated UI in the Android Studio plugin to view and toggle +AutoMobile feature flags without leaving the IDE. + +## UX + +- Tool window tab: "Feature Flags". +- Table view with: + - Flag key + - Current value + - Description (if provided) + - Toggle control +- Search/filter by flag key. +- A refresh action to re-fetch from the daemon socket. + +## Data sources + +Use the AutoMobile daemon Unix socket (e.g., `/tmp/auto-mobile-daemon-.sock`) +to fetch and update feature flags. Avoid MCP tool calls for this surface. + +## Behavior + +- Load flags on tab open and on explicit refresh. +- Optimistically update the UI after a toggle, but roll back on error. +- If a flag is read-only, disable the toggle and show the reason. + +## Error handling + +- If the daemon socket is unavailable, show a reconnect state. +- If a toggle fails, show a toast with the error message and revert. + +## Performance notes + +- Cache the last fetched list and only diff updates when reloading. +- Debounce search input to avoid UI thrash. + +## See also + +- [Feature Flags](../../../mcp/feature-flags.md) +- [IDE Plugin Overview](overview.md) diff --git a/docs/design-docs/plat/android/ide-plugin/navigation-graph.md b/docs/design-docs/plat/android/ide-plugin/navigation-graph.md new file mode 100644 index 000000000..42cc99490 --- /dev/null +++ b/docs/design-docs/plat/android/ide-plugin/navigation-graph.md @@ -0,0 +1,52 @@ +# Navigation Graph Render + +⚠️ Partial + +> **Current state:** Navigation graph data is produced by the MCP server and streamed. The IDE plugin tab for rendering the graph is implemented in the Android Studio plugin, though display completeness varies. See the [Status Glossary](../../../status-glossary.md) for chip definitions. + +## Goal + +Render the current navigation graph inside the Android Studio plugin so +engineers can inspect app flow and validate navigation coverage without +leaving the IDE. + +## UX + +- Tool window tab: "Navigation Graph". +- Graph view with zoom/pan and node selection. +- Selected node shows: + - Screen name + - Last observed activity/package + - Recent transitions (incoming/outgoing) +- Latest cached screenshot for the screen, if available. +- A refresh action to fetch the latest graph snapshot. + +## Data sources + +The MCP server streams navigation graph updates to the plugin. Nodes may +include an optional screenshot reference, exposed via navigation node +resources. + +## Rendering pipeline + +1. Subscribe to the server's navigation graph stream. +2. Normalize nodes/edges into a stable layout model. +3. Render via a lightweight graph UI (no per-frame allocations). +4. Keep a cached layout and only recompute on structural changes. + +## Error handling + +- If the graph is empty, show a "No navigation data yet" state. +- If the MCP server is unreachable, show a reconnect action with diagnostics. +- If parsing fails, log the raw payload and surface a brief error. + +## Performance notes + +- Defer layout work until the view is visible. +- Limit re-renders to changes in graph topology. +- Avoid blocking the UI thread on large graphs. + +## See also + +- [Navigation Graph](../../../mcp/nav/index.md) +- [IDE Plugin Overview](overview.md) diff --git a/docs/design-docs/plat/android/ide-plugin/overview.md b/docs/design-docs/plat/android/ide-plugin/overview.md new file mode 100644 index 000000000..7893635b3 --- /dev/null +++ b/docs/design-docs/plat/android/ide-plugin/overview.md @@ -0,0 +1,19 @@ +# Overview + +✅ Implemented 🧪 Tested + +> **Current state:** The Android Studio/IntelliJ plugin is implemented in `android/ide-plugin/`. Supports MCP over STDIO and daemon socket, and device pool display. Sub-features (navigation graph render, test recording, feature flags UI) have varying completeness — see their individual docs. See the [Status Glossary](../../../status-glossary.md) for chip definitions. + +The AutoMobile IntelliJ/Android Studio plugin attaches to a running MCP server to render navigation data and +manage development workflows. It connects via STDIO or the local daemon socket. + +## Transport selection +The plugin resolves a transport when the user clicks "Attach to MCP": + +1. `AUTOMOBILE_MCP_STDIO_COMMAND` / `-Dautomobile.mcp.stdioCommand` (stdio). +2. Unix socket fallback at `/tmp/auto-mobile-daemon-.sock`. + +## Tool window UX +- The dropdown lists every git worktree and its associated daemon (if any). +- Worktrees without a running daemon are shown as "no server". +- "Rescan servers" re-runs discovery to pick up newly launched daemons. diff --git a/docs/design-docs/plat/android/ide-plugin/test-recording.md b/docs/design-docs/plat/android/ide-plugin/test-recording.md new file mode 100644 index 000000000..09db11313 --- /dev/null +++ b/docs/design-docs/plat/android/ide-plugin/test-recording.md @@ -0,0 +1,236 @@ +# Test Recording + +✅ Implemented 🧪 Tested + +> **Current state:** Test recording is implemented in the Android Studio/IntelliJ plugin. Recording control uses the Unix socket (`~/.auto-mobile/test-recording.sock`). Plan generation, review, and execution via `executePlan` are all supported. See the [Status Glossary](../../../status-glossary.md) for chip definitions. + +The AutoMobile IDE Plugin includes test recording capabilities to capture user interactions and generate executable test plans. + + +Test recording allows developers to: + +- **Record interactions** - Capture taps, swipes, and inputs as they interact with the app +- **Generate YAML plans** - Automatically create executable test plans from recordings +- **Replay tests** - Execute recorded plans via `executePlan` tool +- **Edit recordings** - Manually adjust generated plans before execution + +## Recording Workflow + +Recording control uses the test recording Unix socket (`~/.auto-mobile/test-recording.sock`) +so the IDE can start/stop capture without issuing MCP tool calls. + +```mermaid +flowchart LR + A["Start recording (IDE)"] --> B["Perform interactions in app"]; + B --> C["Stop recording"]; + C --> D["Review and edit generated YAML plan"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,B,C logic; + class D result; +``` + +### Start Recording + +From the IDE plugin tool window: +1. Ensure the AutoMobile server is running +2. Select the target device from the dropdown +3. Click "Start Recording" to begin capturing interactions + +### Perform Interactions + +Interact with your app normally: + +- Tap buttons and UI elements +- Swipe and scroll through lists +- Enter text into fields +- Navigate between screens + +The plugin captures each interaction and records: + +- Element identifiers (resource ID, text, content description) +- Action type (tap, swipe, input) +- Screen context (current activity, view hierarchy signature) +- Timing information + +### Stop Recording + +Click "Stop Recording" when finished. The plugin will: + +- Generate a YAML test plan from the recorded interactions +- Display the plan in the editor +- Validate the plan structure + +### Review and Edit + +The generated YAML plan can be edited before execution: + +```yaml +steps: + - tool: launchApp + params: + appId: com.example.app + + - tool: tapOn + params: + text: "Login" + action: tap + + - tool: inputText + params: + text: "user@example.com" + + - tool: tapOn + params: + id: "loginButton" + action: tap +``` + +## Executing Recorded Tests + +### From IDE Plugin + +Use the "Execute Plan" button in the tool window to run the recorded test: +1. Open the YAML plan file +2. Click "Execute Plan" +3. Monitor execution in the tool window + +### From Code + +Use the `executePlan` MCP tool directly: + +```typescript +await executePlan({ + planContent: yamlPlanString, + platform: "android", + cleanupAppId: "com.example.app" +}) +``` + +### From CI + +Recorded plans can be executed in CI environments: + +```bash +# Execute via MCP server +auto-mobile execute-plan --plan path/to/test.yaml --platform android +``` + +## Plan Structure + +### Basic Format + +```yaml +metadata: + name: "Login flow test" + description: "Tests user login with valid credentials" + appId: com.example.app + +steps: + - tool: launchApp + params: + appId: com.example.app + + - tool: tapOn + params: + text: "Sign In" + action: tap + + - tool: observe + # Capture current screen state +``` + +### Advanced Features + +**Conditional steps**: +```yaml +- tool: tapOn + params: + text: "Skip Tutorial" + action: tap + optional: true # Don't fail if element not found +``` + +**Assertions**: +```yaml +- tool: observe + assert: + contains: "Welcome back" +``` + +**Wait conditions**: +```yaml +- tool: tapOn + params: + text: "Submit" + action: tap + await: + element: + text: "Success" + timeout: 5000 +``` + +## Test Organization + +### File Structure + +Organize recorded tests in your project: + +```text +tests/ + automobile/ + plans/ + onboarding/ + welcome-flow.yaml + tutorial-skip.yaml + login/ + successful-login.yaml + invalid-credentials.yaml + checkout/ + add-to-cart.yaml + complete-purchase.yaml +``` + +### Naming Conventions + +Use descriptive names for test plans: + +- `feature-scenario.yaml` format +- Include happy path and error cases +- Group related tests in directories + +## Best Practices + +1. **Start with clean state** - Always launch app with `coldBoot` or `clearAppData` if needed +2. **Use stable identifiers** - Prefer resource IDs over text when possible +3. **Add assertions** - Include `observe` steps to verify expected state +4. **Handle waits** - Use `await` parameters for elements that load asynchronously +5. **Keep tests focused** - Each plan should test one specific flow +6. **Add cleanup** - Use `cleanupAppId` to terminate app after test + +## Troubleshooting + +### Recording not capturing interactions + +- Ensure Accessibility Service is enabled +- Check MCP server connection in tool window +- Verify device is selected and connected + +### Playback fails on elements not found + +- Use `debugSearch` tool to understand element matching +- Add `optional: true` for non-critical steps +- Update selectors to use more stable identifiers + +### Tests flaky across devices + +- Add explicit waits with `await` parameters +- Check for device-specific UI differences +- Use feature flags to disable animations during testing + +## See Also + +- [MCP tool reference](../../../mcp/tools.md) - Available MCP tools for test plans +- [IDE Plugin Overview](overview.md) - IDE plugin features and setup +- [UI Tests](../../../../using/ui-tests.md) - Writing and running UI tests diff --git a/docs/design-docs/plat/android/index.md b/docs/design-docs/plat/android/index.md new file mode 100644 index 000000000..acf26e6a4 --- /dev/null +++ b/docs/design-docs/plat/android/index.md @@ -0,0 +1,23 @@ +# Overview + +✅ Implemented 🧪 Tested + +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile's Android stack spans the MCP server, daemon, IDE plugin, JUnitRunner, and device-side services. + +## Test Execution + +The Android JUnitRunner executes AutoMobile tests on devices and emulators. It extends the standard +Android testing framework with richer reporting and tighter MCP integration, with a long-term goal of +self-healing test flows. + +## Android Accessibility Service + +The Android Accessibility Service provides real-time access to view hierarchy data and UI elements without +rooting or special permissions beyond accessibility enablement. When enabled, it monitors UI changes, +stores the latest hierarchy in app-private storage, and streams updates over WebSocket. + +## Batteries Included + +AutoMobile includes tooling to minimize setup for required platform dependencies. diff --git a/docs/design-docs/plat/android/junitrunner.md b/docs/design-docs/plat/android/junitrunner.md new file mode 100644 index 000000000..5fb991621 --- /dev/null +++ b/docs/design-docs/plat/android/junitrunner.md @@ -0,0 +1,154 @@ +# JUnitRunner + +✅ Implemented 🧪 Tested + +> **Current state:** `android/junit-runner/` is fully implemented. Includes `@AutoMobileTest` annotation, YAML plan execution via daemon socket, AI-assisted failure recovery using the [Koog framework](https://github.com/JetBrains/koog) (OpenAI, Anthropic, Google), parallel multi-device execution, and memory diagnostics. Published to Maven Central. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Installation + +Add this test Gradle dependency to all Android apps and libraries in your codebase. You can also add it only to the +modules that cover the UI you want to test. + +```gradle +testImplementation("dev.jasonpearson.auto-mobile:auto-mobile-junit-runner:x.y.z") +``` + +This artifact is intended for Maven Central distribution. Use the latest release version once published. + +For local development or testing unpublished changes, publish to your mavenLocal (`~/.m2`) via: + +```bash +cd android +./gradlew :junit-runner:publishToMavenLocal +``` + +and use the above testImplementation dependency with the version from `android/junit-runner/build.gradle.kts`. + +## Configuration + +Configure the runner via system properties or environment variables: + +```properties +# gradle.properties +automobile.ai.provider=anthropic +automobile.junit.timing.ordering=duration-desc +``` + +Set API keys via environment variables: +```bash +export ANTHROPIC_API_KEY="your_api_key_here" +``` + +Or via system property: +```bash +-Dautomobile.anthropic.api.key=your_api_key_here +``` + +Optional proxy endpoint: +```bash +-Dautomobile:your-proxy.example.com +``` + +## AI Recovery + +The runner is designed to eventually support agentic self-healing capabilities, allowing tests to +automatically adapt and recover from common failure scenarios by leveraging AI-driven analysis of test failures and UI changes. + +## Pooled Device Management + +Multi-device support with emulator control and app lifecycle management. As long as you have available adb connections, +AutoMobile can automatically track which one its using for which execution plan or MCP session. CI still needs available +device connections, but AutoMobile handles selection and readiness checks. During STDIO MCP sessions, +🔧 [`setActiveDevice`](../../mcp/tools.md) is set once and reused for the session. + +## Historical Timing Data + +The AutoMobile daemon exposes historical test timing summaries to the JUnitRunner via the +`automobile:test-timings` MCP resource. The runner fetches this data on startup (per JVM) and can optionally +use it to order tests based on historical duration. The default is `automobile.junit.timing.ordering=auto`, +which chooses longest-first when effective parallel forks are greater than 1 after device availability +is applied, and shortest-first otherwise. Set `automobile.junit.timing.ordering=none` to disable ordering, +or explicitly set `duration-desc` (longest-first) / `duration-asc` (shortest-first). + +Timing fetch is automatically disabled when CI is detected (`automobile.ci.mode=true` or `CI=true`). + +Example response format: + +```json +{ + "testTimings": [ + { + "testClass": "com.example.LoginTest", + "testMethod": "testSuccessfulLogin", + "averageDurationMs": 1250, + "sampleSize": 15, + "lastRun": "2026-01-05T12:00:00Z", + "successRate": 0.93 + } + ], + "generatedAt": "2026-01-05T13:00:00Z", + "totalTests": 150 +} +``` + +## Model Providers + +The JUnitRunner supports OpenAI, Anthropic, Google Gemini, and AWS Bedrock for AI self-healing capabilities. + +Configure via system property: +```properties +automobile.ai.provider=anthropic +``` + +For API key setup, see [Configuration](#configuration). + +## CI/CD Integration + +### Environment Variables + +For CI environments, use environment-injected secrets: + +```yaml +# GitHub Actions example +env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + AUTOMOBILE_CI_MODE: true +``` + +### Gradle Configuration + +```gradle +android { + testOptions { + unitTests.all { + systemProperty "automobile.ai.provider", "anthropic" + systemProperty "automobile.ci.mode", "true" + } + } +} +``` + +## Publishing (Manual) + +1. Update `version` in `android/junit-runner/build.gradle.kts` to a release value (no `-SNAPSHOT`). +2. Ensure Maven Central credentials are available as Gradle properties (`mavenCentralUsername`, + `mavenCentralPassword`). These can live in `~/.gradle/gradle.properties` or be provided via + `ORG_GRADLE_PROJECT_` environment variables. +3. Ensure signing credentials are configured (for example `signingInMemoryKey` and + `signingInMemoryKeyPassword`, or the `signing.*` Gradle properties). +4. From `android/`, run: + +```bash +./gradlew :junit-runner:publishToMavenCentral +``` + +5. Release the deployment in https://central.sonatype.com (or run + `./gradlew :junit-runner:publishAndReleaseToMavenCentral` to auto-release). + +## Best Practices + +1. **Use mavenLocal for local iteration** - Helpful for testing unpublished changes +2. **Enable the [Accessibility Service](control-proxy.md)** - Required for real-time view hierarchy access +3. **Configure API keys securely** - Use environment variables in CI, avoid hardcoding +4. **Enable timing optimization** - Use historical timing data to order tests efficiently +5. **Monitor device pool** - Ensure enough devices are available for parallel execution diff --git a/docs/design-docs/plat/android/network-state.md b/docs/design-docs/plat/android/network-state.md new file mode 100644 index 000000000..84c1af1a0 --- /dev/null +++ b/docs/design-docs/plat/android/network-state.md @@ -0,0 +1,109 @@ +# Network State Control + +❌ Not Implemented *(MCP tool)* · ✅ Implemented *(ADB commands, validated)* + +> **Current state:** The `setNetworkState` MCP tool has **not been built**. The ADB commands for Wi-Fi, cellular data, airplane mode, and emulator network shaping are validated on API 35 and documented below. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Provide a single MCP tool to toggle Wi-Fi, cellular, and airplane mode, +plus emulator-friendly latency/bandwidth shaping. + +## Proposed MCP tool + +```typescript +setNetworkState({ + airplaneMode?: boolean, + wifi?: boolean, + cellular?: boolean, + profile?: "edge" | "umts" | "lte" | "full", + delayProfile?: "gprs" | "edge" | "umts" | "none" +}) +``` + +Semantics: + +- Toggles are applied first (airplane, wifi, cellular). +- Profiles are best-effort on emulators only. +- Response includes `supported` and `applied` fields per sub-action. + +## Android implementation + +Wi-Fi: + +- `adb -s shell svc wifi enable` +- `adb -s shell svc wifi disable` + +Cellular: + +- `adb -s shell svc data enable` +- `adb -s shell svc data disable` + +Airplane mode (preferred, API 29/35 emulators): + +- `adb -s shell cmd connectivity airplane-mode enable` +- `adb -s shell cmd connectivity airplane-mode disable` + +Airplane mode fallback: + +- `adb -s shell settings put global airplane_mode_on 1|0` +- `adb -s shell am broadcast -a android.intent.action.AIRPLANE_MODE --ez state true|false` + +Emulator shaping (API 29/35 emulator only): + +- `adb -s emu network speed ` +- `adb -s emu network delay ` + +Notes: + +- Custom ms/throughput values are not supported by `emu network` and should + be rejected with a clear error. +- Physical devices often restrict airplane mode toggles; report + `supported: false` with a reason when blocked. + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Confirmed commands: + +- Wi-Fi toggle: + - `adb -s shell svc wifi disable` + - `adb -s shell settings get global wifi_on` + - `adb -s shell svc wifi enable` + - `adb -s shell settings get global wifi_on` +- Cellular data toggle: + - `adb -s shell svc data disable` + - `adb -s shell settings get global mobile_data` + - `adb -s shell svc data enable` + - `adb -s shell settings get global mobile_data` +- Airplane mode: + - `adb -s shell cmd connectivity airplane-mode enable` + - `adb -s shell settings get global airplane_mode_on` + - `adb -s shell cmd connectivity airplane-mode disable` + - `adb -s shell settings get global airplane_mode_on` +- Emulator speed/delay profiles: + - `adb -s emu network speed lte` + - `adb -s emu network delay umts` + - `adb -s emu network status` + +Observed results: + +- `wifi_on` toggles 0/1 after `svc wifi` disable/enable. +- `mobile_data` toggles 0/1 after `svc data` disable/enable. +- `airplane_mode_on` toggles 1/0 after enable/disable. +- `emu network speed`/`delay` return OK and `emu network status` reports + LTE/UMTS values. + +## Plan + +1. Implement toggles with capability reporting. +2. Add emulator shaping support (speed/delay profiles). +3. Expose `getNetworkState` for verification in assertions. + +## Risks + +- OEM images may block `cmd connectivity airplane-mode`. +- Work profile behavior can differ from personal profile toggles. diff --git a/docs/design-docs/plat/android/notifications.md b/docs/design-docs/plat/android/notifications.md new file mode 100644 index 000000000..c7ef453b0 --- /dev/null +++ b/docs/design-docs/plat/android/notifications.md @@ -0,0 +1,84 @@ +# Notification Triggering + +✅ Implemented 🧪 Tested + +> **Current state:** `postNotification` MCP tool is fully implemented. The AutoMobile SDK `AutoMobileNotifications.post()` hook is implemented in `android/auto-mobile-sdk/`. Apps without SDK integration cannot post notifications that appear as the app-under-test (third-party app limitation). See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Trigger notifications that appear as the app-under-test. Provide +rich content (title, body, big text, big image, actions). + +## MCP tool + +```typescript +postNotification({ + title: string, + body: string, + imageType?: "normal" | "bigPicture", + imagePath?: string, + actions?: Array<{ label: string, actionId: string }>, + channelId?: string +}) +``` + +## Preferred path: AutoMobile SDK hook (app-under-test) + +Add a debug-only SDK component that accepts a broadcast or bound-service +request and posts a notification from the target app process. + +Suggested SDK entrypoint (app side): + +```kotlin +AutoMobileNotifications.post( + title, + body, + style, + imagePath, + actions +) +``` + +**SDK requirement:** the app-under-test must include the AutoMobile SDK +in its debug build so the broadcast receiver is available to the MCP +server. Third-party apps without SDK integration cannot post +notifications that appear as the app-under-test. + +Implementation notes: + +- Use `NotificationManager` / `NotificationCompat` with a known channel ID. +- When `imageType == bigPicture`, load from `imagePath` on device storage + (e.g., `/sdcard/Download/automobile/`). The MCP tool pushes a host file + into this directory before invoking the SDK receiver. +- Actions should target a test-only activity or broadcast receiver. + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Verification commands (after posting via MCP tool): + +- Inspect via system tray UI dump: + - `adb -s shell cmd statusbar expand-notifications` + - `adb -s shell uiautomator dump /sdcard/notification_dump.xml` + - `adb -s shell cat /sdcard/notification_dump.xml` + - `adb -s shell cmd statusbar collapse` + +Observed results: + +- Notifications appear with provided titles/bodies. +- The uiautomator dump includes the notification text for matching. +- Multi-word titles appear truncated in the grouped list view (first token), + but full text is present in the dump. + +## Plan + +1. Implement SDK notification hook (debug-only). +2. Add MCP `postNotification` tool that targets SDK by default. + +## Risks + +- Requires app-under-test integration (not possible for third-party apps). +- Big picture style has size constraints and may fail with large images. diff --git a/docs/design-docs/plat/android/observe.md b/docs/design-docs/plat/android/observe.md new file mode 100644 index 000000000..e6d4611ca --- /dev/null +++ b/docs/design-docs/plat/android/observe.md @@ -0,0 +1,65 @@ +# Observe + +✅ Implemented 🧪 Tested + +> **Current state:** Fully implemented. The tiered observation pipeline (Accessibility Service → uiautomator fallback with perceptual hash caching) is active. WebSocket freshness after interactions with a 5-second timeout is implemented. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## UIAutomator Fallback + +- `dumpsys window` is fetched (with a short-lived memory/disk cache) and used to compute rotation and system insets. +- Screen size is computed from `wm size` plus rotation, with its own memory/disk cache. +- In parallel, the observer collects rotation, wakefulness, and back stack while view hierarchy is fetched separately. +- The active window is primarily derived from the view hierarchy package name, with a fallback to `dumpsys window` when + needed. +- View Hierarchy + - The best and fastest option is fetching it via the pre-installed and enabled accessibility service. This is never + cached because that would introduce lag. + + ```mermaid + flowchart LR + A["Observe()"] --> B{"installed?"}; + B -->|"✅"| C{"running?"}; + B -->|"❌"| E["caching system"]; + C -->|"✅"| D["cat vh.json"]; + C -->|"❌"| E["uiautomator dump"]; + D --> I["Return"] + E --> I; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,G,I result; + class D,E,H logic; + class B,C,F decision; + ``` + + - Latest iteration (WebSocket freshness) + - After interactions, the observer waits for a WebSocket push from the accessibility service to deliver a fresh + view hierarchy. + - The wait uses a 5-second timeout. If no push arrives, the server falls back to a synchronous request to fetch + the latest view hierarchy. + - Debouncing is applied so rapid consecutive interactions do not trigger multiple overlapping fetches. The most + recent pending request wins, and stale requests are dropped. + + - If the accessibility service is not installed or not enabled yet, we fall back to `uiautomator` output that is + cached based on a perceptual hash plus pixel matching within a tolerance threshold. + + ```mermaid + flowchart LR + A["Observe()"] --> B["Screenshot
+perceptual hash"]; + B --> C{"hash
match?"}; + C -->|"✅"| D["pixelmatch"]; + C -->|"❌"| E["uiautomator dump"]; + D --> F{"within tolerance?"}; + F -->|"✅"| G["Return"]; + F -->|"❌"| E; + E --> H["Cache"]; + H --> I["Return New Hierarchy"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A,G,I result; + class D,E,H logic; + class B,C,F decision; + ``` + +See [Observation Overview](../../mcp/observe/index.md) for the full list of collected fields and error handling. \ No newline at end of file diff --git a/docs/design-docs/plat/android/screen-streaming.md b/docs/design-docs/plat/android/screen-streaming.md new file mode 100644 index 000000000..3f64e9589 --- /dev/null +++ b/docs/design-docs/plat/android/screen-streaming.md @@ -0,0 +1,201 @@ +# Real-Time Screen Streaming Architecture + +⚠️ Partial + +> **Current state:** The Android `video-server` module (`android/video-server/`) is fully implemented — `VideoServer.kt` captures via VirtualDisplay, encodes H.264 with MediaCodec, and streams over a LocalSocket. The `videoRecording` MCP tool (record-to-file) uses this server and is ✅ Implemented 🧪 Tested. +> +> The **live IDE screen mirroring** pipeline (MCP video-stream relay → IDE DeviceScreenView with Klarity decoder) is in progress. Milestone 1 (video-server JAR) is complete; Milestones 2–5 are ongoing. See implementation plan below. +> +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Research and design for real-time screen streaming from Android devices to the IDE plugin, enabling interactive device mirroring at up to 60fps with <100ms latency. + +See [Screen Streaming Overview](../../mcp/observe/screen-streaming.md) for the cross-platform architecture, video stream socket protocol, and quality presets. + +## Goals + +- Continuous live streaming for device mirroring in the IDE +- Up to 60fps frame rate +- <100ms end-to-end latency for interactive use +- Integrate with existing WebSocket-based architecture +- Support USB-connected physical devices and emulators + +## Why a Separate Video Server? + +The accessibility service **cannot** use VirtualDisplay for screen capture. A separate JAR running as shell user is required. + +scrcpy achieves permission-less screen capture by running via `adb shell app_process` as **shell user (UID 2000)**, impersonating `com.android.shell`, and accessing hidden `DisplayManagerGlobal.createVirtualDisplay(displayIdToMirror)` via reflection. These privileges are not available to the accessibility service. + +| Capability | Accessibility Service | Shell User (adb) | +|------------|----------------------|------------------| +| Hidden `DisplayManagerGlobal` APIs | No | Yes | +| `SurfaceControl.createDisplay()` | No | Yes | +| Screen capture without dialog | No | Yes | +| Impersonate `com.android.shell` | No | Yes | + +The accessibility service can only do on-demand `takeScreenshot()` or request `MediaProjection` (which requires a user permission dialog **each session** and foreground service on Android 14+). + +## Architecture + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Android Device │ +├────────────────────────────────────────────────────────────────────┤ +│ │ +│ Accessibility Service Video Server JAR │ +│ (normal app process) (shell user via adb) │ +│ │ +│ ├─ View hierarchy extraction ├─ VirtualDisplay capture │ +│ ├─ Gesture injection ├─ MediaCodec H.264 encoding │ +│ ├─ Text input └─ LocalSocket streaming │ +│ └─ WebSocket server (:8765) │ +│ │ +│ UID: app-specific UID: 2000 (shell) │ +│ Started by: Android system Started by: adb shell │ +│ Permissions: Accessibility Permissions: Shell (system) │ +│ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +**Key Components:** + +1. **Video Streaming Server** (new Android component) + - Separate process running via `adb shell` (like scrcpy-server) + - Small JAR pushed to `/data/local/tmp/automobile-video.jar` + - Uses SurfaceControl/VirtualDisplay for capture (no permission dialog) + - MediaCodec H.264 encoder + - Writes to LocalSocket + +2. **Video Stream Proxy** (new MCP server component) + - Connects to video LocalSocket via ADB forward + - Streams binary data to new Unix socket + - Handles reconnection and device switching + +3. **Video Stream Client** (new IDE plugin component) + - Klarity library for H.264 decoding (FFmpeg via JNI) + - Decodes frames directly to Skia Pixmap + - Renders to Compose Canvas (no SwingPanel needed) + +## Protocol Specification + +### Video Stream Header (12 bytes) +``` +┌─────────────────┬─────────────────┬─────────────────┐ +│ codec_id (4) │ width (4) │ height (4) │ +│ big-endian │ big-endian │ big-endian │ +└─────────────────┴─────────────────┴─────────────────┘ + +codec_id values: + 0x68323634 = "h264" (H.264/AVC) + 0x68323635 = "h265" (H.265/HEVC) +``` + +### Packet Header (12 bytes per packet) +``` +┌─────────────────────────────────────┬─────────────────┐ +│ pts_and_flags (8) │ size (4) │ +│ big-endian │ big-endian │ +└─────────────────────────────────────┴─────────────────┘ + +pts_and_flags bit layout: + bit 63: PACKET_FLAG_CONFIG (codec config data, not a frame) + bit 62: PACKET_FLAG_KEY_FRAME (I-frame) + bits 0-61: presentation timestamp in microseconds + +Followed by `size` bytes of encoded frame data. +``` + +## MediaCodec Configuration + +Recommended encoder settings for low-latency streaming: + +```kotlin +val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply { + // Bitrate: 8 Mbps default, adjustable + setInteger(KEY_BIT_RATE, 8_000_000) + + // Frame rate hint (actual rate is variable) + setInteger(KEY_FRAME_RATE, 60) + + // I-frame interval: 10 seconds (frequent enough for seeking) + setInteger(KEY_I_FRAME_INTERVAL, 10) + + // Surface input (zero-copy from GPU) + setInteger(KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatSurface) + + // Repeat frame after 100ms of no changes (reduces idle bandwidth) + setLong(KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000) + + // Optional: request low latency mode (Android 11+) + if (Build.VERSION.SDK_INT >= 30) { + setInteger(KEY_LOW_LATENCY, 1) + } + + // H.264 Baseline profile for maximum compatibility + setInteger(KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) + setInteger(KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31) + + // CBR for consistent bitrate + setInteger(KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) +} +``` + +## Bandwidth and Quality Tradeoffs + +| Quality | Resolution | Bitrate | Bandwidth | Notes | +|---------|------------|---------|-----------|-------| +| Low | 540p | 2 Mbps | ~2.5 MB/s | Good for slow USB | +| Medium | 720p | 4 Mbps | ~5 MB/s | Balanced | +| High | 1080p | 8 Mbps | ~10 MB/s | Full HD | +| Ultra | Native | 16 Mbps | ~20 MB/s | 4K devices | + +USB 2.0: ~30 MB/s theoretical, ~20 MB/s practical - all quality levels supported. + +## Video Decoder: Klarity + +We chose [Klarity](https://github.com/numq/Klarity) for H.264 decoding in the IDE plugin. It renders directly to Skiko Surface (Compose-native, no SwingPanel), supports composable overlays on video content, and bundles at ~20-30MB per platform. + +Klarity decodes H.264 via FFmpeg/JNI, interprets frame data directly as Skia Pixmap via pointer (zero-copy), and renders to Compose Canvas. + +## Implementation Plan + +### Milestone 1: Proof of Concept +- [ ] Create automobile-video-server JAR with basic VirtualDisplay + MediaCodec +- [ ] Test manual execution via `adb shell` +- [ ] Verify H.264 stream output with ffplay + +### Milestone 2: MCP Integration +- [ ] Add video stream socket server to daemon +- [ ] Implement ADB forward management +- [ ] Add start/stop video streaming commands + +### Milestone 3: IDE Plugin Decoder +- [ ] Add Klarity dependency +- [ ] Implement VideoStreamClient +- [ ] Create frame-to-ImageBitmap pipeline +- [ ] Benchmark decode performance + +### Milestone 4: Compose Integration +- [ ] Update DeviceScreenView for live frames +- [ ] Add FPS counter overlay +- [ ] Implement latency measurement +- [ ] Add quality/resolution controls + +### Milestone 5: Polish +- [ ] Handle device disconnect/reconnect +- [ ] Add fallback to screenshot mode +- [ ] Automatic quality adjustment on frame drops +- [ ] Optimize memory (double buffering, frame pooling) + +## References + +### Android Screen Capture +- [scrcpy source code](https://github.com/Genymobile/scrcpy) - Reference implementation +- [Android MediaCodec docs](https://developer.android.com/reference/android/media/MediaCodec) +- [Android VirtualDisplay](https://developer.android.com/reference/android/hardware/display/VirtualDisplay) +- [Low-latency decoding in MediaCodec](https://source.android.com/docs/core/media/low-latency-media) +- [Android 14 MediaProjection requirements](https://developer.android.com/about/versions/14/changes/fgs-types-required) + +### Video Decoding (Desktop) +- [Klarity](https://github.com/numq/Klarity) - Compose Desktop video player (chosen solution) +- [JetBrains Skiko](https://github.com/JetBrains/skiko) - Skia bindings for Kotlin diff --git a/docs/design-docs/plat/android/system-tray-lookfor.md b/docs/design-docs/plat/android/system-tray-lookfor.md new file mode 100644 index 000000000..44094ddb2 --- /dev/null +++ b/docs/design-docs/plat/android/system-tray-lookfor.md @@ -0,0 +1,78 @@ +# System tray lookFor + +✅ Implemented 🧪 Tested + +> **Current state:** `systemTray` MCP tool is fully implemented with open/find/tap/dismiss/clearAll actions. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Enable agents to open the notification shade and wait for a matching +notification by text. + +## MCP tool + +```typescript +systemTray({ + action: "open" | "find" | "tap" | "dismiss" | "clearAll", + notification?: { + title?: string, + body?: string, + appId?: string, + tapActionLabel?: string + }, + awaitTimeout?: number +}) +``` + +## Android implementation + +Open/close the tray (preferred, emulator): + +- `adb -s shell cmd statusbar expand-notifications` +- `adb -s shell cmd statusbar collapse` + +Fallback (gesture): + +- Swipe down from status bar if `cmd statusbar` is unavailable. + +Finding notifications: + +- Use AccessibilityService to read the System UI node tree. +- Search for a node with matching text or resource ID in + `com.android.systemui`. +- Return bounding box + hierarchy path for use in follow-up taps. + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Confirmed commands: + +- Expand/collapse notification shade: + - `adb -s shell cmd statusbar expand-notifications` + - `adb -s shell uiautomator dump /sdcard/notification_dump.xml` + - `adb -s shell cat /sdcard/notification_dump.xml` + - `adb -s shell cmd statusbar collapse` + +Observed results: + +- Notification shade expands and collapses on command. +- uiautomator dump contains notification text suitable for lookFor matching. + +Notes: + +- `adb shell cmd statusbar expand-settings` is also available to expand quick + settings when needed. + +## Plan + +1. Add `systemTray` with open/find/tap/dismiss/clearAll actions. +2. Use accessibility node search to match notification text. +3. Return structured match details for action chaining. + +## Risks + +- OEM System UI layouts vary; emulator support may be the reliable baseline. +- Requires AccessibilityService access to System UI nodes. diff --git a/docs/design-docs/plat/android/take-screenshot.md b/docs/design-docs/plat/android/take-screenshot.md new file mode 100644 index 000000000..bb361a851 --- /dev/null +++ b/docs/design-docs/plat/android/take-screenshot.md @@ -0,0 +1,62 @@ +# takeScreenshot fallback + +🚧 Design Only + +> **Current state:** The `takeScreenshot` MCP tool with server-side "fallback ticket" gating described here has **not been implemented**. Screenshots are captured as part of `observe` result. The underlying ADB screencap paths described here are used by the `observe` implementation. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## Goal + +Provide a screenshot tool that is explicitly a visual fallback when element +lookup fails. The tool should be gated by server-side checks so agents +cannot treat it as a primary discovery method. + +## Proposed MCP tool (Not Implemented) + +```typescript +takeScreenshot({ + reason: "element-not-found", + context: { + action: "tapOn", + text: "Login" + }, + preferReuse: true +}) +``` + +Key semantics: + +- `reason` must be `element-not-found`. +- Server issues a short-lived "fallback ticket" after a not-found failure; + `takeScreenshot` consumes it. Calls without a ticket fail. +- `preferReuse` reuses the most recent `observe` screenshot if it is fresh + (e.g., under 250ms) to avoid another capture. + +## Android implementation + +Preferred capture path (fast, no temp file): + +- `adb -s exec-out screencap -p` + +Fallback path (older devices/emulators): + +- `adb -s shell screencap -p /sdcard/automobile/s.png` +- `adb -s pull /sdcard/automobile/s.png ` + +Notes: + +- API 29/35 emulators support `exec-out` reliably; keep the file-based path + as a compatibility fallback. +- If AccessibilityService already delivered a screenshot in the last N ms, + reuse it and return `reused: true` to keep the tool cheap. + +## Plan + +1. Add MCP tool metadata and server-side gating (fallback ticket). +2. Reuse recent `observe` screenshots when available. +3. Add a `reused` flag to the response for agent transparency. + +## Risks + +- Agents can still call the tool after a legitimate not-found, but server + gating prevents general misuse. +- If `observe` is not delivering screenshots, fallback costs increase. diff --git a/docs/design-docs/plat/android/talkback.md b/docs/design-docs/plat/android/talkback.md new file mode 100644 index 000000000..b373299ac --- /dev/null +++ b/docs/design-docs/plat/android/talkback.md @@ -0,0 +1,83 @@ +# TalkBack Simulation + +❌ Not Implemented *(MCP tools)* · ✅ Implemented *(ADB commands, validated)* + +> **Current state:** The ADB commands for enabling/disabling real TalkBack are validated on API 35. The three proposed MCP tools (`setTalkBackEnabled`, `setA11yFocus`, `announce`) have **not been built** — they are design proposals. The AccessibilityService infrastructure needed by simulated mode is available. +> +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +For the overall accessibility adaptation design (detection strategy, gesture adaptations, and tool-level changes), see [TalkBack/VoiceOver Adaptation](../../mcp/a11y/talkback-voiceover.md). This document covers Android-specific ADB commands and simulation details. + +## Goal + +Support TalkBack testing on emulator images (API 29/35) either by +best-effort enabling real TalkBack or by simulating key behaviors via +AutoMobile's AccessibilityService. + +## Proposed MCP tools ❌ Not Implemented + +```typescript +setTalkBackEnabled({ enabled: boolean }) +setA11yFocus({ resourceId?: string, text?: string }) +announce({ text: string }) +``` + +## Android implementation + +Real TalkBack (best-effort, emulator): + +- Detect the service name via `adb -s shell dumpsys accessibility`. +- Enable: + - `adb -s shell settings put secure enabled_accessibility_services ` + - `adb -s shell settings put secure accessibility_enabled 1` +- Disable: + - `adb -s shell settings delete secure enabled_accessibility_services` + - `adb -s shell settings put secure accessibility_enabled 0` + +Simulated TalkBack (reliable, helper-based): + +- Use AccessibilityService to move focus and announce text via TTS. +- `setA11yFocus` searches nodes and requests focus. +- `announce` uses TextToSpeech to emit the same strings an agent would hear. + +## ADB validation (API 35) + +Status: + +- API 29 not validated yet (no local AVD available). + +Confirmed commands: + +- Read current accessibility state: + - `adb -s shell dumpsys accessibility` +- Enable TalkBack: + - `adb -s shell settings put secure enabled_accessibility_services com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService` + - `adb -s shell settings put secure accessibility_enabled 1` + - `adb -s shell dumpsys accessibility` +- Disable TalkBack: + - `adb -s shell settings delete secure enabled_accessibility_services` + - `adb -s shell settings put secure accessibility_enabled 0` + - `adb -s shell dumpsys accessibility` + +Observed results: + +- `dumpsys accessibility` shows TalkBack enabled after the enable commands. +- `dumpsys accessibility` shows no enabled services after disable. + +Notes: + +- Enabling TalkBack triggers the Android Accessibility Suite permission + dialog and must be accepted via UI automation. +- `settings put secure enabled_accessibility_services ''` does not clear on + API 35; use `settings delete` instead. + +## Plan + +1. Implement simulated focus + announce tools (works on all devices). +2. Add best-effort TalkBack enablement for emulators with the service installed. +3. Report `supported: false` on physical devices without privileges. + +## Risks + +- Some emulator images do not ship TalkBack; simulated mode remains primary. +- Real TalkBack can interfere with automation timing; gating may be needed. diff --git a/docs/design-docs/plat/android/work-profiles.md b/docs/design-docs/plat/android/work-profiles.md new file mode 100644 index 000000000..0ee813cfc --- /dev/null +++ b/docs/design-docs/plat/android/work-profiles.md @@ -0,0 +1,302 @@ +# Work Profiles + +✅ Implemented 🧪 Tested + +> **Current state:** Work profile auto-detection is fully implemented — `installApp`, `launchApp`, `terminateApp`, and the `automobile:apps` resource all detect and respect the active user profile. A manual `userId` override in MCP tool schemas is ❌ Not Implemented; profile selection is automatic only. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +This guide explains how to set up and test Android work profiles with AutoMobile. + +## What is a Work Profile? + +An Android work profile is a separate user profile on a device that allows organizations to manage work-related apps and data separately from personal apps. Each profile has its own user ID: + +- **User 0**: Primary/Personal profile +- **User 10+**: Work profiles or other managed profiles + +## AutoMobile Work Profile Support + +AutoMobile automatically detects and handles work profiles across all app management features: + +- **Auto-detection**: Features automatically detect the appropriate user profile +- **Priority order**: + + ```mermaid + flowchart LR + A["Select user profile"] --> B{"App in foreground?"}; + B -->|"yes"| C["Use foreground app user profile"]; + B -->|"no"| D{"Running work profile exists?"}; + D -->|"yes"| E["Use first running
work profile"]; + D -->|"no"| F["Use primary user
(user 0)"]; + classDef decision fill:#CC2200,stroke-width:0px,color:white; + classDef logic fill:#525FE1,stroke-width:0px,color:white; + classDef result stroke-width:0px; + class A logic; + class B,D decision; + class C,E,F result; + ``` +- **userId in responses**: App management tools include the `userId` field indicating which profile was used +- **Note**: MCP tool schemas do not currently accept a `userId` override; selection is automatic + +### Supported Features + +App management tools that support work profiles: + +- `installApp` - Install APKs to the auto-selected profile +- `launchApp` - Launch apps in the auto-selected profile +- `terminateApp` - Terminate apps in the auto-selected profile +- `automobile:apps` resource - List apps from all profiles with userId and foreground status (recent is a placeholder) + +## Setting Up Work Profile on Android Emulator + +### Prerequisites + +- Android Studio with Android SDK +- Android emulator (API 21+, work profiles supported from Android 5.0) +- ADB installed and in PATH + +### Steps + +#### 1. Create or Start an Emulator + +```bash +# List available AVDs +emulator -list-avds + +# Start an emulator +emulator -avd +``` + +#### 2. Set Up Work Profile Using Test DPC + +Test DPC (Device Policy Controller) is Google's app for testing enterprise features. + +**Option A: Using ADB** + +```bash +# Install Test DPC from Google Play or download the APK +adb install TestDPC.apk + +# Launch Test DPC +adb shell am start -n com.afwsamples.testdpc/.SetupManagementActivity + +# Follow on-screen prompts to set up work profile +``` + +**Option B: Using Device Settings** + +1. Open **Settings** on the emulator +2. Go to **Users & accounts** → **Work profile** +3. Tap **Set up profile** +4. Follow the setup wizard +5. Install Test DPC when prompted + +#### 3. Verify Work Profile + +```bash +# List all users on device +adb shell pm list users + +# Expected output: +# Users: +# UserInfo{0:Owner:13} running +# UserInfo{10:Work profile:30} running +``` + +The output shows: + +- `0` = Personal profile (primary user) +- `10` = Work profile (managed profile) + +#### 4. Install Apps to Work Profile + +```bash +# Install to work profile (user 10) +adb install --user 10 your-app.apk + +# Install to personal profile (user 0) +adb install --user 0 your-app.apk + +# List apps in work profile +adb shell pm list packages --user 10 + +# List apps in personal profile +adb shell pm list packages --user 0 +``` + +## Testing with AutoMobile + +### Example: Install App to Work Profile + +AutoMobile automatically installs to the work profile if it exists: + +```typescript +// Using MCP tool +const result = await installApp({ apkPath: "/path/to/app.apk" }); +console.log(result.userId); // 10 (work profile) +``` + +### Example: Launch App (Auto-detected Profile) + +```typescript +// Auto-detects (uses foreground profile or a running work profile if present) +await launchApp({ packageName: "com.example.app" }); +``` + +### Example: List Apps from All Profiles + +```typescript +const response = await client.request({ + method: "resources/read", + params: { uri: "automobile:apps?deviceId=emulator-5554&platform=android" } +}); +const apps = JSON.parse(response.contents[0].text); + +// Result includes per-device user + system apps: +// { +// query: { deviceId: "emulator-5554", platform: "android" }, +// totalCount: 3, +// deviceCount: 1, +// lastUpdated: "...", +// devices: [ +// { +// deviceId: "emulator-5554", +// platform: "android", +// totalCount: 3, +// lastUpdated: "...", +// apps: [ +// { +// packageName: "com.example.personal", +// type: "user", +// userId: 0, +// userProfile: "personal", +// foreground: false, +// recent: false +// }, +// { +// packageName: "com.example.work", +// type: "user", +// userId: 10, +// userProfile: "work", +// foreground: true, +// recent: false +// }, +// { +// packageName: "com.android.chrome", +// type: "system", +// userIds: [0, 10], +// foreground: false, +// recent: false +// } +// ] +// } +// ] +// } +```yaml + +Note: Chrome can be installed in both profiles! + +## Testing Scenarios + +### Scenario 1: App in Foreground + +```bash +# Launch app in work profile +adb shell am start --user 10 com.example.app/.MainActivity + +# AutoMobile operations will target user 10 automatically +``` + +### Scenario 2: Multiple Profiles + +```bash +# Verify multiple profiles +adb shell pm list users + +# AutoMobile will: +# 1. Check foreground app first +# 2. Fall back to first work profile (user 10) +# 3. Fall back to primary (user 0) +``` + +### Scenario 3: Same App in Both Profiles + +```bash +# Install in both +adb install --user 0 app.apk +adb install --user 10 app.apk + +# List all installations +adb shell pm list packages -f com.example.app --all-users + +# AutoMobile's `automobile:apps` resource will return both with unique userIds +``` + +## Troubleshooting + +### Work Profile Not Showing Up + +```bash +# Check if work profile is running +adb shell pm list users + +# If not running, restart emulator or device +``` + +### App Not Installing to Work Profile + +```bash +# Check available space +adb shell df + +# Verify user ID exists +adb shell pm list users + +# Install with explicit user flag +adb install --user 10 app.apk +``` + +### Cannot Launch App in Work Profile + +```bash +# Verify app is installed in work profile +adb shell pm list packages --user 10 | grep your.app + +# Launch manually to test +adb shell am start --user 10 your.app/.MainActivity +``` + +## ADB Commands Reference + +```bash +# List all users +adb shell pm list users + +# Install to specific user +adb install --user app.apk + +# Uninstall from specific user +adb shell pm uninstall --user com.package.name + +# List packages for user +adb shell pm list packages --user + +# Clear app data for user +adb shell pm clear --user com.package.name + +# Force stop app for user +adb shell am force-stop --user com.package.name + +# Launch app in user +adb shell am start --user com.package.name/.MainActivity + +# Get foreground app with user +adb shell dumpsys activity activities | grep -E "(mResumedActivity|mFocusedActivity|topResumedActivity)" +``` + +## API Version Compatibility + +- **Android 5.0+ (API 21+)**: Work profiles supported +- **Android 7.0+ (API 24+)**: Enhanced work profile features +- **Android 9.0+ (API 28+)**: Improved work profile UI + +AutoMobile's work profile features work on all Android versions that support work profiles (API 21+). diff --git a/docs/design-docs/plat/ios/ide-plugin/feature-flags.md b/docs/design-docs/plat/ios/ide-plugin/feature-flags.md new file mode 100644 index 000000000..d8b90676b --- /dev/null +++ b/docs/design-docs/plat/ios/ide-plugin/feature-flags.md @@ -0,0 +1,47 @@ +# Control Feature Flags + +🚧 Design Only + +> **Current state:** This feature depends on XcodeExtension/XcodeCompanion which are scaffolded but not feature-complete. Feature flag UI for the Xcode IDE is **not yet implemented**. See the [Status Glossary](../../../status-glossary.md) for chip definitions. + +## Goal + +Provide a dedicated UI in the Xcode extension to view and toggle +AutoMobile feature flags without leaving the IDE. + +## UX + +- Menu bar extension: "Feature Flags". +- Table view with: + - Flag key + - Current value + - Description (if provided) + - Toggle control +- Search/filter by flag key. +- A refresh action to re-fetch from the daemon socket. + +## Data sources + +Use the AutoMobile daemon Unix socket (e.g., `/tmp/auto-mobile-daemon-.sock`) +to fetch and update feature flags. Avoid MCP tool calls for this surface. + +## Behavior + +- Load flags on extension open and on explicit refresh. +- Optimistically update the UI after a toggle, but roll back on error. +- If a flag is read-only, disable the toggle and show the reason. + +## Error handling + +- If the daemon socket is unavailable, show a reconnect state. +- If a toggle fails, show an alert with the error message and revert. + +## Performance notes + +- Cache the last fetched list and only diff updates when reloading. +- Debounce search input to avoid UI thrash. + +## See also + +- [Feature Flags](../../../mcp/feature-flags.md) +- [IDE Plugin Overview](overview.md) diff --git a/docs/design-docs/plat/ios/ide-plugin/overview.md b/docs/design-docs/plat/ios/ide-plugin/overview.md new file mode 100644 index 000000000..53aaebcaf --- /dev/null +++ b/docs/design-docs/plat/ios/ide-plugin/overview.md @@ -0,0 +1,63 @@ +# Overview + +⚠️ Partial + +> **Current state:** +> - **XcodeCompanion** (`ios/XcodeCompanion/`) — Scaffolded macOS executable app. All major views are defined (DevicesView, RecordingView, ExecutionView, PerformanceView, SettingsView, MenuBarView, FeatureFlagsView) and `MCPConnectionManager` is wired up. Feature completeness is ongoing. +> - **XcodeExtension** (`ios/XcodeExtension/`) — Scaffolded Xcode Source Editor Extension. 5 commands registered; command implementations are minimal stubs. +> +> See the [Status Glossary](../../../status-glossary.md) for chip definitions. + +Xcode does not support the rich plugin APIs available in Android Studio. The closest +parity is a companion macOS app plus an Xcode Source Editor Extension (XcodeKit). +This approach provides UI parity with the Android IDE plugin while staying within +supported Apple tooling. + +## Components + +- **AutoMobile Xcode Companion** (macOS app) + - Hosts all UI surfaces: navigation graph, performance views, feature flags, + test recording, and plan execution. + - Connects to MCP over STDIO or daemon socket. + - Provides a menu bar mode plus a docked window for persistent views. +- **Xcode Source Editor Extension** + - Adds commands to insert plan templates, run plans, or open the companion app. + - Keeps source edits inside Xcode without embedding a custom tool window. + +## Transport selection + +The companion app selects an MCP transport in this order: + +1. `AUTOMOBILE_MCP_STDIO_COMMAND` or `automobile.mcp.stdioCommand`. +2. Unix socket fallback at `/tmp/auto-mobile-daemon-.sock`. + +## UX goals + +- One-click attach to MCP server. +- Live navigation graph rendering. +- Feature flag toggles. +- Test recording and plan generation. +- Execution log view with errors and timing. +- Menu bar actions: Record Test, Stop Recording Test, feature flags submenu, and deep links to companion views. + +## Recording flow + +- The companion app uses the daemon Unix socket to request recording start/stop. +- The MCP server emits resource update notifications when a new recorded plan is available. +- The companion app listens for resource updates to load the latest recorded plan. + +## Distribution and signing + +- The companion app should be signed and notarized (non-App-Store distribution). +- If signing constraints block features, provide a fallback unsigned developer build. +- When iOS tooling is enabled but the app is missing, `doctor` should surface a download link. + +## Notes + +- The companion app provides the rich UI that Xcode does not allow. +- The Source Editor Extension is optional but improves developer workflow. + +## See also + +- [Test recording](test-recording.md) +- [iOS automation server](../xctestservice.md) diff --git a/docs/design-docs/plat/ios/ide-plugin/test-recording.md b/docs/design-docs/plat/ios/ide-plugin/test-recording.md new file mode 100644 index 000000000..73981653a --- /dev/null +++ b/docs/design-docs/plat/ios/ide-plugin/test-recording.md @@ -0,0 +1,45 @@ +# Test Recording + +🚧 Design Only + +> **Current state:** This workflow depends on the XcodeCompanion app which is currently scaffolded (views and navigation defined, MCPConnectionManager wired up) but feature-complete recording is **not yet implemented**. See [iOS IDE Plugin Overview](overview.md) and the [Status Glossary](../../../status-glossary.md) for chip definitions. + +The AutoMobile Xcode companion app can record interactions on an iOS simulator and +generate executable YAML plans. This mirrors the Android IDE plugin recording workflow +while adapting to Xcode constraints. + +## Recording workflow + +1. Attach to a running MCP server. +2. Select a simulator/device. +3. Click "Start Recording" in the companion app. +4. Perform interactions in the simulator. +5. Click "Stop Recording" to generate a plan. + +## Captured data + +- Tap, swipe, and text input actions. +- Element selectors (accessibility id, label, type). +- Screen context (view hierarchy signature, active app). +- Timing metadata for waits and transitions. + +## Output + +The companion app generates a YAML plan and opens it in Xcode using the Source Editor +Extension or a standard file open action. + +## Execution + +- The companion app can execute the plan via MCP. +- The Xcode extension can trigger plan execution for the active file. + +## MCP integration + +- The companion app requests recording start/stop over the daemon Unix socket. +- The MCP server publishes the latest plan as a resource and emits update notifications. +- The companion app listens for resource updates to load the recorded plan. + +## See also + +- [Xcode integration](overview.md) +- [MCP tool reference](../../../mcp/tools.md) diff --git a/docs/design-docs/plat/ios/index.md b/docs/design-docs/plat/ios/index.md new file mode 100644 index 000000000..c30a8c1d7 --- /dev/null +++ b/docs/design-docs/plat/ios/index.md @@ -0,0 +1,77 @@ +# Overview + +✅ Implemented 🧪 Tested 📱 Simulator Only + +> See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile iOS automation uses native XCTest APIs for both observations and touch injection. +The XCTestService provides a WebSocket server that exposes XCUITest capabilities, while +simctl handles simulator lifecycle management. + +```mermaid +flowchart TB + subgraph "MCP Client" + Agent[AI Agent] + end + + subgraph "TypeScript MCP Server (macOS)" + MCP[MCP Server] + WSClient[WebSocket Client] + Simctl[simctl] + end + + subgraph "iOS Simulator/Device" + XCTestService[XCTestService] + SimApp[YourAwesome.app] + end + + Agent -->|MCP Protocol| MCP + Xcode[AutoMobile Xcode Integration] -->|MCP Protocol + Unix socket| MCP + MCP --> WSClient + MCP --> Simctl + WSClient -->|ws://localhost:8765| XCTestService + Simctl -->|simulator lifecycle| SimApp + XCTestService -->|XCUITest APIs| SimApp +``` + +## Components + +| Component | Description | Status | +|-----------|-------------|--------| +| [XCTestService](xctestservice.md) | WebSocket server using native XCUITest APIs for element location and touch injection. | ✅ Implemented 🧪 Tested 📱 Simulator Only | +| [XCTestRunner](xctestrunner.md) | Test execution framework (plan execution, test ordering, retries). | ✅ Implemented 🧪 Tested | +| [simctl integration](simctl.md) | Simulator lifecycle and app management. | ✅ Implemented 🧪 Tested | +| [Managed App Configuration](managed-app-config.md) | MDM policies and app config payloads. | 🚧 Design Only | +| [Managed Apple IDs](managed-apple-ids.md) | Account policies and device profiles. | 🚧 Design Only | +| [Xcode integration](ide-plugin/overview.md) | Companion macOS app + source editor extension. | ⚠️ Partial | +| [Screen Streaming](screen-streaming.md) | AVFoundation/ScreenCaptureKit live mirroring. | 🚧 Design Only | + +## Status + +- XCTestService fully implemented: WebSocket server, XCUITest element location, gesture injection, hierarchy debouncing, FPS monitoring. +- XCTestRunner fully implemented: `AutoMobileTestCase` base class, plan execution, retry, test ordering by timing, CI/local modes. +- Xcode Companion app (macOS): scaffolded with all views; feature completeness ongoing. +- Xcode Source Editor Extension: scaffolded with 5 registered commands; implementations are minimal stubs. +- Physical device support tracked in GitHub issues [#912](https://github.com/jasonpearson/auto-mobile/issues/912), [#913](https://github.com/jasonpearson/auto-mobile/issues/913), [#914](https://github.com/jasonpearson/auto-mobile/issues/914). + +## Parity goal + +The iOS toolset should reach feature parity with Android over time. The design +prioritizes consistent behavior and comparable UX across platforms, even when the +underlying system tooling differs. + +## System requirements + +- macOS 13.0+ (Ventura or newer). +- Xcode 15.0+ and Command Line Tools. + +## Limitations + +- macOS required (Xcode and iOS Simulator). +- Simulator-only currently; physical device support requires provisioning (see GitHub issues #912-914). +- Docker is not supported for iOS automation. + +## See also + +- [MCP server](../../mcp/index.md) +- [MCP tool reference](../../mcp/tools.md) diff --git a/docs/design-docs/plat/ios/managed-app-config.md b/docs/design-docs/plat/ios/managed-app-config.md new file mode 100644 index 000000000..7c4f9f659 --- /dev/null +++ b/docs/design-docs/plat/ios/managed-app-config.md @@ -0,0 +1,60 @@ +# Managed App Configuration + +🚧 Design Only + +> **Current state:** This page provides guidance for testing in MDM-managed environments. AutoMobile has no specific implementation for detecting or surfacing Managed App Configuration state. Physical devices are required; simulators do not reproduce MDM behavior. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +MDM-managed devices can deliver Managed App Configuration values to apps. These +settings are surfaced through `UserDefaults` under `com.apple.configuration.managed`. +AutoMobile should behave predictably when managed configuration is present. + +## Scope + +- MDM-managed devices and policies +- Managed App Configuration payloads +- Simulator limitations vs physical device behavior + +## Managed App Configuration + +Managed App Config is delivered by MDM and read by apps at runtime. It can: + +- Change app behavior based on enterprise policy +- Toggle features or endpoints +- Inject environment-specific values + +AutoMobile considerations: + +- Support reading managed configuration values when the app exposes them for automation. +- Provide guidance for test apps to surface managed config state in UI where needed. +- Avoid assumptions about defaults when managed config is present. + +## MDM policy effects + +MDM policies can affect automation flows: + +- App install restrictions +- App launch policies and per-app VPN +- Network access limitations +- Restrictions on data sharing + +AutoMobile considerations: + +- Detect and surface policy-related errors when install/launch fails. +- Provide troubleshooting guidance for profile-caused failures. +- Track which policies are active when tests run (if observable). + +## Simulator vs Device + +- Simulators do not fully emulate MDM or managed configuration policies. +- Physical devices are required to validate Managed App Config behavior. +- Test plans should document when a scenario requires a managed device. + +## Limitations + +- Access to MDM state is limited without device-side instrumentation. +- Policies vary by organization and MDM vendor. + +## See also + +- [Managed Apple IDs and profiles](managed-apple-ids.md) +- [iOS overview](index.md) diff --git a/docs/design-docs/plat/ios/managed-apple-ids.md b/docs/design-docs/plat/ios/managed-apple-ids.md new file mode 100644 index 000000000..5d644cff4 --- /dev/null +++ b/docs/design-docs/plat/ios/managed-apple-ids.md @@ -0,0 +1,56 @@ +# Managed Apple IDs + +🚧 Design Only + +> **Current state:** This page provides guidance for testing in enterprise environments with Managed Apple IDs. AutoMobile has no specific implementation for detecting or surfacing managed account restrictions. Physical devices are required; simulators do not reproduce profile policies. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Managed Apple IDs and configuration profiles are common in enterprise deployments. +They can impose restrictions that affect app installation, network access, and +system behavior. + +## Scope + +- Managed Apple IDs +- Configuration profiles and device restrictions +- Automation implications + +## Managed Apple IDs + +Managed Apple IDs are tied to enterprise enrollment and may enforce restrictions +across apps and services. These policies can impact automation flows: + +- App installation or update constraints +- Network access policies +- App-to-app data sharing limitations + +AutoMobile considerations: + +- Surface policy-related failures with clear errors. +- Provide troubleshooting guidance for account or profile restrictions. +- Avoid relying on behaviors that differ between managed and unmanaged IDs. + +## Configuration profiles + +Profiles can enforce device-wide settings such as VPN, web filtering, and +app launch restrictions. + +AutoMobile considerations: + +- Detect and report when policies block actions. +- Keep test plans explicit about required profiles. +- Prefer stable selectors in UI that may show policy banners. + +## Simulator vs Device + +- Simulators do not fully reproduce managed account or profile policies. +- Physical devices are required for reliable validation. + +## Limitations + +- Policy details vary by organization and MDM vendor. +- Some restrictions are opaque without device-side instrumentation. + +## See also + +- [Managed App Configuration (MDM)](managed-app-config.md) +- [iOS overview](index.md) diff --git a/docs/design-docs/plat/ios/screen-streaming.md b/docs/design-docs/plat/ios/screen-streaming.md new file mode 100644 index 000000000..484177dab --- /dev/null +++ b/docs/design-docs/plat/ios/screen-streaming.md @@ -0,0 +1,308 @@ +# iOS Real-Time Screen Streaming Architecture + +🚧 Design Only + +> **Current state:** This document is a research and design proposal. None of the milestones have been implemented. iOS automation currently uses on-demand screenshots via `xcrun simctl io booted screenshot`. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +Research and design for real-time screen streaming from iOS devices/simulators to the IDE plugin. + +## Goals + +- Continuous live streaming for device mirroring in the IDE +- Up to 60fps frame rate +- <100ms end-to-end latency for interactive use +- Support USB-connected physical devices and simulators +- macOS only (iOS development requires macOS) + +## Key Difference from Android + +Unlike Android (which requires a shell-user JAR running on device), iOS devices expose their screen as a **video capture source** accessible from macOS via AVFoundation. No app or daemon needs to run on the iOS device itself. + +| Aspect | Android | iOS | +|--------|---------|-----| +| Capture location | On device (shell JAR) | On Mac (AVFoundation) | +| Requires app on device | Yes (video server) | No | +| Transport | ADB socket → MCP server | Direct macOS API | +| Encoding | MediaCodec H.264 on device | Already decoded frames from macOS | +| Decoding | Klarity in IDE plugin | Not needed (raw frames) | + +## Physical iOS Devices (USB) + +### How It Works + +iOS devices connected via USB appear as external video capture devices on macOS. This is the same mechanism QuickTime Player uses for screen mirroring. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ macOS │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌──────────────────────────────────────┐ │ +│ │ iOS Device │ │ MCP Server (Node.js) │ │ +│ │ via USB │────▶│ │ │ +│ │ │ │ AVFoundation capture │ │ +│ └─────────────┘ │ ↓ │ │ +│ │ Raw frames (CVPixelBuffer) │ │ +│ │ ↓ │ │ +│ │ Unix socket: video-stream.sock │ │ +│ └──────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────┐ │ +│ │ IDE Plugin (Kotlin/JVM) │ │ +│ │ │ │ +│ │ Unix socket client │ │ +│ │ ↓ │ │ +│ │ Raw frame → ImageBitmap │ │ +│ │ ↓ │ │ +│ │ DeviceScreenView (Compose Desktop) │ │ +│ └──────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ USB + │ +┌─────────────────┐ +│ iPhone/iPad │ +│ (no app needed) │ +└─────────────────┘ +``` + +### Implementation + +#### Step 1: Enable Screen Capture Devices + +```swift +import CoreMediaIO +import AVFoundation + +func enableScreenCaptureDevices() { + var prop = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyAllowScreenCaptureDevices), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + var allow: UInt32 = 1 + CMIOObjectSetPropertyData( + CMIOObjectID(kCMIOObjectSystemObject), + &prop, + 0, + nil, + UInt32(MemoryLayout.size), + &allow + ) +} +``` + +#### Step 2: Discover iOS Devices + +```swift +// Warmup required - without this, notifications won't fire +let _ = AVCaptureDevice.devices() + +// Listen for device connections +NotificationCenter.default.addObserver( + forName: .AVCaptureDeviceWasConnected, + object: nil, + queue: nil +) { notification in + guard let device = notification.object as? AVCaptureDevice else { return } + if device.deviceType == .external && device.hasMediaType(.muxed) { + // This is a USB-connected iOS device + startCapture(from: device) + } +} + +// Or discover immediately +let devices = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external], + mediaType: .muxed, + position: .unspecified +).devices +``` + +#### Step 3: Capture Frames + +```swift +class iOSScreenCapture: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + private let session = AVCaptureSession() + private let output = AVCaptureVideoDataOutput() + + func start(device: AVCaptureDevice) throws { + let input = try AVCaptureDeviceInput(device: device) + + session.beginConfiguration() + session.addInput(input) + + output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "capture")) + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA + ] + session.addOutput(output) + + session.commitConfiguration() + session.startRunning() + } + + func captureOutput(_ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + // Convert to raw bytes and send to Unix socket + CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } + + let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + + let data = Data(bytes: baseAddress!, count: bytesPerRow * height) + sendToSocket(data) + } +} +``` + +### Considerations + +1. **Initialization delay**: After enabling `kCMIOHardwarePropertyAllowScreenCaptureDevices`, devices take a few seconds to appear +2. **Rate limiting**: Rapidly toggling the property can cause delays up to 60 seconds +3. **No encoding needed**: Frames come as raw `CVPixelBuffer` (BGRA), no H.264 decoding required +4. **macOS only**: This API is not available on other platforms + +## iOS Simulator + +### Current Approach + +The existing implementation uses `xcrun simctl` for screenshots: + +```bash +xcrun simctl io booted screenshot /path/to/screenshot.png +``` + +### Streaming Options + +#### Option A: Repeated Screenshots (Current) + +Poll `simctl io screenshot` at target frame rate. Simple but: +- High overhead (process spawn per frame) +- Limited to ~10-15 fps realistically +- File I/O for each frame + +#### Option B: Video Recording + Pipe + +```bash +xcrun simctl io booted recordVideo --codec=h264 - +``` + +Pipe to ffmpeg or Klarity for decoding. However: +- `recordVideo` doesn't support stdout piping well +- Designed for file output, not streaming + +#### Option C: SimulatorKit Framework (Private) + +Apple's private `SimulatorKit.framework` may have streaming APIs, but: +- Undocumented +- May break between Xcode versions +- App Store restrictions if distributed + +#### Option D: Screen Capture API + +Use macOS `CGWindowListCreateImage` or `SCStreamOutput` to capture simulator window: + +```swift +import ScreenCaptureKit + +// Find simulator window +let windows = try await SCShareableContent.current.windows +let simWindow = windows.first { $0.owningApplication?.bundleIdentifier == "com.apple.iphonesimulator" } + +// Stream the window +let filter = SCContentFilter(desktopIndependentWindow: simWindow!) +let config = SCStreamConfiguration() +config.width = 1170 +config.height = 2532 +config.minimumFrameInterval = CMTime(value: 1, timescale: 60) + +let stream = SCStream(filter: filter, configuration: config, delegate: self) +try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .main) +try await stream.startCapture() +``` + +**Recommended for simulator**: ScreenCaptureKit (macOS 12.3+) provides low-latency window capture. + +## Architecture Decision + +### Physical iOS Devices + +Use **AVFoundation capture** via native macOS helper: + +1. **MCP Server spawns Swift helper** (or embeds via Swift/C bridge) +2. Helper uses AVFoundation to capture iOS device screen +3. Raw BGRA frames sent to Unix socket +4. IDE plugin receives frames directly (no decoding needed) + +### iOS Simulator + +Use **ScreenCaptureKit** to capture simulator window: + +1. MCP Server spawns Swift helper +2. Helper uses SCStream to capture simulator window +3. Raw frames sent to Unix socket +4. IDE plugin receives frames directly + +### Why Not Klarity for iOS? + +Klarity is an H.264 decoder. iOS capture provides **raw frames** (CVPixelBuffer/BGRA), not encoded video. We can send these directly to the IDE plugin without encoding/decoding overhead. + +## Protocol + +Since iOS provides raw frames (not H.264), use a simpler protocol: + +### Frame Header (16 bytes) +``` +┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐ +│ width (4) │ height (4) │ bytesPerRow (4) │ timestamp (4) │ +│ uint32 LE │ uint32 LE │ uint32 LE │ uint32 LE (ms) │ +└─────────────────┴─────────────────┴─────────────────┴─────────────────┘ + +Followed by `height * bytesPerRow` bytes of BGRA pixel data. +``` + +## Implementation Plan + +### Milestone 1: Physical Device Capture +- [ ] Create Swift helper for AVFoundation capture +- [ ] Implement Unix socket frame streaming +- [ ] Test with MCP server integration + +### Milestone 2: Simulator Capture +- [ ] Create ScreenCaptureKit-based capture for simulator windows +- [ ] Handle simulator window discovery +- [ ] Integrate with same Unix socket protocol + +### Milestone 3: IDE Plugin Integration +- [ ] Add raw frame receiver (simpler than Klarity H.264 path) +- [ ] Convert BGRA to ImageBitmap for Compose +- [ ] Unify with Android video stream UI + +## Resolved Questions + +1. ~~**Swift helper distribution**: Bundle as executable with MCP server? Use Swift-to-Node bridge?~~ + **Resolved**: Swift-to-Node bridge. This allows the MCP server to spawn and communicate with the Swift helper process. + +2. ~~**Permissions**: ScreenCaptureKit requires screen recording permission. How to handle permission prompts?~~ + **Resolved**: The end user handles permission prompts. The IDE plugin should display a helpful message when permission is needed. + +3. ~~**Multiple devices**: Can we capture multiple iOS devices simultaneously via AVFoundation?~~ + **Resolved**: Single device streaming at a time. This matches the Android approach and simplifies the architecture. + +4. ~~**Entitlements**: Does capturing iOS device screen require special macOS entitlements?~~ + **Resolved**: No special entitlements needed for iOS device capture over USB via AVFoundation. Standard code signing and notarization are sufficient. + +## References + +- [AVCaptureDevice Documentation](https://developer.apple.com/documentation/avfoundation/avcapturedevice) +- [CoreMediaIO - Enabling Screen Capture](https://developer.apple.com/forums/thread/759245) +- [ScreenCaptureKit](https://developer.apple.com/documentation/screencapturekit) +- [USB iPhone Screen Recording in Swift](https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html) +- [libimobiledevice](https://libimobiledevice.org/) diff --git a/docs/design-docs/plat/ios/simctl.md b/docs/design-docs/plat/ios/simctl.md new file mode 100644 index 000000000..ed4a3a301 --- /dev/null +++ b/docs/design-docs/plat/ios/simctl.md @@ -0,0 +1,32 @@ +# simctl Integration + +✅ Implemented 🧪 Tested 📱 Simulator Only + +> **Current state:** `simctl` integration is fully implemented for simulator lifecycle, app management, device discovery, and demo mode. macOS only. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +AutoMobile uses `simctl` for iOS simulator lifecycle and app management. This layer is +responsible for booting simulators, installing apps, launching processes, and controlling +system-level simulator behaviors. + +## Responsibilities + +- Simulator lifecycle: boot, shutdown, erase. +- App lifecycle: install, uninstall, launch, terminate. +- Device discovery and capability reporting. +- Status bar configuration (demo mode) when supported. + +## Usage patterns + +- Prefer deterministic simulator selection by device identifier. +- Keep simulator state consistent between runs (reset/erase when needed). +- Use dedicated simulators for parallel test execution. + +## Limitations + +- macOS only (requires Xcode Command Line Tools). +- Simulator-only; physical devices are out of scope for simctl. + +## See also + +- [XCTestService](xctestrunner.md) - Touch injection and element queries. +- [iOS overview](index.md) diff --git a/docs/design-docs/plat/ios/testmanagerd.md b/docs/design-docs/plat/ios/testmanagerd.md new file mode 100644 index 000000000..b80a69585 --- /dev/null +++ b/docs/design-docs/plat/ios/testmanagerd.md @@ -0,0 +1,69 @@ +# testmanagerd — Why XCUITest Is the Only Viable Path + +✅ Implemented + +> **Current state:** This document explains why `XCTestService` uses XCUITest (the only sanctioned path to cross-process accessibility on iOS). The `simctl spawn` approach replacing `xcodebuild` is implemented. This is a reference/architecture doc, not a feature to implement. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +## What Is testmanagerd? + +`testmanagerd` is a privileged system daemon that ships on every iOS device and simulator. It is responsible for: + +- Coordinating test execution between Xcode and the device +- Brokering **cross-process accessibility access** on behalf of XCUITest +- Managing the `XCTRunnerDaemonSession` that connects the test runner to the target app + +It is not a public framework — it is a private Apple daemon (`/System/Library/PrivateFrameworks/XCTAutomationSupport.framework`) that Xcode's toolchain communicates with over a private IPC transport called **DTXTransport / DTXConnection**, located under: + +``` +Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/PrivateFrameworks/DTXConnectionServices.framework +``` + +## How XCUITest Uses testmanagerd + +When a test calls `XCUIApplication.snapshot()` or `XCUIElement.tap()`, the call chain is: + +``` +XCUIApplication.snapshot() + → XCTRunnerDaemonSession (in runner process) + → testmanagerd (via DTXConnection / private IPC) + → target app's accessibility tree +``` + +The key insight is that **testmanagerd holds the privileged entitlements** required to read another process's accessibility tree. Neither the test runner nor a third-party process can do this directly without going through testmanagerd. + +## Why No Public Equivalent Exists + +VoiceOver uses the same underlying mechanism (the `com.apple.accessibility.axserver` daemon family) with private entitlements that Apple does not expose to third parties. The entitlements required for cross-process accessibility (`com.apple.private.accessibility.inspection.allow`) are restricted to Apple-internal and testmanagerd-approved callers. + +Attempts to replicate this without XCUITest face two hard blockers: + +1. **Entitlement restriction**: The private entitlement is checked by the kernel — it cannot be spoofed by a user-space app. +2. **No public API**: `AXUIElement` (the public accessibility API on macOS) has no iOS equivalent that works cross-process without these entitlements. + +## Why XCTestService Uses This Path + +XCTestService is an XCUITest runner that starts a WebSocket server. By running as an XCUITest, it inherits the `XCTRunnerDaemonSession → testmanagerd` connection and can call `XCUIApplication.snapshot()` to read the full accessibility hierarchy of any foreground app. + +This is the **only Apple-sanctioned path** to cross-process, privileged accessibility access on iOS. + +## How simctl spawn Relates + +When launching via `xcrun simctl spawn `, the runner binary is the `XCTestServiceUITests-Runner` executable embedded inside the `.app` bundle. When spawned: + +1. The runner binary locates its embedded `.xctest` bundle (`XCTestServiceUITests.xctest`) co-located inside `XCTestServiceUITests-Runner.app/`. +2. It sets up the `XCTRunnerDaemonSession → testmanagerd` connection (the same as when launched via `xcodebuild test-without-building`). +3. The test's `waitForExpectations` keeps the process alive without burning CPU in a RunLoop spin loop. + +The benefit over `xcodebuild test-without-building` is that `simctl spawn` skips the full xcodebuild test pipeline startup overhead (no `.xctestrun` file parsing, no build product re-verification, no `xcodebuild` process lifecycle). This makes service startup significantly faster for simulators. + +**Physical devices** continue to use `xcodebuild test-without-building` because `simctl spawn` only works for simulators. + +## Summary + +| Question | Answer | +|---|---| +| Why XCUITest? | Only path to cross-process privileged accessibility access on iOS | +| Who provides that access? | `testmanagerd`, a privileged system daemon | +| How does the runner connect? | Via `XCTRunnerDaemonSession → testmanagerd → DTXConnection` | +| Why can't third parties replicate it? | Private entitlements + no public API | +| Why simctl spawn for simulators? | Skips xcodebuild overhead while preserving the testmanagerd connection | diff --git a/docs/design-docs/plat/ios/xctestrunner.md b/docs/design-docs/plat/ios/xctestrunner.md new file mode 100644 index 000000000..4223b6c87 --- /dev/null +++ b/docs/design-docs/plat/ios/xctestrunner.md @@ -0,0 +1,109 @@ +# XCTest Runner + +✅ Implemented 🧪 Tested + +> **Current state:** `XCTestRunner` is a fully implemented Swift package (`ios/XCTestRunner/`) with `AutoMobileTestCase`, `AutoMobilePlanExecutor`, `AutoMobileTestObserver`, `TestTimingCache`, and `AutoMobileSession`. Unit tests cover plan execution, test ordering, and environment variable parsing. Integration tests run against the system Reminders app. See the [Status Glossary](../../status-glossary.md) for chip definitions. + +The iOS test execution layer mirrors the Android JUnitRunner by providing a structured +way to execute AutoMobile plans within XCTest and collect timing data for optimization. + +## Goals + +- Run AutoMobile plans from XCTest with deterministic setup/teardown. +- Integrate with MCP device/session management. +- Collect and publish test timing history. +- Enable optional AI-assisted recovery for flaky UI flows. + +## Architecture + +- `AutoMobileTestCase`: base XCTestCase that loads a YAML plan and executes it via MCP. +- `AutoMobilePlanExecutor`: library that wraps plan execution with retries and cleanup. +- `XCTestObservation` integration to record timing data and pass metadata to MCP. + +## Configuration + +Environment variables and test scheme settings configure how the runner connects to MCP, loads plans, +and orders tests. + +When using the daemon socket transport, the runner will attempt to start the AutoMobile daemon if it +is not detected. + +### Environment variables + +Primary: +- `AUTOMOBILE_DAEMON_SOCKET_PATH`: Daemon socket path (default: `/tmp/auto-mobile-daemon-$UID.sock`). +- `AUTOMOBILE_TEST_PLAN`: Default YAML plan path for a test target. +- `AUTOMOBILE_TEST_RETRY_COUNT`: Retry attempts for plan execution (default: `0`). +- `AUTOMOBILE_TEST_RETRY_DELAY_SECONDS`: Backoff between retries (default: `1`). +- `AUTOMOBILE_TEST_TIMEOUT_SECONDS`: Per-test timeout override in seconds (default: `300`). +- `AUTOMOBILE_CI_MODE`: Marks runs as CI for metadata and disables timing fetch in CI. +- `AUTOMOBILE_APP_VERSION`: Metadata for plan execution. +- `AUTOMOBILE_GIT_COMMIT`: Metadata for plan execution. + +Legacy (still supported): +- `AUTO_MOBILE_DAEMON_SOCKET_PATH` +- `MCP_ENDPOINT` +- `PLAN_PATH` +- `RETRY_COUNT` +- `TEST_TIMEOUT` +- `AUTO_MOBILE_APP_VERSION` +- `APP_VERSION` +- `AUTO_MOBILE_GIT_COMMIT` +- `GITHUB_SHA` +- `GIT_COMMIT` +- `CI_COMMIT_SHA` +- `CI` +- `GITHUB_ACTIONS` + +### Test scheme settings + +Use Xcode scheme settings (Test -> Arguments) for ordering and timing: +- `-parallel-testing-worker-count `: Used to resolve timing ordering when set. +- `XCTEST_PARALLEL_THREAD_COUNT=`: Environment variable alternative to control worker count. +- `automobile.junit.timing.ordering`: `auto`, `duration-asc`, `duration-desc`, `none` (via UserDefaults + or environment variable). +- `automobile.junit.timing.enabled`: Enable/disable timing fetch (default: `true`). +- `automobile.junit.timing.lookback.days`: Timing history window (default: `90`). +- `automobile.junit.timing.limit`: Max timing records to load (default: `1000`). +- `automobile.junit.timing.min.samples`: Minimum samples per test (default: `1`). +- `automobile.junit.timing.fetch.timeout.ms`: Timing fetch timeout in ms (default: `5000`). +- `automobile.ci.mode`: Disable timing fetch in CI (default: `false`). + +## Example usage + +```swift +final class LoginFlowTests: AutoMobileTestCase { + override var planPath: String { "Tests/Plans/login-success.yaml" } + + func testLoginFlow() throws { + _ = try executePlan() + } +} +```yaml + +## Timing data + +The runner should fetch timing history during startup to order tests when parallel +execution is enabled and report results after each run. + +## CI vs local execution + +Both local and CI execution use the daemon socket transport. + +Local: +```bash +AUTOMOBILE_TEST_PLAN=Plans/launch-reminders-app.yaml \ +swift test --filter RemindersLaunchPlanTests +```yaml + +CI: +```bash +AUTOMOBILE_CI_MODE=1 \ +AUTOMOBILE_TEST_PLAN=Plans/launch-reminders-app.yaml \ +xcodebuild test -scheme XCTestRunner -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +## See also + +- [MCP test timings](../../mcp/daemon/index.md) +- [iOS automation server](xctestservice.md) diff --git a/docs/design-docs/plat/ios/xctestservice.md b/docs/design-docs/plat/ios/xctestservice.md new file mode 100644 index 000000000..2801a4931 --- /dev/null +++ b/docs/design-docs/plat/ios/xctestservice.md @@ -0,0 +1,64 @@ +# Accessibility Bridge + +✅ Implemented 🧪 Tested 📱 Simulator Only + +> **Current state:** `XCTestService` is a fully implemented Swift package (`ios/XCTestService/`) with WebSocket server, `ElementLocator`, `GesturePerformer`, `CommandHandler`, `HierarchyDebouncer`, and `DisplayLinkFPSMonitor`. Tests cover command handling, hierarchy debouncing, perf timing, and model serialization. Physical device support requires provisioning (issues [#912–914](https://github.com/jasonpearson/auto-mobile/issues/912)). See the [Status Glossary](../../status-glossary.md) for chip definitions. + +The iOS automation server is a native iOS app that exposes the accessibility tree and element +queries over a WebSocket connection. It is the iOS counterpart to the Android accessibility +service and focuses on reliable observation delivery. + +## Responsibilities + +- Serve the accessibility tree via WebSocket. +- Support element lookup by id, text, and type. +- Provide element bounds for touch injection. +- Emit view hierarchy updates when the UI changes. +- Track first responder and focus state. + +## WebSocket protocol + +Client to server command: + +```json +{ + "id": "cmd_abc123", + "action": "getViewHierarchy", + "params": {} +} +``` + +Server to client response: + +```json +{ + "id": "cmd_abc123", + "status": "success", + "result": { + "timestamp": 1704067200.5, + "screenSize": { "width": 390, "height": 844 }, + "elements": [ + { + "id": "UIButton_67890", + "type": "UIButton", + "label": "Submit", + "identifier": "submitButton", + "frame": { "x": 100, "y": 400, "width": 190, "height": 44 }, + "isEnabled": true, + "isVisible": true, + "traits": ["button"] + } + ] + } +} +``` + +## Limitations + +- Simulator-only currently. +- Physical device support requires provisioning (see GitHub issues #912-914). + +## See also + +- [XCTestService](xctestrunner.md) - Touch injection via native XCUITest APIs. +- [MCP tool reference](../../mcp/tools.md) diff --git a/docs/design-docs/status-glossary.md b/docs/design-docs/status-glossary.md new file mode 100644 index 000000000..adb1be8e2 --- /dev/null +++ b/docs/design-docs/status-glossary.md @@ -0,0 +1,85 @@ +# Status Glossary + +This page defines the status chips used throughout the design documentation to reflect the current implementation state of each feature. + +## How to Read Status Chips + +Each design document may include one or more status chips in the header or inline with specific features. Chips describe whether the feature is implemented, tested, platform-limited, or still in design. + +## Status Definitions + +| Chip | Meaning | +|------|---------| +| ✅ Implemented | Code exists and is actively used in production builds. | +| 🧪 Tested | Automated unit or integration tests cover the feature. | +| ⚠️ Partial | Feature is partially implemented — some sub-features work, others do not. | +| 🔒 Internal | Exists in code but is not exposed via MCP or CLI by default; requires custom configuration. | +| 🚧 Design Only | Document describes a proposed design; no corresponding implementation exists yet. | +| ❌ Not Implemented | A specific sub-feature or proposed MCP tool is documented but has not been built. | +| 🤖 Emulator Only | Only functional on Android emulators; physical Android devices are not supported. | +| 📱 Simulator Only | Only functional on iOS simulators; physical iOS devices are not supported. | +| 🤖 Android Only | Feature exists on Android but has no iOS equivalent. | +| 🍎 iOS Only | Feature exists on iOS but has no Android equivalent. | + +## Master Status List + +### Features Not Yet Implemented + +The following items are documented as designs or proposals but have **no corresponding implementation** at this time: + +#### MCP Tools (Proposed, Not in Tool Registry) + +- **`setNetworkState`** — Wi-Fi/cellular/airplane mode toggle. ADB commands validated, MCP tool not built. See [network-state.md](plat/android/network-state.md). +- **`setTalkBackEnabled`** — Enable/disable TalkBack. ADB commands validated, MCP tool not built. See [talkback.md](plat/android/talkback.md). +- **`setA11yFocus`** — Move accessibility focus to element. Not yet implemented. See [talkback.md](plat/android/talkback.md). +- **`announce`** — TTS announcement via AccessibilityService. Not yet implemented. See [talkback.md](plat/android/talkback.md). +- **`takeScreenshot` (standalone with fallback-ticket gating)** — The proposed standalone `takeScreenshot` tool with server-side "fallback ticket" security model is not built. Screenshots are available via `observe`. See [take-screenshot.md](plat/android/take-screenshot.md). +- **Standalone `await`/`assert` YAML steps** — `executePlan` steps named `await` and `assert` (as described in the design doc) are not implemented. `waitFor` params on individual tools and `expectations` in step params are the current approach. See [executeplan-assertions.md](plat/android/executeplan-assertions.md). +- **TalkBack tool adaptations (Phases 2–4)** — ACTION_CLICK routing, two-finger scroll, focus tracking for TalkBack mode are not yet implemented. See [talkback-voiceover.md](mcp/a11y/talkback-voiceover.md). +- **iOS VoiceOver adaptation** — All phases are design-only. See [talkback-voiceover.md](mcp/a11y/talkback-voiceover.md). +- **iOS XcodeExtension feature flag UI** — Not implemented; stubs only. See [ios/ide-plugin/feature-flags.md](plat/ios/ide-plugin/feature-flags.md). +- **iOS XcodeCompanion test recording** — Not implemented; scaffold only. See [ios/ide-plugin/test-recording.md](plat/ios/ide-plugin/test-recording.md). + +#### iOS Platform + +- **Physical device support** — iOS automation is simulator-only. Physical devices require provisioning and signing work tracked in GitHub issues [#912](https://github.com/jasonpearson/auto-mobile/issues/912), [#913](https://github.com/jasonpearson/auto-mobile/issues/913), [#914](https://github.com/jasonpearson/auto-mobile/issues/914). See [iOS overview](plat/ios/index.md). +- **iOS live screen streaming** — AVFoundation/ScreenCaptureKit pipeline is a design document; no implementation. See [iOS screen streaming](plat/ios/screen-streaming.md). +- **Managed App Configuration** — Guidance for MDM-managed apps; no AutoMobile implementation. See [managed-app-config.md](plat/ios/managed-app-config.md). +- **Managed Apple IDs** — Guidance for managed accounts; no AutoMobile implementation. See [managed-apple-ids.md](plat/ios/managed-apple-ids.md). + +#### Android SDK + +- **`AutoMobileBiometrics.overrideResult()`** — Optional SDK hook for deterministic biometric bypass in apps under test. Not implemented. See [biometrics.md](plat/android/biometrics.md). + +#### Vision + +- **Hybrid vision fallback (Tier 1 local models)** — Proposed Florence-2 / PaddleOCR local model layer. Not implemented. See [vision-fallback.md](mcp/observe/vision-fallback.md). +- **Vision fallback in tools other than `tapOn`** — `swipeOn`, `scrollUntil`, etc. integration. Not implemented. + +### Features That Are Partial or Internal + +- **Vision fallback (Claude)** — Works for `tapOn` on Android only; disabled by default and not exposed via MCP. See [vision-fallback.md](mcp/observe/vision-fallback.md). +- **TalkBack enablement (ADB)** — ADB commands are validated and documented; the MCP tool wrapper is not yet built. +- **Android live screen streaming (IDE mirroring)** — The Android `video-server` JAR (H.264, VirtualDisplay) is built; the full end-to-end IDE mirroring pipeline is in progress. The `videoRecording` MCP tool (record-to-file) is fully implemented. See [Android screen streaming](plat/android/screen-streaming.md). +- **iOS XcodeCompanion** — Scaffolded macOS app with all views and navigation defined; feature completeness is ongoing. See [iOS IDE plugin](plat/ios/ide-plugin/overview.md). +- **iOS XcodeExtension** — Scaffold with 5 registered commands; implementations are minimal stubs. +- **`highlight` tool** — Fully implemented on Android; returns an unsupported error on iOS. +- **`rawViewHierarchy` (control-proxy source)** — Android only. iOS returns XCUITest JSON. +- **Work profile `userId` override** — Auto-detection works; manual `userId` parameter is not supported in MCP tool schemas. + +### Features That Lack Test Coverage + +> All TypeScript MCP server features have unit tests. The items below are platform-level features with limited or no automated coverage. + +- **iOS XcodeCompanion** — Only scaffold/smoke tests exist. +- **iOS XcodeExtension** — Only scaffold/smoke tests exist. +- **Android IDE plugin** — UI and Compose Desktop views are lightly tested; daemon communication has more coverage. +- **Biometric enrollment flow** — Enrollment steps require real emulator; only capability probing is covered by unit tests. +- **Live screen streaming (IDE mirroring)** — End-to-end streaming is not covered by automated tests. + +## See Also + +- [Design Documentation Index](index.md) +- [MCP Tools Reference](mcp/tools.md) +- [Android Platform Overview](plat/android/index.md) +- [iOS Platform Overview](plat/ios/index.md) diff --git a/docs/faq.md b/docs/faq.md index 629abf2a3..83ec493d4 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,175 +1,85 @@ -# Frequently Asked Questions +# FAQ -## General Questions +#### What can I use this for? -### What is AutoMobile? +- [Explore app ux](using/ux-exploration.md), [create UI tests](using/ui-tests.md), and easily create [bug reports]() with built-in [video recording](design-docs/mcp/observe/video-recording.md) and [visual highlights](design-docs/mcp/observe/visual-highlighting.md). +- Measure [startup](using/perf-analysis/startup.md), [scroll framerate](using/perf-analysis/scroll-framerate.md), and [screen transitions](using/perf-analysis/screen-transition.md). +- Audit accessibility compliance with [contrast ratios](using/a11y.md#contrast) & [tap targets](using/a11y.md#tap-targets). +- (Coming soon) record tests via AutoMobile's companion Android plugin & MacOS app. +- Run tests natively via [JUnitRunner for Android](design-docs/plat/android/junitrunner.md) and [XCTestRunner for iOS](design-docs/plat/ios/xctestrunner.md). -AutoMobile is a comprehensive set of tools that enables AI agents to interact with mobile devices. It provides automated -testing, performance monitoring, and device interaction via an MCP server, custom test runner, and agentic loop that is -compatible with multiple foundation model providers. +#### Why another MCP? Aren't they all worthless? -### Do I need root access on my device? +MCP is currently the best way to give AI agents **deterministic tools**. Most MCPs just provide simple data access as API wrappers. There is no simple API to wrap to truly conduct mobile device automation as of 2026 and it does not appear that anyone else is creating one. -No, AutoMobile is designed to work entirely within standard `adb` permissions. All functionality operates without requiring -device root access, making it suitable for both physical devices and emulators. If you can access it over `adb`, it works. +#### Won't this bloat my context window a lot? -And if you don't have Android command line or platform tools installed, AutoMobile can find them and set them up for you. +As for context bloat there are MCP benchmarks we run on every change that keep context usage as low as possible while delivering value. Most MCPs take up 50-100k tokens just being loaded into memory. AutoMobile keeps all of its tools, resources, and templates around 12k. We're committed to keeping this usage low and finding new ways to reduce it. -### Which AI clients are supported? +#### Is my AI agent supported? -Any MCP-compatible tool calling client can use AutoMobile's MCP, including: +Any MCP tool-compatible client. See [installation](install.md) for configuration examples. The project does make use of MCP resources because they have significant advantages and adoption across AI agents, please file an issue if this is not supported in your workflow. -- Firebender (I wrote AutoMobile with it) -- Claude Desktop -- Goose -- Cursor -- fast-agent -- claude code -- Other MCP-compatible tools and frameworks +#### Do I need root access? -I don't have time to test every client. As I can put integration tests in place for clients I will, but mostly I plan -to do integration testing against `fast-agent` because it has a complete MCP client implementation and is open source. +No. Core automation works with standard `adb` permissions on emulators and physical devices. Some advanced features are emulator-only and are called out as such. -### What are the system requirements? +#### After installation how do I get it to look at a device? -- Node.js 20 or later +If you have already connected an Android or iOS device to your computer AutoMobile will automatically detect and assume it should run automation on it. Otherwise it looks for installed system images and provides tools to start one of them. -AutoMobile will automatically download and install the following unless they already exist. -- Command line tools installed via Homebrew or manually in $ANDROID_HOME/cmdline_tools -- Android SDK installed at ANDROID_HOME, -- At least one Android device or emulator +#### What happens if I have more than one device? -Physical devices do need USB debugging enabled for AutoMobile to function with them. +Yes. AutoMobile supports multiple connected devices with automatic selection for CI/parallel testing. For consistent device selection, use `setActiveDevice` or connect only one device. -### How do I enable USB debugging? +#### Does it affect app performance? -TODO: Can I automate this with AutoMobile +No. Almost all functionality works without adding the Android AutoMobile SDK to your app. That library currently provides better navigation graph alignment and Compose recomposition count data and is only meant to be run in internal variants. -1. Go to Settings > About Phone -2. Tap "Build Number" 7 times to enable Developer Options -3. Go to Settings > Developer Options -4. Enable "USB Debugging" -5. Connect your device and accept the debugging prompt +#### Where is data stored? -### Can I use multiple devices simultaneously? +| Location | Contents | +|----------|----------| +| `/tmp/auto-mobile/` | Logs, caches (host machine) | +| `~/.auto-mobile/sqlite.db` | Navigation graph, tool history, test records, performance records | -AutoMobile supports multiple connected devices, but if you're using it as an MCP with an agent it's going to automatically -assign operations based on its internal heuristics - this is to support the CI automation use case where multiple emulators -are being driven from a single CI job with parallel test execution. If you want consistency in which device it selects -just keep one connected. +Logs rotate at 10MB. Observe caches expire after a short TTL. -### Does this cost anything? +#### My app crashed while using AutoMobile -Its an open source project, but that doesn't mean the AI model or emulator providers are free. Any way you look at it, -mobile UI testing has a cost. I am pretty sure this will end up reducing costs by running more efficient tests faster. -Looking forward to proving that with data. +Assuming you haven't tried out the platform specific SDK integration, that's your app. If AutoMobile can cause a crash, so can a user. -### How accurate is text-based tapping? +#### What data is collected? -Text-based tapping uses fuzzy search algorithms and view hierarchy analysis to find the best matching elements. It -handles variations in text, partial matches, and different UI frameworks (XML and Compose). It prefers to use context -description values if they are available, so the more effort you put into accessibility the better this works. In my -experience it's great. - -### What happens if an interaction fails? - -When AutoMobile is invoked as an MCP it gives feedback as to why the interaction failed with relevant detail about the -current view hierarchy, - -### How fast are the interactions? - -Interaction speed depends on device performance and complexity. Typical operations: - -- Simple taps: 98-167ms -- Complex scrolling: 1-3 seconds, longer if you need to search for a specific element in a long list -- App launches: What is your app startup speed? -- View hierarchy analysis: 9-48ms - -I'm constantly looking to improve the speed of operations, suggestions and contributions welcome. - -### Does it affect app performance? - -Nope. - -### How much device storage is used? - -AutoMobile stores temporary files for screenshots, logs, etc, typically using less than 100MB of device storage. It cleans -up old files automatically once you reach the storage limit by using internal heuristics on what information is the least -valuable to keep (screenshots and view hierarchy). This is all stored - -### Tool calls are failing - -If running as an MCP -1. Check the MCP tool call output, usually the explanation is it didn't find the element specified or it's a WebView which is not supported yet. -2. Check MCP server logs at `/tmp/auto-mobile/logs/server.log` - -### Gestures not working properly? - -Assuming you've already tried looking at MCP tool call output: -1. Turn on "Show Taps" and "Show Pointer" to visually watch the gestures that AutoMobile is attempting. -2. Record a video via `srccpy` and file an issue. - -### App crashes during testing? - -That's your app implementation. If AutoMobile can cause it to crash, a user can too. - -### Can I integrate with CI/CD systems? - -See [ci setup docs](features/test-execution/ci.md). - -### Is there API documentation? - -AutoMobile's [MCP Server](features/mcp-server/index.md) is fully documented with system design diagrams and -explanations. If you find any of this wanting feel free to file an issue. - -For AutoMobile's CLI you can always run the tool without commands to get helpful explanations. It is not going to have -a dedicated documentation page beyond running tool output and updating [this page](features/cli.md). - -```text -npm install -g auto-mobile@latest -auto-mobile --cli -``` - -### Can I extend the functionality? - -AutoMobile has a bunch of different parts. You can mix and match the MCP with a different JUnitRunner, use the CLI tool -with your own agent. AutoMobile is designed to have its components work together, but I'm not setting up any blockers to -how it gets used. If there are components worth extracting we'll extract them. +View hierarchy and screenshots for the foreground app, stored locally. Vision fallback (if enabled) sends screenshots to your configured model provider. No built-in analytics. ### How do I report bugs or request features? -- [File issues on the GitHub repository](https://github.com/zillow/auto-mobile/issues) +- [File issues on the GitHub repository](https://github.com/kaeawc/auto-mobile/issues) - Include device information, logs, and reproduction steps. For bonus points include an AutoMobile plan. It would be best - if reproductions could point at publicly available apps that have been released. I've done my testing against Zillow, - Slack, Google Keep, YouTube Music, Bluesky, Google Calendar, and more. + if reproductions could point at publicly available apps that have been released. I've done my testing against + Zillow, Slack, Google Keep, YouTube Music, Bluesky, Google Calendar, and more. - Feature requests are welcomed as are contributions. Please file an issue before starting a contribution. -### What data is collected? - -AutoMobile collects whatever happens to be displayed in the view hierarchy of the current top app or launcher. It only -stores this data on the machine its being invoked and there is no outside processing service, no analytics whatsoever. -It is designed to be used with a foundation model and its up to you how you share your data with model providers. - -### Can it access sensitive app data? - -AutoMobile uses `adb` for all operations. It cannot access encrypted app data or system-level information without -appropriate permissions. If you grant those, then it can access that sensitive information. +#### What about existing `androidTest` code? -### What do we do with androidTest now? +[`rm -rf`](https://www.github.com/kaeawc/auto-mobile/blob/main/scripts/delete_androidTest.sh) -[`rm -rf`](https://www.github.com/zillow/auto-mobile/blob/main/scripts/delete_androidTest.sh) - -No seriously, once you're fully on AutoMobile you should just delete them. Use the above script, by default it will perform -a dry-run to tell you explicitly what its about to delete. Only do this after you've fully migrated your project to not -need them anymore. +No seriously, once you're fully on AutoMobile you should just delete them. Use the above script; by default it performs +a dry run and tells you exactly what it would delete. Only do this after you've fully migrated your project. ```shell ../scripts/delete_androidTest.sh --execute 🧹 Cleaning up androidTest sources and dependencies... -📍 Working in: ~/zillow/auto-mobile/junitrunner +📍 Working in: ~/kaeawc/auto-mobile/junitrunner 🗂️ [DRY RUN] Removing androidTest source directories... 📝 [DRY RUN] Removing androidTestImplementation dependencies... 🧽 [DRY RUN] Cleaning up empty test directories... ✅ Cleanup complete! 🔍 You may want to review changes before committing ``` + +## Getting Help + +- **Issues**: [GitHub Issues](https://github.com/kaeawc/auto-mobile/issues) +- **Include**: Device info, logs, reproduction steps, AutoMobile plan if possible diff --git a/docs/features/batteries-included.md b/docs/features/batteries-included.md deleted file mode 100644 index dd5d6d5a0..000000000 --- a/docs/features/batteries-included.md +++ /dev/null @@ -1,67 +0,0 @@ -# Batteries Included - -AutoMobile comes with extensive built-in functionality to minimize setup friction and provide a seamless -out-of-the-box experience. These "batteries included" features automatically handle common configuration tasks and -provide intelligent fallbacks. - -## Android SDK Management - -### Automatic SDK Detection - -- `$ANDROID_HOME/platform-tools` -- `$HOME/Android/Sdk/platform-tools` (Linux/macOS) -- `%LOCALAPPDATA%\Android\Sdk\platform-tools` (Windows) -- System PATH locations - -### SDK Download and Setup - -When no Android SDK is detected, AutoMobile can automatically download and configure the required components. - -TODO: Add documentation based on real implementation - -### ANDROID_HOME Configuration - -If `$ANDROID_HOME` is not set, AutoMobile will: - -1. Attempt to locate the SDK automatically -2. Set the environment variable for the current session -3. Provide guidance for permanent configuration - -```bash -# AutoMobile will automatically configure these paths: -export ANDROID_HOME=/path/to/android/sdk -export PATH=$PATH:$ANDROID_HOME/platform-tools -export PATH=$PATH:$ANDROID_HOME/tools -``` - -## Taking Out the Batteries - -### Selective Feature Disabling - -You can opt out of any or all automated behaviors via CLI arguments: - -#### SDK Management - -```bash -# Disable automatic SDK detection -npx auto-mobile --no-auto-sdk - -# Use specific SDK path without validation -npx auto-mobile --android-home /custom/path --no-validate-sdk -``` - -#### Device Management - -```bash -# Disable device auto-detection -npx auto-mobile --no-auto-device - -# Skip USB debugging setup assistance -npx auto-mobile --no-setup-assistance - -# Disable emulator auto-start -npx auto-mobile --no-auto-emulator -``` - -By leveraging these batteries-included features, AutoMobile provides a smooth onboarding experience while -maintaining the flexibility to customize behavior for specific requirements and environments. diff --git a/docs/features/cli.md b/docs/features/cli.md deleted file mode 100644 index cf097a630..000000000 --- a/docs/features/cli.md +++ /dev/null @@ -1,96 +0,0 @@ -# CLI - -## Basic Usage - -```bash -auto-mobile --cli help -``` - -```shell -AutoMobile CLI - Android Device Automation - -Usage: -auto-mobile --cli [--param value ...] -auto-mobile --cli help [tool-name] - -Examples: -auto-mobile --cli listDevices -auto-mobile --cli observe -auto-mobile --cli tapOn --text "Submit" -auto-mobile --cli startDevice --avdName "pixel_7_api_34" - -Options: -help [tool-name] Show help for a specific tool - -Parameters: -Parameters are passed as --key value pairs -Values are parsed as JSON if possible, otherwise as strings -Boolean values: --flag true or --flag false -Numbers: --count 5 -Objects: --options '{"key": "value"}' - - -Available Tools: -================ - -Observation: -observe - Take a screenshot and get the view hierarchy of what is displayed on screen - -App Management: -listApps - List all apps installed on the device -stopApp - Stop a running app (Maestro equivalent of terminateApp) -launchApp - Launch an app by package name -terminateApp - Terminate an app by package name -clearAppData - Clear data for an app by package name -installApp - Install an APK file on the device - -Interactions: -clearText - Clear text from the currently focused input field -selectAllText - Select all text in the currently focused input field using long press + tap on 'Select All' -pressButton - Press a hardware button on the device -swipeOnElement - Swipe on a specific element -swipeOnScreen - Swipe on screen in a specific direction -pullToRefresh - Perform a pull-to-refresh gesture on a list -openSystemTray - Open the system notification tray by swiping down from the status bar -pressKey - Press a hardware key on the device (Maestro equivalent of pressButton) -clearState - Clear app state and data (Maestro equivalent of clearAppData) -inputText - Input text to the device (Maestro equivalent of sendText) -openLink - Open a URL in the default browser (Maestro equivalent of openUrl) -tapOn - Unified tap command supporting coordinates, text, and selectors -doubleTapOn - Unified double tap command supporting coordinates, text, and selectors -longPressOn - Unified long press command supporting coordinates, text, and selectors -scroll - Scroll in a direction on a scrollable container, optionally to find an element (supports text and selectors) -swipe - Unified scroll command supporting direction and speed (no index support due to reliability) -openUrl - Open a URL in the default browser -changeOrientation - Change the device orientation - -Emulator Management: -setActiveDevice - Set the active device ID for subsequent operations -enableDemoMode - Enable demo mode with consistent status bar indicators for screenshots -disableDemoMode - Disable demo mode and return to normal status bar behavior -listDevices - List all connected devices (both physical devices and emulators) -listDeviceImages - List all available device images -checkRunningDevices - Check which devices are currently running -startDevice - Start a device with the specified device image -killEmulator - Kill a running device - -Source Mapping: -addAppConfig - Add Android app source configuration for indexing activities and fragments -setAndroidAppSource - Configure Android app source directory for code analysis when user provides app package ID and source path with explicit permission to read the source directory. Use this when user wants to analyze or find source files for a specific Android app they have access to. -getAppConfigs - Get all configured Android app source directories -getSourceIndex - Get or create source index for an Android app (activities and fragments) -findActivitySource - Find source file information for an activity by class name -findFragmentSource - Find source file information for a fragment by class name - -Plan Management: -exportPlan - Export a repeatable YAML plan based on logged tool calls. Omits emulator and most observe calls, keeping only the last observe call. Plans are automatically saved to /tmp/auto-mobile/plans directory when no outputPath is specified. -executePlan - Execute a series of tool calls from a YAML plan content. Stops execution if any step fails (success: false). Optionally can resume execution from a specific step index. - -Assertions: -assertVisible - Assert that an element is visible on the screen -assertNotVisible - Assert that an element is not visible on the screen - -Total: 43 tools available - -Use 'auto-mobile --cli help ' for detailed information about a specific tool. -``` diff --git a/docs/features/index.md b/docs/features/index.md deleted file mode 100644 index f5f15a9ac..000000000 --- a/docs/features/index.md +++ /dev/null @@ -1,64 +0,0 @@ -# Features - -#### MCP Server - -AutoMobile's main usage is driven through its Model Context Protocol ([MCP](https://modelcontextprotocol.io/introduction)) -server. It has [observation](mcp-server/observation.md) built into its [interaction loop](mcp-server/interaction-loop.md) -that is fast. This is supported with performant frame rate observation to determine UI idling. Together, that allows for -accurate and precise exploration that gets better as more capabilities and heuristics are added. Every widget and -interaction added to the [AutoMobile Playground app](https://github.com/zillow/auto-mobile/blob/main/android/playground/README.md) -is tested with AutoMobile in order to keep improving. - -#### Source Mapping - -We combine project path config with deep view hierarchy analysis to determine the code is being rendered. This opens a -lot of possibilities so we're looking for ways to improve indexing performance and accuracy. - -#### Automated Test Authoring - -When used in test authoring mode AutoMobile will write tests for you. It logs every tool call used during an exploration -as well as the relevant source files observed. Once the target app is stopped it determines the correct path for the test -and writes it to disk. This test will always be immediately runnable and correct because no AI is used in this process. -AutoMobile authors [yaml plans](test-authoring/plan-syntax.md) of the tool calls logged along with platform specific -unit tests via code writing tools. For Android this is a KotlinPoet wrapper Clikt app. - -The Clikt application is a Kotlin-based command-line interface that facilitates test authoring and device interaction. -It leverages the Clikt library for creating intuitive command-line interfaces and serves as a bridge between the MCP -server's capabilities and native Android testing frameworks. This component enables developers to author tests in Kotlin -while benefiting from the comprehensive device automation capabilities provided by the broader AutoMobile ecosystem. - -#### Test Execution - -The Android JUnitRunner is responsible for executing authored tests on Android devices and emulators. It extends the -standard Android testing framework to provide enhanced capabilities including intelligent test execution, detailed -reporting, and integration with the MCP server's device management features. The runner is designed to eventually -support agentic self-healing capabilities, allowing tests to automatically adapt and recover from common failure -scenarios by leveraging AI-driven analysis of test failures and UI changes. - -#### Device Management - -Multi-device support with emulator control and app lifecycle management. As long as you have available adb connections, -AutoMobile can automatically track which one its using for which execution plan or MCP session. It also means that CI -setup just requires an open adb connection and AutoMobile will do the rest. - -#### Android Accessibility Service - -The Android Accessibility Service provides real-time access to view hierarchy data and user interface elements without -requiring device rooting or special permissions beyond accessibility service enablement. This service acts as a bridge -between the Android system's accessibility framework and AutoMobile's automation capabilities. When enabled, the -accessibility service continuously monitors UI changes and provides detailed information about view hierarchies. It -writes this file to disk on every update which AutoMobile can then query over adb. The service runs without additional -performance overhead. - -#### Batteries Included - -AutoMobile comes with extensive functionality to [minimize and automate setup](batteries-included.md) of required -platform tools. - -## Components - -#### MCP Server - -The Model Context Protocol ([MCP](https://modelcontextprotocol.io/introduction)) server is the core component of AutoMobile, built with Node.js and TypeScript using the -MCP TypeScript SDK. It serves as both a server for AI agents to interact with Android devices and a command-line -interface for direct usage. You can read more about its setup and system design in our [MCP server docs](mcp-server/index.md) diff --git a/docs/features/mcp-server/actions.md b/docs/features/mcp-server/actions.md deleted file mode 100644 index c0f1ec356..000000000 --- a/docs/features/mcp-server/actions.md +++ /dev/null @@ -1,34 +0,0 @@ -# Features - MCP Server - Actions - -#### Observe - -In case of instances where the agent needs to make an observation to determine what interactions it should attempt we -expose the [observe](observation.md) ability as a tool call. - -#### Interactions - -- 👆 **Tap**: Intelligent text-based or resource-id tapping with fuzzy search and view hierarchy analysis. -- 👉 **Swipe**: Directional swiping within element bounds with configurable release timing. -- ⏰ **Long Press**: Extended touch gestures for context menus and advanced interactions. -- 📜 **Scroll**: Intelligent scrolling until target text becomes visible. -- 📳 **Shake**: Accelerometer simulation of shaking the device. - -#### App Management - -- 📱 **List Apps**: Enumerate all installed applications including system apps. -- 🚀 **Launch App**: Start applications by package name. -- ❌ **Terminate App**: Force-stop the specified application if its running. -- 🗑️ **Clear App Data**: Reset application state and storage. -- 📦 **Install App**: Deploy an app to the device -- 🔗 **Query Deep Links**: Query the application for its registered deep links - -#### Input Methods - -- ⌨️ **Send Keys**: Keyboard input, optionally using ADBKeyboard for unicode as needed on Android. -- 🗑️ **Clear Text**: Deletes all text from a specified element, or the currently focused text field. -- 🔘 **Press Button**: Hardware button simulation (home, back, menu, power, volume) - -#### Device Configuration - -- 🔄 **Change Orientation**: Toggle between portrait and landscape modes -- 🌐 **Open URL**: Launch URLs or deep links in default browser diff --git a/docs/features/mcp-server/index.md b/docs/features/mcp-server/index.md deleted file mode 100644 index d136f4ec1..000000000 --- a/docs/features/mcp-server/index.md +++ /dev/null @@ -1,49 +0,0 @@ -# Features - MCP Server - -AutoMobile's MCP makes its various [actions](actions.md) available as tool calls and automatically performs -[observations](observation.md) within an [interaction loop](interaction-loop.md). This is a simple overview diagram, -for more detail see the [full MCP server system design](system-design.md). - -```mermaid -stateDiagram-v2 - Agent: Agent - RequestHandler: Request Handler - DeviceSessionManager: Device Session Manager - InteractionLoop: Interaction Loop - AuthorTest: Author Test - - Agent --> RequestHandler - RequestHandler --> Agent - RequestHandler --> DeviceSessionManager - InteractionLoop --> RequestHandler: 🖼️ Processed Results - DeviceSessionManager --> InteractionLoop: 📱 - RequestHandler --> AuthorTest: on App Stopped - -``` - -## Configuration - -AutoMobile MCP is designed to be run in STDIO mode in production settings like workstations and CI automation. - -```shell -npx -y auto-mobile@latest -``` - -If you have a private npm registry you can instead do the following - -```shell -npx --registry https://your.awesome.private.registry.net/path/to/npm/proxy -y auto-mobile@latest -``` - -A lot of MCP clients configure MCP servers through JSON, this sample will work with most - -```json -{ - "mcpServers": { - "AutoMobile": { - "command": "npx", - "args": ["-y", "auto-mobile@latest"] - } - } -} -``` diff --git a/docs/features/mcp-server/interaction-loop.md b/docs/features/mcp-server/interaction-loop.md deleted file mode 100644 index 2db7a7a57..000000000 --- a/docs/features/mcp-server/interaction-loop.md +++ /dev/null @@ -1,23 +0,0 @@ -# Features - MCP Server - Interaction Loop - -This interaction loop is supported by comprehensive [observation](observation.md) of UI state and performant -frame rate observation to determine UI idling. Together, that allows for accurate and precise exploration with the -[action tool calls](actions.md). - -```mermaid -sequenceDiagram - participant Agent as AI Agent - participant MCP as MCP Server - participant Device as Device - - Agent->>MCP: 🤖 Interaction Request - MCP->>Device: 👀 Observe - Device-->>MCP: 📱 UI State/Data (Cached) - - MCP->>Device: ⚡ Execute Actions - Device-->>MCP: ✅ Result - - MCP->>Device: 👀 Observe - Device-->>MCP: 📱 UI State/Data - MCP-->>Agent: 🔄 Interaction Response with UI State -``` diff --git a/docs/features/mcp-server/observation.md b/docs/features/mcp-server/observation.md deleted file mode 100644 index 8186a7e3f..000000000 --- a/docs/features/mcp-server/observation.md +++ /dev/null @@ -1,83 +0,0 @@ -# Features - MCP Server - Observation - -Each observation captures a snapshot of the current state of a device's screen and UI. When executed, it -collects multiple data points in parallel to minimize observation latency. These operations are incredibly platform -specific and will likely require a different ordering of steps per platform. All of this is to drive the -[interaction loop](interaction-loop.md). - -## Android - -- Pre-fetch `dumpsys window` and `wm size` output, potentially from cache, to optimize subsequent parallel operations. The cached -value should have been written from the last most recent observation from the current device. -- At this point we can determine physical screen size, active window, current orientation, and system insets. - This includes the status bar height, navigation bar height, and other gesture navigation areas. -- We then optionally query two things simultaneously: - - Full active window refresh via `dumpsys window`. This ensures that if for some reason we didn't have the correct active window and application package name, we'll have it in time before the view hierarch fetch starts. - - `gfxinfo reset` in order to reset all framerate calculations and data collection. We need this reset in order to - properly idle between interactions. -- View Hierarchy - - The best and fastest option is fetching it via the pre-installed and enabled accessibility service. This is never - cached because that would introduce lag. - - ```mermaid - flowchart LR - A["Observe()"] --> B{"installed?"}; - B -->|"✅"| C{"running?"}; - B -->|"❌"| E["caching system"]; - C -->|"✅"| D["cat vh.json"]; - C -->|"❌"| E["uiautomator dump"]; - D --> I["Return"] - E --> I; - classDef decision fill:#FF3300,stroke-width:0px,color:white; - classDef logic fill:#525FE1,stroke-width:0px,color:white; - classDef result stroke-width:0px; - class A,G,I result; - class D,E,H logic; - class B,C,F decision; - ``` - - - If the accessibility service is not installed or not enabled yet, we fall back to `uiautomator` output that is - cached based on screenshot dHash + pixel matching within a tool-variable threshold. Most tools use the default - threshold, but certain operations like text manipulation require exact pixel matching to have confidence that we're - capturing the actual latest view hierarchy. - - ```mermaid - flowchart LR - A["Observe()"] --> B["Screenshot
+dHash"]; - B --> C{"hash
match?"}; - C -->|"✅"| D["pixelmatch"]; - C -->|"❌"| E["uiautomator dump"]; - D --> F{>99.8%?}; - F -->|"✅"| G["Return"]; - F -->|"❌"| E; - E --> H["Cache"]; - H --> I["Return New Hierarchy"]; - classDef decision fill:#FF3300,stroke-width:0px,color:white; - classDef logic fill:#525FE1,stroke-width:0px,color:white; - classDef result stroke-width:0px; - class A,G,I result; - class D,E,H logic; - class B,C,F decision; - ``` - -All collected data is assembled into an object containing: - -- `timestamp`: ISO timestamp of the observation -- `screenSize`: Current screen dimensions (rotation-aware) -- `systemInsets`: UI insets for all screen edges -- `rotation`: Current device rotation value -- `activeWindow`: Current app and activity information -- `viewHierarchy`: Complete UI hierarchy (if available) -- `focusedElement`: Currently focused UI element (if any) -- `intentChooserDetected`: Whether a system intent chooser is visible -- `error`: Any error messages encountered during observation - -The observation gracefully handles various error conditions: - -- Screen off or device locked states -- Missing accessibility service -- Network timeouts or ADB connection issues -- Partial failures (returns available data even if some operations fail) - -Each error is captured in the result object without causing the entire observation to fail, ensuring maximum data -availability for automation workflows. diff --git a/docs/features/mcp-server/system-design.md b/docs/features/mcp-server/system-design.md deleted file mode 100644 index 17693870b..000000000 --- a/docs/features/mcp-server/system-design.md +++ /dev/null @@ -1,51 +0,0 @@ -# Features - MCP Server - System Design - -AutoMobile's MCP makes its various [actions](actions.md) available as tool calls and automatically performs -[observations](observation.md) within an [interaction loop](interaction-loop.md). - -```mermaid -stateDiagram-v2 - Agent: Agent - RequestHandler: Request Handler - DeviceSessionManager: Device Session Manager - InteractionLoop: Interaction Loop - AuthorTest: Author Test - - - InitialObserve: Observe - FinalObserve: Observe - MoreActions?: More Actions? - ExecuteAction: Execute Action - ValidateAction: Validate Action - ChangeExpected?: Change Expected? - ValidChange: Valid Change? - ActionableError: throw ActionableError - - Agent --> RequestHandler : 📥 Tool Call Request - RequestHandler --> Agent : 📤 Tool Call Response - RequestHandler --> DeviceSessionManager - InteractionLoop --> RequestHandler: 🖼️ Processed Results - DeviceSessionManager --> InteractionLoop: 📱 - RequestHandler --> AuthorTest: on App Stopped - - state InteractionLoop { - InitialObserve --> ExecuteActions - ExecuteActions --> FinalObserve - - - state ExecuteActions { - MoreActions? --> ExecuteAction: ✅ - ExecuteAction --> ValidateAction - ValidateAction --> MoreActions?: ✅ - ValidateAction --> Done: ❌ - MoreActions? --> Done: ❌ - } - - state FinalObserve { - ChangeExpected? --> ValidChange: ✅ - ChangeExpected? --> Success: ❌ - ValidChange --> Success: ✅ - ValidChange --> ActionableError: ❌ - } - } -``` diff --git a/docs/features/test-authoring/index.md b/docs/features/test-authoring/index.md deleted file mode 100644 index 8c3943568..000000000 --- a/docs/features/test-authoring/index.md +++ /dev/null @@ -1,50 +0,0 @@ -# Test Authoring - -Steps to take whether you're setting up AutoMobile test authoring & execution for the first time or debugging an issue. - -#### Ensure prerequisites are met - -- AutoMobile should be [installed](../../installation.md). -- Android device or emulator connected and accessible via ADB. -- The target app installed on the device. - -#### Configure AutoMobile your project - -This is what will allow AutoMobile to recognize what source code you're testing from the view hierarchy. Add -environment variables to your AutoMobile MCP configuration like so: - -```json -{ - "androidProjectPath": "/absolute/path/to/your/android/project/root", - "androidAppId": "com.example.app", - "mode": "testAuthoring" -} -``` - -#### Explore your app via AutoMobile - -Give AutoMobile a goal to perform. It can be simple or complex. - -Example Prompt: - -``` -Open the My Example App, complete Login with credentials -testuser@example.com -password123 -``` - -Unless there is some non-standard UX that AutoMobile doesn't understand how to navigate it shouldn't need overly -specific instructions. You can also point your agent at an existing test (Espresso/Maestro/Zephyr) and ask it to -perform the same operations. - -#### Force Close the App - -Once you've completed your interaction sequence, force close the app. AutoMobile will then automatically write the plan -and test in the relevant module of the tested UI. You can also tell AutoMobile to close the app as part of its prompt. - -If you have a use-case where you'd prefer to trigger exporting plans differently, please file an issue. - -## Next - -- Explore [plan syntax](plan-syntax.md) for more complex interactions -- Learn about [test execution options](../test-execution/index.md). diff --git a/docs/features/test-authoring/plan-syntax.md b/docs/features/test-authoring/plan-syntax.md deleted file mode 100644 index 920ed0103..000000000 --- a/docs/features/test-authoring/plan-syntax.md +++ /dev/null @@ -1,108 +0,0 @@ -# Test Authoring - Plan Syntax - -## General Format - -The main components are `name` and `steps`, where each item in `steps` is a tool call. -```yaml ---- -name: launch-clock-app -description: Very simple test to launch Clock app -steps: - - tool: launchApp - appId: com.google.android.deskclock - label: Launch Clock application - - - tool: stopApp - appId: com.google.android.deskclock -``` - -## Full Syntax - -Set up demo mode with 1pm time and 4G connectivity. This tends to make AutoMobile's view hierarchy cache highly efficient -as there are fewer changes between screenshots given the same screen. It is highly recommended to include this in every -AutoMobile plan. - -TODO: Add gradle property to always set demo mode as well as a CLI flag on executePlan. - -```yaml - - tool: enableDemoMode - time: "1300" - mobileDataType: "4g" - mobileSignalLevel: 4 - wifiLevel: 0 - batteryLevel: 85 - batteryPlugged: false - label: Enable demo mode with 1pm time and 4G connectivity -``` - -Launch an app - -```yaml - - tool: launchApp - appId: com.example.android.app - label: Launch Zillow application -``` - -Input text (with unicode support) - -```yaml - - tool: inputText - text: "My name is John Smith 🎉" - label: Enter name with emoji -``` - -Navigate back - -```yaml - - tool: pressButton - button: "back" - label: Go back to main app -``` - -tapOn options - -```yaml - - tool: tapOn - x: 442 - y: 219 - label: Open search field - - - tool: tapOn - id: "com.example.android.app:id/search_close_btn" - label: Tap this specific button - - - tool: tapOn - text: "Search" - label: Navigate to search section -``` - -Swipe/Scroll options - -```yaml - - tool: swipeOnElement - elementId: "com.example.android.app:id/homes_map_drawer_bottom_sheet" - direction: "up" - duration: 1000 - label: Expand property listings - - - tool: swipeOnScreen - direction: "left" - duration: 1000 - includeSystemInsets: false - label: Enter full-screen photo viewing mode - - - tool: scroll - direction: "down" - elementId: "com.example.android.app:id/content" - lookFor: - text: "New Jersey" - duration: 1000 -``` - -Observe - -```yaml - - tool: observe - withViewHierarchy: true - label: Final observation of home photo gallery -``` diff --git a/docs/features/test-execution/ci.md b/docs/features/test-execution/ci.md deleted file mode 100644 index 102c693b4..000000000 --- a/docs/features/test-execution/ci.md +++ /dev/null @@ -1,18 +0,0 @@ -# Test Execution - CI - -Since AutoMobile is a tool designed to automate mobile interactions one of the big early use cases is running it on CI. - -## Run plans on CI with no agent capabilities - -1. Install AutoMobile: `npm install -g auto-mobile@latest` -2. Ensure one or more Android emulators are running and detectable by `adb devices` -3. Run test plans: `auto-mobile --cli executePlan --planContent "$(cat my-plan.yaml)"` - -TODO: bash script for parallel test execution & clearing app data between tests. - -## Run plans on CI with agentic healing, parallelism, and reporting - -1. Add [AutoMobile's JUnitRunner](junitrunner.md) to your Android app & libraries. -2. Read [provider guides](../../mcp-clients/index.md) and setup relevant environment variables -3. Ensure one or more Android emulators are running and detectable by `adb devices` before unit tests are run -4. Run unit tests via `./gradlew testUnitTestDebug` diff --git a/docs/features/test-execution/index.md b/docs/features/test-execution/index.md deleted file mode 100644 index 3da368a3d..000000000 --- a/docs/features/test-execution/index.md +++ /dev/null @@ -1,18 +0,0 @@ -# Test Execution - -## Environments - -If you haven't already, add [AutoMobile JUnitRunner dependency](junitrunner.md). - -#### Local - -Right-click on the test created in Android Studio and run. See other [execution options](options.md). - -#### CI - -Read the [guide](ci.md) - -## Performance - -Ensure that the [accessibility service](../index.md#android-accessibility-service) has been installed and running -on the target device. AutoMobile will eventually automate this setup but for now it needs to be done manually. diff --git a/docs/features/test-execution/junitrunner.md b/docs/features/test-execution/junitrunner.md deleted file mode 100644 index 8841d16c8..000000000 --- a/docs/features/test-execution/junitrunner.md +++ /dev/null @@ -1,20 +0,0 @@ -# Test Execution - JUnitRunner - -Add this test Gradle dependency to all Android apps and libraries in your codebase. You could opt to only add it to some -modules, but AutoMobile's automatic test authoring will still attempt to place tests in the module it thinks most closely -matches the UI being tested. - -```gradle -testImplementation("com.zillow.automobile.junitrunner:x.y.z") -``` - -Note that this artifact hasn't been published to Maven Central just yet and is forthcoming. - - -In the meantime, publish to your mavenLocal (`~/.m2`) via: - -``` -./gradlew publishToMavenLocal -``` - -and use the above testImplementation dependency with `x.y.z` version from `android/junit-runner/build.gradle.kts`. diff --git a/docs/features/test-execution/options.md b/docs/features/test-execution/options.md deleted file mode 100644 index 7798d58c5..000000000 --- a/docs/features/test-execution/options.md +++ /dev/null @@ -1,42 +0,0 @@ -# Test Execution - Options - -Three ways to execute AutoMobile plans: - -## Execution via JUnit Runner - -```kotlin -@AutoMobileTest("login-flow.yaml") -class LoginFlowTest { - // Test automatically executes the YAML plan - // JUnit runner handles plan loading and execution -} -``` - -## Manual CLI Execution - -```bash -# Execute plan from command line with YAML content -auto-mobile --cli executePlan --planContent "$(cat my-plan.yaml)" - -# Execute starting from a specific step (0-based index) -auto-mobile --cli executePlan --planContent "$(cat my-plan.yaml)" --startStep 2 -``` - -## Manual MCP Agent Execution - -```yaml -# Agent calls executePlan tool -- tool: executePlan - planContent: | - name: login-test - steps: - - tool: launchApp - params: - appId: com.example.app - - tool: tapOn - params: - text: Login -``` - - -Plans execute sequentially and stop on the first failed step. Use `startStep` parameter to resume from a specific point. diff --git a/docs/img/bug-repro.gif b/docs/img/bug-repro.gif new file mode 100644 index 000000000..1208f1e8b Binary files /dev/null and b/docs/img/bug-repro.gif differ diff --git a/docs/img/camera-gallery.gif b/docs/img/camera-gallery.gif new file mode 100644 index 000000000..51eb1e985 Binary files /dev/null and b/docs/img/camera-gallery.gif differ diff --git a/docs/img/clock-app.gif b/docs/img/clock-app.gif new file mode 100644 index 000000000..f681dd05d Binary files /dev/null and b/docs/img/clock-app.gif differ diff --git a/docs/img/deeplink-startup.gif b/docs/img/deeplink-startup.gif new file mode 100644 index 000000000..7d9026d8c Binary files /dev/null and b/docs/img/deeplink-startup.gif differ diff --git a/docs/img/google-maps.gif b/docs/img/google-maps.gif new file mode 100644 index 000000000..05212b96c Binary files /dev/null and b/docs/img/google-maps.gif differ diff --git a/docs/img/install.gif b/docs/img/install.gif new file mode 100644 index 000000000..7b32bde2e Binary files /dev/null and b/docs/img/install.gif differ diff --git a/docs/img/scroll-transition-perf.gif b/docs/img/scroll-transition-perf.gif new file mode 100644 index 000000000..f88b9760a Binary files /dev/null and b/docs/img/scroll-transition-perf.gif differ diff --git a/docs/img/uninstall.gif b/docs/img/uninstall.gif new file mode 100644 index 000000000..cb597123f Binary files /dev/null and b/docs/img/uninstall.gif differ diff --git a/docs/img/youtube-search.gif b/docs/img/youtube-search.gif new file mode 100644 index 000000000..403ee27cf Binary files /dev/null and b/docs/img/youtube-search.gif differ diff --git a/docs/index.md b/docs/index.md index 9c4722944..a93bbc5d3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,58 +1,42 @@ # AutoMobile -AutoMobile is a set of tools for mobile automation. You can use it for UI testing or as a development workflow -assistant. - -The first platform supported is Android with plans to extend to iOS. - -**How do I get started?** - -- [Installation](installation.md) - Install AutoMobile in your environment or IDE -- [Test Authoring](features/test-authoring/index.md) - Automatically write tests -- [Test Execution](features/test-execution/index.md) - Run tests locally or on CI - -```mermaid -stateDiagram-v2 - Agent: Agent - RequestHandler: Request Handler - DeviceSessionManager: Device Session Manager - InteractionLoop: Interaction Loop - AuthorTest: Author Test - - Agent --> RequestHandler - RequestHandler --> Agent - RequestHandler --> DeviceSessionManager - InteractionLoop --> RequestHandler: 🖼️ Processed Results - DeviceSessionManager --> InteractionLoop: 📱 - RequestHandler --> AuthorTest: on App Stopped - -``` +AutoMobile is an MCP server that lets AI agents control your Android & iOS devices using natural language. [Install it now](install.md) and [get started](using/ux-exploration.md). -**Additional Resources** +It uses standard platform tools like `adb` & `simctl` paired with its own additional Kotlin & Swift libraries and apps. All components are open source. The point is to provide mobile engineers with AI workflow tools to perform UX deep dives, reproduce bugs, and run automated tests. -- [FAQ](faq.md) - Frequently asked questions -- [Why build this?](origin.md) - Motivation and origin story -- [Features](features/index.md) - Understand how AutoMobile works -- [Contributing](contributing/index.md) - If you're looking to contribute to the project +??? example "See demo: Clock app alarm" + ![Setting an alarm in the Clock app](img/clock-app.gif) + *An AI agent navigating to the Clock app, creating a new alarm* -## Acknowledgement +??? example "See demo: YouTube search" + ![Searching YouTube for a video](img/youtube-search.gif) + *An AI agent searching YouTube and browsing results* -By continuing to use AutoMobile, [you acknowledge and agree to the warnings and responsible use requirements](security.md). +### Explore and Test -## License +| Task | What it does | +|------|-------------| +| **[Explore app UX](using/ux-exploration.md)** | Navigate your app, discover screens, map user flows, identify confusing interactions | +| **[Reproduce bugs](using/reproducting-bugs.md)** | Paste a bug report and get exact reproduction steps with screenshots | +| **[Create UI tests](using/ui-tests.md)** | Describe test scenarios in plain English, get executable test plans | +| **[Measure startup time](using/perf-analysis/startup.md)** | Profile cold and warm launch performance | +| **[Check scroll performance](using/perf-analysis/scroll-framerate.md)** | Detect jank and dropped frames | +| **[Audit contrast](using/a11y.md#contrast)** | Find accessibility issues with color contrast | +| **[Check tap targets](using/a11y.md#tap-targets)** | Ensure touch targets meet size guidelines | -```text -Copyright (C) 2025 Zillow Group +## How it works -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +- 🤖 **Fast UX Inspection** Kotlin [Accessibility Service](design-docs/plat/android/control-proxy.md) and Swift [XCTestService](design-docs/plat/ios/xctestservice.md) to enable fast, accurate observations. 10x faster than the next fastest observation toolkit. +- 🦾 **Full Touch Injection** Tap, Swipe, Pinch, Drag & Drop, Shake with automatic element targeting. +- ♻️ **Tool Feedback** [Observations](design-docs/mcp/observe/index.md) drive the [interaction loop](design-docs/mcp/interaction-loop.md) for all [tool calls](design-docs/mcp/tools.md). +- 🧪 **Test Execution** [Kotlin JUnitRunner](design-docs/plat/android/junitrunner.md) & [Swift XCTestRunner](design-docs/plat/ios/xctestrunner.md) execute tests natively handling device pooling, multi-device tests, and automatically optimizing test timing. + +## License - https://www.apache.org/licenses/LICENSE-2.0 +```yaml +Copyright 2025 Zillow, Inc. +Copyright 2025-2026 Jason Pearson -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +Licensed under the Apache License, Version 2.0 +https://www.apache.org/licenses/LICENSE-2.0 ``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 000000000..2a43e56e9 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,22 @@ +# Install + +You can use our interactive installer to step through all host platform requirements and configuration options. It checks host dependencies, optionally downloads Android or iOS developer tools, and configures the MCP daemon. + +``` bash title="One-line install (click to copy)" +curl -fsSL https://raw.githubusercontent.com/kaeawc/auto-mobile/main/scripts/install.sh | bash +``` + +![Install Demo](img/install.gif) + +Once you've finished that, learn [how to use AutoMobile](using/ux-exploration.md) + +## Uninstalling + +To remove AutoMobile and its configurations, use the uninstall script: + +``` bash title="One-line uninstall (click to copy)" +curl -fsSL https://raw.githubusercontent.com/kaeawc/auto-mobile/main/scripts/uninstall.sh | bash +``` + +??? example "See demo: Uninstall" + ![Uninstall Demo](img/uninstall.gif) diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index d7e8b0f1a..000000000 --- a/docs/installation.md +++ /dev/null @@ -1,77 +0,0 @@ -# Installation - -Right now this guide assumes you are a software engineer who is roughly familiar with AI coding assistants like Cursor, -Claude Code, or Firebender. - -### MCP Server Configuration for your Agent - -AutoMobile is distributed mainly as an npm package. - -Its primary use case for local development environments is as an MCP server. - -1-click install: - -- [Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=auto-mobile&config=eyJfX3R5cGVuYW1lIjoiQ2F0YWxvZ0l0ZW1NY3BDb25maWdDb21tYW5kIiwiY29tbWFuZCI6Im5weCIsImFyZ3MiOlsiLXkiLCJhdXRvLW1vYmlsZUBsYXRlc3QiXSwiZW52IjpudWxsfQ==) - -If your favorite MCP client doesn't have that capability yet, copy the following into your MCP config: - -```json -{ - "mcpServers": { - "AutoMobile": { - "command": "npx", - "args": [ - "-y", - "auto-mobile@latest" - ] - } - } -} -``` - -### Prerequisites - -- Node.js 20 or later - -#### Android SDK + Emulator Setup - -AutoMobile will automatically download and install the following unless they already exist: - -- Command line tools installed via Homebrew or manually in `$ANDROID_HOME/cmdline_tools` -- Android SDK installed at `$ANDROID_HOME` -- At least one Android device or emulator - -Physical devices do need USB debugging enabled for AutoMobile to function with them. Ripgrep makes it go faster. - - -If you have a private npm registry for proxying public npm: - -```json -{ - "mcpServers": { - "AutoMobile": { - "command": "npx", - "args": [ - "-y", - "--registry", - "https://your.awesome.private.registry.net/path/to/npm/proxy", - "auto-mobile@latest" - ] - } - } -} -``` - -You can also install it directly as a CLI tool. - -```shell -npm install -g auto-mobile@latest - -# Test CLI mode to check installation succeeded -auto-mobile --cli -``` - -For full integration: - -1. Follow [MCP client config](mcp-clients/index.md) guide. -2. Add [AutoMobile JUnitRunner test dependency](features/test-execution/junitrunner.md) to all Android application and library modules. diff --git a/docs/mcp-clients/cursor.md b/docs/mcp-clients/cursor.md deleted file mode 100644 index bdc293163..000000000 --- a/docs/mcp-clients/cursor.md +++ /dev/null @@ -1,20 +0,0 @@ -# Cursor MCP Config - -This is a simple sample of how to get AutoMobile running with Cursor, for other options see the -[overview](index.md). - -You can either perform a [1-click install](cursor://anysphere.cursor-deeplink/mcp/install?name=auto-mobile&config=eyJfX3R5cGVuYW1lIjoiQ2F0YWxvZ0l0ZW1NY3BDb25maWdDb21tYW5kIiwiY29tbWFuZCI6Im5weCIsImFyZ3MiOlsiLXkiLCJhdXRvLW1vYmlsZUBsYXRlc3QiXSwiZW52IjpudWxsfQ==), -or copy the following into your Cursor `mcp.json`: - -```json -{ - "mcpServers": { - "AutoMobile": { - "command": "npx", - "args": ["-y", "auto-mobile@latest"] - } - } -} -``` - -![cursor-mcp-server-success.png](../img/cursor-mcp-server-success.png) diff --git a/docs/mcp-clients/firebender.md b/docs/mcp-clients/firebender.md deleted file mode 100644 index a73860017..000000000 --- a/docs/mcp-clients/firebender.md +++ /dev/null @@ -1,14 +0,0 @@ -# Firebender MCP Config - -```json -{ - "mcpServers": { - "AutoMobile": { - "command": "npx", - "args": ["-y", "auto-mobile@latest"] - } - } -} -``` - -![firebender-mcp-server-setup-prod.png](../img/firebender-mcp-server-setup-prod.png) diff --git a/docs/mcp-clients/goose.md b/docs/mcp-clients/goose.md deleted file mode 100644 index c1a40fe46..000000000 --- a/docs/mcp-clients/goose.md +++ /dev/null @@ -1,8 +0,0 @@ -# Goose MCP Config - -This is a simple sample of how to get AutoMobile running with Goose, for other options see the -[overview](index.md). - -![goose-mcp-server-setup-prod.png](../img/goose-mcp-server-setup-prod.png) - -![goose-mcp-server-success.png](../img/goose-mcp-server-success.png) diff --git a/docs/mcp-clients/index.md b/docs/mcp-clients/index.md deleted file mode 100644 index 646ed0a15..000000000 --- a/docs/mcp-clients/index.md +++ /dev/null @@ -1,10 +0,0 @@ -# MCP Client Support - -AutoMobile MCP is designed to be run in STDIO mode in production settings like workstations and CI automation. See -[MCP Server Configuration](../features/mcp-server/index.md) - -We have specific documentation for clients we have used AutoMobile with: - -* [Firebender](firebender.md) -* [Cursor](cursor.md) -* [Goose](goose.md) diff --git a/docs/origin.md b/docs/origin.md deleted file mode 100644 index c7b6412f3..000000000 --- a/docs/origin.md +++ /dev/null @@ -1,9 +0,0 @@ -# Origin - -Mobile engineers have a hard time having high confidence when simple changes can have cascading consequences. The UI -tests meant to provide confidence are slow, brittle, and generally expensive to run. Product owners and designers have a -tough time dogfooding mobile apps on both platforms. Accessibility audits require experts in mobile accessibility - and -after 15 years we’re still applying WCAG once a quarter. - -Basically everyone is missing something and it comes down to ease of access. It turns out there are low level tools that -have been available and open sourced for years. diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md deleted file mode 100644 index f91b0ffae..000000000 --- a/docs/providers/anthropic.md +++ /dev/null @@ -1,17 +0,0 @@ -# Model Providers - Anthropic 🚧 - -🚧 Koog integration in progress - -```shell -export ANTHROPIC_API_KEY="your_api_key_here" -export ANTHROPIC_PROXY_ENDPOINT="your_proxy_endpoint_here" -``` - - -On CI you should be providing these as environment injected secrets with masking to protect your credentials. - -## Available Models - -* Claude 4 Sonnet -* Claude 4 Opus -* Claude 3.7 Sonnet diff --git a/docs/providers/aws-bedrock.md b/docs/providers/aws-bedrock.md deleted file mode 100644 index 9d73f0450..000000000 --- a/docs/providers/aws-bedrock.md +++ /dev/null @@ -1,21 +0,0 @@ -# Model Providers - AWS Bedrock 📋 - -📋 Koog does not yet support AWS Bedrock - -## Environment Requirements - -```shell -export AWS_ID="your_secrets" -export AWS_SECRET_KEY="your_secrets" -export AWS_REGION`="your_secrets" -export AWS_PROFILE="your_profile" -``` - -On CI you should be providing these as environment injected secrets with masking to protect your credentials. - -## Available Models - -Amazon's Bedrock service basically makes all other models available through their API. While this might be nice to -switch and learn in you should get the same performance as using model providers directly. The main difference -will be the pricing - Anthropic and other providers have direct subscription plans which might be more cost effective -whereas AWS will just the current price per token. diff --git a/docs/providers/google.md b/docs/providers/google.md deleted file mode 100644 index f7065254b..000000000 --- a/docs/providers/google.md +++ /dev/null @@ -1,17 +0,0 @@ -# Model Providers - Google 🚧 - -🚧 Koog integration in progress - -## Environment setup - -```shell -export GOOGLE_GEMINI_API_KEY="your_api_key_here" -export GOOGLE_GEMINI_PROXY_ENDPOINT="your_proxy_endpoint_here" -``` - -On CI you should be providing these as environment injected secrets with masking to protect your credentials. - -## Available Models - -* Gemini 2.5 Pro -* Gemini 2.5 Flash diff --git a/docs/providers/openai.md b/docs/providers/openai.md deleted file mode 100644 index f235350e0..000000000 --- a/docs/providers/openai.md +++ /dev/null @@ -1,15 +0,0 @@ -# Model Providers - OpenAI 🚧 - -🚧 Koog integration in progress - -## Environment setup - -```shell -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_PROXY_ENDPOINT="your_proxy_endpoint_here" -``` - -## Available models - -* o3 -* o3-pro diff --git a/docs/providers/overview.md b/docs/providers/overview.md deleted file mode 100644 index cd9030948..000000000 --- a/docs/providers/overview.md +++ /dev/null @@ -1,16 +0,0 @@ -# Model Providers - -AutoMobile comes equiped with an agent primarily accessible via its JUnitRunner. It will stay bundled with the JUnitRunner -unless we find usecases to make it a more general component. It is generally able to access most of the top model providers -with an architecture for extensibility as needed. The main goal of this agent is to provide extremely easy access to use -the provider that meets your needs. - -In Progress: - -- 🚧 [OpenAI](openai.md) -- 🚧 [Anthropic](anthropic.md) -- 🚧 [Google](google.md) - -Not yet supported: - -- 📋 [AWS Bedrock](aws-bedrock.md) diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index 4c3e8aa63..000000000 --- a/docs/security.md +++ /dev/null @@ -1,42 +0,0 @@ -# Security - -Zillow Group takes the security of our software products and services seriously, which includes all source code repositories in our GitHub organizations. - -**Please do not report security vulnerabilities through public GitHub issues.** - -To report security issues, please follow [Zillow Group's Responsible Disclosure](https://www.zillowgroup.com/security/disclosure/). - -## Responsible Use Disclaimer - -AutoMobile is an experimental software project designed to enable AI agent interaction with mobile devices. See [LICENSE](https://github.com/zillow/auto-mobile/blob/main/LICENSE). - -## Warnings and Limitations - -Security Risks - -- AutoMobile executes system-level commands and accesses device internals -- The software may expose sensitive device information and system functions -- Shell command execution capabilities present potential security vulnerabilities -- No security audits have been performed on this experimental codebase - -Not Intended for Production - -- This software is experimental and not intended for production environments -- No stability, reliability, or performance guarantees are provided -- Features may change or be removed without notice -- Testing and validation remain incomplete - -Device and Data Risks - -- AutoMobile accesses and manipulates mobile device functions -- Risk of unintended device modifications or data loss -- Screenshot and UI hierarchy data may contain sensitive information -- App installation, removal, and data manipulation capabilities present data risks - -## Responsible Use Requirements - -- Use only on devices you own or have explicit permission to test -- Do not use on devices containing sensitive or production data -- Implement appropriate security measures in your testing environment, do not expose AutoMobile as a networked MCP. -- Comply with all applicable laws and regulations -- Obtain necessary approvals before deployment in any organizational context diff --git a/docs/using/a11y.md b/docs/using/a11y.md new file mode 100644 index 000000000..1a726ac5d --- /dev/null +++ b/docs/using/a11y.md @@ -0,0 +1,39 @@ +# Accessibility Analysis + +AutoMobile's accessibility analysis capabilties can be turned on via [feature flags](../design-docs/mcp/feature-flags.md). This instantly adds inspection and reporting to all functions, and therefore all AutoMobile tests when run with this enabled. It does add some extra compute overhead that scales with UI complexity, but our benchmarks show this is negligible (<1ms). AutoMobile makes a best effort to correctly inspect mobile user experiences according to these standards. + +Reuse prompts from [exploring an app](ux-exploration.md) with an addition to pay attention to accessibility results. + +## Standards + +AutoMobile follows the guidelines from WCAG 2.1 Level AA/AAA and Material Design to provide its recommendations and checks. For now it has checks for contrast and tap targets and we have plans to implement more. + +Note: I'm happy to add Apple design guidelines if someone can point me to the right documentation. + +### Contrast + +- **Normal Text**: Minimum 4.5:1 contrast ratio (AA), 7:1 (AAA) +- **Large Text**: Minimum 3:1 contrast ratio (AA), 4.5:1 (AAA) +- **UI Components**: Minimum 3:1 contrast ratio + +Large text is defined as: + +- 18pt (24px) or larger +- 14pt (18.5px) bold or larger + +### Tap Targets + +Ensure interactive elements meet minimum size requirements for accessibility. + +- WCAG 2.1 compliance + - 44x44 dp minimum +- Material Design compliance: + - 48x48 dp minimum size + - 8dp spacing between tap targets + +## Resources + +- [WCAG 2.1 Contrast Guidelines](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) +- [WCAG 2.1 Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html) +- [Material Design Principles](https://m3.material.io/foundations/overview/principles) +- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) diff --git a/docs/using/perf-analysis/index.md b/docs/using/perf-analysis/index.md new file mode 100644 index 000000000..9cd77ae1b --- /dev/null +++ b/docs/using/perf-analysis/index.md @@ -0,0 +1,35 @@ +# Performance Analysis + +AutoMobile's performance analysis capabilties can be turned on via [feature flags](../../design-docs/mcp/feature-flags.md). + +#### What is Measured + +- Touch Latency: Time from touch to first frame response +- Time to First Frame: How quickly the first screen renders +- Time to Interactive: When the UI becomes responsive +- Jank: How many missed frames and therefore UI stuttering a user would observe during app startup. This is represented by `missedVsyncCount`, `frameDeadlineMissedCount` +- FPS: Time series of frames per second during app startup + +#### Performance Thresholds + +- Touch Latency: < 100ms to stay within human perception of "instant" response +- Transition Time: < 300ms for simple navigation +- Frame Time P90: < 16ms (60 FPS) +- Frame Time P99: < 20ms +- Missed Frames: 0 for smooth transitions + +#### Best Practices + +- Measure current performance before optimizing. +- Device snapshots can make reproducing or finding issues with microbenchmarks easier. +- Varying device resources (memory, network, etc) can help identify bottlenecks. +- Emulators & simulators don't reflect real device performance, recommend also baselining and iterating on physical devices as well. +- Test common user flows. +- Performance issues show up more clearly on low-end devices. +- Regressions most often creep in over time, its important to setup baselines and compare against them regularly. + +#### Example Uses + +- [Startup](startup.md) +- [Screen transition](screen-transition.md) +- [Scroll framerate](scroll-framerate.md) diff --git a/docs/using/perf-analysis/screen-transition.md b/docs/using/perf-analysis/screen-transition.md new file mode 100644 index 000000000..945dc5523 --- /dev/null +++ b/docs/using/perf-analysis/screen-transition.md @@ -0,0 +1,11 @@ +# Screen Transition + +Screen transitions should be smooth and responsive. Janky transitions hurt user experience and make your app feel slow. Use AI to measure and optimize how quickly your app transitions between screens. + +Make sure to read [the overview guide for performance analysis](index.md). + +**Example Prompts** + +> Navigate to in the app and measure the screen transition performance entering it from different destinations. Highlight and investigate any transitions that are performance outliers. +> +> Navigate to in the app, quickly switch back and forth between and and measure the screen transition performance in both directions. diff --git a/docs/using/perf-analysis/scroll-framerate.md b/docs/using/perf-analysis/scroll-framerate.md new file mode 100644 index 000000000..da4623bdd --- /dev/null +++ b/docs/using/perf-analysis/scroll-framerate.md @@ -0,0 +1,14 @@ +# Scroll Framerate + +Scroll performance is critical to user experience. Janky scrolling is one of the most noticeable performance issues - users immediately feel stuttering and frame drops when swiping through lists, feeds, or grids. + +Make sure to read [the overview guide for performance analysis](index.md). + +**Example Prompts** + +> Scroll through the list of items on and measure the framerate. Highlight and investigate any transitions that are performance outliers. +> +> Scroll back through history in this screen as fast and as far as possible. Generate a timeseries of swipe # against available performance data + +??? example "See demo: Scroll performance" + ![Scroll performance demo](../../img/scroll-transition-perf.gif) diff --git a/docs/using/perf-analysis/startup.md b/docs/using/perf-analysis/startup.md new file mode 100644 index 000000000..4b72c1721 --- /dev/null +++ b/docs/using/perf-analysis/startup.md @@ -0,0 +1,21 @@ +# Startup + +App startup performance affects user experience and app store ratings. AutoMobile helps measure startup through UI observation and idle detection. Use AI to measure and optimize how quickly your app launches and becomes interactive. + +Make sure to read [the overview guide for performance analysis](index.md). + +**Example Prompts** + +> Launch the app and measure how long until the home screen is interactive. +> +> Let's performance test startup via deep link . Run a series of tests with warm and then cold boot to get a baseline of time to first frame and time to UI stability. +> +> Take a snapshot of the currently running that capture . Spin up 3 different devices using that snapshot with low, standard, and high memory. Run cold and warm boot app startup tests. + +??? example "See demo: Deep link startup" + ![App startup via deep link demo](../../img/deeplink-startup.gif) + *Demo: An AI agent launching a test app via deepLink to measure startup time to first frame rendered and UI stable.* + +**See Also** + +- [Android Launch Time Guide](https://developer.android.com/topic/performance/vitals/launch-time) diff --git a/docs/using/reproducting-bugs.md b/docs/using/reproducting-bugs.md new file mode 100644 index 000000000..48030bbc7 --- /dev/null +++ b/docs/using/reproducting-bugs.md @@ -0,0 +1,25 @@ +# Reproducing Bugs + +Use AutoMobile to systematically reproduce bugs and create reproducible test cases. + +**Example Prompt** + +> We're reproducing a bug report for the app. When you encounter a defect or notice something off, highlight it, especially if it will help reproduce the bug. If reproduced take a device snapshot to make further reproduction and debugging easier. +> +> + +The agent will: + +1. Attempt to find the relevant screen or behavior in the app +2. Reproduce any steps or context provided to approximate the state. +3. Draw [visual highlights](../design-docs/mcp/observe/visual-highlighting.md) around defects or important elements. +4. Take a [snapshot](../design-docs/mcp/storage/snapshots.md) of device state to be shared on other machines. + +??? example "See demo: Bug reproduction" + ![Bug reproduction workflow](../img/bug-repro.gif) + *Demo: An AI agent reproducing a sample counter bug and [highlighting](../design-docs/mcp/observe/visual-highlighting.md) the main issue.* + +**Best Practices** + +- Include app state, user actions, expected vs actual behavior, stacktraces, any additional context. +- Document the environment by noting device, OS version, app version. diff --git a/docs/using/ui-tests.md b/docs/using/ui-tests.md new file mode 100644 index 000000000..ee05f477d --- /dev/null +++ b/docs/using/ui-tests.md @@ -0,0 +1,58 @@ +# UI Tests + +AutoMobile will be able to record and playback sessions via IDE integrations. + +## Recording a Test + +See the design docs for [Android](../design-docs/plat/android/ide-plugin/test-recording.md) & [iOS](../design-docs/plat/ios/ide-plugin/test-recording.md). For the moment this is still WIP. + +## Example Test + +```yaml +steps: + - tool: launchApp + appId: com.example.app + + - tool: tapOn + action: tap + text: "Login" + + - tool: inputText + text: "user@example.com" +``` + +```kotlin + +@RunWith(AutoMobileRunner::class) +class ClockAppTest { + + @Test + fun `Given we have a Clock app we should be able to set an alarm`() { + val result = AutoMobilePlan("test-plans/clock-set-alarm.yaml").execute() + + assertTrue(result.success) + } +} +``` + +```swift +import XCTest +@testable import XCTestRunner + +final class RemindersTests: AutoMobileTestCase { + func testLaunchRemindersPlan() throws { + let result = try executePlan("launch-reminders-app.yaml") + } +} +``` + +## Running a Test + +AutoMobile supports Android with [JUnitRunner](../design-docs/plat/android/junitrunner.md) and iOS with [XCTestRunner](../design-docs/plat/ios/xctestrunner.md). We avoid conventional UI test harnesses like Android's connectedAndroidTest and iOS's XCUITest in favor of proxying all device setup and interaction via AutoMobile. Therefore you can think of AutoMobile's tests as closer to UI snapshot tests that can run as JVM or XCTest unit tests. + +This approach allows AutoMobile to manage device pooling, support multi-client tests, and automatically optimize test selection based on timing data. + +## Related + +- [JUnitRunner](../design-docs/plat/android/junitrunner.md) - Test framework details +- [IDE Plugin](../design-docs/plat/android/ide-plugin/overview.md) - Recording and debugging diff --git a/docs/using/ux-exploration.md b/docs/using/ux-exploration.md new file mode 100644 index 000000000..862d91e5c --- /dev/null +++ b/docs/using/ux-exploration.md @@ -0,0 +1,42 @@ +# UX Exploration + +Use AI agents to explore your app, get answers about the user experience. + +**Example Prompts** + +> Open my app and... +> +> Explore the main features and identify key user flows of the current app +> +> Use the search features in the app to find +> +> Explore the onboarding flow and report any confusing steps +> +> Are there any interactive elements that are hard to interact with in the current screen? +> +> Use and choose a date 1 week in the future. + +The agent will: + +1. Look for available devices, launch an Android emulator or iOS simulator +2. Look for installed apps. If the specified one is not installed it can attempt to install it. +3. Launch the requested app. +4. Use device interaction [tool calls](../design-docs/mcp/tools.md) to tap, swipe, pinch, drag, and generally interact to accomplish the given tasks. +5. At each step the agent will have full device state and observations to keep iterating. + +??? example "See demo: Google Maps exploration" + ![Exploring Google Maps](../img/google-maps.gif) + *Demo: An AI agent exploring Google Maps, searching for locations, and interacting with map controls.* + +??? example "See demo: Clock app alarm" + ![Setting an alarm in the Clock app](../img/clock-app.gif) + *Demo: An AI agent navigating to the Clock app, opening the alarm tab, and creating a new alarm.* + +??? example "See demo: Camera gallery" + ![Taking a photo and viewing the gallery](../img/camera-gallery.gif) + *Demo: An AI agent opening the Camera app, taking a photo, and viewing it in the Gallery.* + +**Best Practices** + +- Describe what you want to explore instead of how when you want more general explorations. +- Specifically state interaction methods when you want the agent to take specific routes (deep links, search by scrolling, etc) diff --git a/eslint.config.mjs b/eslint.config.mjs index b09d92ae2..a3cb27360 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,28 @@ export const baseRules = { {args: "none", caughtErrors: "none"}, ], + "@typescript-eslint/naming-convention": [ + 2, + // Interfaces: PascalCase, no "I" prefix, no "Interface" suffix + { + selector: "interface", + format: ["PascalCase"], + custom: { + regex: "^I[A-Z]|Interface$", + match: false, + }, + }, + // Classes: PascalCase, no "Impl" suffix + { + selector: "class", + format: ["PascalCase"], + custom: { + regex: "Impl$", + match: false, + }, + }, + ], + /** * Enforced rules */ @@ -125,6 +147,29 @@ export const baseRules = { "@stylistic/function-call-spacing": 2, "@stylistic/type-annotation-spacing": 2, + // import rules + // Prevent .js/.ts extensions in relative imports (which cause test failures with esbuild-register) + // This uses no-restricted-syntax since import/extensions doesn't cleanly support this use case + "no-restricted-syntax": [ + 2, + { + selector: "ImportDeclaration[source.value=/^\\..*\\.js$/]", + message: "Do not use .js extension in relative imports. Use extensionless imports instead (e.g., './foo' not './foo.js'). This causes MODULE_NOT_FOUND errors in tests.", + }, + { + selector: "ImportDeclaration[source.value=/^\\..*\\.ts$/]", + message: "Do not use .ts extension in relative imports. Use extensionless imports instead (e.g., './foo' not './foo.ts').", + }, + { + selector: "CallExpression[callee.name='setTimeout']", + message: "Use Timer.setTimeout() instead. Import { Timer, defaultTimer } from 'utils/SystemTimer'.", + }, + { + selector: "CallExpression[callee.name='setInterval']", + message: "Use Timer.setInterval() instead. Import { Timer, defaultTimer } from 'utils/SystemTimer'.", + }, + ], + // file whitespace "no-multiple-empty-lines": [2, {max: 2, maxEOF: 0}], "no-mixed-spaces-and-tabs": 2, @@ -152,7 +197,7 @@ const languageOptions = { export default [ { - ignores: ["ios/**/*", "android/**/*"], + ignores: ["ios/**/*", "android/**/*", "scratch/**/*"], }, { files: ["**/*.ts"], @@ -178,4 +223,25 @@ export default [ "no-mixed-spaces-and-tabs": "off", }, }, + { + // SystemTimer.ts is the implementation that wraps raw setTimeout/setInterval + files: ["**/SystemTimer.ts"], + plugins, + languageOptions, + rules: { + ...baseRules, + "no-restricted-syntax": [ + 2, + { + selector: "ImportDeclaration[source.value=/^\\..*\\.js$/]", + message: "Do not use .js extension in relative imports. Use extensionless imports instead (e.g., './foo' not './foo.js'). This causes MODULE_NOT_FOUND errors in tests.", + }, + { + selector: "ImportDeclaration[source.value=/^\\..*\\.ts$/]", + message: "Do not use .ts extension in relative imports. Use extensionless imports instead (e.g., './foo' not './foo.ts').", + }, + // setTimeout/setInterval rules intentionally omitted - this file wraps them + ], + }, + }, ]; diff --git a/firebender.json b/firebender.json index 81f5df445..86f0f70be 100644 --- a/firebender.json +++ b/firebender.json @@ -16,12 +16,7 @@ { "filePathMatches": "*", "rulesPaths": [ - "README.md", - "docs/ai/validation.md", - "roadmap/README.md", - "roadmap/bugs/TEMPLATE.md", - "roadmap/features/TEMPLATE.md", - "roadmap/features/README.md" + "CLAUDE.md" ] } ], @@ -30,5 +25,6 @@ "dist/", ".view_hierarchy_cache/", "*.js" - ] + ], + "mcpServers": {} } diff --git a/ios/.gitignore b/ios/.gitignore index ee1c04811..5085767fd 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -1,9 +1,71 @@ +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved -/scratch +# Xcode +DerivedData/ +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +xcuserdata/ -/roadmap +# Build artifacts +build/ +*.ipa +*.dSYM.zip +*.dSYM -/keystore +# Code coverage +*.gcda +*.gcno +*.gcov +codecov.yml -/build -**/xcuserdata +# Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Archives +*.xcarchive + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Finder +.Spotlight-V100 +.Trashes + +# IDE +*.hmap +*.swp +*~ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +# CocoaPods (if used) +Pods/ + +# Carthage (if used) +Carthage/Build/ +Carthage/Checkouts/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +iOSInjectionProject/ diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md new file mode 100644 index 000000000..49f466567 --- /dev/null +++ b/ios/CLAUDE.md @@ -0,0 +1,17 @@ +# AutoMobile iOS + +Quick reference for AI agents working in `ios/`. Run all commands from the repo root unless noted. + +## Project Layout +- `ios/` - Swift packages and Xcode projects +- `scripts/ios/` - iOS build and test scripts + +## Common Commands (from repo root) +- `./scripts/ios/swift-build.sh` +- `./scripts/ios/swift-test.sh` +- `./scripts/ios/xcodegen-generate.sh` +- `./scripts/ios/xcode-build.sh` + +## Notes +- Use the scripts in `scripts/ios/` instead of invoking Xcode directly. +- If you need XcodeGen, install it with `brew install xcodegen`. diff --git a/ios/Playground/Configurations/Debug.xcconfig b/ios/Playground/Configurations/Debug.xcconfig new file mode 100644 index 000000000..9a87941b7 --- /dev/null +++ b/ios/Playground/Configurations/Debug.xcconfig @@ -0,0 +1,6 @@ +// Debug configuration +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +SWIFT_OPTIMIZATION_LEVEL = -Onone +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG +GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited) +ENABLE_TESTABILITY = YES diff --git a/ios/Playground/Configurations/Release.xcconfig b/ios/Playground/Configurations/Release.xcconfig new file mode 100644 index 000000000..249286232 --- /dev/null +++ b/ios/Playground/Configurations/Release.xcconfig @@ -0,0 +1,5 @@ +// Release configuration +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +SWIFT_OPTIMIZATION_LEVEL = -O +SWIFT_COMPILATION_MODE = wholemodule +VALIDATE_PRODUCT = YES diff --git a/ios/Playground/Playground.xcodeproj/project.pbxproj b/ios/Playground/Playground.xcodeproj/project.pbxproj new file mode 100644 index 000000000..889bad8f9 --- /dev/null +++ b/ios/Playground/Playground.xcodeproj/project.pbxproj @@ -0,0 +1,347 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 007B2120C2735B42A20C510B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2F4747AC5700DF138836D5F /* Theme.swift */; }; + 15B37A3EDBAFD6BD0FC98BFC /* DemosTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25213AE8A9FD9A5B1EEFD97F /* DemosTab.swift */; }; + 5339DAC5909A688059ABBA80 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01BCE22E951887741F32904 /* ContentView.swift */; }; + 54C16E199D10E281E1E316CF /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FD18EC8D1B9A665615C374 /* SettingsTab.swift */; }; + 553F94B4D033BCA44B07AC4E /* PlaygroundApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6EA6F80147865B6F32DD526 /* PlaygroundApp.swift */; }; + 97CA6F2DE628A139094EF72D /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B46870C2DDF4A2F73A5778B /* Colors.swift */; }; + C437D38FD2B064F815AE6F33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAC67DC1CDDC454A7BB7870A /* Assets.xcassets */; }; + EA349DF37933D70B410F7135 /* DiscoverTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF86B8E1368C9B39424F13C0 /* DiscoverTab.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0C5B41C44C18F0045A6B61C2 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 25213AE8A9FD9A5B1EEFD97F /* DemosTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemosTab.swift; sourceTree = ""; }; + 2B46870C2DDF4A2F73A5778B /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; + 64FD18EC8D1B9A665615C374 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; + BDA9169E926B14087F3B1BA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C2F4747AC5700DF138836D5F /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + C6D8C10EFC976E6B3CCD16BD /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + C6EA6F80147865B6F32DD526 /* PlaygroundApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaygroundApp.swift; sourceTree = ""; }; + CAC67DC1CDDC454A7BB7870A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets.xcassets; path = Sources/Assets.xcassets; sourceTree = SOURCE_ROOT; }; + D01BCE22E951887741F32904 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + E40DD68F934F5F0D2981ACA1 /* Playground.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Playground.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FF86B8E1368C9B39424F13C0 /* DiscoverTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverTab.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 3F6B916872C30820F6241E84 /* Products */ = { + isa = PBXGroup; + children = ( + E40DD68F934F5F0D2981ACA1 /* Playground.app */, + ); + name = Products; + sourceTree = ""; + }; + 6BB7FE49E94ED3C6DF5E9D08 /* Theme */ = { + isa = PBXGroup; + children = ( + 2B46870C2DDF4A2F73A5778B /* Colors.swift */, + C2F4747AC5700DF138836D5F /* Theme.swift */, + ); + path = Theme; + sourceTree = ""; + }; + AABC9BE64A1302199D5D2AF8 /* Sources */ = { + isa = PBXGroup; + children = ( + D01BCE22E951887741F32904 /* ContentView.swift */, + BDA9169E926B14087F3B1BA2 /* Info.plist */, + C6EA6F80147865B6F32DD526 /* PlaygroundApp.swift */, + EAF2D62F55B68E2361617BFC /* Tabs */, + 6BB7FE49E94ED3C6DF5E9D08 /* Theme */, + ); + path = Sources; + sourceTree = ""; + }; + C8708B4984B178C033908015 /* Configurations */ = { + isa = PBXGroup; + children = ( + C6D8C10EFC976E6B3CCD16BD /* Debug.xcconfig */, + 0C5B41C44C18F0045A6B61C2 /* Release.xcconfig */, + ); + path = Configurations; + sourceTree = ""; + }; + E23F411CF4F5D5291A0322DE = { + isa = PBXGroup; + children = ( + CAC67DC1CDDC454A7BB7870A /* Assets.xcassets */, + C8708B4984B178C033908015 /* Configurations */, + AABC9BE64A1302199D5D2AF8 /* Sources */, + 3F6B916872C30820F6241E84 /* Products */, + ); + sourceTree = ""; + }; + EAF2D62F55B68E2361617BFC /* Tabs */ = { + isa = PBXGroup; + children = ( + 25213AE8A9FD9A5B1EEFD97F /* DemosTab.swift */, + FF86B8E1368C9B39424F13C0 /* DiscoverTab.swift */, + 64FD18EC8D1B9A665615C374 /* SettingsTab.swift */, + ); + path = Tabs; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 739B1FD817D42F0264714A50 /* Playground */ = { + isa = PBXNativeTarget; + buildConfigurationList = CBF67F902A381FB2989A98D4 /* Build configuration list for PBXNativeTarget "Playground" */; + buildPhases = ( + E40BECB45945A673BB0CC3F9 /* Sources */, + C071FD4667C21888C52DF25C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Playground; + packageProductDependencies = ( + ); + productName = Playground; + productReference = E40DD68F934F5F0D2981ACA1 /* Playground.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DF84942AD828BBDD499F04C0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 739B1FD817D42F0264714A50 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = C1581E14B552D0BE7FA2423D /* Build configuration list for PBXProject "Playground" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = E23F411CF4F5D5291A0322DE; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 739B1FD817D42F0264714A50 /* Playground */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C071FD4667C21888C52DF25C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C437D38FD2B064F815AE6F33 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E40BECB45945A673BB0CC3F9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97CA6F2DE628A139094EF72D /* Colors.swift in Sources */, + 5339DAC5909A688059ABBA80 /* ContentView.swift in Sources */, + 15B37A3EDBAFD6BD0FC98BFC /* DemosTab.swift in Sources */, + EA349DF37933D70B410F7135 /* DiscoverTab.swift in Sources */, + 553F94B4D033BCA44B07AC4E /* PlaygroundApp.swift in Sources */, + 54C16E199D10E281E1E316CF /* SettingsTab.swift in Sources */, + 007B2120C2735B42A20C510B /* Theme.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 18883859C44E4AE8042B204F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C6D8C10EFC976E6B3CCD16BD /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 4F8F737A22C49C70A327F32E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Sources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.Playground; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DF83FEB514DF89BC00722881 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Sources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.Playground; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FE96C092F5D790A83D093866 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C5B41C44C18F0045A6B61C2 /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C1581E14B552D0BE7FA2423D /* Build configuration list for PBXProject "Playground" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 18883859C44E4AE8042B204F /* Debug */, + FE96C092F5D790A83D093866 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + CBF67F902A381FB2989A98D4 /* Build configuration list for PBXNativeTarget "Playground" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F8F737A22C49C70A327F32E /* Debug */, + DF83FEB514DF89BC00722881 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = DF84942AD828BBDD499F04C0 /* Project object */; +} diff --git a/ios/playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to ios/Playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/ios/Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme b/ios/Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme new file mode 100644 index 000000000..525a1c7f6 --- /dev/null +++ b/ios/Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Playground/Sources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Playground/Sources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..51bb24a8b --- /dev/null +++ b/ios/Playground/Sources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Playground/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Playground/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..13613e3ee --- /dev/null +++ b/ios/Playground/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/playground/Playground/Assets.xcassets/Contents.json b/ios/Playground/Sources/Assets.xcassets/Contents.json similarity index 100% rename from ios/playground/Playground/Assets.xcassets/Contents.json rename to ios/Playground/Sources/Assets.xcassets/Contents.json diff --git a/ios/Playground/Sources/ContentView.swift b/ios/Playground/Sources/ContentView.swift new file mode 100644 index 000000000..12abbcbfc --- /dev/null +++ b/ios/Playground/Sources/ContentView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +enum Tab: Hashable { + case discover + case demos + case settings +} + +struct ContentView: View { + @State private var selectedTab: Tab = .discover + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + TabView(selection: $selectedTab) { + DiscoverTab() + .tabItem { + Label("Discover", systemImage: "magnifyingglass") + } + .tag(Tab.discover) + + DemosTab() + .tabItem { + Label("Demos", systemImage: "play.fill") + } + .tag(Tab.demos) + + SettingsTab() + .tabItem { + Label("Settings", systemImage: "gearshape.fill") + } + .tag(Tab.settings) + } + .tint(.autoMobileRed) + } +} + +#Preview { + ContentView() + .autoMobileTheme() +} diff --git a/ios/Playground/Sources/Info.plist b/ios/Playground/Sources/Info.plist new file mode 100644 index 000000000..3871d6340 --- /dev/null +++ b/ios/Playground/Sources/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Playground/Sources/PlaygroundApp.swift b/ios/Playground/Sources/PlaygroundApp.swift new file mode 100644 index 000000000..85632ba9d --- /dev/null +++ b/ios/Playground/Sources/PlaygroundApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct PlaygroundApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .autoMobileTheme() + } + } +} diff --git a/ios/Playground/Sources/Tabs/DemosTab.swift b/ios/Playground/Sources/Tabs/DemosTab.swift new file mode 100644 index 000000000..267a10209 --- /dev/null +++ b/ios/Playground/Sources/Tabs/DemosTab.swift @@ -0,0 +1,566 @@ +import SwiftUI + +struct DemosTab: View { + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + NavigationStack { + List { + Section("Performance") { + NavigationLink { + ScrollPerformanceDemo() + } label: { + DemoRow( + title: "Scroll Performance", + description: "Test scrolling with many items", + icon: "scroll.fill" + ) + } + + NavigationLink { + AnimationDemo() + } label: { + DemoRow( + title: "Animations", + description: "Various animation types and timings", + icon: "wand.and.stars" + ) + } + + NavigationLink { + HeavyComputationDemo() + } label: { + DemoRow( + title: "Heavy Computation", + description: "Stress test with intensive calculations", + icon: "cpu.fill" + ) + } + } + + Section("UI Components") { + NavigationLink { + FormDemo() + } label: { + DemoRow( + title: "Forms & Input", + description: "Text fields, pickers, and toggles", + icon: "rectangle.and.pencil.and.ellipsis" + ) + } + + NavigationLink { + AlertsDemo() + } label: { + DemoRow( + title: "Alerts & Sheets", + description: "Modal presentations and dialogs", + icon: "exclamationmark.bubble.fill" + ) + } + } + + Section("Accessibility") { + NavigationLink { + AccessibilityDemo() + } label: { + DemoRow( + title: "Accessibility", + description: "VoiceOver and Dynamic Type", + icon: "accessibility.fill" + ) + } + } + } + .navigationTitle("Demos") + } + } +} + +struct DemoRow: View { + let title: String + let description: String + let icon: String + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(theme.primary) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + .foregroundStyle(theme.textPrimary) + Text(description) + .font(.caption) + .foregroundStyle(theme.textSecondary) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Scroll Performance Demo + +struct ScrollPerformanceDemo: View { + private let items = (1...1000).map { "Item \($0)" } + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + List(items, id: \.self) { item in + HStack { + Circle() + .fill(theme.primary) + .frame(width: 40, height: 40) + + VStack(alignment: .leading) { + Text(item) + .font(.headline) + .foregroundStyle(theme.textPrimary) + Text("Scroll quickly to test performance") + .font(.caption) + .foregroundStyle(theme.textSecondary) + } + } + .padding(.vertical, 4) + } + .scrollContentBackground(.hidden) + .background(theme.background) + .navigationTitle("Scroll Performance") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Animation Demo + +struct AnimationDemo: View { + @State private var isAnimating = false + @State private var rotation: Double = 0 + @State private var scale: CGFloat = 1.0 + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + ScrollView { + VStack(spacing: 40) { + // Continuous rotation + VStack(spacing: 8) { + Text("Continuous Rotation") + .font(.headline) + .foregroundStyle(theme.textPrimary) + + Image(systemName: "gear") + .font(.system(size: 60)) + .foregroundStyle(theme.primary) + .rotationEffect(.degrees(rotation)) + .onAppear { + withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) { + rotation = 360 + } + } + } + + // Scale animation + VStack(spacing: 8) { + Text("Tap to Scale") + .font(.headline) + .foregroundStyle(theme.textPrimary) + + Circle() + .fill(Color.autoMobileRed) + .frame(width: 80, height: 80) + .scaleEffect(scale) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { + scale = scale == 1.0 ? 1.5 : 1.0 + } + } + } + + // Toggle animation + VStack(spacing: 8) { + Text("Toggle Animation") + .font(.headline) + .foregroundStyle(theme.textPrimary) + + RoundedRectangle(cornerRadius: 12) + .fill(isAnimating ? theme.primary : Color.autoMobileDarkGrey) + .frame(width: isAnimating ? 200 : 100, height: 60) + .animation(.easeInOut(duration: 0.5), value: isAnimating) + + Button(isAnimating ? "Reset" : "Animate") { + isAnimating.toggle() + } + .buttonStyle(.borderedProminent) + .tint(theme.primary) + } + + Spacer() + } + .padding() + } + .background(theme.background) + .navigationTitle("Animations") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Heavy Computation Demo + +struct HeavyComputationDemo: View { + @State private var result: String = "Tap a button to test" + @State private var isComputing = false + @State private var progress: Double = 0 + @State private var selectedDuration: Double = 1.0 + @Environment(\.autoMobileTheme) private var theme + + private let durations: [Double] = [0.5, 1.0, 2.0, 3.0, 5.0] + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Main Thread Blocking Section + VStack(spacing: 12) { + Text("Block Main Thread") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(theme.textPrimary) + + Text("This will freeze the UI completely by sleeping on the main thread. Use this to test jank detection.") + .font(.body) + .foregroundStyle(theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + // Duration picker + VStack(spacing: 8) { + Text("Duration: \(String(format: "%.1f", selectedDuration))s") + .font(.subheadline) + .foregroundStyle(theme.textSecondary) + + Picker("Duration", selection: $selectedDuration) { + ForEach(durations, id: \.self) { duration in + Text("\(String(format: "%.1f", duration))s").tag(duration) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + } + + Button { + blockMainThread() + } label: { + Label("Block Main Thread", systemImage: "exclamationmark.triangle.fill") + } + .buttonStyle(.borderedProminent) + .tint(.autoMobileRed) + } + .padding() + .background(Color.autoMobileRed.opacity(0.1)) + .cornerRadius(12) + + Divider() + .padding(.horizontal) + + // Background Computation Section + VStack(spacing: 12) { + Text("Background Computation") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(theme.textPrimary) + + Text("This runs intensive calculations in the background without blocking the UI.") + .font(.body) + .foregroundStyle(theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + ProgressView(value: progress) + .padding(.horizontal, 40) + .tint(theme.primary) + + Button { + startComputation() + } label: { + if isComputing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + Text("Start Computation") + } + } + .buttonStyle(.borderedProminent) + .tint(theme.primary) + .disabled(isComputing) + } + .padding() + .background(theme.surfaceVariant) + .cornerRadius(12) + + // Result display + Text(result) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(theme.textPrimary) + .padding() + .frame(maxWidth: .infinity) + .background(theme.surfaceVariant) + .cornerRadius(8) + + Spacer() + } + .padding() + } + .background(theme.background) + .navigationTitle("Heavy Computation") + .navigationBarTitleDisplayMode(.inline) + } + + private func blockMainThread() { + result = "Blocking main thread for \(String(format: "%.1f", selectedDuration))s..." + + // This intentionally blocks the main thread to cause jank + Thread.sleep(forTimeInterval: selectedDuration) + + result = "Main thread blocked for \(String(format: "%.1f", selectedDuration))s" + } + + private func startComputation() { + isComputing = true + progress = 0 + result = "Computing in background..." + + Task { + var sum: Double = 0 + let iterations = 10_000_000 + let updateInterval = iterations / 100 + + for i in 0.. Accessibility > Display & Text Size.") + .dynamicTypeSize(dynamicTypeSize) + .foregroundStyle(theme.textSecondary) + } + + Section("VoiceOver Labels") { + HStack { + Image(systemName: "star.fill") + .foregroundStyle(Color.autoMobileWarning) + .accessibilityLabel("Favorite") + + Text("Favorite Item") + .foregroundStyle(theme.textPrimary) + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.autoMobileSuccess) + .accessibilityLabel("Completed") + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Favorite Item, Completed") + + Button { + // Action + } label: { + HStack { + Image(systemName: "plus") + Text("Add Item") + } + } + .tint(theme.primary) + .accessibilityHint("Double tap to add a new item") + } + + Section("AutoMobile Colors") { + HStack { + Rectangle() + .fill(Color.autoMobileLalala) + .frame(width: 40, height: 40) + .cornerRadius(4) + Text("Primary (Lalala)") + .foregroundStyle(theme.textPrimary) + } + + HStack { + Rectangle() + .fill(Color.autoMobileRed) + .frame(width: 40, height: 40) + .cornerRadius(4) + Text("Secondary (Red)") + .foregroundStyle(theme.textPrimary) + } + + HStack { + Rectangle() + .fill(Color.autoMobileEggshell) + .frame(width: 40, height: 40) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.autoMobileLightGrey, lineWidth: 1) + ) + Text("Background (Eggshell)") + .foregroundStyle(theme.textPrimary) + } + } + } + .scrollContentBackground(.hidden) + .background(theme.background) + .navigationTitle("Accessibility") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + DemosTab() + .autoMobileTheme() +} diff --git a/ios/Playground/Sources/Tabs/DiscoverTab.swift b/ios/Playground/Sources/Tabs/DiscoverTab.swift new file mode 100644 index 000000000..2bef5c535 --- /dev/null +++ b/ios/Playground/Sources/Tabs/DiscoverTab.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct DiscoverTab: View { + @State private var searchText = "" + @Environment(\.autoMobileTheme) private var theme + + private let videos = [ + VideoItem(id: "1", title: "Getting Started with SwiftUI", thumbnail: "video.fill", duration: "10:23"), + VideoItem(id: "2", title: "Advanced Animations", thumbnail: "wand.and.stars", duration: "15:45"), + VideoItem(id: "3", title: "Building Custom Components", thumbnail: "cube.fill", duration: "22:10"), + VideoItem(id: "4", title: "State Management Deep Dive", thumbnail: "arrow.triangle.2.circlepath", duration: "18:30"), + VideoItem(id: "5", title: "Networking Best Practices", thumbnail: "network", duration: "12:55"), + ] + + var filteredVideos: [VideoItem] { + if searchText.isEmpty { + return videos + } + return videos.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + NavigationStack { + List(filteredVideos) { video in + NavigationLink(value: video) { + VideoRowView(video: video) + } + } + .scrollContentBackground(.hidden) + .background(theme.background) + .navigationTitle("Discover") + .searchable(text: $searchText, prompt: "Search videos") + .navigationDestination(for: VideoItem.self) { video in + VideoDetailView(video: video) + } + } + } +} + +struct VideoItem: Identifiable, Hashable { + let id: String + let title: String + let thumbnail: String + let duration: String +} + +struct VideoRowView: View { + let video: VideoItem + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + HStack(spacing: 12) { + Image(systemName: video.thumbnail) + .font(.system(size: 24)) + .foregroundStyle(theme.primary) + .frame(width: 60, height: 40) + .background(theme.surfaceVariant) + .cornerRadius(8) + + VStack(alignment: .leading, spacing: 4) { + Text(video.title) + .font(.headline) + .foregroundStyle(theme.textPrimary) + .lineLimit(2) + + Text(video.duration) + .font(.caption) + .foregroundStyle(theme.textSecondary) + } + } + .padding(.vertical, 4) + } +} + +struct VideoDetailView: View { + let video: VideoItem + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + VStack(spacing: 20) { + // Video player placeholder + ZStack { + Rectangle() + .fill(Color.autoMobileBlack) + .aspectRatio(16/9, contentMode: .fit) + + Image(systemName: "play.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(Color.autoMobileWhite) + } + .cornerRadius(12) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 8) { + Text(video.title) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(theme.textPrimary) + + Text("Duration: \(video.duration)") + .font(.subheadline) + .foregroundStyle(theme.textSecondary) + + Text("This is a sample video for testing the AutoMobile iOS Playground app. The video demonstrates various features and capabilities.") + .font(.body) + .foregroundStyle(theme.textSecondary) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + + Spacer() + } + .background(theme.background) + .navigationTitle("Video") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + DiscoverTab() + .autoMobileTheme() +} diff --git a/ios/Playground/Sources/Tabs/SettingsTab.swift b/ios/Playground/Sources/Tabs/SettingsTab.swift new file mode 100644 index 000000000..d596c7d5e --- /dev/null +++ b/ios/Playground/Sources/Tabs/SettingsTab.swift @@ -0,0 +1,222 @@ +import SwiftUI + +struct SettingsTab: View { + @AppStorage("userName") private var userName = "" + @AppStorage("notificationsEnabled") private var notificationsEnabled = true + @AppStorage("darkModeEnabled") private var darkModeEnabled = false + @AppStorage("analyticsEnabled") private var analyticsEnabled = true + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + NavigationStack { + Form { + Section("Account") { + HStack { + Image(systemName: "person.circle.fill") + .font(.system(size: 50)) + .foregroundStyle(theme.primary) + + VStack(alignment: .leading) { + Text(userName.isEmpty ? "Guest User" : userName) + .font(.headline) + .foregroundStyle(theme.textPrimary) + Text("Tap to edit profile") + .font(.caption) + .foregroundStyle(theme.textSecondary) + } + } + .padding(.vertical, 8) + + TextField("Display Name", text: $userName) + } + + Section("Preferences") { + Toggle("Enable Notifications", isOn: $notificationsEnabled) + + Toggle("Dark Mode", isOn: $darkModeEnabled) + + Toggle("Analytics", isOn: $analyticsEnabled) + } + + Section("Storage") { + NavigationLink { + StorageSettingsView() + } label: { + Label("Manage Storage", systemImage: "internaldrive.fill") + } + + NavigationLink { + CacheSettingsView() + } label: { + Label("Clear Cache", systemImage: "trash.fill") + } + } + + Section("About") { + HStack { + Text("Version") + Spacer() + Text("1.0.0") + .foregroundStyle(.secondary) + } + + HStack { + Text("Build") + Spacer() + Text("1") + .foregroundStyle(.secondary) + } + + Link(destination: URL(string: "https://github.com")!) { + HStack { + Text("View Source Code") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.secondary) + } + } + } + + Section { + Button("Sign Out", role: .destructive) { + userName = "" + } + .foregroundStyle(Color.autoMobileRed) + } + } + .scrollContentBackground(.hidden) + .background(theme.background) + .navigationTitle("Settings") + } + } +} + +struct StorageSettingsView: View { + @State private var documents: Double = 125.5 + @State private var cache: Double = 45.2 + @State private var other: Double = 12.8 + @Environment(\.autoMobileTheme) private var theme + + var total: Double { documents + cache + other } + + var body: some View { + List { + Section { + VStack(spacing: 16) { + Text(String(format: "%.1f MB", total)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textPrimary) + + Text("Total Storage Used") + .font(.subheadline) + .foregroundStyle(theme.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } + + Section("Breakdown") { + StorageRow(title: "Documents", size: documents, color: .autoMobileLalala) + StorageRow(title: "Cache", size: cache, color: .autoMobileRed) + StorageRow(title: "Other", size: other, color: .autoMobileDarkGrey) + } + } + .scrollContentBackground(.hidden) + .background(theme.background) + .navigationTitle("Storage") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct StorageRow: View { + let title: String + let size: Double + let color: Color + + var body: some View { + HStack { + Circle() + .fill(color) + .frame(width: 12, height: 12) + + Text(title) + + Spacer() + + Text(String(format: "%.1f MB", size)) + .foregroundStyle(.secondary) + } + } +} + +struct CacheSettingsView: View { + @State private var showingClearAlert = false + @State private var isClearing = false + @Environment(\.autoMobileTheme) private var theme + + var body: some View { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "trash.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(Color.autoMobileRed) + + Text("45.2 MB") + .font(.title) + .fontWeight(.bold) + .foregroundStyle(theme.textPrimary) + + Text("Cached data can be safely cleared") + .font(.subheadline) + .foregroundStyle(theme.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } + + Section { + Button { + showingClearAlert = true + } label: { + HStack { + Spacer() + if isClearing { + ProgressView() + } else { + Text("Clear Cache") + } + Spacer() + } + } + .foregroundStyle(Color.autoMobileRed) + .disabled(isClearing) + } + } + .scrollContentBackground(.hidden) + .background(theme.background) + .navigationTitle("Clear Cache") + .navigationBarTitleDisplayMode(.inline) + .alert("Clear Cache?", isPresented: $showingClearAlert) { + Button("Cancel", role: .cancel) { } + Button("Clear", role: .destructive) { + clearCache() + } + } message: { + Text("This will remove all cached data. Downloads and saved content will not be affected.") + } + } + + private func clearCache() { + isClearing = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + isClearing = false + } + } +} + +#Preview { + SettingsTab() + .autoMobileTheme() +} diff --git a/ios/Playground/Sources/Theme/Colors.swift b/ios/Playground/Sources/Theme/Colors.swift new file mode 100644 index 000000000..66d8ff361 --- /dev/null +++ b/ios/Playground/Sources/Theme/Colors.swift @@ -0,0 +1,40 @@ +import SwiftUI + +// MARK: - Design System Color Palette + +extension Color { + // Core AutoMobile colors + static let autoMobileBlack = Color(hex: 0x000000) + static let autoMobileRed = Color(hex: 0xFF0000) // Only for standalone "AutoMobile" word/wordmark + static let autoMobileEggshell = Color(hex: 0xF8F8FF) + static let autoMobileLalala = Color(hex: 0x1A1A1A) + static let autoMobileWhite = Color(hex: 0xFFFFFF) + + // Promo video colors + static let promoOrange = Color(hex: 0xFF3300) + static let promoBlue = Color(hex: 0x525FE1) + + // Greys + static let autoMobileLightGrey = Color(hex: 0xBDBDBD) + static let autoMobileDarkGrey = Color(hex: 0x424242) + + // Semantic colors for states + static let autoMobileSuccess = Color(hex: 0x4CAF50) + static let autoMobileWarning = Color(hex: 0xFF9800) + static let autoMobileError = Color(hex: 0xF44336) + static let autoMobileInfo = Color.promoBlue +} + +// MARK: - Color Hex Initializer + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/ios/Playground/Sources/Theme/Theme.swift b/ios/Playground/Sources/Theme/Theme.swift new file mode 100644 index 000000000..4ce5ae49c --- /dev/null +++ b/ios/Playground/Sources/Theme/Theme.swift @@ -0,0 +1,118 @@ +import SwiftUI + +// MARK: - AutoMobile Theme + +struct AutoMobileTheme { + let colorScheme: ColorScheme + + // Primary colors + var primary: Color { + colorScheme == .dark ? .autoMobileWhite : .autoMobileLalala + } + + var onPrimary: Color { + colorScheme == .dark ? .autoMobileLalala : .autoMobileWhite + } + + // Secondary colors (red accent) + var secondary: Color { .autoMobileRed } + var onSecondary: Color { .autoMobileWhite } + + // Background colors + var background: Color { + colorScheme == .dark ? .autoMobileBlack : .autoMobileEggshell + } + + var onBackground: Color { + colorScheme == .dark ? .autoMobileWhite : .autoMobileLalala + } + + // Surface colors + var surface: Color { + colorScheme == .dark ? .autoMobileLalala : .autoMobileWhite + } + + var onSurface: Color { + colorScheme == .dark ? .autoMobileWhite : .autoMobileBlack + } + + var surfaceVariant: Color { + colorScheme == .dark ? .autoMobileLalala : .autoMobileEggshell + } + + // Semantic colors + var success: Color { .autoMobileSuccess } + var warning: Color { .autoMobileWarning } + var error: Color { .autoMobileError } + var info: Color { .autoMobileInfo } + + // Text colors + var textPrimary: Color { onSurface } + var textSecondary: Color { + colorScheme == .dark ? .autoMobileLightGrey : .autoMobileDarkGrey + } +} + +// MARK: - Environment Key + +private struct AutoMobileThemeKey: EnvironmentKey { + static let defaultValue = AutoMobileTheme(colorScheme: .light) +} + +extension EnvironmentValues { + var autoMobileTheme: AutoMobileTheme { + get { self[AutoMobileThemeKey.self] } + set { self[AutoMobileThemeKey.self] = newValue } + } +} + +// MARK: - Theme View Modifier + +struct AutoMobileThemeModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + content + .environment(\.autoMobileTheme, AutoMobileTheme(colorScheme: colorScheme)) + .tint(.autoMobileRed) + .accentColor(.autoMobileRed) + } +} + +extension View { + func autoMobileTheme() -> some View { + modifier(AutoMobileThemeModifier()) + } +} + +// MARK: - Themed View Helpers + +extension View { + func autoMobileSurface() -> some View { + modifier(AutoMobileSurfaceModifier()) + } + + func autoMobileBackground() -> some View { + modifier(AutoMobileBackgroundModifier()) + } +} + +struct AutoMobileSurfaceModifier: ViewModifier { + @Environment(\.autoMobileTheme) private var theme + + func body(content: Content) -> some View { + content + .background(theme.surface) + .foregroundStyle(theme.onSurface) + } +} + +struct AutoMobileBackgroundModifier: ViewModifier { + @Environment(\.autoMobileTheme) private var theme + + func body(content: Content) -> some View { + content + .background(theme.background) + .foregroundStyle(theme.onBackground) + } +} diff --git a/ios/Playground/project.yml b/ios/Playground/project.yml new file mode 100644 index 000000000..7a9ba4545 --- /dev/null +++ b/ios/Playground/project.yml @@ -0,0 +1,49 @@ +name: Playground +options: + bundleIdPrefix: dev.jasonpearson.automobile + deploymentTarget: + iOS: "16.0" + xcodeVersion: "15.0" + +configFiles: + Debug: Configurations/Debug.xcconfig + Release: Configurations/Release.xcconfig + +settings: + base: + SWIFT_VERSION: "5.0" + +targets: + Playground: + type: application + platform: iOS + sources: + - path: Sources + excludes: + - "*.xcassets" + - path: Sources/Theme + - path: Sources/Assets.xcassets + type: folder + settings: + base: + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + PRODUCT_BUNDLE_IDENTIFIER: dev.jasonpearson.automobile.Playground + INFOPLIST_FILE: Sources/Info.plist + CODE_SIGN_STYLE: Automatic + GENERATE_INFOPLIST_FILE: NO + +schemes: + Playground: + build: + targets: + Playground: all + run: + config: Debug + test: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/ios/README.md b/ios/README.md index 3b4af137f..f2f24e17d 100644 --- a/ios/README.md +++ b/ios/README.md @@ -1,7 +1,244 @@ -# AutoMobile for iOS +# iOS Platform Components + +This directory contains all iOS-specific components for the AutoMobile automation platform. ## Overview -A set of iOS libraries and applications that integrate AutoMobile into an iOS app. XCTestCase test execution, -automated test authoring code generation, and a sample Playground app that is also used to actively test and improve -AutoMobile's capabilities on iOS. +The iOS platform implementation provides native automation capabilities for iOS simulators using XCTest APIs. Touch injection and gesture simulation use native XCUITest APIs (`XCUICoordinate`, `XCUIElement`) which work on both simulators and physical devices. + +## Architecture + +The iOS platform consists of the following components: + +1. **XCTest Service** - Native iOS automation server with WebSocket API +2. **XCTest Runner** - Test execution framework for plan-based tests +3. **Xcode Companion** - macOS companion app for IDE integration +4. **Xcode Extension** - Xcode source editor extension + +For detailed architecture documentation, see `docs/design-docs/plat/ios/`. + +## Components + +### XCTestService + +**Path**: `ios/XCTestService/` +**Type**: Swift Package (iOS library) +**Purpose**: WebSocket-based automation server using native XCTest APIs + +Core automation library providing: +- WebSocket server (port 8765) for external automation clients +- Element location via `ElementLocator` using XCUITest queries +- Touch/gesture injection via `GesturePerformer` using native `XCUICoordinate` APIs +- App lifecycle management via `XCUIApplication` +- Screenshot capture + +```bash +cd ios/XCTestService +swift build +swift test +``` + +#### Code signing for physical devices + +XCTestService deployment to physical devices requires a valid provisioning profile and signing identity. AutoMobile +attempts automatic signing via Xcode when possible and falls back to manual signing with matching profiles. +The MCP server also verifies the installed XCTestService app bundle hash (excluding signing artifacts) against the +expected release hash before starting. + +Manual overrides (optional): +- `AUTOMOBILE_IOS_TEAM_ID` or `AUTOMOBILE_IOS_TEAM_IDS` (comma-separated) +- `AUTOMOBILE_IOS_PROFILE_UUID` / `AUTOMOBILE_IOS_PROFILE_NAME` / `AUTOMOBILE_IOS_PROFILE_SPECIFIER` +- `AUTOMOBILE_IOS_CODE_SIGN_IDENTITY` +- `AUTOMOBILE_IOS_CODE_SIGN_ENTITLEMENTS_PATH` +- `AUTOMOBILE_IOS_SKIP_XCTESTSERVICE_APP_HASH` (skip installed-app hash verification) +- `AUTOMOBILE_XCTESTSERVICE_APP_HASH` or `AUTOMOBILE_XCTESTSERVICE_APP_HASH_DEVICE` (expected app bundle hash override) + +### XCTestRunner + +**Path**: `ios/XCTestRunner/` +**Type**: Swift Package (iOS/macOS library) +**Purpose**: XCTest integration for plan execution + +XCTest framework integration mirroring Android's JUnitRunner: +- `AutoMobileTestCase` base class for plan-based tests +- `AutoMobilePlanExecutor` with retry and cleanup logic +- XCTestObservation integration for timing data +- YAML plan parsing and MCP execution + +```bash +cd ios/XCTestRunner +swift build +swift test +``` + +### XcodeCompanion + +**Path**: `ios/XcodeCompanion/` +**Type**: Swift Package (macOS app) +**Purpose**: IDE companion application + +Native macOS app providing: +- Device and simulator management UI +- Test recording workflow +- Plan execution with live logs +- Performance metrics and graphs +- Feature flags configuration +- Menu bar integration + +```bash +cd ios/XcodeCompanion +swift build +swift run AutoMobileCompanion +``` + +### XcodeExtension + +**Path**: `ios/XcodeExtension/` +**Type**: Swift Package (Xcode extension) +**Purpose**: Xcode source editor integration + +Xcode Source Editor Extension providing: +- YAML plan template generation +- Plan execution from editor +- Recording controls +- Quick access to Companion app + +```bash +cd ios/XcodeExtension +swift build +swift test +``` + +## System Requirements + +- **macOS**: 13.0 (Ventura) or later +- **Xcode**: 15.0 or later +- **Swift**: 5.9 or later +- **Bun**: 1.3.6 or later +- **Node.js**: 18+ (alternative to Bun) + +## Building All Components + +### Quick Validation + +```bash +# CI validation (suitable for CI/CD) +./scripts/ci/validate-ios.sh + +# Local validation (includes tests and detailed output) +./scripts/local/validate-ios.sh +``` + +### Individual Components + +```bash +# Build a specific component +./scripts/local/build-ios-component.sh AccessibilityService + +# Test a specific component +./scripts/local/test-ios-component.sh AccessibilityService +``` + +### Manual Build + +```bash +# Build all Swift components +for dir in ios/*/; do + if [[ -f "$dir/Package.swift" ]]; then + echo "Building $(basename $dir)..." + (cd "$dir" && swift build) + fi +done + +# Build TypeScript components +cd ios/SimctlIntegration +bun install && bun run build +``` + +## Development Workflow + +### 1. Local Development + +```bash +# Validate all components build +./scripts/local/validate-ios.sh + +# Work on specific component +cd ios/AccessibilityService +swift build +swift test + +# Run companion app for UI testing +cd ios/XcodeCompanion +swift run AutoMobileCompanion +``` + +### 2. Integration Testing + +```bash +# Launch companion app +cd ios/XcodeCompanion +swift run AutoMobileCompanion + +# Boot iOS simulator +xcrun simctl boot "iPhone 15 Pro" + +# Install and launch AccessibilityService on simulator +# (requires Xcode project setup) +``` + +### 3. CI/CD Integration + +The CI validation scripts are designed to run in GitHub Actions or other CI environments: + +```yaml +# Example GitHub Actions step +- name: Validate iOS Components + run: ./scripts/ci/validate-ios.sh +``` + +## Project Status + +**Current Status**: MVP Scaffolds Complete + +All components have been scaffolded with: +- ✅ Basic structure and architecture +- ✅ Core types and interfaces +- ✅ Build configuration (Package.swift, tsconfig.json) +- ✅ Test scaffolding +- ✅ README documentation +- ✅ CI/CD validation scripts + +**Next Steps**: +1. Integrate YAML parsing in XCTestRunner +2. Connect MCP client in Companion and Runner +3. Create Xcode projects for app distribution +4. Add comprehensive test coverage +5. Add real iOS app integration examples +6. Physical device support (see GitHub issues #912, #913, #914) + +## Documentation + +- **Design Docs**: `docs/design-docs/plat/ios/` + - `index.md` - Architecture overview + - `simctl.md` - Simulator lifecycle + - `xctestrunner.md` - XCTest integration + - `ide-plugin/overview.md` - Xcode companion and extension + - `ide-plugin/test-recording.md` - Recording workflow + +- **Installation**: `docs/install/plat/ios.md` + +## Contributing + +When contributing to iOS components: + +1. Follow Swift style guidelines +2. Use SwiftUI for UI components +3. Keep TypeScript aligned with project standards +4. Add tests for new functionality +5. Update component README files +6. Run validation scripts before committing + +## License + +See the main project LICENSE file. diff --git a/ios/WebDriverAgent b/ios/WebDriverAgent deleted file mode 160000 index 3067e1d02..000000000 --- a/ios/WebDriverAgent +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3067e1d02ce2ec2a95ef0a63e6ecd8fa71b49f5e diff --git a/ios/XCTestRunner/Package.swift b/ios/XCTestRunner/Package.swift new file mode 100644 index 000000000..ee9defba2 --- /dev/null +++ b/ios/XCTestRunner/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "XCTestRunner", + platforms: [ + .iOS(.v15), + .macOS(.v13), + ], + products: [ + .library( + name: "XCTestRunner", + targets: ["XCTestRunner"] + ), + ], + dependencies: [ + // YAML parsing dependency will be added here + // Example: .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.0") + ], + targets: [ + .target( + name: "XCTestRunner", + dependencies: [] + ), + .testTarget( + name: "XCTestRunnerTests", + dependencies: ["XCTestRunner"], + resources: [ + .process("Resources"), + ] + ), + ] +) diff --git a/ios/XCTestRunner/README.md b/ios/XCTestRunner/README.md new file mode 100644 index 000000000..103151d64 --- /dev/null +++ b/ios/XCTestRunner/README.md @@ -0,0 +1,223 @@ +# iOS XCTest Runner + +XCTest integration framework for executing AutoMobile YAML automation plans. + +## Overview + +The XCTest Runner provides XCTest integration for iOS automation, mirroring the Android JUnitRunner functionality. It enables: + +- Execution of YAML automation plans within XCTest framework +- Automatic retry logic for flaky tests +- Timing data collection and performance tracking +- XCTestObservation integration for test lifecycle hooks +- Environment variable configuration +- Test ordering and organization + +## Architecture + +Based on the design documented in `docs/design-docs/plat/ios/xctestrunner.md`, this component: + +1. Provides `AutoMobileTestCase` base class for plan-based tests +2. Wraps plan execution with `AutoMobilePlanExecutor` +3. Integrates with XCTestObservation for timing and lifecycle events +4. Supports configuration via environment variables and test schemes +5. Enables timing history collection and analysis + +## Components + +### AutoMobileTestCase + +Base XCTestCase class for executing automation plans: + +```swift +import XCTest +import XCTestRunner + +final class MyAppTests: AutoMobileTestCase { + override var planPath: String { "Plans/login-flow.yaml" } + override var retryCount: Int { 2 } + override var timeoutSeconds: TimeInterval { 300 } + + func testLoginFlow() throws { + try executePlan() + } +} +``` + +### AutoMobilePlanExecutor + +Executes automation plans with retry and cleanup logic: + +```swift +let config = AutoMobilePlanExecutor.Configuration( + transport: .daemonSocket, + planPath: "Plans/checkout.yaml", + retryCount: 3 +) + +let executor = AutoMobilePlanExecutor(configuration: config) +try executor.execute() +``` + +### AutoMobileTestObserver + +Collects timing data and test results: + +```swift +// Register observer (typically in test suite setup) +let observer = AutoMobileTestObserver.register() + +// After tests complete +let timingData = observer.getTimingData() +try observer.exportTimingData(to: "timing-history.json") +``` + +## Configuration + +### Environment Variables + +Primary: +- `AUTOMOBILE_DAEMON_SOCKET_PATH`: Daemon socket path (default: `/tmp/auto-mobile-daemon-$UID.sock`). +- `AUTOMOBILE_TEST_PLAN`: Path to YAML automation plan. +- `AUTOMOBILE_TEST_RETRY_COUNT`: Number of retry attempts (default: `0`). +- `AUTOMOBILE_TEST_TIMEOUT_SECONDS`: Test timeout in seconds (default: `300`). +- `AUTOMOBILE_TEST_RETRY_DELAY_SECONDS`: Retry backoff in seconds (default: `1`). +- `AUTOMOBILE_CI_MODE`: Marks runs as CI for metadata and timing fetch behavior. +- `AUTOMOBILE_APP_VERSION`: App version metadata passed to MCP. +- `AUTOMOBILE_GIT_COMMIT`: Git commit metadata passed to MCP. + +Legacy (still supported): +- `AUTO_MOBILE_DAEMON_SOCKET_PATH` +- `MCP_ENDPOINT` +- `PLAN_PATH` +- `RETRY_COUNT` +- `TEST_TIMEOUT` +- `AUTO_MOBILE_APP_VERSION` +- `APP_VERSION` +- `AUTO_MOBILE_GIT_COMMIT` +- `GITHUB_SHA` +- `GIT_COMMIT` +- `CI_COMMIT_SHA` +- `CI` +- `GITHUB_ACTIONS` + +### Test Scheme Settings + +Configure in Xcode test scheme: +1. Edit Scheme → Test → Arguments +2. Add environment variables +3. Configure test ordering and parallelization + +Test ordering and timing settings (via environment variables or UserDefaults): +- `automobile.junit.timing.ordering`: `auto`, `duration-asc`, `duration-desc`, `none`. +- `automobile.junit.timing.enabled`: Enable/disable timing fetch (default: `true`). +- `automobile.junit.timing.lookback.days`: Timing history window (default: `90`). +- `automobile.junit.timing.limit`: Max timing records to load (default: `1000`). +- `automobile.junit.timing.min.samples`: Minimum samples per test (default: `1`). +- `automobile.junit.timing.fetch.timeout.ms`: Timing fetch timeout in ms (default: `5000`). +- `automobile.ci.mode`: Disable timing fetch in CI (default: `false`). + +Parallel worker count is derived from either: +- `-parallel-testing-worker-count ` (Xcode argument). +- `XCTEST_PARALLEL_THREAD_COUNT=` (environment variable). + +Example scheme argument values: +``` +-automobile.junit.timing.ordering duration-desc +-automobile.junit.timing.limit 500 +``` + +## Building + +```bash +# Build the package +swift build + +# Run tests +swift test + +# Build for iOS +xcodebuild -scheme XCTestRunner -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +## Integration with Xcode + +1. Add XCTestRunner package to your Xcode project +2. Import XCTestRunner in your test files +3. Subclass AutoMobileTestCase +4. Configure test scheme with environment variables +5. Run tests via Xcode Test Navigator or xcodebuild + +When using the daemon socket transport (default), XCTestRunner will attempt to start the AutoMobile +daemon automatically if it cannot connect. + +## Example XCTest Target (Reminders) + +Plan fixtures live in `ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans`: +- `Plans/launch-reminders-app.yaml` +- `Plans/add-reminder.yaml` + +Platform-specific plans should declare a top-level `platform` field (e.g., `platform: ios`). Multi-device plans must declare platform per device at the top level. + +Sample XCTest case: + +```swift +import XCTest +import XCTestRunner + +final class RemindersLaunchPlanTests: AutoMobileTestCase { + override var planPath: String { "Plans/launch-reminders-app.yaml" } + + func testLaunchRemindersPlan() throws { + try executePlan() + } +} +``` + +The sample target is implemented in `ios/XCTestRunner/Sources/XCTestRunnerTests/RemindersIntegrationTests.swift` +and compiles as part of the `XCTestRunnerTests` target. + +Integration test (opt-in, requires MCP + iOS simulator running): + +```bash +AUTOMOBILE_DAEMON_SOCKET_PATH=/tmp/auto-mobile-daemon-$UID.sock \ +swift test --filter RemindersLaunchPlanTests +``` + +Note: The Reminders plans assume English UI labels and may need adjustment for other locales. + +## CI vs local execution + +Both local and CI execution use the daemon socket transport: + +```bash +AUTOMOBILE_TEST_PLAN=Plans/launch-reminders-app.yaml \ +swift test --filter RemindersLaunchPlanTests +``` + +CI should set explicit MCP metadata: + +```bash +AUTOMOBILE_CI_MODE=1 \ +AUTOMOBILE_TEST_PLAN=Plans/launch-reminders-app.yaml \ +AUTOMOBILE_APP_VERSION="1.2.3" \ +AUTOMOBILE_GIT_COMMIT="$GITHUB_SHA" \ +xcodebuild test -scheme XCTestRunner -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +## Development Status + +**MVP Scaffold** - This is a minimal viable product scaffold with: +- AutoMobileTestCase base class +- AutoMobilePlanExecutor with retry logic +- XCTestObservation integration +- Timing data collection +- Test scaffolding + +**Next Steps:** +- Implement YAML plan parsing (integrate Yams) +- Implement MCP client for tool execution +- Add assertion verification logic +- Add comprehensive test coverage +- Add example test cases +- Integrate with Xcode test schemes diff --git a/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileEnvironment.swift b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileEnvironment.swift new file mode 100644 index 000000000..b05694cb7 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileEnvironment.swift @@ -0,0 +1,416 @@ +import Darwin +import Foundation + +struct AutoMobileEnvironment { + private let values: [String: String] + + init(values: [String: String] = ProcessInfo.processInfo.environment) { + self.values = values + } + + func firstNonEmpty(_ keys: [String]) -> String? { + for key in keys { + if let value = values[key], !value.isEmpty { + return value + } + } + return nil + } + + func intValue(_ keys: [String]) -> Int? { + if let stringValue = firstNonEmpty(keys) { + return Int(stringValue) + } + return nil + } + + func doubleValue(_ keys: [String]) -> Double? { + if let stringValue = firstNonEmpty(keys) { + return Double(stringValue) + } + return nil + } + + func boolValue(_ keys: [String]) -> Bool? { + guard let value = firstNonEmpty(keys) else { + return nil + } + return ["1", "true", "yes", "y"].contains(value.lowercased()) + } +} + +enum AutoMobileDaemonSocket { + static var defaultPath: String { + let uid = String(getuid()) + return "/tmp/auto-mobile-daemon-\(uid).sock" + } +} + +enum SimulatorDetection { + /// Check if any iOS simulator is currently booted (fast check) + static func hasBootedSimulator() -> Bool { + PerfTimer.log("hasBootedSimulator: starting xcrun simctl") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "list", "devices", "booted", "--json"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + PerfTimer.log("hasBootedSimulator: waiting for simctl to complete") + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + PerfTimer.log("hasBootedSimulator: simctl failed with status \(process.terminationStatus)") + return false + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + PerfTimer.log("hasBootedSimulator: parsing \(data.count) bytes of JSON") + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let devices = json["devices"] as? [String: [[String: Any]]] + else { + PerfTimer.log("hasBootedSimulator: failed to parse JSON") + return false + } + + var bootedCount = 0 + for (_, deviceList) in devices { + bootedCount += deviceList.count + } + PerfTimer.log("hasBootedSimulator: found \(bootedCount) booted simulators") + return bootedCount > 0 + } catch { + PerfTimer.log("hasBootedSimulator: ERROR - \(error)") + return false + } + } +} + +public enum DaemonManager { + public struct PidFileData: Decodable { + public let pid: Int + public let port: Int? + public let socketPath: String? + public let startedAt: Int64? + public let version: String? + } + + public static var pidFilePath: String { + let uid = String(getuid()) + return ProcessInfo.processInfo.environment["AUTOMOBILE_DAEMON_PID_FILE_PATH"] + ?? ProcessInfo.processInfo.environment["AUTO_MOBILE_DAEMON_PID_FILE_PATH"] + ?? "/tmp/auto-mobile-daemon-\(uid).pid" + } + + public static var socketPath: String { + let uid = String(getuid()) + return ProcessInfo.processInfo.environment["AUTOMOBILE_DAEMON_SOCKET_PATH"] + ?? ProcessInfo.processInfo.environment["AUTO_MOBILE_DAEMON_SOCKET_PATH"] + ?? "/tmp/auto-mobile-daemon-\(uid).sock" + } + + public static func isDaemonRunning() -> Bool { + guard FileManager.default.fileExists(atPath: pidFilePath) else { + return false + } + guard let data = FileManager.default.contents(atPath: pidFilePath), + let pidData = try? JSONDecoder().decode(PidFileData.self, from: data) + else { + return false + } + return isProcessRunning(pid: pidData.pid) + } + + public static func isProcessRunning(pid: Int) -> Bool { + return kill(Int32(pid), 0) == 0 + } + + public static func startDaemon(repoRoot _: String? = nil) -> Bool { + PerfTimer.log("startDaemon: searching for auto-mobile executable") + guard let autoMobilePath = findExecutable("auto-mobile") else { + PerfTimer.log("startDaemon: ERROR - auto-mobile not found in PATH") + return false + } + PerfTimer.log("startDaemon: found auto-mobile at \(autoMobilePath)") + + let process = Process() + process.executableURL = URL(fileURLWithPath: autoMobilePath) + process.arguments = ["--daemon", "start"] + + // Inherit essential environment variables for device discovery + var env = ProcessInfo.processInfo.environment + // Ensure PATH includes /usr/bin for xcrun/simctl + let currentPath = env["PATH"] ?? "" + if !currentPath.contains("/usr/bin") { + env["PATH"] = "/usr/bin:/usr/local/bin:\(currentPath)" + } + process.environment = env + + PerfTimer.log("startDaemon: launching process with args: \(process.arguments ?? [])") + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + PerfTimer.log("startDaemon: process launched, waiting for exit") + process.waitUntilExit() + let status = process.terminationStatus + PerfTimer.log("startDaemon: process exited with status \(status)") + return status == 0 + } catch { + PerfTimer.log("startDaemon: ERROR - failed to run process: \(error)") + return false + } + } + + public static func ensureDaemonRunning(repoRoot: String? = nil, timeoutSeconds: TimeInterval = 15) -> Bool { + PerfTimer.log("ensureDaemonRunning: checking isDaemonRunning") + if isDaemonRunning() { + PerfTimer.log("ensureDaemonRunning: daemon already running") + return true + } + + PerfTimer.log("ensureDaemonRunning: starting daemon") + guard startDaemon(repoRoot: repoRoot) else { + PerfTimer.log("ensureDaemonRunning: startDaemon failed") + return false + } + + PerfTimer.log("ensureDaemonRunning: waiting for daemon") + return waitForDaemon(timeoutSeconds: timeoutSeconds) + } + + public static func waitForDaemon(timeoutSeconds: TimeInterval) -> Bool { + PerfTimer.log("waitForDaemon: timeout=\(timeoutSeconds)s") + let deadline = Date().addingTimeInterval(timeoutSeconds) + var pollCount = 0 + while Date() < deadline { + pollCount += 1 + if isDaemonRunning() && FileManager.default.fileExists(atPath: socketPath) { + PerfTimer.log("waitForDaemon: ready after \(pollCount) polls") + return true + } + Thread.sleep(forTimeInterval: 0.2) + } + PerfTimer.log("waitForDaemon: TIMEOUT after \(pollCount) polls") + return false + } + + private static func findRepoRoot() -> String? { + var current = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + for _ in 0 ..< 10 { + let packageJson = current.appendingPathComponent("package.json") + let srcIndex = current.appendingPathComponent("src/index.ts") + if FileManager.default.fileExists(atPath: packageJson.path), + FileManager.default.fileExists(atPath: srcIndex.path) + { + return current.path + } + current = current.deletingLastPathComponent() + } + return nil + } + + private static func findExecutable(_ name: String) -> String? { + let commonPaths = [ + "/usr/local/bin/\(name)", + "/opt/homebrew/bin/\(name)", + "/usr/bin/\(name)", + "\(NSHomeDirectory())/.bun/bin/\(name)", + "\(NSHomeDirectory())/.local/bin/\(name)", + ] + for path in commonPaths { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = [name] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus == 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty + { + return path + } + } + } catch {} + return nil + } + + public struct RefreshDevicesResult { + public let success: Bool + public let addedDevices: Int + public let totalDevices: Int + public let availableDevices: Int + } + + public static func releaseSession(_ sessionId: String) -> Bool { + guard isDaemonRunning() else { + print("[AutoMobile] Cannot release session: daemon not running") + return false + } + + let requestId = UUID().uuidString + let request: [String: Any] = [ + "id": requestId, + "type": "daemon_request", + "method": "daemon/releaseSession", + "params": ["sessionId": sessionId], + ] + + guard let requestData = try? JSONSerialization.data(withJSONObject: request), + var requestLine = String(data: requestData, encoding: .utf8) + else { + print("[AutoMobile] Failed to serialize release session request") + return false + } + requestLine.append("\n") + + let result = sendDaemonRequest(requestLine, timeoutSeconds: 5) + if let result = result, let success = result["success"] as? Bool, success { + if let resultData = result["result"] as? [String: Any], + let alreadyReleased = resultData["alreadyReleased"] as? Bool, + alreadyReleased + { + print("[AutoMobile] Session \(sessionId) was already released (auto-released by daemon)") + } else { + print("[AutoMobile] Session \(sessionId) released") + } + return true + } + if let result = result, let error = result["error"] as? String { + print("[AutoMobile] Failed to release session \(sessionId): \(error)") + } else { + print("[AutoMobile] Failed to release session \(sessionId)") + } + return false + } + + public static func refreshDevicePool(timeoutSeconds: TimeInterval = 30) -> RefreshDevicesResult { + PerfTimer.log("refreshDevicePool START") + guard isDaemonRunning() else { + PerfTimer.log("refreshDevicePool: daemon not running") + return RefreshDevicesResult(success: false, addedDevices: 0, totalDevices: 0, availableDevices: 0) + } + + let requestId = UUID().uuidString + let request: [String: Any] = [ + "id": requestId, + "type": "daemon_request", + "method": "daemon/refreshDevices", + "params": [String: Any](), + ] + + guard let requestData = try? JSONSerialization.data(withJSONObject: request), + var requestLine = String(data: requestData, encoding: .utf8) + else { + PerfTimer.log("refreshDevicePool: failed to serialize request") + return RefreshDevicesResult(success: false, addedDevices: 0, totalDevices: 0, availableDevices: 0) + } + requestLine.append("\n") + + PerfTimer.log("refreshDevicePool: sending daemon request") + let result = sendDaemonRequest(requestLine, timeoutSeconds: timeoutSeconds) + guard let result = result, + let success = result["success"] as? Bool, success, + let resultData = result["result"] as? [String: Any] + else { + PerfTimer.log("refreshDevicePool: request failed") + return RefreshDevicesResult(success: false, addedDevices: 0, totalDevices: 0, availableDevices: 0) + } + + let addedDevices = resultData["addedDevices"] as? Int ?? 0 + let totalDevices = resultData["totalDevices"] as? Int ?? 0 + let availableDevices = resultData["availableDevices"] as? Int ?? 0 + + PerfTimer.log("refreshDevicePool END: +\(addedDevices) devices, \(availableDevices)/\(totalDevices) available") + return RefreshDevicesResult( + success: true, + addedDevices: addedDevices, + totalDevices: totalDevices, + availableDevices: availableDevices + ) + } + + private static func sendDaemonRequest(_ request: String, timeoutSeconds: TimeInterval) -> [String: Any]? { + let socketFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFd >= 0 else { + print("[AutoMobile] Failed to create socket") + return nil + } + defer { Darwin.close(socketFd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + socketPath.withCString { cString in + _ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in + strcpy(ptr, cString) + } + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult == 0 else { + print("[AutoMobile] Failed to connect to daemon socket: \(errno)") + return nil + } + + // Set socket timeout + var tv = timeval(tv_sec: Int(timeoutSeconds), tv_usec: 0) + setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + + guard let requestData = request.data(using: .utf8) else { + return nil + } + let written = requestData.withUnsafeBytes { ptr in + Darwin.write(socketFd, ptr.baseAddress, ptr.count) + } + guard written == requestData.count else { + print("[AutoMobile] Failed to write request to socket") + return nil + } + + var buffer = Data() + let readBuffer = UnsafeMutablePointer.allocate(capacity: 4096) + defer { readBuffer.deallocate() } + + while true { + let bytesRead = Darwin.read(socketFd, readBuffer, 4096) + if bytesRead > 0 { + buffer.append(readBuffer, count: bytesRead) + if let responseStr = String(data: buffer, encoding: .utf8), + responseStr.contains("\n") + { + let lines = responseStr.split(separator: "\n", maxSplits: 1) + if let firstLine = lines.first, + let lineData = String(firstLine).data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] + { + return json + } + } + } else { + break + } + } + + print("[AutoMobile] Timeout or error waiting for daemon response") + return nil + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunner/AutoMobilePlanExecutor.swift b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobilePlanExecutor.swift new file mode 100644 index 000000000..c8eebbcd0 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobilePlanExecutor.swift @@ -0,0 +1,1257 @@ +import Foundation +import Network + +public protocol AutoMobileLogger { + func info(_ message: String) + func warn(_ message: String) + func error(_ message: String) +} + +public struct StdoutLogger: AutoMobileLogger { + public init() {} + + public func info(_ message: String) { + print("[AutoMobile][INFO] \(message)") + } + + public func warn(_ message: String) { + print("[AutoMobile][WARN] \(message)") + } + + public func error(_ message: String) { + print("[AutoMobile][ERROR] \(message)") + } +} + +public protocol AutoMobileTimer { + func now() -> TimeInterval + func sleep(seconds: TimeInterval) +} + +public final class SystemTimer: AutoMobileTimer { + public init() {} + + public func now() -> TimeInterval { + return Date().timeIntervalSince1970 + } + + public func sleep(seconds: TimeInterval) { + Thread.sleep(forTimeInterval: seconds) + } +} + +public final class FakeTimer: AutoMobileTimer { + public private(set) var currentTime: TimeInterval + public private(set) var sleeps: [TimeInterval] = [] + + public init(initialTime: TimeInterval = 0) { + currentTime = initialTime + } + + public func now() -> TimeInterval { + return currentTime + } + + public func sleep(seconds: TimeInterval) { + sleeps.append(seconds) + currentTime += seconds + } +} + +public protocol AutoMobilePlanLoading { + func loadPlan(at path: String, bundle: Bundle?) throws -> String +} + +public enum PlanLoaderError: Error, CustomStringConvertible { + case notFound(String) + case unreadable(String) + + public var description: String { + switch self { + case let .notFound(path): + return "Plan not found at path: \(path)" + case let .unreadable(path): + return "Plan found but could not be read: \(path)" + } + } +} + +public struct DefaultPlanLoader: AutoMobilePlanLoading { + public init() {} + + public func loadPlan(at path: String, bundle: Bundle?) throws -> String { + if let direct = resolveDirectPath(path) { + return try readFile(at: direct) + } + + if let bundle = bundle { + if let resourceURL = bundle.url(forResource: path, withExtension: nil) { + return try readFile(at: resourceURL) + } + if let resourceURL = resolveBundleResource(path: path, bundle: bundle) { + return try readFile(at: resourceURL) + } + if let fallbackURL = resolveBundleFallback(path: path, bundle: bundle) { + return try readFile(at: fallbackURL) + } + } + + if let mainURL = Bundle.main.url(forResource: path, withExtension: nil) { + return try readFile(at: mainURL) + } + + let cwdURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let relativeURL = cwdURL.appendingPathComponent(path) + if FileManager.default.fileExists(atPath: relativeURL.path) { + return try readFile(at: relativeURL) + } + + throw PlanLoaderError.notFound(path) + } + + private func resolveDirectPath(_ path: String) -> URL? { + let url = URL(fileURLWithPath: path) + if url.path.hasPrefix("/"), FileManager.default.fileExists(atPath: url.path) { + return url + } + if FileManager.default.fileExists(atPath: url.path) { + return url + } + return nil + } + + private func resolveBundleResource(path: String, bundle: Bundle) -> URL? { + let parts = path.split(separator: ".") + if parts.count >= 2 { + let name = parts.dropLast().joined(separator: ".") + let ext = String(parts.last ?? "") + return bundle.url(forResource: name, withExtension: ext) + } + return nil + } + + private func resolveBundleFallback(path: String, bundle: Bundle) -> URL? { + guard path.contains("/") else { + return nil + } + let filename = URL(fileURLWithPath: path).lastPathComponent + if let resourceURL = bundle.url(forResource: filename, withExtension: nil) { + return resourceURL + } + return resolveBundleResource(path: filename, bundle: bundle) + } + + private func readFile(at url: URL) throws -> String { + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + throw PlanLoaderError.unreadable(url.path) + } + } +} + +public struct MCPToolResponse { + public let text: String +} + +public struct MCPResourceResponse { + public let text: String +} + +public enum MCPClientError: Error, CustomStringConvertible, Equatable { + case invalidEndpoint(String) + case invalidResponse(String) + case serverError(String) + case requestFailed(String) + case sessionExpired + + public var description: String { + switch self { + case let .invalidEndpoint(message): + return "Invalid MCP endpoint: \(message)" + case let .invalidResponse(message): + return "Invalid MCP response: \(message)" + case let .serverError(message): + return "MCP server error: \(message)" + case let .requestFailed(message): + return "MCP request failed: \(message)" + case .sessionExpired: + return "MCP session expired" + } + } + + public var isRetryable: Bool { + switch self { + case .invalidEndpoint: + return false + case .invalidResponse: + return false + case .serverError: + return true + case .requestFailed: + return true + case .sessionExpired: + return true + } + } +} + +public protocol AutoMobileMCPClient { + func initialize(timeout: TimeInterval) throws + func callTool(name: String, arguments: [String: Any], timeout: TimeInterval) throws -> MCPToolResponse + func readResource(uri: String, timeout: TimeInterval) throws -> MCPResourceResponse + func resetSession() +} + +public final class AutoMobileDaemonClient: AutoMobileMCPClient { + private let socketPath: String + private let logger: AutoMobileLogger + private let queue = DispatchQueue(label: "AutoMobileDaemonClient") + private var connection: NWConnection? + private var buffer = Data() + private var requestId: Int64 = 0 + + public init(socketPath: String, logger: AutoMobileLogger = StdoutLogger()) { + self.socketPath = socketPath + self.logger = logger + } + + public func initialize(timeout: TimeInterval) throws { + PerfTimer.log("DaemonClient.initialize START") + try ensureConnection(timeout: timeout) + PerfTimer.log("DaemonClient.initialize END") + } + + public func callTool(name: String, arguments: [String: Any], timeout: TimeInterval) throws -> MCPToolResponse { + PerfTimer.log("DaemonClient.callTool START: name=\(name)") + let params: [String: Any] = [ + "name": name, + "arguments": arguments, + ] + let result = try sendRequest(method: "tools/call", params: params, timeout: timeout) + let text = try extractTextContent(from: result) + PerfTimer.log("DaemonClient.callTool END: name=\(name), responseLength=\(text.count)") + return MCPToolResponse(text: text) + } + + public func readResource(uri: String, timeout: TimeInterval) throws -> MCPResourceResponse { + PerfTimer.log("DaemonClient.readResource START: uri=\(uri)") + let params: [String: Any] = [ + "uri": uri, + ] + let result = try sendRequest(method: "resources/read", params: params, timeout: timeout) + let text = try extractResourceTextContent(from: result) + PerfTimer.log("DaemonClient.readResource END: uri=\(uri), responseLength=\(text.count)") + return MCPResourceResponse(text: text) + } + + public func resetSession() { + connection?.cancel() + connection = nil + buffer = Data() + } + + private func ensureConnection(timeout: TimeInterval) throws { + if connection != nil { + PerfTimer.log("ensureConnection: already connected") + return + } + + PerfTimer.log("ensureConnection: creating NWConnection to \(socketPath)") + let connection = NWConnection(to: .unix(path: socketPath), using: .tcp) + let semaphore = DispatchSemaphore(value: 0) + var connectionError: Error? + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + PerfTimer.log("ensureConnection: NWConnection ready") + semaphore.signal() + case let .failed(error): + PerfTimer.log("ensureConnection: NWConnection failed - \(error)") + connectionError = error + semaphore.signal() + case .cancelled: + PerfTimer.log("ensureConnection: NWConnection cancelled") + connectionError = MCPClientError.requestFailed("Daemon connection cancelled") + semaphore.signal() + default: + break + } + } + + PerfTimer.log("ensureConnection: starting connection") + connection.start(queue: queue) + let timeoutResult = semaphore.wait(timeout: .now() + timeout) + if timeoutResult == .timedOut { + PerfTimer.log("ensureConnection: TIMEOUT") + connection.cancel() + throw MCPClientError.requestFailed("Timed out connecting to daemon socket") + } + + if let error = connectionError { + connection.cancel() + throw MCPClientError.requestFailed(error.localizedDescription) + } + + PerfTimer.log("ensureConnection: connected successfully") + self.connection = connection + } + + private func sendRequest(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + PerfTimer.log("sendRequest START: method=\(method)") + try ensureConnection(timeout: timeout) + guard let connection = connection else { + throw MCPClientError.requestFailed("Daemon connection unavailable") + } + + requestId += 1 + let request: [String: Any] = [ + "id": "\(requestId)", + "type": "mcp_request", + "method": method, + "params": params, + "timeoutMs": Int(timeout * 1000), + ] + + let data = try JSONSerialization.data(withJSONObject: request, options: []) + var payload = data + payload.append(0x0A) + PerfTimer.log("sendRequest: sending \(payload.count) bytes") + + let sendSemaphore = DispatchSemaphore(value: 0) + var sendError: Error? + connection.send(content: payload, completion: .contentProcessed { error in + sendError = error + sendSemaphore.signal() + }) + + let sendTimeout = sendSemaphore.wait(timeout: .now() + timeout) + if sendTimeout == .timedOut { + PerfTimer.log("sendRequest: TIMEOUT sending") + throw MCPClientError.requestFailed("Timed out sending daemon request") + } + if let error = sendError { + throw MCPClientError.requestFailed(error.localizedDescription) + } + PerfTimer.log("sendRequest: sent successfully, waiting for response") + + let responseData = try receiveLine(timeout: timeout) + PerfTimer.log("sendRequest: received \(responseData.count) bytes") + + let jsonObject = try JSONSerialization.jsonObject(with: responseData, options: []) + guard let response = jsonObject as? [String: Any] else { + throw MCPClientError.invalidResponse("Expected JSON object response from daemon") + } + + let success = response["success"] as? Bool ?? false + if !success { + let message = response["error"] as? String ?? "Daemon returned error" + PerfTimer.log("sendRequest ERROR: \(message)") + throw MCPClientError.serverError(message) + } + guard let result = response["result"] as? [String: Any] else { + throw MCPClientError.invalidResponse("Missing result in daemon response") + } + PerfTimer.log("sendRequest END: method=\(method)") + return result + } + + private func receiveLine(timeout: TimeInterval) throws -> Data { + guard let connection = connection else { + throw MCPClientError.requestFailed("Daemon connection unavailable") + } + + let semaphore = DispatchSemaphore(value: 0) + var output: Data? + var receiveError: Error? + + func receiveChunk() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in + if let data = data { + self.buffer.append(data) + if let lineRange = self.buffer.firstRange(of: Data([0x0A])) { + let lineData = self.buffer.subdata(in: 0 ..< lineRange.lowerBound) + self.buffer.removeSubrange(0 ... lineRange.lowerBound) + output = lineData + semaphore.signal() + return + } + } + + if let error = error { + receiveError = error + semaphore.signal() + return + } + + if isComplete { + receiveError = MCPClientError.requestFailed("Daemon connection closed") + semaphore.signal() + return + } + + receiveChunk() + } + } + + receiveChunk() + + let waitResult = semaphore.wait(timeout: .now() + timeout) + if waitResult == .timedOut { + throw MCPClientError.requestFailed("Timed out waiting for daemon response") + } + if let error = receiveError { + throw MCPClientError.requestFailed(error.localizedDescription) + } + guard let output = output else { + throw MCPClientError.invalidResponse("Daemon response missing data") + } + return output + } + + private func extractTextContent(from result: [String: Any]) throws -> String { + guard let content = result["content"] as? [[String: Any]] else { + throw MCPClientError.invalidResponse("Missing content array") + } + for item in content { + if let type = item["type"] as? String, type == "text", + let text = item["text"] as? String + { + return text + } + } + throw MCPClientError.invalidResponse("Missing text content") + } + + private func extractResourceTextContent(from result: [String: Any]) throws -> String { + guard let contents = result["contents"] as? [[String: Any]], let first = contents.first else { + throw MCPClientError.invalidResponse("Missing resource contents") + } + if let text = first["text"] as? String { + return text + } + throw MCPClientError.invalidResponse("Missing resource text content") + } +} + +public final class StreamableHTTPMCPClient: AutoMobileMCPClient { + private let endpoint: URL + private let logger: AutoMobileLogger + private let session: URLSession + private var sessionId: String? + private var requestId: Int64 = 0 + + public init(endpoint: URL, logger: AutoMobileLogger = StdoutLogger(), session: URLSession = .shared) throws { + guard endpoint.scheme != nil else { + throw MCPClientError.invalidEndpoint(endpoint.absoluteString) + } + self.endpoint = endpoint + self.logger = logger + self.session = session + } + + public func initialize(timeout: TimeInterval) throws { + let params: [String: Any] = [ + "protocolVersion": "2024-11-05", + "capabilities": [:], + "clientInfo": [ + "name": "auto-mobile-xctest-runner", + "version": "0.1.0", + ], + ] + _ = try sendRequest(method: "initialize", params: params, timeout: timeout) + } + + public func callTool(name: String, arguments: [String: Any], timeout: TimeInterval) throws -> MCPToolResponse { + if sessionId == nil { + try initialize(timeout: timeout) + } + + let params: [String: Any] = [ + "name": name, + "arguments": arguments, + ] + + do { + let result = try sendRequest(method: "tools/call", params: params, timeout: timeout) + let text = try extractTextContent(from: result) + return MCPToolResponse(text: text) + } catch let error as MCPClientError where error == .sessionExpired { + resetSession() + try initialize(timeout: timeout) + let result = try sendRequest(method: "tools/call", params: params, timeout: timeout) + let text = try extractTextContent(from: result) + return MCPToolResponse(text: text) + } + } + + public func readResource(uri: String, timeout: TimeInterval) throws -> MCPResourceResponse { + if sessionId == nil { + try initialize(timeout: timeout) + } + + let params: [String: Any] = [ + "uri": uri, + ] + + do { + let result = try sendRequest(method: "resources/read", params: params, timeout: timeout) + let text = try extractResourceTextContent(from: result) + return MCPResourceResponse(text: text) + } catch let error as MCPClientError where error == .sessionExpired { + resetSession() + try initialize(timeout: timeout) + let result = try sendRequest(method: "resources/read", params: params, timeout: timeout) + let text = try extractResourceTextContent(from: result) + return MCPResourceResponse(text: text) + } + } + + public func resetSession() { + sessionId = nil + } + + private func sendRequest(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + requestId += 1 + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": requestId, + "method": method, + "params": params, + ] + + let data = try JSONSerialization.data(withJSONObject: payload, options: []) + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.httpBody = data + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + if let sessionId = sessionId { + request.setValue(sessionId, forHTTPHeaderField: "MCP-Session-Id") + } + + let responseData = try performRequest(request: request, timeout: timeout) + + let jsonObject = try JSONSerialization.jsonObject(with: responseData, options: []) + guard let response = jsonObject as? [String: Any] else { + throw MCPClientError.invalidResponse("Expected JSON object response") + } + + if let error = response["error"] as? [String: Any] { + let message = error["message"] as? String ?? "Unknown MCP error" + throw MCPClientError.serverError(message) + } + + guard let result = response["result"] as? [String: Any] else { + throw MCPClientError.invalidResponse("Missing result in MCP response") + } + return result + } + + private func performRequest(request: URLRequest, timeout: TimeInterval) throws -> Data { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + let task = session.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + result = .failure(MCPClientError.requestFailed(error.localizedDescription)) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + result = .failure(MCPClientError.invalidResponse("Missing HTTP response")) + return + } + + if httpResponse.statusCode == 404 { + result = .failure(MCPClientError.sessionExpired) + return + } + + if let sessionHeader = self.extractSessionId(from: httpResponse) { + self.sessionId = sessionHeader + } + + guard let data = data else { + result = .failure(MCPClientError.invalidResponse("Empty response body")) + return + } + result = .success(data) + } + task.resume() + + let timeoutResult = semaphore.wait(timeout: .now() + timeout + 1) + if timeoutResult == .timedOut { + task.cancel() + throw MCPClientError.requestFailed("Request timed out") + } + + switch result { + case let .success(data): + return data + case let .failure(error): + throw error + case .none: + throw MCPClientError.requestFailed("Request failed without response") + } + } + + private func extractSessionId(from response: HTTPURLResponse) -> String? { + for (key, value) in response.allHeaderFields { + let keyString = String(describing: key).lowercased() + guard keyString == "mcp-session-id" else { + continue + } + if let valueString = value as? String { + return valueString + } + } + return nil + } + + private func extractTextContent(from result: [String: Any]) throws -> String { + guard let content = result["content"] as? [[String: Any]] else { + throw MCPClientError.invalidResponse("Missing content array") + } + for item in content { + if let type = item["type"] as? String, type == "text", + let text = item["text"] as? String + { + return text + } + } + throw MCPClientError.invalidResponse("Missing text content") + } + + private func extractResourceTextContent(from result: [String: Any]) throws -> String { + guard let contents = result["contents"] as? [[String: Any]], let first = contents.first else { + throw MCPClientError.invalidResponse("Missing resource contents") + } + if let text = first["text"] as? String { + return text + } + throw MCPClientError.invalidResponse("Missing resource text content") + } +} + +public final class AutoMobilePlanExecutor { + public enum PlanPlatform: String { + case ios + case android + } + + public enum Transport { + case daemonUnixSocket(path: String) + case streamableHttp(url: URL) + } + + public struct CleanupOptions { + public let appId: String + public let clearAppData: Bool + + public init(appId: String, clearAppData: Bool = false) { + self.appId = appId + self.clearAppData = clearAppData + } + } + + public struct Configuration { + public let transport: Transport + public let planPath: String + public let retryCount: Int + public let timeoutSeconds: TimeInterval + public let retryDelaySeconds: TimeInterval + public let startStep: Int + public let parameters: [String: String] + public let cleanup: CleanupOptions? + public let planBundle: Bundle? + public let defaultPlatform: PlanPlatform + + public init( + transport: Transport, + planPath: String, + retryCount: Int = 0, + timeoutSeconds: TimeInterval = 300, + retryDelaySeconds: TimeInterval = 1, + startStep: Int = 0, + parameters: [String: String] = [:], + cleanup: CleanupOptions? = nil, + planBundle: Bundle? = nil, + defaultPlatform: PlanPlatform = .ios + ) { + self.transport = transport + self.planPath = planPath + self.retryCount = max(0, retryCount) + self.timeoutSeconds = timeoutSeconds + self.retryDelaySeconds = retryDelaySeconds + self.startStep = startStep + self.parameters = parameters + self.cleanup = cleanup + self.planBundle = planBundle + self.defaultPlatform = defaultPlatform + } + } + + public struct TestMetadata { + public let testClass: String + public let testMethod: String + public let appVersion: String? + public let gitCommit: String? + public let isCi: Bool? + + public init( + testClass: String, + testMethod: String, + appVersion: String? = nil, + gitCommit: String? = nil, + isCi: Bool? = nil + ) { + self.testClass = testClass + self.testMethod = testMethod + self.appVersion = appVersion + self.gitCommit = gitCommit + self.isCi = isCi + } + } + + public struct FailedStep: Decodable { + public let stepIndex: Int + public let tool: String + public let error: String + public let device: String? + } + + public struct ExecutePlanResult: Decodable { + public let success: Bool + public let executedSteps: Int + public let totalSteps: Int + public let failedStep: FailedStep? + public let error: String? + public let platform: String? + public let deviceMapping: [String: String]? + } + + public enum ExecutorError: Error, CustomStringConvertible { + case planNotFound(String) + case invalidPlan(String) + case mcpFailure(String) + case executionFailed(String) + case invalidResponse(String) + + public var description: String { + switch self { + case let .planNotFound(path): + return "Plan not found: \(path)" + case let .invalidPlan(message): + return "Invalid plan: \(message)" + case let .mcpFailure(message): + return "MCP failure: \(message)" + case let .executionFailed(message): + return "Plan execution failed: \(message)" + case let .invalidResponse(message): + return "Invalid response: \(message)" + } + } + + public var isRetryable: Bool { + switch self { + case .planNotFound, .invalidPlan: + return false + case .mcpFailure, .executionFailed, .invalidResponse: + return true + } + } + } + + private let configuration: Configuration + private let planLoader: AutoMobilePlanLoading + private let mcpClient: AutoMobileMCPClient + private let timer: AutoMobileTimer + private let logger: AutoMobileLogger + private let sessionIdProvider: () -> String + + public init( + configuration: Configuration, + planLoader: AutoMobilePlanLoading = DefaultPlanLoader(), + mcpClient: AutoMobileMCPClient? = nil, + timer: AutoMobileTimer = SystemTimer(), + logger: AutoMobileLogger = StdoutLogger(), + sessionIdProvider: @escaping () -> String = { AutoMobileSession.currentSessionUuid() } + ) { + self.configuration = configuration + self.planLoader = planLoader + self.timer = timer + self.logger = logger + self.sessionIdProvider = sessionIdProvider + + if let mcpClient = mcpClient { + self.mcpClient = mcpClient + } else { + switch configuration.transport { + case let .daemonUnixSocket(path): + self.mcpClient = AutoMobileDaemonClient(socketPath: path, logger: logger) + case let .streamableHttp(url): + do { + self.mcpClient = try StreamableHTTPMCPClient(endpoint: url, logger: logger) + } catch { + self.mcpClient = FailingMCPClient(error: error) + } + } + } + } + + public func execute(testMetadata: TestMetadata? = nil) throws -> ExecutePlanResult { + var lastError: Error? + + for attempt in 0 ... configuration.retryCount { + do { + if attempt > 0 { + logger.info("Retry attempt \(attempt + 1) of \(configuration.retryCount + 1)") + } + return try executeOnce(testMetadata: testMetadata) + } catch { + lastError = error + let shouldRetry = shouldRetry(error: error, attempt: attempt) + logger.warn("Plan execution attempt \(attempt + 1) failed: \(error)") + if shouldRetry { + timer.sleep(seconds: configuration.retryDelaySeconds) + } else { + break + } + } + } + + if let error = lastError { + throw error + } + throw ExecutorError.executionFailed("Unknown failure") + } + + private func executeOnce(testMetadata: TestMetadata?) throws -> ExecutePlanResult { + PerfTimer.log("executeOnce START") + let planContent: String + do { + planContent = try PerfTimer.measure("loadPlan") { + try planLoader.loadPlan(at: configuration.planPath, bundle: configuration.planBundle) + } + } catch let error as PlanLoaderError { + throw ExecutorError.planNotFound(error.description) + } catch { + throw ExecutorError.planNotFound(error.localizedDescription) + } + PerfTimer.log("planContent loaded, length=\(planContent.count) chars") + + let substituted = PerfTimer.measure("substituteParameters") { + substituteParameters(in: planContent, parameters: configuration.parameters) + } + let planMetadata = try PerfTimer.measure("parsePlanMetadata") { + try PlanMetadataParser.parse(from: substituted) + } + PerfTimer + .log( + "planMetadata: platform=\(planMetadata.platform.map { String(describing: $0) } ?? "nil"), hasDevices=\(planMetadata.hasDevices), deviceLabels=\(planMetadata.deviceLabels)" + ) + + let platform = try resolvePlatform(from: planMetadata) + PerfTimer.log("resolved platform=\(platform)") + + let sessionUuid = sessionIdProvider() + PerfTimer.log("sessionUuid=\(sessionUuid)") + + let arguments = PerfTimer.measure("buildExecutePlanArguments") { + buildExecutePlanArguments( + planContent: substituted, + sessionUuid: sessionUuid, + platform: platform, + deviceLabels: planMetadata.deviceLabels, + testMetadata: testMetadata + ) + } + PerfTimer.log("arguments built, keys=\(arguments.keys.sorted())") + + do { + try PerfTimer.measure("mcpClient.initialize") { + try mcpClient.initialize(timeout: configuration.timeoutSeconds) + } + PerfTimer.log("calling executePlan tool with timeout=\(configuration.timeoutSeconds)s") + let response = try PerfTimer.measure("mcpClient.callTool(executePlan)") { + try mcpClient.callTool( + name: "executePlan", + arguments: arguments, + timeout: configuration.timeoutSeconds + ) + } + PerfTimer.log("executePlan response received, length=\(response.text.count) chars") + let result = try PerfTimer.measure("decodeExecutePlanResult") { + try decodeExecutePlanResult(from: response.text) + } + PerfTimer + .log("executeOnce END - success=\(result.success), steps=\(result.executedSteps)/\(result.totalSteps)") + if result.success { + return result + } + throw ExecutorError.executionFailed(buildFailureMessage(from: result)) + } catch let error as MCPClientError { + PerfTimer.log("executeOnce ERROR: MCPClientError - \(error.description)") + throw ExecutorError.mcpFailure(error.description) + } catch let error as ExecutorError { + PerfTimer.log("executeOnce ERROR: ExecutorError - \(error)") + throw error + } catch { + PerfTimer.log("executeOnce ERROR: \(error.localizedDescription)") + throw ExecutorError.executionFailed(error.localizedDescription) + } + } + + private func buildExecutePlanArguments( + planContent: String, + sessionUuid: String, + platform: PlanPlatform, + deviceLabels: [String], + testMetadata: TestMetadata? + ) + -> [String: Any] + { + let base64Content = Data(planContent.utf8).base64EncodedString() + var args: [String: Any] = [ + "planContent": "base64:\(base64Content)", + "platform": platform.rawValue, + "startStep": configuration.startStep, + "sessionUuid": sessionUuid, + ] + + if let cleanup = configuration.cleanup { + args["cleanupAppId"] = cleanup.appId + args["cleanupClearAppData"] = cleanup.clearAppData + } + + if !deviceLabels.isEmpty { + args["devices"] = deviceLabels + } + + if let metadata = testMetadata { + var metadataArgs: [String: Any] = [ + "testClass": metadata.testClass, + "testMethod": metadata.testMethod, + ] + if let appVersion = metadata.appVersion { + metadataArgs["appVersion"] = appVersion + } + if let gitCommit = metadata.gitCommit { + metadataArgs["gitCommit"] = gitCommit + } + if let isCi = metadata.isCi { + metadataArgs["isCi"] = isCi + } + args["testMetadata"] = metadataArgs + } + + return args + } + + private func substituteParameters(in content: String, parameters: [String: String]) -> String { + guard !parameters.isEmpty else { + return content + } + var substituted = content + for (key, value) in parameters { + substituted = substituted.replacingOccurrences(of: "${\(key)}", with: value) + } + return substituted + } + + private func decodeExecutePlanResult(from text: String) throws -> ExecutePlanResult { + guard let data = text.data(using: .utf8) else { + throw ExecutorError.invalidResponse("Response text is not valid UTF-8") + } + do { + return try JSONDecoder().decode(ExecutePlanResult.self, from: data) + } catch { + throw ExecutorError.invalidResponse("Failed to decode executePlan response: \(error)") + } + } + + private func shouldRetry(error: Error, attempt: Int) -> Bool { + if attempt >= configuration.retryCount { + return false + } + if let executorError = error as? ExecutorError { + return executorError.isRetryable + } + if let mcpError = error as? MCPClientError { + return mcpError.isRetryable + } + return true + } + + private func buildFailureMessage(from result: ExecutePlanResult) -> String { + var message = "" + if let failedStep = result.failedStep { + message += "Test plan execution failed at step \(failedStep.stepIndex + 1) (\(failedStep.tool)):" + message += "\n Error: \(failedStep.error)" + message += "\n Executed: \(result.executedSteps)/\(result.totalSteps) steps" + if let device = failedStep.device { + message += "\n Device: \(device)" + } + } else { + message = result.error ?? "AutoMobile plan failed" + } + return message + } +} + +private final class FailingMCPClient: AutoMobileMCPClient { + private let error: Error + + init(error: Error) { + self.error = error + } + + func initialize(timeout _: TimeInterval) throws { + throw error + } + + func callTool(name _: String, arguments _: [String: Any], timeout _: TimeInterval) throws -> MCPToolResponse { + throw error + } + + func readResource(uri _: String, timeout _: TimeInterval) throws -> MCPResourceResponse { + throw error + } + + func resetSession() {} +} + +private struct PlanMetadata { + let platform: AutoMobilePlanExecutor.PlanPlatform? + let devicePlatforms: [String: AutoMobilePlanExecutor.PlanPlatform] + let deviceLabels: [String] + let hasDevices: Bool +} + +private enum PlanMetadataParser { + static func parse(from yamlContent: String) throws -> PlanMetadata { + let lines = yamlContent.split(whereSeparator: \.isNewline).map { String($0) } + var platform: AutoMobilePlanExecutor.PlanPlatform? + var devicePlatforms: [String: AutoMobilePlanExecutor.PlanPlatform] = [:] + var deviceLabels: [String] = [] + var hasDevices = false + + var index = 0 + while index < lines.count { + let line = stripComments(from: lines[index]) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + index += 1 + continue + } + + let indent = indentationLevel(line) + if indent == 0 && trimmed.hasPrefix("platform:") { + let value = trimmed.dropFirst("platform:".count).trimmingCharacters(in: .whitespaces) + let normalized = unquote(value) + if let parsed = AutoMobilePlanExecutor.PlanPlatform(rawValue: normalized) { + platform = parsed + } else if !normalized.isEmpty { + throw AutoMobilePlanExecutor.ExecutorError.invalidPlan("Unknown platform value: \(normalized)") + } + index += 1 + continue + } + + if indent == 0 && trimmed.hasPrefix("devices:") { + hasDevices = true + let listIndent = indentOfNextListItem(startingAt: index + 1, lines: lines) ?? (indent + 2) + index += 1 + var currentLabel: String? + var currentPlatform: AutoMobilePlanExecutor.PlanPlatform? + + while index < lines.count { + let rawLine = stripComments(from: lines[index]) + if rawLine.trimmingCharacters(in: .whitespaces).isEmpty { + index += 1 + continue + } + + let currentIndent = indentationLevel(rawLine) + if currentIndent < listIndent { + break + } + + let trimmedLine = rawLine.trimmingCharacters(in: .whitespaces) + if currentIndent == listIndent && trimmedLine.hasPrefix("-") { + if let label = currentLabel { + deviceLabels.append(label) + } + if let label = currentLabel, let platformValue = currentPlatform { + devicePlatforms[label] = platformValue + } else if currentLabel != nil || currentPlatform != nil { + throw AutoMobilePlanExecutor.ExecutorError.invalidPlan( + "Each device entry must include label and platform." + ) + } + currentLabel = nil + currentPlatform = nil + + let remainder = trimmedLine.dropFirst().trimmingCharacters(in: .whitespaces) + if remainder.isEmpty { + index += 1 + continue + } + if remainder.contains(":") { + let (key, value) = splitKeyValue(remainder) + if key == "label" { + currentLabel = value + } else if key == "platform" { + currentPlatform = try parsePlatform(value) + } else if key == "name" { + currentLabel = value + } + } else { + currentLabel = remainder + } + index += 1 + continue + } + + if currentIndent > listIndent { + let (key, value) = splitKeyValue(trimmedLine) + if key == "label" || key == "name" { + currentLabel = value + } else if key == "platform" { + currentPlatform = try parsePlatform(value) + } + index += 1 + continue + } + + index += 1 + } + + if let label = currentLabel { + deviceLabels.append(label) + } + if let label = currentLabel, let platformValue = currentPlatform { + devicePlatforms[label] = platformValue + } else if currentLabel != nil || currentPlatform != nil { + throw AutoMobilePlanExecutor.ExecutorError.invalidPlan( + "Each device entry must include label and platform." + ) + } + continue + } + + index += 1 + } + + if hasDevices && deviceLabels.isEmpty { + throw AutoMobilePlanExecutor.ExecutorError.invalidPlan( + "Multi-device plans must declare at least one device." + ) + } + + if hasDevices && devicePlatforms.count != deviceLabels.count { + throw AutoMobilePlanExecutor.ExecutorError.invalidPlan( + "Multi-device plans must declare platform for each device." + ) + } + + return PlanMetadata( + platform: platform, + devicePlatforms: devicePlatforms, + deviceLabels: deviceLabels, + hasDevices: hasDevices + ) + } + + private static func parsePlatform(_ value: String) throws -> AutoMobilePlanExecutor.PlanPlatform { + let normalized = unquote(value) + guard let platform = AutoMobilePlanExecutor.PlanPlatform(rawValue: normalized) else { + throw AutoMobilePlanExecutor.ExecutorError.invalidPlan("Unknown platform value: \(value)") + } + return platform + } + + private static func indentationLevel(_ line: String) -> Int { + return line.prefix { $0 == " " }.count + } + + private static func indentOfNextListItem(startingAt startIndex: Int, lines: [String]) -> Int? { + var index = startIndex + while index < lines.count { + let line = stripComments(from: lines[index]) + if line.trimmingCharacters(in: .whitespaces).isEmpty { + index += 1 + continue + } + let indent = indentationLevel(line) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("-") { + return indent + } + if indent == 0 { + return nil + } + index += 1 + } + return nil + } + + private static func stripComments(from line: String) -> String { + guard let hashIndex = line.firstIndex(of: "#") else { + return line + } + return String(line[.. (String, String) { + let parts = line.split(separator: ":", maxSplits: 1).map { String($0) } + if parts.count == 2 { + return ( + parts[0].trimmingCharacters(in: .whitespaces), + unquote(parts[1].trimmingCharacters(in: .whitespaces)) + ) + } + return (line.trimmingCharacters(in: .whitespaces), "") + } + + private static func unquote(_ value: String) -> String { + if value.count >= 2 { + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { + return String(value.dropFirst().dropLast()) + } + } + return value + } +} + +extension AutoMobilePlanExecutor { + private func resolvePlatform(from metadata: PlanMetadata) throws -> PlanPlatform { + if metadata.hasDevices { + let platforms = Set(metadata.devicePlatforms.values) + if platforms.count > 1 { + throw ExecutorError.invalidPlan( + "Multi-device plans with mixed platforms are not supported by XCTestRunner." + ) + } + if let onlyPlatform = platforms.first { + if let declared = metadata.platform, declared != onlyPlatform { + throw ExecutorError.invalidPlan( + "Plan platform '\(declared.rawValue)' does not match device platform '\(onlyPlatform.rawValue)'." + ) + } + return onlyPlatform + } + } + + if let declared = metadata.platform { + return declared + } + + return configuration.defaultPlatform + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileSession.swift b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileSession.swift new file mode 100644 index 000000000..e549f38f6 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileSession.swift @@ -0,0 +1,22 @@ +import Foundation + +public final class AutoMobileSession { + public static let shared = AutoMobileSession() + private static let sessionKey = "AutoMobileSession.sessionUuid" + + private init() {} + + public static func currentSessionUuid() -> String { + return shared.sessionUuid() + } + + public func sessionUuid() -> String { + let threadDict = Thread.current.threadDictionary + if let existing = threadDict[AutoMobileSession.sessionKey] as? String { + return existing + } + let uuid = UUID().uuidString + threadDict[AutoMobileSession.sessionKey] = uuid + return uuid + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileTestCase.swift b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileTestCase.swift new file mode 100644 index 000000000..de3a57449 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/AutoMobileTestCase.swift @@ -0,0 +1,617 @@ +import Darwin +import Foundation +import XCTest + +/// Module load logging - this runs when the test module is first loaded +private let _moduleLoadLog: Void = { + let line = "[XCTestRunner] Module loaded at \(Date())\n" + fputs(line, stderr) +}() + +public enum AutoMobileTestCaseError: Error, CustomStringConvertible { + case missingPlanPath + case invalidEndpoint(String) + case executorUnavailable + case devicePoolUnavailable(String) + + public var description: String { + switch self { + case .missingPlanPath: + return "Missing AutoMobile test plan path." + case let .invalidEndpoint(endpoint): + return "Invalid MCP endpoint: \(endpoint)" + case .executorUnavailable: + return "AutoMobile plan executor is unavailable." + case let .devicePoolUnavailable(details): + return "Device pool unavailable: \(details)" + } + } +} + +/// Base XCTestCase for executing AutoMobile YAML automation plans via MCP. +open class AutoMobileTestCase: XCTestCase { + override open class var defaultTestSuite: XCTestSuite { + // Ensure module load logging is triggered + _ = _moduleLoadLog + PerfTimer.log("defaultTestSuite START for \(self)") + if self == AutoMobileTestCase.self { + PerfTimer.log("defaultTestSuite: returning empty suite for base class") + return XCTestSuite(name: "AutoMobileTestCase") + } + PerfTimer.log("defaultTestSuite: registering observer") + _ = AutoMobileTestObserver.registerIfNeeded() + + PerfTimer.log("defaultTestSuite: calling super.defaultTestSuite") + let baseSuite = super.defaultTestSuite + let tests = baseSuite.tests + PerfTimer.log("defaultTestSuite: found \(tests.count) tests") + let orderingSelection = resolveTimingOrderingSelection() + let timingAvailable = TestTimingCache.shared.hasTimings() + logTimingOrdering(selection: orderingSelection, timingAvailable: timingAvailable) + + let timingOrderingActive = orderingSelection.resolved != .none && timingAvailable + if timingOrderingActive { + let orderedTests = orderTestsByTiming(tests, strategy: orderingSelection.resolved) + baseSuite.setValue(orderedTests, forKey: "tests") + } + PerfTimer.log("defaultTestSuite END for \(self)") + return baseSuite + } + + open var planPath: String { + if let value = environment.firstNonEmpty(["AUTOMOBILE_TEST_PLAN", "PLAN_PATH"]) { + return value + } + return "" + } + + open var mcpEndpoint: String { + return environment.firstNonEmpty([ + "AUTOMOBILE_MCP_URL", + "AUTOMOBILE_MCP_HTTP_URL", + "MCP_ENDPOINT", + ]) ?? "http://localhost:9000/auto-mobile/streamable" + } + + open var daemonSocketPath: String { + return environment.firstNonEmpty([ + "AUTOMOBILE_DAEMON_SOCKET_PATH", + "AUTO_MOBILE_DAEMON_SOCKET_PATH", + ]) ?? AutoMobileDaemonSocket.defaultPath + } + + open var retryCount: Int { + return environment.intValue(["AUTOMOBILE_TEST_RETRY_COUNT", "RETRY_COUNT"]) ?? 0 + } + + open var timeoutSeconds: TimeInterval { + return environment.doubleValue(["AUTOMOBILE_TEST_TIMEOUT_SECONDS", "TEST_TIMEOUT"]) ?? 300 + } + + open var retryDelaySeconds: TimeInterval { + return environment.doubleValue(["AUTOMOBILE_TEST_RETRY_DELAY_SECONDS"]) ?? 1 + } + + open var startStep: Int { + return 0 + } + + open var planParameters: [String: String] { + return [:] + } + + open var cleanupOptions: AutoMobilePlanExecutor.CleanupOptions? { + return nil + } + + open var planBundle: Bundle? { + return Bundle(for: type(of: self)) + } + + open func setUpAutoMobile() throws {} + open func tearDownAutoMobile() throws {} + + private var executor: AutoMobilePlanExecutor? + private let environment = AutoMobileEnvironment() + private static let devicePoolCheckLock = NSLock() + private static var devicePoolCheckCompleted = false + + override open func setUpWithError() throws { + PerfTimer.log("setUpWithError START for \(name)") + try PerfTimer.measure("super.setUpWithError") { + try super.setUpWithError() + } + try PerfTimer.measure("setUpAutoMobile") { + try setUpAutoMobile() + } + let config = try PerfTimer.measure("makeConfiguration") { + try makeConfiguration() + } + PerfTimer.log("Configuration: planPath=\(config.planPath), transport=\(config.transport)") + executor = PerfTimer.measure("createExecutor") { + AutoMobilePlanExecutor(configuration: config) + } + PerfTimer.log("setUpWithError END for \(name)") + } + + override open func tearDownWithError() throws { + print("[AutoMobileTestCase] tearDownWithError starting for \(name)") + try tearDownAutoMobile() + executor = nil + // Note: Session release is handled automatically by the daemon after executePlan completes + try super.tearDownWithError() + print("[AutoMobileTestCase] tearDownWithError complete") + } + + public func executePlan() throws -> AutoMobilePlanExecutor.ExecutePlanResult { + PerfTimer.log("executePlan START") + guard let executor = executor else { + PerfTimer.log("ERROR: executor is nil") + throw AutoMobileTestCaseError.executorUnavailable + } + let metadata = PerfTimer.measure("buildTestMetadata") { + buildTestMetadata() + } + PerfTimer.log("Executing with metadata: testClass=\(metadata.testClass), testMethod=\(metadata.testMethod)") + let result = try PerfTimer.measure("executor.execute") { + try executor.execute(testMetadata: metadata) + } + PerfTimer.log("executePlan END - success=\(result.success), steps=\(result.executedSteps)/\(result.totalSteps)") + return result + } + + private func makeConfiguration() throws -> AutoMobilePlanExecutor.Configuration { + PerfTimer.log("makeConfiguration: resolving planPath") + let planPath = planPath.trimmingCharacters(in: .whitespacesAndNewlines) + PerfTimer.log("makeConfiguration: planPath=\(planPath)") + guard !planPath.isEmpty else { + PerfTimer.log("ERROR: planPath is empty") + throw AutoMobileTestCaseError.missingPlanPath + } + + let transport: AutoMobilePlanExecutor.Transport + PerfTimer.log("makeConfiguration: checking for MCP endpoint env vars") + if let endpoint = environment.firstNonEmpty([ + "AUTOMOBILE_MCP_URL", + "AUTOMOBILE_MCP_HTTP_URL", + "MCP_ENDPOINT", + ]) { + PerfTimer.log("makeConfiguration: using HTTP transport endpoint=\(endpoint)") + let normalizedEndpoint = normalizeEndpoint(endpoint) + guard let endpointURL = URL(string: normalizedEndpoint) else { + throw AutoMobileTestCaseError.invalidEndpoint(normalizedEndpoint) + } + transport = .streamableHttp(url: endpointURL) + } else { + PerfTimer.log("makeConfiguration: using Unix socket transport at \(daemonSocketPath)") + transport = .daemonUnixSocket(path: daemonSocketPath) + } + + PerfTimer.log("makeConfiguration: creating Configuration object") + return AutoMobilePlanExecutor.Configuration( + transport: transport, + planPath: planPath, + retryCount: retryCount, + timeoutSeconds: timeoutSeconds, + retryDelaySeconds: retryDelaySeconds, + startStep: startStep, + parameters: planParameters, + cleanup: cleanupOptions, + planBundle: planBundle + ) + } + + private func buildTestMetadata() -> AutoMobilePlanExecutor.TestMetadata { + let className = String(describing: type(of: self)) + let methodName = testMethodName() + let appVersion = environment.firstNonEmpty([ + "AUTOMOBILE_APP_VERSION", + "AUTO_MOBILE_APP_VERSION", + "APP_VERSION", + ]) + let gitCommit = environment.firstNonEmpty([ + "AUTOMOBILE_GIT_COMMIT", + "AUTO_MOBILE_GIT_COMMIT", + "GITHUB_SHA", + "GIT_COMMIT", + "CI_COMMIT_SHA", + ]) + let isCi = environment.boolValue(["AUTOMOBILE_CI_MODE", "CI", "GITHUB_ACTIONS"]) + + return AutoMobilePlanExecutor.TestMetadata( + testClass: className, + testMethod: methodName, + appVersion: appVersion, + gitCommit: gitCommit, + isCi: isCi + ) + } + + private func testMethodName() -> String { + if let selector = invocation?.selector { + return NSStringFromSelector(selector) + } + let fullName = name + if let range = fullName.range(of: " ") { + let suffix = fullName[range.upperBound...] + return suffix.trimmingCharacters(in: CharacterSet(charactersIn: "]")) + } + return fullName + } + + private func normalizeEndpoint(_ endpoint: String) -> String { + let trimmed = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.contains("/auto-mobile/streamable") || trimmed.contains("/auto-mobile/sse") { + return trimmed + } + if trimmed.hasSuffix("/auto-mobile") { + return "\(trimmed)/streamable" + } + return "\(trimmed)/auto-mobile/streamable" + } + + private struct BootedDevicesResource: Decodable { + let totalCount: Int? + let devices: [BootedDeviceInfo] + } + + private struct BootedDeviceInfo: Decodable { + let name: String? + let platform: String + let deviceId: String + let poolStatus: String? + } + + private func ensureDevicePoolReady() throws { + Self.devicePoolCheckLock.lock() + defer { Self.devicePoolCheckLock.unlock() } + if Self.devicePoolCheckCompleted { + return + } + + let bootedSimulatorDetected = hasBootedSimulator() + let usesDaemonSocket = isDaemonSocketTransport() + + var resource = try fetchBootedDevicesResource( + timeoutSeconds: 5, + allowDaemonStart: usesDaemonSocket + ) + + if usesDaemonSocket, resource.devices.contains(where: { $0.poolStatus == nil }) { + try startDaemon() + resource = try fetchBootedDevicesResource(timeoutSeconds: 5, allowDaemonStart: false) + } + + let devices = resource.devices + if bootedSimulatorDetected, devices.isEmpty { + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Booted iOS simulator detected, but no booted iOS devices reported by the daemon." + ) + } + + let missingStatus = devices.filter { $0.poolStatus == nil } + if !missingStatus.isEmpty { + let names = missingStatus.map { $0.name ?? $0.deviceId }.joined(separator: ", ") + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Booted devices missing pool status: \(names). Ensure the AutoMobile daemon is running." + ) + } + + let unavailable = devices.filter { $0.poolStatus != "idle" } + if !unavailable.isEmpty { + let details = unavailable.map { + "\($0.name ?? $0.deviceId)=\($0.poolStatus ?? "unknown")" + }.joined(separator: ", ") + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Booted devices unavailable: \(details)" + ) + } + + Self.devicePoolCheckCompleted = true + } + + private func makeMcpClient() throws -> AutoMobileMCPClient { + if let endpoint = environment.firstNonEmpty([ + "AUTOMOBILE_MCP_URL", + "AUTOMOBILE_MCP_HTTP_URL", + "MCP_ENDPOINT", + ]) { + let normalizedEndpoint = normalizeEndpoint(endpoint) + guard let endpointURL = URL(string: normalizedEndpoint) else { + throw AutoMobileTestCaseError.invalidEndpoint(normalizedEndpoint) + } + return try StreamableHTTPMCPClient(endpoint: endpointURL) + } + return AutoMobileDaemonClient(socketPath: daemonSocketPath) + } + + private func isDaemonSocketTransport() -> Bool { + return environment.firstNonEmpty([ + "AUTOMOBILE_MCP_URL", + "AUTOMOBILE_MCP_HTTP_URL", + "MCP_ENDPOINT", + ]) == nil + } + + private func fetchBootedDevicesResource( + timeoutSeconds: TimeInterval, + allowDaemonStart: Bool + ) + throws -> BootedDevicesResource + { + let client = try makeMcpClient() + + do { + try client.initialize(timeout: timeoutSeconds) + } catch { + if allowDaemonStart { + try startDaemon() + return try fetchBootedDevicesResource(timeoutSeconds: timeoutSeconds, allowDaemonStart: false) + } + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Failed to initialize MCP client: \(error.localizedDescription)" + ) + } + + let response: MCPResourceResponse + do { + response = try client.readResource(uri: "automobile:devices/booted/ios", timeout: timeoutSeconds) + } catch { + if allowDaemonStart { + try startDaemon() + return try fetchBootedDevicesResource(timeoutSeconds: timeoutSeconds, allowDaemonStart: false) + } + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Failed to read booted device resource: \(error.localizedDescription)" + ) + } + + guard let data = response.text.data(using: .utf8) else { + throw AutoMobileTestCaseError.devicePoolUnavailable("Invalid device pool response.") + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let object = jsonObject as? [String: Any], + let error = object["error"] as? String, + !error.isEmpty + { + throw AutoMobileTestCaseError.devicePoolUnavailable(error) + } + + let decoder = JSONDecoder() + do { + return try decoder.decode(BootedDevicesResource.self, from: data) + } catch { + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Failed to parse booted device resource: \(error.localizedDescription)" + ) + } + } + + private func startDaemon() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["auto-mobile", "--daemon", "start"] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + } catch { + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Failed to start daemon: \(error.localizedDescription)" + ) + } + + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: errorData, encoding: .utf8) ?? "" + let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + throw AutoMobileTestCaseError.devicePoolUnavailable( + "Failed to start daemon: \(message.isEmpty ? "exit code \(process.terminationStatus)" : message)" + ) + } + } + + func hasBootedSimulator() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "list", "devices", "--json"] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + } catch { + return false + } + + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return false + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + guard let json = try? JSONSerialization.jsonObject(with: data, options: []), + let payload = json as? [String: Any], + let devices = payload["devices"] as? [String: Any] + else { + return false + } + + for (_, value) in devices { + guard let deviceList = value as? [[String: Any]] else { + continue + } + for device in deviceList { + let state = device["state"] as? String + let isAvailable = device["isAvailable"] as? Bool ?? true + if state == "Booted", isAvailable { + return true + } + } + } + + return false + } + + private enum TimingOrderingStrategy: String { + case none + case auto + case durationAsc + case durationDesc + } + + private struct TimingOrderingSelection { + let requested: TimingOrderingStrategy + let resolved: TimingOrderingStrategy + } + + private struct TimingCandidate { + let test: XCTest + let index: Int + let durationMs: Int? + } + + private class func resolveTimingOrderingSelection() -> TimingOrderingSelection { + let rawValue = timingConfigValue("automobile.junit.timing.ordering")? + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "auto" + let requested = parseTimingOrderingStrategy(rawValue) + let parallelWorkers = resolveParallelWorkerCount() + let resolved: TimingOrderingStrategy + if requested == .auto { + resolved = parallelWorkers > 1 ? .durationDesc : .durationAsc + } else { + resolved = requested + } + return TimingOrderingSelection(requested: requested, resolved: resolved) + } + + private class func parseTimingOrderingStrategy(_ rawValue: String) -> TimingOrderingStrategy { + switch rawValue { + case "auto": + return .auto + case "duration-asc", "duration_asc", "shortest-first", "shortest_first", "shortest": + return .durationAsc + case "duration-desc", "duration_desc", "longest-first", "longest_first", "longest": + return .durationDesc + case "none", "off", "false", "disabled": + return .none + default: + return .none + } + } + + private class func resolveParallelWorkerCount() -> Int { + if let argumentValue = argumentValue(flag: "-parallel-testing-worker-count"), + let workerCount = Int(argumentValue), workerCount > 0 + { + return workerCount + } + if let envValue = ProcessInfo.processInfo.environment["XCTEST_PARALLEL_THREAD_COUNT"], + let workerCount = Int(envValue), workerCount > 0 + { + return workerCount + } + return 1 + } + + private class func argumentValue(flag: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } + + private class func logTimingOrdering(selection: TimingOrderingSelection, timingAvailable: Bool) { + if selection.requested == .auto { + print( + "AutoMobileTestCase: Timing ordering=auto (resolved=\(selection.resolved.rawValue)), timing data available=\(timingAvailable)" + ) + } else { + print( + "AutoMobileTestCase: Timing ordering=\(selection.requested.rawValue), timing data available=\(timingAvailable)" + ) + } + } + + private class func orderTestsByTiming( + _ tests: [XCTest], + strategy: TimingOrderingStrategy + ) + -> [XCTest] + { + if strategy == .none || tests.isEmpty { + return tests + } + + let candidates = tests.enumerated().map { index, test in + guard let testCase = test as? XCTestCase, + let methodName = testMethodName(from: testCase) + else { + return TimingCandidate(test: test, index: index, durationMs: nil) + } + let className = String(describing: type(of: testCase)) + let durationMs = TestTimingCache.shared.getTiming(testClass: className, testMethod: methodName)? + .averageDurationMs + return TimingCandidate(test: test, index: index, durationMs: durationMs) + } + + let withTiming = candidates.filter { $0.durationMs != nil } + let withoutTiming = candidates.filter { $0.durationMs == nil } + + if withTiming.isEmpty { + return tests + } + + let sortedWithTiming: [TimingCandidate] + switch strategy { + case .durationDesc: + sortedWithTiming = withTiming.sorted { + if $0.durationMs == $1.durationMs { + return $0.index < $1.index + } + return ($0.durationMs ?? 0) > ($1.durationMs ?? 0) + } + case .durationAsc: + sortedWithTiming = withTiming.sorted { + if $0.durationMs == $1.durationMs { + return $0.index < $1.index + } + return ($0.durationMs ?? 0) < ($1.durationMs ?? 0) + } + case .auto, .none: + sortedWithTiming = withTiming + } + + let sortedWithoutTiming = withoutTiming.sorted { $0.index < $1.index } + return sortedWithTiming.map { $0.test } + sortedWithoutTiming.map { $0.test } + } + + private class func timingConfigValue(_ key: String) -> String? { + if let value = UserDefaults.standard.object(forKey: key) { + if let stringValue = value as? String { + return stringValue + } + return String(describing: value) + } + return ProcessInfo.processInfo.environment[key] + } + + private class func testMethodName(from testCase: XCTestCase) -> String? { + let fullName = testCase.name + if let range = fullName.range(of: " ") { + let suffix = fullName[range.upperBound...] + return suffix.trimmingCharacters(in: CharacterSet(charactersIn: "]")) + } + return fullName + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunner/PerfTimer.swift b/ios/XCTestRunner/Sources/XCTestRunner/PerfTimer.swift new file mode 100644 index 000000000..f8aa0c2f1 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/PerfTimer.swift @@ -0,0 +1,39 @@ +import Darwin +import Foundation + +/// Simple performance timing utility for debugging test execution +public enum PerfTimer { + private static let startTime = Date() + + /// Returns elapsed time since process start in milliseconds + public static func elapsed() -> Int { + return Int(Date().timeIntervalSince(startTime) * 1000) + } + + /// Log a message with elapsed time prefix (uses stderr for immediate unbuffered output) + public static func log(_ message: String) { + let ms = elapsed() + let line = "[PERF +\(ms)ms] \(message)\n" + fputs(line, stderr) + } + + /// Measure a block and log its duration + public static func measure(_ label: String, block: () throws -> T) rethrows -> T { + let start = Date() + log("\(label) START") + let result = try block() + let durationMs = Int(Date().timeIntervalSince(start) * 1000) + log("\(label) END (\(durationMs)ms)") + return result + } + + /// Measure an async block and log its duration + public static func measureAsync(_ label: String, block: () async throws -> T) async rethrows -> T { + let start = Date() + log("\(label) START") + let result = try await block() + let durationMs = Int(Date().timeIntervalSince(start) * 1000) + log("\(label) END (\(durationMs)ms)") + return result + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunner/TestTimingCache.swift b/ios/XCTestRunner/Sources/XCTestRunner/TestTimingCache.swift new file mode 100644 index 000000000..d964007a0 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/TestTimingCache.swift @@ -0,0 +1,271 @@ +import Foundation + +struct TestTimingStatusCounts: Codable { + let passed: Int + let failed: Int + let skipped: Int + + init(passed: Int = 0, failed: Int = 0, skipped: Int = 0) { + self.passed = passed + self.failed = failed + self.skipped = skipped + } +} + +struct TestTimingEntry: Codable { + let testClass: String + let testMethod: String + let averageDurationMs: Int + let sampleSize: Int + let lastRun: String? + let lastRunTimestampMs: Int? + let successRate: Double? + let failureRate: Double? + let stdDevDurationMs: Int? + let statusCounts: TestTimingStatusCounts? +} + +struct TestTimingSummary: Codable { + let testTimings: [TestTimingEntry] + let generatedAt: String? + let totalTests: Int + let totalSamples: Int + + init( + testTimings: [TestTimingEntry] = [], + generatedAt: String? = nil, + totalTests: Int = 0, + totalSamples: Int = 0 + ) { + self.testTimings = testTimings + self.generatedAt = generatedAt + self.totalTests = totalTests + self.totalSamples = totalSamples + } +} + +struct TestTimingKey: Hashable { + let testClass: String + let testMethod: String +} + +final class TestTimingCache { + static let shared = TestTimingCache() + + private let jsonDecoder = JSONDecoder() + private let loadLock = NSLock() + private var loaded = false + private var timingMap: [TestTimingKey: TestTimingEntry] = [:] + private var summary: TestTimingSummary? + + private init() {} + + func prefetchIfEnabled() { + guard isEnabled() else { + return + } + + if loaded { + return + } + + loadLock.lock() + defer { loadLock.unlock() } + + if loaded { + return + } + + loadFromDaemon() + loaded = true + } + + func getTiming(testClass: String, testMethod: String) -> TestTimingEntry? { + prefetchIfEnabled() + return timingMap[TestTimingKey(testClass: testClass, testMethod: testMethod)] + } + + func hasTimings() -> Bool { + prefetchIfEnabled() + return !timingMap.isEmpty + } + + func getSummary() -> TestTimingSummary? { + prefetchIfEnabled() + return summary + } + + func clear() { + timingMap = [:] + summary = nil + loaded = false + } + + private func isEnabled() -> Bool { + if isCiMode() { + return false + } + return config.boolValue(forKey: "automobile.junit.timing.enabled", defaultValue: true) + } + + private func isCiMode() -> Bool { + if config.boolValue(forKey: "automobile.ci.mode", defaultValue: false) { + return true + } + guard let envValue = ProcessInfo.processInfo.environment["CI"] else { + return false + } + return envValue.lowercased() == "true" || envValue == "1" + } + + private func loadFromDaemon() { + let uri = buildRequestUri() + let timeoutSeconds = Double(resolveTimeoutMs()) / 1000.0 + do { + let client = try AutoMobileTestTimingClient(environment: AutoMobileEnvironment()) + let payload = try client.readResource(uri: uri, timeout: timeoutSeconds) + guard let data = payload.data(using: .utf8) else { + return + } + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let object = jsonObject as? [String: Any], + let error = object["error"] as? String, + !error.isEmpty + { + return + } + let parsed = try jsonDecoder.decode(TestTimingSummary.self, from: data) + summary = parsed + timingMap = Dictionary( + uniqueKeysWithValues: parsed.testTimings.map { + (TestTimingKey(testClass: $0.testClass, testMethod: $0.testMethod), $0) + } + ) + } catch {} + } + + private func buildRequestUri() -> String { + var params: [String: String] = [:] + params["lookbackDays"] = String(resolvePositiveIntProperty( + "automobile.junit.timing.lookback.days", + fallback: 90 + )) + params["limit"] = String(resolvePositiveIntProperty("automobile.junit.timing.limit", fallback: 1000)) + params["minSamples"] = String(resolveMinSamples()) + params["devicePlatform"] = "ios" + + let sessionUuid = AutoMobileSession.currentSessionUuid() + if !sessionUuid.isEmpty { + params["sessionUuid"] = sessionUuid + } + + if params.isEmpty { + return "automobile:test-timings" + } + + let query = params + .map { key, value in + "\(key)=\(encodeQueryParam(value))" + } + .joined(separator: "&") + return "automobile:test-timings?\(query)" + } + + private func resolveMinSamples() -> Int { + let value = config.intValue(forKey: "automobile.junit.timing.min.samples", defaultValue: 1) + return max(0, value) + } + + private func resolvePositiveIntProperty(_ key: String, fallback: Int) -> Int { + let value = config.intValue(forKey: key, defaultValue: fallback) + return value > 0 ? value : fallback + } + + private func resolveTimeoutMs() -> Int { + let value = config.intValue(forKey: "automobile.junit.timing.fetch.timeout.ms", defaultValue: 5000) + return value > 0 ? value : 5000 + } + + private func encodeQueryParam(_ value: String) -> String { + return value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + } + + private var config: TimingConfig { + return TimingConfig() + } +} + +private struct TimingConfig { + private let defaults = UserDefaults.standard + private let environment = ProcessInfo.processInfo.environment + + func stringValue(forKey key: String) -> String? { + if let value = defaults.object(forKey: key) { + if let stringValue = value as? String { + return stringValue + } + return String(describing: value) + } + return environment[key] + } + + func intValue(forKey key: String, defaultValue: Int) -> Int { + if let value = stringValue(forKey: key), let parsed = Int(value) { + return parsed + } + return defaultValue + } + + func boolValue(forKey key: String, defaultValue: Bool) -> Bool { + guard let value = stringValue(forKey: key)?.lowercased() else { + return defaultValue + } + if ["1", "true", "yes", "y"].contains(value) { + return true + } + if ["0", "false", "no", "n"].contains(value) { + return false + } + return defaultValue + } +} + +private final class AutoMobileTestTimingClient { + private let mcpClient: AutoMobileMCPClient + + init(environment: AutoMobileEnvironment) throws { + if let endpoint = environment.firstNonEmpty([ + "AUTOMOBILE_MCP_URL", + "AUTOMOBILE_MCP_HTTP_URL", + "MCP_ENDPOINT", + ]) { + let normalizedEndpoint = AutoMobileTestTimingClient.normalizeEndpoint(endpoint) + guard let endpointURL = URL(string: normalizedEndpoint) else { + throw MCPClientError.invalidEndpoint(normalizedEndpoint) + } + mcpClient = try StreamableHTTPMCPClient(endpoint: endpointURL) + } else { + let socketPath = environment.firstNonEmpty([ + "AUTOMOBILE_DAEMON_SOCKET_PATH", + "AUTO_MOBILE_DAEMON_SOCKET_PATH", + ]) ?? AutoMobileDaemonSocket.defaultPath + mcpClient = AutoMobileDaemonClient(socketPath: socketPath) + } + } + + func readResource(uri: String, timeout: TimeInterval) throws -> String { + let response = try mcpClient.readResource(uri: uri, timeout: timeout) + return response.text + } + + private static func normalizeEndpoint(_ endpoint: String) -> String { + let trimmed = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.contains("/auto-mobile/streamable") || trimmed.contains("/auto-mobile/sse") { + return trimmed + } + if trimmed.hasSuffix("/auto-mobile") { + return "\(trimmed)/streamable" + } + return "\(trimmed)/auto-mobile/streamable" + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunner/XCTestObservationIntegration.swift b/ios/XCTestRunner/Sources/XCTestRunner/XCTestObservationIntegration.swift new file mode 100644 index 000000000..d0fe3bdaf --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunner/XCTestObservationIntegration.swift @@ -0,0 +1,146 @@ +import Foundation +import XCTest + +/// XCTestObservation integration for collecting timing data and test results +public class AutoMobileTestObserver: NSObject, XCTestObservation { + private static let registrationLock = NSLock() + private static var sharedObserver: AutoMobileTestObserver? + + /// Timing data for test cases + public struct TimingData { + public let testName: String + public let duration: TimeInterval + public let startTime: Date + public let endTime: Date + public let passed: Bool + } + + /// Collected timing data + private var timingData: [TimingData] = [] + + /// Current test start times keyed by test instance + private var testStartTimes: [ObjectIdentifier: Date] = [:] + + private let timingLock = NSLock() + + /// Register this observer with the test observation center + public static func register() -> AutoMobileTestObserver { + return registerIfNeeded() + } + + public static func registerIfNeeded() -> AutoMobileTestObserver { + registrationLock.lock() + defer { registrationLock.unlock() } + + if let existing = sharedObserver { + return existing + } + + let observer = AutoMobileTestObserver() + XCTestObservationCenter.shared.addTestObserver(observer) + sharedObserver = observer + return observer + } + + /// Called when a test case starts + public func testCaseWillStart(_ testCase: XCTestCase) { + timingLock.lock() + testStartTimes[ObjectIdentifier(testCase)] = Date() + timingLock.unlock() + print("Test case starting: \(testCase.name)") + } + + /// Called when a test case finishes + public func testCaseDidFinish(_ testCase: XCTestCase) { + timingLock.lock() + let key = ObjectIdentifier(testCase) + guard let startTime = testStartTimes.removeValue(forKey: key) else { + timingLock.unlock() + return + } + timingLock.unlock() + + let endTime = Date() + let duration = endTime.timeIntervalSince(startTime) + + let timing = TimingData( + testName: testCase.name, + duration: duration, + startTime: startTime, + endTime: endTime, + passed: testCase.testRun?.totalFailureCount == 0 + ) + + timingLock.lock() + timingData.append(timing) + timingLock.unlock() + + print("Test case finished: \(testCase.name) - Duration: \(duration)s - Passed: \(timing.passed)") + } + + /// Called when a test suite starts + public func testSuiteWillStart(_ testSuite: XCTestSuite) { + print("Test suite starting: \(testSuite.name)") + } + + /// Called when a test suite finishes + public func testSuiteDidFinish(_ testSuite: XCTestSuite) { + print("Test suite finished: \(testSuite.name)") + printSummary() + } + + /// Gets all collected timing data + public func getTimingData() -> [TimingData] { + timingLock.lock() + let data = timingData + timingLock.unlock() + return data + } + + /// Exports timing data to JSON + public func exportTimingData(to path: String) throws { + let jsonData = try JSONEncoder().encode(timingData) + try jsonData.write(to: URL(fileURLWithPath: path)) + } + + /// Prints a summary of timing data + private func printSummary() { + guard !timingData.isEmpty else { + return + } + + print("\n=== Test Timing Summary ===") + print("Total tests: \(timingData.count)") + print("Passed: \(timingData.filter { $0.passed }.count)") + print("Failed: \(timingData.filter { !$0.passed }.count)") + + let totalDuration = timingData.reduce(0) { $0 + $1.duration } + print("Total duration: \(String(format: "%.2f", totalDuration))s") + + if let slowest = timingData.max(by: { $0.duration < $1.duration }) { + print("Slowest test: \(slowest.testName) (\(String(format: "%.2f", slowest.duration))s)") + } + + if let fastest = timingData.min(by: { $0.duration < $1.duration }) { + print("Fastest test: \(fastest.testName) (\(String(format: "%.2f", fastest.duration))s)") + } + + print("===========================\n") + } +} + +/// Make TimingData Encodable for JSON export +extension AutoMobileTestObserver.TimingData: Encodable { + enum CodingKeys: String, CodingKey { + case testName, duration, startTime, endTime, passed + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(testName, forKey: .testName) + try container.encode(duration, forKey: .duration) + try container.encode(ISO8601DateFormatter().string(from: startTime), forKey: .startTime) + try container.encode(ISO8601DateFormatter().string(from: endTime), forKey: .endTime) + try container.encode(passed, forKey: .passed) + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunnerTests/RemindersIntegrationTests.swift b/ios/XCTestRunner/Sources/XCTestRunnerTests/RemindersIntegrationTests.swift new file mode 100644 index 000000000..fc01d0660 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunnerTests/RemindersIntegrationTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import XCTestRunner + +class RemindersIntegrationBase: AutoMobileTestCase { + override var planBundle: Bundle? { + return Bundle.module + } + + override func setUpAutoMobile() throws { + PerfTimer.log("setUpAutoMobile START") + + // Skip if no simulator is booted - this is a fast check + let hasBooted = PerfTimer.measure("SimulatorDetection.hasBootedSimulator") { + SimulatorDetection.hasBootedSimulator() + } + guard hasBooted else { + throw XCTSkip("No booted simulator found. Boot a simulator first.") + } + + let daemonRunning = PerfTimer.measure("DaemonManager.ensureDaemonRunning") { + DaemonManager.ensureDaemonRunning() + } + guard daemonRunning else { + throw XCTSkip("Failed to start AutoMobile Daemon. Ensure auto-mobile is installed and on PATH.") + } + + let refreshResult = PerfTimer.measure("DaemonManager.refreshDevicePool") { + DaemonManager.refreshDevicePool() + } + PerfTimer + .log( + "refreshDevicePool result: success=\(refreshResult.success), availableDevices=\(refreshResult.availableDevices)" + ) + guard refreshResult.success, refreshResult.availableDevices > 0 else { + throw XCTSkip("No devices available in pool after refresh. Boot a simulator first.") + } + + PerfTimer.log("setUpAutoMobile END") + } +} + +final class RemindersLaunchPlanTests: RemindersIntegrationBase { + override var planPath: String { + return ProcessInfo.processInfo.environment["AUTOMOBILE_TEST_PLAN"] + ?? ProcessInfo.processInfo.environment["PLAN_PATH"] + ?? "launch-reminders-app.yaml" + } + + func testLaunchRemindersPlan() throws { + PerfTimer.log("testLaunchRemindersPlan START - planPath: \(planPath)") + let result = try executePlan() + PerfTimer.log("testLaunchRemindersPlan END - result: \(result)") + } +} + +final class RemindersAddPlanTests: RemindersIntegrationBase { + override var planPath: String { + return ProcessInfo.processInfo.environment["AUTOMOBILE_TEST_PLAN"] + ?? ProcessInfo.processInfo.environment["PLAN_PATH"] + ?? "add-reminder.yaml" + } + + func testAddReminderPlan() throws { + PerfTimer.log("testAddReminderPlan START - planPath: \(planPath)") + let result = try executePlan() + PerfTimer.log("testAddReminderPlan END - result: \(result)") + } +} diff --git a/ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans/add-reminder.yaml b/ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans/add-reminder.yaml new file mode 100644 index 000000000..2c97d2a86 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans/add-reminder.yaml @@ -0,0 +1,32 @@ +--- +name: add-reminder +description: Create a basic reminder in the iOS Reminders app (English UI assumed) +platform: ios +steps: + - tool: launchApp + appId: com.apple.reminders + label: Launch Reminders + + - tool: observe + waitFor: + text: "Reminders" + timeout: 20000 + label: Wait for Reminders UI + + - tool: tapOn + text: "New Reminder" + action: "tap" + label: Focus new reminder field + + - tool: inputText + text: "AutoMobile XCTest demo" + label: Type reminder title + + - tool: tapOn + text: "Done" + action: "tap" + label: Save reminder + + - tool: terminateApp + appId: com.apple.reminders + label: Close Reminders diff --git a/ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans/launch-reminders-app.yaml b/ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans/launch-reminders-app.yaml new file mode 100644 index 000000000..0b2dfad42 --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunnerTests/Resources/Plans/launch-reminders-app.yaml @@ -0,0 +1,18 @@ +--- +name: launch-reminders-app +description: Launch the iOS Reminders app and wait for the main UI +platform: ios +steps: + - tool: launchApp + appId: com.apple.reminders + label: Launch Reminders + + - tool: observe + waitFor: + text: "Reminders" + timeout: 20000 + label: Wait for Reminders UI + + - tool: terminateApp + appId: com.apple.reminders + label: Close Reminders diff --git a/ios/XCTestRunner/Sources/XCTestRunnerTests/XCTestRunnerTests.swift b/ios/XCTestRunner/Sources/XCTestRunnerTests/XCTestRunnerTests.swift new file mode 100644 index 000000000..8cce1143e --- /dev/null +++ b/ios/XCTestRunner/Sources/XCTestRunnerTests/XCTestRunnerTests.swift @@ -0,0 +1,448 @@ +import XCTest +@testable import XCTestRunner + +final class XCTestRunnerTests: XCTestCase { + func testExecutePlanBuildsExpectedArguments() throws { + let planContent = "name: Test Plan\nsteps:\n - tool: observe" + let planLoader = FakePlanLoader(content: planContent) + let mcpClient = FakeMCPClient() + let timer = FakeTimer() + + mcpClient.queueResponse(success: true, executedSteps: 1, totalSteps: 1) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "test-plan.yaml", + retryCount: 0, + timeoutSeconds: 5, + retryDelaySeconds: 0, + startStep: 0, + parameters: [:], + cleanup: AutoMobilePlanExecutor.CleanupOptions(appId: "com.example.app", clearAppData: true), + planBundle: nil + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: timer, + logger: NullLogger() + ) + + let metadata = AutoMobilePlanExecutor.TestMetadata( + testClass: "MyTests", + testMethod: "testLogin", + appVersion: "1.2.3", + gitCommit: "abc123", + isCi: true + ) + + _ = try executor.execute(testMetadata: metadata) + + XCTAssertEqual(mcpClient.calls.count, 1) + let call = mcpClient.calls[0] + XCTAssertEqual(call.name, "executePlan") + XCTAssertEqual(call.arguments["platform"] as? String, "ios") + XCTAssertEqual(call.arguments["startStep"] as? Int, 0) + XCTAssertEqual(call.arguments["cleanupAppId"] as? String, "com.example.app") + XCTAssertEqual(call.arguments["cleanupClearAppData"] as? Bool, true) + + let testMetadata = call.arguments["testMetadata"] as? [String: Any] + XCTAssertEqual(testMetadata?["testClass"] as? String, "MyTests") + XCTAssertEqual(testMetadata?["testMethod"] as? String, "testLogin") + XCTAssertEqual(testMetadata?["appVersion"] as? String, "1.2.3") + XCTAssertEqual(testMetadata?["gitCommit"] as? String, "abc123") + XCTAssertEqual(testMetadata?["isCi"] as? Bool, true) + + let encoded = call.arguments["planContent"] as? String + XCTAssertNotNil(encoded) + let decoded = decodePlanContent(from: encoded) + XCTAssertEqual(decoded, planContent) + XCTAssertTrue(timer.sleeps.isEmpty) + } + + func testExecutePlanRetriesOnFailure() throws { + let planLoader = FakePlanLoader(content: "name: Retry Plan\nsteps:\n - tool: observe") + let mcpClient = FakeMCPClient() + let timer = FakeTimer() + + mcpClient.queueError(NSError(domain: "MCP", code: 1, userInfo: [NSLocalizedDescriptionKey: "Timeout"])) + mcpClient.queueResponse(success: true, executedSteps: 1, totalSteps: 1) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "retry-plan.yaml", + retryCount: 1, + timeoutSeconds: 5, + retryDelaySeconds: 1, + startStep: 0, + parameters: [:], + cleanup: nil, + planBundle: nil + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: timer, + logger: NullLogger() + ) + + _ = try executor.execute(testMetadata: nil) + + XCTAssertEqual(mcpClient.calls.count, 2) + XCTAssertEqual(timer.sleeps, [1]) + } + + func testExecutePlanStopsAfterRetries() throws { + let planLoader = FakePlanLoader(content: "name: Fail Plan\nsteps:\n - tool: observe") + let mcpClient = FakeMCPClient() + let timer = FakeTimer() + + mcpClient.queueError(NSError(domain: "MCP", code: 1, userInfo: [NSLocalizedDescriptionKey: "Timeout"])) + mcpClient.queueError(NSError(domain: "MCP", code: 1, userInfo: [NSLocalizedDescriptionKey: "Timeout"])) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "fail-plan.yaml", + retryCount: 1, + timeoutSeconds: 5, + retryDelaySeconds: 1, + startStep: 0, + parameters: [:], + cleanup: nil, + planBundle: nil + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: timer, + logger: NullLogger() + ) + + XCTAssertThrowsError(try executor.execute(testMetadata: nil)) + XCTAssertEqual(timer.sleeps, [1]) + } + + func testParameterSubstitution() throws { + let planContent = "name: Substitution\nsteps:\n - tool: launchApp\n appId: ${appId}" + let planLoader = FakePlanLoader(content: planContent) + let mcpClient = FakeMCPClient() + + mcpClient.queueResponse(success: true, executedSteps: 1, totalSteps: 1) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "sub-plan.yaml", + retryCount: 0, + timeoutSeconds: 5, + retryDelaySeconds: 0, + startStep: 0, + parameters: ["appId": "com.example.app"], + cleanup: nil, + planBundle: nil + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: FakeTimer(), + logger: NullLogger() + ) + + _ = try executor.execute(testMetadata: nil) + + guard let encoded = mcpClient.calls.first?.arguments["planContent"] as? String else { + XCTFail("Missing plan content") + return + } + guard let decoded = decodePlanContent(from: encoded) else { + XCTFail("Plan content was not base64 encoded") + return + } + XCTAssertTrue(decoded.contains("appId: com.example.app")) + } + + func testPlanPlatformOverridesDefault() throws { + let planContent = "name: Platform Plan\nplatform: android\nsteps:\n - tool: observe" + let planLoader = FakePlanLoader(content: planContent) + let mcpClient = FakeMCPClient() + + mcpClient.queueResponse(success: true, executedSteps: 1, totalSteps: 1) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "platform-plan.yaml", + retryCount: 0, + timeoutSeconds: 5, + retryDelaySeconds: 0, + startStep: 0, + parameters: [:], + cleanup: nil, + planBundle: nil, + defaultPlatform: .ios + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: FakeTimer(), + logger: NullLogger() + ) + + _ = try executor.execute(testMetadata: nil) + + XCTAssertEqual(mcpClient.calls.first?.arguments["platform"] as? String, "android") + } + + func testPlanDevicesPassedToExecutePlan() throws { + let planContent = """ + name: Multi-device Plan + devices: + - label: ios-1 + platform: ios + steps: + - tool: observe + device: ios-1 + """ + let planLoader = FakePlanLoader(content: planContent) + let mcpClient = FakeMCPClient() + + mcpClient.queueResponse(success: true, executedSteps: 1, totalSteps: 1) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "multi-device.yaml", + retryCount: 0, + timeoutSeconds: 5, + retryDelaySeconds: 0, + startStep: 0, + parameters: [:], + cleanup: nil, + planBundle: nil, + defaultPlatform: .ios + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: FakeTimer(), + logger: NullLogger() + ) + + _ = try executor.execute(testMetadata: nil) + + let devices = mcpClient.calls.first?.arguments["devices"] as? [String] + XCTAssertEqual(devices, ["ios-1"]) + } + + func testAutoMobileTestObserver() { + let observer = AutoMobileTestObserver.register() + XCTAssertNotNil(observer) + } + + func testExecutePlanFailureIncludesFailedStepInfo() throws { + let planContent = "name: Fail Plan\nsteps:\n - tool: tapOn\n element: Submit Button" + let planLoader = FakePlanLoader(content: planContent) + let mcpClient = FakeMCPClient() + let timer = FakeTimer() + + mcpClient.queueResponse( + success: false, + executedSteps: 3, + totalSteps: 10, + failedStep: ( + stepIndex: 3, + tool: "tapOn", + error: "Element \"Submit Button\" not found on screen", + device: "iPhone-15-Pro" + ), + error: "Plan execution failed" + ) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "fail-plan.yaml", + retryCount: 0, + timeoutSeconds: 5, + retryDelaySeconds: 0, + startStep: 0, + parameters: [:], + cleanup: nil, + planBundle: nil + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: timer, + logger: NullLogger() + ) + + XCTAssertThrowsError(try executor.execute(testMetadata: nil)) { error in + let errorDescription = String(describing: error) + XCTAssertTrue(errorDescription.contains("step 4"), "Error should contain step index (1-based)") + XCTAssertTrue(errorDescription.contains("tapOn"), "Error should contain tool name") + XCTAssertTrue( + errorDescription.contains("Element \"Submit Button\" not found on screen"), + "Error should contain error text" + ) + XCTAssertTrue(errorDescription.contains("iPhone-15-Pro"), "Error should contain device") + XCTAssertTrue(errorDescription.contains("3/10"), "Error should contain step counts") + } + } + + func testExecutePlanFailureFallsBackToErrorWhenNoFailedStep() throws { + let planContent = "name: Fail Plan\nsteps:\n - tool: observe" + let planLoader = FakePlanLoader(content: planContent) + let mcpClient = FakeMCPClient() + let timer = FakeTimer() + + mcpClient.queueResponse( + success: false, + executedSteps: 5, + totalSteps: 10, + failedStep: nil, + error: "Connection timeout" + ) + + let config = try AutoMobilePlanExecutor.Configuration( + transport: .streamableHttp(url: XCTUnwrap(URL(string: "http://localhost:9000/auto-mobile/streamable"))), + planPath: "fail-plan.yaml", + retryCount: 0, + timeoutSeconds: 5, + retryDelaySeconds: 0, + startStep: 0, + parameters: [:], + cleanup: nil, + planBundle: nil + ) + + let executor = AutoMobilePlanExecutor( + configuration: config, + planLoader: planLoader, + mcpClient: mcpClient, + timer: timer, + logger: NullLogger() + ) + + XCTAssertThrowsError(try executor.execute(testMetadata: nil)) { error in + let errorDescription = String(describing: error) + XCTAssertTrue( + errorDescription.contains("Connection timeout"), + "Error should contain fallback error message" + ) + } + } +} + +private struct FakePlanLoader: AutoMobilePlanLoading { + let content: String + + func loadPlan(at _: String, bundle _: Bundle?) throws -> String { + return content + } +} + +private final class FakeMCPClient: AutoMobileMCPClient { + struct Call { + let name: String + let arguments: [String: Any] + } + + private var queuedResults: [Result] = [] + private(set) var calls: [Call] = [] + private(set) var initializeCount = 0 + + func queueResponse(success: Bool, executedSteps: Int, totalSteps: Int) { + let payload: [String: Any] = [ + "success": success, + "executedSteps": executedSteps, + "totalSteps": totalSteps, + ] + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + let text = String(data: data ?? Data(), encoding: .utf8) ?? "{}" + queuedResults.append(.success(MCPToolResponse(text: text))) + } + + func queueResponse( + success: Bool, + executedSteps: Int, + totalSteps: Int, + failedStep: (stepIndex: Int, tool: String, error: String, device: String?)?, + error: String? + ) { + var payload: [String: Any] = [ + "success": success, + "executedSteps": executedSteps, + "totalSteps": totalSteps, + ] + if let failedStep = failedStep { + var failedStepDict: [String: Any] = [ + "stepIndex": failedStep.stepIndex, + "tool": failedStep.tool, + "error": failedStep.error, + ] + if let device = failedStep.device { + failedStepDict["device"] = device + } + payload["failedStep"] = failedStepDict + } + if let error = error { + payload["error"] = error + } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + let text = String(data: data ?? Data(), encoding: .utf8) ?? "{}" + queuedResults.append(.success(MCPToolResponse(text: text))) + } + + func queueError(_ error: Error) { + queuedResults.append(.failure(error)) + } + + func initialize(timeout _: TimeInterval) throws { + initializeCount += 1 + } + + func callTool(name: String, arguments: [String: Any], timeout _: TimeInterval) throws -> MCPToolResponse { + calls.append(Call(name: name, arguments: arguments)) + guard !queuedResults.isEmpty else { + return MCPToolResponse(text: "{\"success\":true,\"executedSteps\":0,\"totalSteps\":0}") + } + return try queuedResults.removeFirst().get() + } + + func readResource(uri _: String, timeout _: TimeInterval) throws -> MCPResourceResponse { + return MCPResourceResponse(text: "{}") + } + + func resetSession() {} +} + +private struct NullLogger: AutoMobileLogger { + func info(_: String) {} + func warn(_: String) {} + func error(_: String) {} +} + +private func decodePlanContent(from encoded: String?) -> String? { + guard let encoded = encoded else { + return nil + } + guard encoded.hasPrefix("base64:") else { + return nil + } + let base64 = String(encoded.dropFirst("base64:".count)) + guard let data = Data(base64Encoded: base64) else { + return nil + } + return String(data: data, encoding: .utf8) +} diff --git a/ios/XCTestService/Configurations/BaseSettings.xcconfig b/ios/XCTestService/Configurations/BaseSettings.xcconfig new file mode 100644 index 000000000..ed05d876c --- /dev/null +++ b/ios/XCTestService/Configurations/BaseSettings.xcconfig @@ -0,0 +1,78 @@ +// XCTestService Base Configuration +// Strict compiler settings for quality and safety + +// ============================================================================= +// Architecture +// ============================================================================= +EXCLUDED_ARCHS = i386 + +// ============================================================================= +// Warnings - Treat as Errors +// ============================================================================= +GCC_TREAT_WARNINGS_AS_ERRORS = YES +SWIFT_TREAT_WARNINGS_AS_ERRORS = YES + +// ============================================================================= +// Swift Strict Concurrency +// 'targeted' provides useful checking without requiring full Sendable conformance +// which is impractical for test helpers using NSLock and DispatchQueue +// ============================================================================= +SWIFT_STRICT_CONCURRENCY = targeted + +// ============================================================================= +// GCC Warnings (also apply to Swift where relevant) +// ============================================================================= +GCC_WARN_PEDANTIC = YES +GCC_WARN_SHADOW = YES +GCC_WARN_64_TO_32_BIT_CONVERSION = YES +GCC_WARN_MISSING_PARENTHESES = YES +GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES +GCC_WARN_SIGN_COMPARE = YES +GCC_WARN_ABOUT_POINTER_SIGNEDNESS = YES +GCC_WARN_UNUSED_PARAMETER = YES +GCC_WARN_UNUSED_VALUE = YES +GCC_WARN_UNUSED_VARIABLE = YES +GCC_WARN_ALLOW_INCOMPLETE_PROTOCOL = YES + +// ============================================================================= +// Clang Warnings +// ============================================================================= +CLANG_ANALYZER_NONNULL = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +CLANG_WARN_ENUM_CONVERSION = YES +CLANG_WARN_INT_CONVERSION = YES +CLANG_WARN_ASSIGN_ENUM = YES +CLANG_WARN_BOOL_CONVERSION = YES +CLANG_WARN_CONSTANT_CONVERSION = YES +CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES +CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES +CLANG_WARN_UNREACHABLE_CODE = YES +CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES +CLANG_WARN_COMMA = YES +CLANG_WARN_EMPTY_BODY = YES +CLANG_WARN_INFINITE_RECURSION = YES +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES +CLANG_WARN_RANGE_LOOP_ANALYSIS = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_SUSPICIOUS_MOVE = YES +CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES +CLANG_WARN_OBJC_LITERAL_CONVERSION = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE + +// ============================================================================= +// Static Analysis +// ============================================================================= +RUN_CLANG_STATIC_ANALYZER = YES +CLANG_STATIC_ANALYZER_MODE = deep + +// ============================================================================= +// Code Quality +// ============================================================================= +ENABLE_STRICT_OBJC_MSGSEND = YES +GCC_NO_COMMON_BLOCKS = YES + +// ============================================================================= +// Preprocessor +// ============================================================================= +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) diff --git a/ios/XCTestService/Configurations/Debug.xcconfig b/ios/XCTestService/Configurations/Debug.xcconfig new file mode 100644 index 000000000..6fc6413c6 --- /dev/null +++ b/ios/XCTestService/Configurations/Debug.xcconfig @@ -0,0 +1,19 @@ +// XCTestService Debug Configuration + +#include "BaseSettings.xcconfig" + +// ============================================================================= +// Debug-specific Settings +// ============================================================================= +// Performance optimization: use 'dwarf' instead of 'dwarf-with-dsym' to skip +// dSYM generation during debug builds (faster incremental builds) +DEBUG_INFORMATION_FORMAT = dwarf +ENABLE_TESTABILITY = YES +GCC_OPTIMIZATION_LEVEL = 0 +SWIFT_OPTIMIZATION_LEVEL = -Onone +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG + +// ============================================================================= +// Debug Preprocessor +// ============================================================================= +GCC_PREPROCESSOR_DEFINITIONS = $(inherited) DEBUG=1 diff --git a/ios/XCTestService/Configurations/Release.xcconfig b/ios/XCTestService/Configurations/Release.xcconfig new file mode 100644 index 000000000..cc5fe8f30 --- /dev/null +++ b/ios/XCTestService/Configurations/Release.xcconfig @@ -0,0 +1,18 @@ +// XCTestService Release Configuration + +#include "BaseSettings.xcconfig" + +// ============================================================================= +// Release-specific Settings +// ============================================================================= +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +ENABLE_NS_ASSERTIONS = NO +GCC_OPTIMIZATION_LEVEL = s +SWIFT_OPTIMIZATION_LEVEL = -O +SWIFT_COMPILATION_MODE = wholemodule +VALIDATE_PRODUCT = YES + +// ============================================================================= +// Release Hardening +// ============================================================================= +ENABLE_HARDENED_RUNTIME = NO diff --git a/ios/XCTestService/Package.swift b/ios/XCTestService/Package.swift new file mode 100644 index 000000000..3503682a5 --- /dev/null +++ b/ios/XCTestService/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "XCTestService", + platforms: [ + .iOS(.v15), + .macOS(.v13), + ], + products: [ + .library( + name: "XCTestService", + targets: ["XCTestService"] + ), + ], + targets: [ + .target( + name: "XCTestService", + dependencies: [], + path: "Sources/XCTestService" + ), + .testTarget( + name: "XCTestServiceTests", + dependencies: ["XCTestService"], + path: "Tests/XCTestServiceTests" + ), + // Note: XCTestServiceUITests is excluded from SPM as it requires iOS simulator + // Use Xcode project (via XcodeGen) for UI tests + ] +) diff --git a/ios/XCTestService/Sources/XCTestService/CommandHandler.swift b/ios/XCTestService/Sources/XCTestService/CommandHandler.swift new file mode 100644 index 000000000..077958ec8 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/CommandHandler.swift @@ -0,0 +1,457 @@ +import Foundation +#if canImport(XCTest) && os(iOS) + import XCTest +#endif + +/// Handles WebSocket commands matching Android AccessibilityService protocol +public class CommandHandler: CommandHandling { + private let elementLocator: ElementLocating + private let gesturePerformer: GesturePerforming + private let perfProvider: PerfProvider + + public init( + elementLocator: ElementLocating, + gesturePerformer: GesturePerforming, + perfProvider: PerfProvider = PerfProvider.instance + ) { + self.elementLocator = elementLocator + self.gesturePerformer = gesturePerformer + self.perfProvider = perfProvider + } + + /// Factory for testing - allows injecting fakes + public static func createForTesting( + elementLocator: ElementLocating, + gesturePerformer: GesturePerforming, + perfProvider: PerfProvider + ) + -> CommandHandler + { + return CommandHandler( + elementLocator: elementLocator, + gesturePerformer: gesturePerformer, + perfProvider: perfProvider + ) + } + + /// Handle an incoming request and return a response + public func handle(_ request: WebSocketRequest) -> Any { + let startTime = Date() + + do { + switch request.type { + // View hierarchy commands + case RequestType.requestHierarchy.rawValue, + RequestType.requestHierarchyIfStale.rawValue: + return try handleRequestHierarchy(request, startTime: startTime) + + case RequestType.requestScreenshot.rawValue: + return try handleRequestScreenshot(request, startTime: startTime) + + // Gesture commands + case RequestType.requestTapCoordinates.rawValue: + return try handleTapCoordinates(request, startTime: startTime) + + case RequestType.requestSwipe.rawValue: + return try handleSwipe(request, startTime: startTime) + + case RequestType.requestTwoFingerSwipe.rawValue: + return try handleTwoFingerSwipe(request, startTime: startTime) + + case RequestType.requestDrag.rawValue: + return try handleDrag(request, startTime: startTime) + + case RequestType.requestPinch.rawValue: + return try handlePinch(request, startTime: startTime) + + // Text input commands + case RequestType.requestSetText.rawValue: + return try handleSetText(request, startTime: startTime) + + case RequestType.requestImeAction.rawValue: + return try handleImeAction(request, startTime: startTime) + + case RequestType.requestSelectAll.rawValue: + return try handleSelectAll(request, startTime: startTime) + + case RequestType.requestPressHome.rawValue: + return try handlePressHome(request, startTime: startTime) + + // Action commands + case RequestType.requestAction.rawValue: + return try handleAction(request, startTime: startTime) + + case RequestType.requestLaunchApp.rawValue: + return try handleLaunchApp(request, startTime: startTime) + + // Clipboard commands + case RequestType.requestClipboard.rawValue: + return try handleClipboard(request, startTime: startTime) + + // Accessibility features + case RequestType.getCurrentFocus.rawValue: + return try handleGetCurrentFocus(request, startTime: startTime) + + case RequestType.getTraversalOrder.rawValue: + return try handleGetTraversalOrder(request, startTime: startTime) + + case RequestType.addHighlight.rawValue: + return try handleAddHighlight(request, startTime: startTime) + + default: + return WebSocketResponse.error( + type: "error", + requestId: request.requestId, + error: "Unknown command type: \(request.type)", + totalTimeMs: totalTimeMs(from: startTime) + ) + } + } catch { + return WebSocketResponse.error( + type: responseType(for: request.type), + requestId: request.requestId, + error: error.localizedDescription, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + } + + // MARK: - View Hierarchy + + private func handleRequestHierarchy( + _ request: WebSocketRequest, + startTime _: Date + ) + throws -> HierarchyUpdateResponse + { + perfProvider.serial("handleRequestHierarchy") + defer { perfProvider.end() } + + let disableAllFiltering = request.disableAllFiltering ?? false + let hierarchy: ViewHierarchy + do { + hierarchy = try perfProvider.track("extraction") { + try elementLocator.getViewHierarchy(disableAllFiltering: disableAllFiltering) + } + } catch { + print("[CommandHandler] Hierarchy extraction failed: \(error)") + throw CommandError.executionFailed("Failed to get view hierarchy: \(error.localizedDescription)") + } + + // Get accumulated timing for this operation + let perfTimings = perfProvider.flush() + + return HierarchyUpdateResponse( + requestId: request.requestId, + data: hierarchy, + perfTiming: perfTimings?.first + ) + } + + private func handleRequestScreenshot(_ request: WebSocketRequest, startTime _: Date) throws -> ScreenshotResponse { + let data = try gesturePerformer.getScreenshot() + let base64 = data.base64EncodedString() + + return ScreenshotResponse( + requestId: request.requestId, + data: base64, + format: "png" + ) + } + + // MARK: - Gestures + + private func handleTapCoordinates(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let x = request.x, let y = request.y else { + throw CommandError.missingParameter("x and y coordinates") + } + + let duration = request.duration ?? 0 + try gesturePerformer.tap(x: Double(x), y: Double(y), duration: TimeInterval(duration) / 1000.0) + + return WebSocketResponse.success( + type: ResponseType.tapCoordinatesResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleSwipe(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let x1 = request.x1, let y1 = request.y1, + let x2 = request.x2, let y2 = request.y2 + else { + throw CommandError.missingParameter("x1, y1, x2, y2") + } + + let duration = request.duration ?? 300 + try gesturePerformer.swipe( + startX: Double(x1), startY: Double(y1), + endX: Double(x2), endY: Double(y2), + duration: TimeInterval(duration) / 1000.0 + ) + + return WebSocketResponse.success( + type: ResponseType.swipeResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleTwoFingerSwipe(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + // Stub: Two-finger swipe not yet implemented on iOS + return WebSocketResponse.error( + type: ResponseType.swipeResult.rawValue, + requestId: request.requestId, + error: "Two-finger swipe not yet implemented on iOS", + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleDrag(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let x1 = request.x1, let y1 = request.y1, + let x2 = request.x2, let y2 = request.y2 + else { + throw CommandError.missingParameter("x1, y1, x2, y2") + } + + let pressDuration = request.pressDurationMs ?? request.holdTime ?? 600 + let dragDuration = request.dragDurationMs ?? 300 + let holdDuration = request.holdDurationMs ?? 100 + + try gesturePerformer.drag( + startX: Double(x1), startY: Double(y1), + endX: Double(x2), endY: Double(y2), + pressDuration: TimeInterval(pressDuration) / 1000.0, + dragDuration: TimeInterval(dragDuration) / 1000.0, + holdDuration: TimeInterval(holdDuration) / 1000.0 + ) + + return WebSocketResponse.success( + type: ResponseType.dragResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handlePinch(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let centerX = request.centerX, let centerY = request.centerY, + let distanceStart = request.distanceStart, let distanceEnd = request.distanceEnd + else { + throw CommandError.missingParameter("centerX, centerY, distanceStart, distanceEnd") + } + + let duration = request.duration ?? 300 + let scale = Double(distanceEnd) / Double(distanceStart) + + try gesturePerformer.pinch( + centerX: Double(centerX), + centerY: Double(centerY), + scale: scale, + duration: TimeInterval(duration) / 1000.0 + ) + + return WebSocketResponse.success( + type: ResponseType.pinchResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + // MARK: - Text Input + + private func handleSetText(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let text = request.text else { + throw CommandError.missingParameter("text") + } + + if let resourceId = request.resourceId { + try gesturePerformer.setText(resourceId: resourceId, text: text) + } else { + try gesturePerformer.typeText(text: text) + } + + return WebSocketResponse.success( + type: ResponseType.setTextResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleImeAction(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let action = request.action else { + throw CommandError.missingParameter("action") + } + + try gesturePerformer.performImeAction(action) + + return WebSocketResponse.success( + type: ResponseType.imeActionResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleSelectAll(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + try gesturePerformer.selectAll() + + return WebSocketResponse.success( + type: ResponseType.selectAllResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handlePressHome(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + try gesturePerformer.pressHome() + + return WebSocketResponse.success( + type: ResponseType.pressHomeResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + // MARK: - Actions + + private func handleAction(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let action = request.action else { + throw CommandError.missingParameter("action") + } + + try gesturePerformer.performAction(action, resourceId: request.resourceId) + + return WebSocketResponse.success( + type: ResponseType.actionResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleLaunchApp(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + guard let bundleId = request.bundleId else { + throw CommandError.missingParameter("bundleId") + } + + try gesturePerformer.launchApp(bundleId: bundleId) + elementLocator.trackObservedBundleId(bundleId) + + return WebSocketResponse.success( + type: ResponseType.launchAppResult.rawValue, + requestId: request.requestId, + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + // MARK: - Clipboard + + private func handleClipboard(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + // Stub: Clipboard operations not yet fully implemented + return WebSocketResponse.error( + type: ResponseType.clipboardResult.rawValue, + requestId: request.requestId, + error: "Clipboard operations not yet implemented on iOS", + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + // MARK: - Accessibility Features + + private func handleGetCurrentFocus(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + // Stub: Focus tracking not yet implemented + return WebSocketResponse.error( + type: ResponseType.currentFocusResult.rawValue, + requestId: request.requestId, + error: "Current focus not yet implemented on iOS", + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleGetTraversalOrder(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + // Stub: Traversal order not yet implemented + return WebSocketResponse.error( + type: ResponseType.traversalOrderResult.rawValue, + requestId: request.requestId, + error: "Traversal order not yet implemented on iOS", + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + private func handleAddHighlight(_ request: WebSocketRequest, startTime: Date) throws -> WebSocketResponse { + // Stub: Highlights not yet implemented + return WebSocketResponse.error( + type: ResponseType.highlightResponse.rawValue, + requestId: request.requestId, + error: "Highlights not yet implemented on iOS", + totalTimeMs: totalTimeMs(from: startTime) + ) + } + + // MARK: - Helpers + + private func totalTimeMs(from startTime: Date) -> Int64 { + return Int64(Date().timeIntervalSince(startTime) * 1000) + } + + private func responseType(for requestType: String) -> String { + switch requestType { + case RequestType.requestHierarchy.rawValue, + RequestType.requestHierarchyIfStale.rawValue: + return ResponseType.hierarchyUpdate.rawValue + case RequestType.requestScreenshot.rawValue: + return ResponseType.screenshot.rawValue + case RequestType.requestTapCoordinates.rawValue: + return ResponseType.tapCoordinatesResult.rawValue + case RequestType.requestSwipe.rawValue, + RequestType.requestTwoFingerSwipe.rawValue: + return ResponseType.swipeResult.rawValue + case RequestType.requestDrag.rawValue: + return ResponseType.dragResult.rawValue + case RequestType.requestPinch.rawValue: + return ResponseType.pinchResult.rawValue + case RequestType.requestSetText.rawValue: + return ResponseType.setTextResult.rawValue + case RequestType.requestImeAction.rawValue: + return ResponseType.imeActionResult.rawValue + case RequestType.requestSelectAll.rawValue: + return ResponseType.selectAllResult.rawValue + case RequestType.requestPressHome.rawValue: + return ResponseType.pressHomeResult.rawValue + case RequestType.requestAction.rawValue: + return ResponseType.actionResult.rawValue + case RequestType.requestLaunchApp.rawValue: + return ResponseType.launchAppResult.rawValue + case RequestType.requestClipboard.rawValue: + return ResponseType.clipboardResult.rawValue + default: + return "error" + } + } +} + +// MARK: - Errors + +public enum CommandError: LocalizedError { + case unknownCommand(String) + case missingParameter(String) + case invalidParameter(String, String) + case elementNotFound(String) + case executionFailed(String) + case notSupported(String) + + public var errorDescription: String? { + switch self { + case let .unknownCommand(cmd): + return "Unknown command: \(cmd)" + case let .missingParameter(param): + return "Missing required parameter: \(param)" + case let .invalidParameter(param, value): + return "Invalid value '\(value)' for parameter '\(param)'" + case let .elementNotFound(id): + return "Element not found: \(id)" + case let .executionFailed(reason): + return "Command execution failed: \(reason)" + case let .notSupported(feature): + return "Feature not supported: \(feature)" + } + } +} diff --git a/ios/XCTestService/Sources/XCTestService/DisplayLinkFPSMonitor.swift b/ios/XCTestService/Sources/XCTestService/DisplayLinkFPSMonitor.swift new file mode 100644 index 000000000..c7b139bc7 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/DisplayLinkFPSMonitor.swift @@ -0,0 +1,280 @@ +import Foundation +import QuartzCore + +#if canImport(UIKit) && os(iOS) +import UIKit +#endif + +/// FPS monitor that uses CADisplayLink to measure actual frame delivery timing. +/// +/// Based on Apple's recommended patterns from WWDC and documentation: +/// - Uses `link.timestamp` for actual frame timing (not CACurrentMediaTime) +/// - Measures actual intervals between callbacks to determine real FPS +/// - Detects jank when frame time exceeds budget (16.67ms for 60Hz, 8.33ms for 120Hz) +/// +/// Note: For ProMotion (120Hz) on iPhone, the app's Info.plist must include: +/// `CADisableMinimumFrameDurationOnPhone` +/// +/// Reference: WWDC sessions on hitches and frame pacing +public class DisplayLinkFPSMonitor: PerformanceMetricsProvider { + /// How often to report aggregated metrics (in seconds) + public static let defaultReportIntervalSeconds: Double = 0.5 + + /// Frame time thresholds for jank detection + /// - 60Hz budget: 16.67ms + /// - 120Hz budget: 8.33ms + /// We use 2x the 60Hz budget as the jank threshold (33.33ms = definitely dropped frames) + public static let defaultJankThresholdMs: Double = 33.33 + + #if canImport(UIKit) && os(iOS) + private var displayLink: CADisplayLink? + #endif + private var monitoringCallback: (@Sendable (PerformanceSnapshot) -> Void)? + private var _isMonitoring = false + private let lock = NSLock() + + // Frame timing tracking using CADisplayLink timestamps + private var lastLinkTimestamp: CFTimeInterval = 0 + private var frameCount: Int = 0 + private var frameTimes: [Double] = [] + private var jankFrameCount: Int = 0 + private var lastReportTime: CFTimeInterval = 0 + + /// Report interval in seconds + private let reportInterval: Double + + /// Jank threshold in milliseconds + /// Frames taking longer than this are considered janky (dropped frames) + private let jankThresholdMs: Double + + /// Time provider for timestamps in snapshots + private let timeProvider: TimeProvider + + public init( + reportInterval: Double = defaultReportIntervalSeconds, + jankThresholdMs: Double = defaultJankThresholdMs, + timeProvider: TimeProvider = SystemTimeProvider() + ) { + self.reportInterval = reportInterval + self.jankThresholdMs = jankThresholdMs + self.timeProvider = timeProvider + } + + public func collectMetrics() async -> PerformanceSnapshot? { + // Use nonisolated synchronous helper to avoid Swift 6 async/lock warning + return collectMetricsSync() + } + + /// Synchronous helper for collecting metrics (avoids async context lock issues) + private nonisolated func collectMetricsSync() -> PerformanceSnapshot? { + lock.lock() + let snapshot = createSnapshot() + lock.unlock() + return snapshot + } + + public func startMonitoring(callback: @escaping @Sendable (PerformanceSnapshot) -> Void) { + lock.lock() + defer { lock.unlock() } + + guard !_isMonitoring else { return } + + _isMonitoring = true + monitoringCallback = callback + resetMetrics() + + #if canImport(UIKit) && os(iOS) + // Create display link on the main thread + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let displayLink = CADisplayLink(target: self, selector: #selector(self.displayLinkFired)) + + // Use .common mode to ensure callbacks continue during scrolling/gestures + // Reference: UIKit Animation Debugging skill - Pattern 6 + displayLink.add(to: .main, forMode: .common) + + self.lock.lock() + self.displayLink = displayLink + self.lastLinkTimestamp = 0 // Will be set on first callback + self.lastReportTime = CACurrentMediaTime() + self.lock.unlock() + + print("[DisplayLinkFPSMonitor] Started monitoring") + } + #else + print("[DisplayLinkFPSMonitor] CADisplayLink not available on this platform") + #endif + } + + public func stopMonitoring() { + lock.lock() + defer { lock.unlock() } + + _isMonitoring = false + monitoringCallback = nil + + #if canImport(UIKit) && os(iOS) + displayLink?.invalidate() + displayLink = nil + #endif + + print("[DisplayLinkFPSMonitor] Stopped monitoring") + } + + public var isMonitoring: Bool { + lock.lock() + defer { lock.unlock() } + return _isMonitoring + } + + // MARK: - Display Link Callback + + #if canImport(UIKit) && os(iOS) + @objc private func displayLinkFired(_ link: CADisplayLink) { + lock.lock() + + // Use link.timestamp - the time when the frame will be displayed + // This is the proper way to measure actual frame intervals + // Reference: Display Performance skill - "UIScreen Lies, Actual Presentation Tells Truth" + let currentTimestamp = link.timestamp + + if lastLinkTimestamp > 0 { + let frameDuration = currentTimestamp - lastLinkTimestamp + + if frameDuration > 0 { + let frameTimeMs = frameDuration * 1000.0 + frameTimes.append(frameTimeMs) + frameCount += 1 + + // Check for jank (frame time > threshold indicates dropped frames) + // A frame taking >33ms means we missed at least one vsync at 60Hz + if frameTimeMs > jankThresholdMs { + jankFrameCount += 1 + } + } + } + + lastLinkTimestamp = currentTimestamp + + // Check if we should report + let currentTime = CACurrentMediaTime() + let timeSinceLastReport = currentTime - lastReportTime + + if timeSinceLastReport >= reportInterval { + let snapshot = createSnapshot() + let callback = monitoringCallback + resetMetrics() + lastReportTime = currentTime + lock.unlock() + + // Call callback outside the lock + if let snapshot = snapshot { + callback?(snapshot) + } + } else { + lock.unlock() + } + } + #endif + + // MARK: - Metrics Calculation + + /// Create a snapshot from current accumulated metrics. + /// Must be called with lock held. + private func createSnapshot() -> PerformanceSnapshot? { + guard frameCount > 0 else { return nil } + + // Calculate average frame time from actual intervals + let totalFrameTime = frameTimes.reduce(0, +) + let avgFrameTimeMs = totalFrameTime / Double(frameCount) + + // Calculate FPS from average frame time + // Don't cap at 60 - ProMotion devices can reach 120Hz + let fps = avgFrameTimeMs > 0 ? Float(1000.0 / avgFrameTimeMs) : nil + + // Include CPU/memory if available + let cpuPercent = Self.collectCpuUsagePercent() + let memoryMb = Self.collectMemoryUsageMb() + + return PerformanceSnapshot( + timestamp: timeProvider.currentTimeMillis(), + fps: fps, + frameTimeMs: Float(avgFrameTimeMs), + jankFrames: jankFrameCount, + touchLatencyMs: nil, // Would need touch event tracking + ttffMs: nil, + ttiMs: nil, + cpuUsagePercent: cpuPercent, + memoryUsageMb: memoryMb, + screenName: nil + ) + } + + /// Reset metrics for the next reporting interval. + /// Must be called with lock held. + private func resetMetrics() { + frameCount = 0 + frameTimes.removeAll(keepingCapacity: true) + jankFrameCount = 0 + } +} + +// MARK: - System Metrics Collection + +extension DisplayLinkFPSMonitor { + /// Collect memory usage using task_info. + /// Returns memory in MB or nil if unavailable. + public static func collectMemoryUsageMb() -> Float? { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + guard result == KERN_SUCCESS else { return nil } + + // Convert bytes to MB + return Float(info.resident_size) / (1024 * 1024) + } + + /// Collect CPU usage percentage. + /// This is approximate and based on thread CPU time. + public static func collectCpuUsagePercent() -> Float? { + var threadList: thread_act_array_t? + var threadCount = mach_msg_type_number_t(0) + + let result = task_threads(mach_task_self_, &threadList, &threadCount) + guard result == KERN_SUCCESS, let threads = threadList else { return nil } + + defer { + vm_deallocate( + mach_task_self_, + vm_address_t(bitPattern: threads), + vm_size_t(Int(threadCount) * MemoryLayout.stride) + ) + } + + var totalCpu: Double = 0 + + for i in 0.. = [ + "UIView", + "UIImageView", + "UIWindow", + ] + public enum LocatorError: LocalizedError { + case noApplication + case elementNotFound(String) + + public var errorDescription: String? { + switch self { + case .noApplication: + return "No application available for element lookup" + case let .elementNotFound(id): + return "Element not found: \(id)" + } + } + } + + #if canImport(XCTest) && os(iOS) + /// The foreground app we're currently observing (not springboard) + /// At most one instance besides springboard should exist + private var foregroundApp: XCUIApplication? + private var foregroundBundleId: String? + + /// Springboard app for detecting foreground app - always kept + private lazy var springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + + /// Cache of resource IDs to XCUIElements + private var elementCache: [String: XCUIElement] = [:] + + /// Performance tracking provider + private let perfProvider: PerfProvider + + public init( + application: XCUIApplication? = nil, + perfProvider: PerfProvider = PerfProvider.instance + ) { + foregroundApp = application + self.perfProvider = perfProvider + } + + // MARK: - Main Thread Helper + + /// Executes a throwing closure on the main thread and returns the result. + /// XCUITest APIs must be called on the main thread. + private func runOnMainThread(_ block: @escaping () throws -> T) throws -> T { + if Thread.isMainThread { + return try block() + } + + var result: Result! + DispatchQueue.main.sync { + do { + result = try .success(block()) + } catch { + result = .failure(error) + } + } + return try result.get() + } + + /// Executes a non-throwing closure on the main thread and returns the result. + private func runOnMainThread(_ block: @escaping () -> T) -> T { + if Thread.isMainThread { + return block() + } + + var result: T! + DispatchQueue.main.sync { + result = block() + } + return result + } + + public func setApplication(_ app: XCUIApplication) { + // Release old app reference before setting new one + foregroundApp = nil + foregroundBundleId = nil + + foregroundApp = app + elementCache.removeAll() + } + + public func trackObservedBundleId(_ bundleId: String) { + guard bundleId != "com.apple.springboard" else { return } + observedBundleIds.insert(bundleId) + } + + /// Set the application to observe with its bundle ID + public func setApplication(_ app: XCUIApplication, bundleId: String) { + // Release old app reference before setting new one + foregroundApp = nil + foregroundBundleId = nil + + foregroundApp = app + foregroundBundleId = bundleId + elementCache.removeAll() + + // Track this bundle ID for future detection + if bundleId != "com.apple.springboard" { + observedBundleIds.insert(bundleId) + } + } + + /// Detect and switch to the foreground application if current app is not in foreground + private func ensureForegroundApp() { + // Run XCUITest state checks on main thread + // IMPORTANT: Create fresh XCUIApplication instances to check state, because + // cached instances may return stale state values + let stateInfo: (springboardState: UInt, currentAppState: UInt?, currentBundleId: String?) = + perfProvider.track("checkState") { + runOnMainThread { + let sbState = self.springboard.state.rawValue + // Create fresh instance to get accurate state (cached instances return stale state) + let freshAppState: UInt? = self.foregroundBundleId.map { bundleId in + XCUIApplication(bundleIdentifier: bundleId).state.rawValue + } + return (sbState, freshAppState, self.foregroundBundleId) + } + } + + let isCurrentAppInForeground = (stateInfo.currentAppState ?? 0) >= 4 // .runningForeground only (3 = .runningBackground) + let isCurrentAppSpringboard = stateInfo.currentBundleId == "com.apple.springboard" + + // If we have an app (not springboard) and it's in foreground, we're good + // Note: We always try to detect when current app is springboard, because + // springboard reports as foreground even when another app is on top + if isCurrentAppInForeground && !isCurrentAppSpringboard { + return + } + + // Always try to detect foreground app first, even if springboard reports as foreground + // This is because springboard may report as foreground even when another app is visible + + // Try to find the foreground app by checking springboard + if let detectedBundleId = perfProvider.track("detectForeground", block: { detectForegroundAppBundleId() }) { + if detectedBundleId != foregroundBundleId { + print("[ElementLocator] Switching to foreground app: \(detectedBundleId)") + // Release old app before creating new one + foregroundApp = nil + foregroundBundleId = nil + elementCache.removeAll() + + // Create new app instance for the detected bundle + foregroundApp = XCUIApplication(bundleIdentifier: detectedBundleId) + foregroundBundleId = detectedBundleId + + // Track this bundle ID for future detection + observedBundleIds.insert(detectedBundleId) + } + } else if !isCurrentAppInForeground { + // No foreground app detected and current app is in the background. + // Fall back to springboard (user is on the home screen). + if foregroundBundleId != "com.apple.springboard" { + print("[ElementLocator] No foreground app detected, switching to springboard") + foregroundApp = nil + foregroundBundleId = nil + elementCache.removeAll() + + foregroundApp = springboard + foregroundBundleId = "com.apple.springboard" + } + } + } + + /// Get the current application to observe + /// Returns foreground app if available and in foreground, otherwise springboard + private var currentApplication: XCUIApplication { + // Check state on main thread using fresh instance (cached instances return stale state) + let stateInfo: (state: UInt?, bundleId: String?) = runOnMainThread { + let freshState: UInt? = self.foregroundBundleId.map { bundleId in + XCUIApplication(bundleIdentifier: bundleId).state.rawValue + } + return (freshState, self.foregroundBundleId) + } + let foregroundAppInForeground = (stateInfo.state ?? 0) >= 4 // .runningForeground only + if let app = foregroundApp, foregroundAppInForeground { + return app + } + return springboard + } + + /// Common system apps to check when detecting foreground app + /// These are apps that might be launched by the user during testing + private static let commonSystemApps: [String] = [ + "com.apple.Preferences", // Settings + "com.apple.mobilesafari", // Safari + "com.apple.MobileAddressBook", // Contacts + "com.apple.mobilephone", // Phone + "com.apple.MobileSMS", // Messages + "com.apple.mobileslideshow", // Photos + "com.apple.camera", // Camera + "com.apple.AppStore", // App Store + "com.apple.Maps", // Maps + "com.apple.Health", // Health + "com.apple.Fitness", // Fitness + "com.apple.weather", // Weather + "com.apple.mobilenotes", // Notes + "com.apple.reminders", // Reminders + "com.apple.mobilecal", // Calendar + "com.apple.mobilemail", // Mail + "com.apple.Music", // Music + "com.apple.Podcasts", // Podcasts + "com.apple.TV", // TV + "com.apple.news", // News + "com.apple.stocks", // Stocks + "com.apple.tips", // Tips + "com.apple.iBooks", // Books + "com.apple.DocumentsApp", // Files + "com.apple.calculator", // Calculator + "com.apple.VoiceMemos", // Voice Memos + "com.apple.compass", // Compass + "com.apple.measure", // Measure + "com.apple.facetime", // FaceTime + "com.apple.Home", // Home + "com.apple.shortcuts", // Shortcuts + "com.apple.Translate", // Translate + "com.apple.Magnifier", // Magnifier + "com.apple.clock", // Clock + "com.apple.findmy", // Find My + "com.apple.Passbook", // Wallet + "dev.jasonpearson.automobile.Playground", // AutoMobile Playground app + ] + + /// Bundle IDs that have been observed during this session + /// Used to check for foreground app without requiring hardcoded list + private var observedBundleIds: Set = [] + + /// Detect the bundle ID of the foreground app + /// Returns nil if detection fails or springboard is in front + private func detectForegroundAppBundleId() -> String? { + // First, try to find bundle IDs from springboard's element tree + // This can work when apps embed their bundle ID in element identifiers + let snapshot: XCUIElementSnapshot? = perfProvider.track("springboardSnapshot") { + runOnMainThread { + try? self.springboard.snapshot() + } + } + + if let snapshot = snapshot { + let result: String? = perfProvider.track("checkCandidates") { + var candidateBundleIds: [String] = [] + collectBundleIdsFromElement(snapshot, into: &candidateBundleIds) + + for bundleId in candidateBundleIds { + if bundleId == "com.apple.springboard" { + continue + } + + let stateRawValue: UInt = runOnMainThread { + let testApp = XCUIApplication(bundleIdentifier: bundleId) + return testApp.state.rawValue + } + if stateRawValue >= 4 { // .runningForeground only + print("[ElementLocator] Found foreground app from springboard: \(bundleId)") + return bundleId + } + } + return nil + } + if let foundBundleId = result { + return foundBundleId + } + } + + // Fallback: Check observed bundle IDs first (apps we've seen before) + let observedResult: String? = perfProvider.track("checkObserved") { + for bundleId in observedBundleIds { + // Skip current app (we already know it's not in foreground) + if bundleId == foregroundBundleId { + continue + } + + let stateRawValue: UInt = runOnMainThread { + let testApp = XCUIApplication(bundleIdentifier: bundleId) + return testApp.state.rawValue + } + if stateRawValue >= 4 { // .runningForeground only + return bundleId + } + } + return nil + } + if let found = observedResult { + return found + } + + // Fallback: Check common system apps directly + // This is necessary because when another app is in foreground, + // springboard's element tree may not contain that app's bundle ID + return perfProvider.track("checkSystemApps") { + for bundleId in Self.commonSystemApps { + // Skip current app (we already know it's not in foreground) + if bundleId == foregroundBundleId { + continue + } + // Skip already checked in observedBundleIds + if observedBundleIds.contains(bundleId) { + continue + } + + let stateRawValue: UInt = runOnMainThread { + let testApp = XCUIApplication(bundleIdentifier: bundleId) + return testApp.state.rawValue + } + if stateRawValue >= 4 { // .runningForeground only + return bundleId + } + } + return nil + } + } + + /// Collect all potential bundle IDs from springboard element tree + private func collectBundleIdsFromElement(_ element: XCUIElementSnapshot, into bundleIds: inout [String], depth: Int = 0) { + let identifier = element.identifier + + // Many springboard elements have identifiers like "com.apple.AppName-window" + // or just the bundle ID directly + // Also handle formats like "@card:dev.jasonpearson.automobile.Playground:sceneID:..." + if !identifier.isEmpty { + var cleanId = identifier + + // Handle @card: prefix format (e.g., "@card:dev.jasonpearson.automobile.Playground:sceneID:...") + if identifier.hasPrefix("@card:") { + cleanId = String(identifier.dropFirst(6)) // Remove "@card:" + // Take just the bundle ID part (before :sceneID: or similar) + if let colonIndex = cleanId.firstIndex(of: ":") { + cleanId = String(cleanId[.. ViewHierarchy { + perfProvider.serial("getViewHierarchy") + defer { perfProvider.end() } + + // First, ensure we're observing the foreground app + perfProvider.track("ensureForegroundApp") { + ensureForegroundApp() + } + + elementCache.removeAll() + + // Use the observed app's bundle identifier for packageName + let bundleId = foregroundBundleId ?? "com.apple.springboard" + + // Use snapshot() for fast hierarchy extraction - single IPC call captures everything + // snapshot() captures all element data in ONE IPC call (fast!) + // vs accessing properties individually which is extremely slow + // IMPORTANT: Create a FRESH XCUIApplication instance for each snapshot to avoid + // stale accessibility cache. Cached instances may not reflect system-presented + // alerts like permission dialogs. + let snapshot = try perfProvider.track("snapshot") { + try runOnMainThread { + let freshApp = XCUIApplication(bundleIdentifier: bundleId) + return try freshApp.snapshot() + } + } + + // Get screen bounds for offscreen filtering + let screenBounds = snapshot.frame + + // Build hierarchy from snapshot (no more IPC calls - all data is local) + let rawElement = perfProvider.track("buildHierarchy") { + buildElementInfoFromSnapshot( + snapshot, + depth: 0, + screenBounds: screenBounds + ) + } + + // Apply optimization - flatten structural wrappers and filter empty nodes + // Skip optimization when disableAllFiltering is true (for raw hierarchy debugging) + let rootElement: UIElementInfo + if disableAllFiltering { + rootElement = rawElement + } else { + rootElement = perfProvider.track("optimize") { + let optimizedElements = optimizeHierarchy(rawElement, isRoot: true) + return optimizedElements.first ?? rawElement + } + } + + // Get window info from snapshot + let frame = snapshot.frame + let windowInfo = WindowInfo( + id: 0, + type: 1, // Application window + isActive: true, + isFocused: true, + bounds: ElementBounds( + left: Int(frame.origin.x), + top: Int(frame.origin.y), + right: Int(frame.origin.x + frame.width), + bottom: Int(frame.origin.y + frame.height) + ) + ) + + // Check for system alerts from multiple sources: + // 1. Alerts in the app's own snapshot tree (permission dialogs presented within the app) + // 2. Alerts in SpringBoard's tree (system dialogs managed by SpringBoard) + // System permission dialogs may appear in either location depending on iOS version. + let systemAlerts = perfProvider.track("systemAlerts") { + getSystemAlerts(appSnapshot: snapshot) + } + + // If there are system alerts, include them in the hierarchy + let finalHierarchy: UIElementInfo + if !systemAlerts.isEmpty { + // Create a wrapper that contains both the app hierarchy and alerts + var children = rootElement.node ?? [] + children.append(contentsOf: systemAlerts) + finalHierarchy = UIElementInfo( + text: rootElement.text, + resourceId: rootElement.resourceId, + className: rootElement.className, + bounds: rootElement.bounds, + clickable: rootElement.clickable, + focused: rootElement.focused, + scrollable: rootElement.scrollable, + selected: rootElement.selected, + role: rootElement.role, + node: children + ) + } else { + finalHierarchy = rootElement + } + + // Get screen scale and dimensions for coordinate conversion + // iOS reports bounds in points, but screenshots are in pixels + // screenScale converts: pixels = points * screenScale + let (screenScale, screenWidth, screenHeight): (Float, Int, Int) = runOnMainThread { + let scale = Float(UIScreen.main.scale) + let bounds = UIScreen.main.bounds + return (scale, Int(bounds.width), Int(bounds.height)) + } + + return ViewHierarchy( + packageName: bundleId, + hierarchy: finalHierarchy, + windowInfo: windowInfo, + windows: [windowInfo], + screenScale: screenScale, + screenWidth: screenWidth, + screenHeight: screenHeight + ) + } + + /// Get system alerts from the app snapshot and springboard. + /// Checks two sources because system permission dialogs may appear in either: + /// 1. The foreground app's accessibility tree (common on modern iOS) + /// 2. SpringBoard's accessibility tree (for some system-level dialogs) + /// Alert elements are extracted separately from the main hierarchy tree to ensure + /// they are always visible as top-level children and never lost to optimization. + /// Deduplicates by alert label text to avoid showing the same alert twice. + private func getSystemAlerts(appSnapshot: XCUIElementSnapshot) -> [UIElementInfo] { + // Check for alerts in the app's own snapshot tree + let appAlerts = collectAlertElements(from: appSnapshot).map { snapshot in + buildElementInfoFromSnapshot(snapshot, depth: 0, screenBounds: snapshot.frame) + } + + // Also check SpringBoard for alerts not in the app's tree + let springboardAlerts = getAlertsFromSpringboard() + + // Deduplicate by alert label text + var seenLabels: Set = [] + var combined: [UIElementInfo] = [] + + for alert in appAlerts { + let label = alert.text ?? "" + if !seenLabels.contains(label) { + seenLabels.insert(label) + combined.append(alert) + } + } + + for alert in springboardAlerts { + let label = alert.text ?? "" + if !seenLabels.contains(label) { + seenLabels.insert(label) + combined.append(alert) + } + } + + if !combined.isEmpty { + print("[ElementLocator] Found \(combined.count) system alert(s): appAlerts=\(appAlerts.count), springboardAlerts=\(springboardAlerts.count)") + } + + return combined + } + + /// Get alerts from a fresh springboard snapshot. + /// Uses single snapshot() + tree traversal instead of .alerts query which can hang + /// indefinitely on system permission dialogs, blocking the main thread. + /// IMPORTANT: Creates a new XCUIApplication each call to avoid stale cached state. + private func getAlertsFromSpringboard() -> [UIElementInfo] { + let alertSnapshots: [XCUIElementSnapshot] = runOnMainThread { + let freshSpringboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + guard let snapshot = try? freshSpringboard.snapshot() else { return [] } + return self.collectAlertElements(from: snapshot) + } + + return alertSnapshots.map { snapshot in + buildElementInfoFromSnapshot( + snapshot, + depth: 0, + screenBounds: snapshot.frame + ) + } + } + + /// Recursively collect alert-type element snapshots from a snapshot tree. + /// Used instead of .alerts query which can hang on system permission dialogs. + private func collectAlertElements(from snapshot: XCUIElementSnapshot) -> [XCUIElementSnapshot] { + if snapshot.elementType == .alert { + // Found an alert - return it without recursing into children + // (buildElementInfoFromSnapshot will handle the alert's children) + return [snapshot] + } + var alerts: [XCUIElementSnapshot] = [] + for child in snapshot.children { + alerts.append(contentsOf: collectAlertElements(from: child)) + } + return alerts + } + + /// Build element info from XCUIElementSnapshot - all data is already captured, no IPC calls + /// Applies early filtering: offscreen elements, zero-area elements + /// Only sets boolean fields when true (nil = false) to reduce JSON size + private func buildElementInfoFromSnapshot( + _ snapshot: XCUIElementSnapshot, + depth: Int, + screenBounds: CGRect + ) + -> UIElementInfo + { + let frame = snapshot.frame + + // Skip zero-area elements + let hasZeroArea = frame.width <= 0 || frame.height <= 0 + + let bounds = ElementBounds( + left: Int(frame.origin.x), + top: Int(frame.origin.y), + right: Int(frame.origin.x + frame.width), + bottom: Int(frame.origin.y + frame.height) + ) + + // Get identifier + let identifier = snapshot.identifier + + // Get children from snapshot (already captured - fast!) + // Filter out offscreen and zero-area children + // Alert-type elements are SKIPPED here because they are extracted separately + // by collectAlertElements() and added as top-level system alerts. This ensures + // permission dialogs are always visible and never lost to hierarchy optimization. + var childNodes: [UIElementInfo]? = nil + if depth < ElementLocator.maxDepth { + let children = snapshot.children + if !children.isEmpty { + let filteredChildren = children.compactMap { child -> UIElementInfo? in + // Skip alert elements - they are extracted separately as system alerts + // to ensure they're always visible as top-level children + if child.elementType == .alert { + return nil + } + + let childFrame = child.frame + + // Skip zero-area children + if childFrame.width <= 0 || childFrame.height <= 0 { + return nil + } + + // Skip completely offscreen children (with margin) + let margin: CGFloat = 50 + let expandedScreen = screenBounds.insetBy(dx: -margin, dy: -margin) + if !expandedScreen.intersects(childFrame) { + return nil + } + + return buildElementInfoFromSnapshot( + child, + depth: depth + 1, + screenBounds: screenBounds + ) + } + childNodes = filteredChildren.isEmpty ? nil : filteredChildren + } + } + + // Map element type to className + let className = mapElementType(snapshot.elementType) + + // Determine boolean properties - only set to "true", leave nil for false + // This significantly reduces JSON size + let isEnabled = snapshot.isEnabled + + // Only mark specific element types as clickable (not generic UIViews) + let isClickableType = isActuallyClickableType(snapshot.elementType) + let isClickable = isEnabled && isClickableType + + let isScrollable = isScrollableType(snapshot.elementType) + let isCheckable = isCheckableType(snapshot.elementType) + let isSelected = snapshot.isSelected + // UISwitch reports toggle state via value ("0"/"1"), not isSelected + let isChecked: Bool + if isCheckable, let value = snapshot.value as? String { + isChecked = value == "1" + } else { + isChecked = isCheckable && isSelected + } + let hasFocus = snapshot.hasFocus + let isPassword = snapshot.elementType == .secureTextField + + // Only include actions for text input elements (click is implied by clickable) + var actions: [String]? = nil + if isEnabled && (snapshot.elementType == .textField || snapshot.elementType == .textView || + snapshot.elementType == .secureTextField) + { + actions = ["set_text", "clear_text"] + } + + // Get label - use for text (don't duplicate in content-desc) + let label = snapshot.label.isEmpty ? nil : snapshot.label + + // Use resourceId (don't duplicate in testTag) + let resId = identifier.isEmpty ? nil : identifier + + return UIElementInfo( + text: label, + textSize: nil, + contentDesc: nil, // Don't duplicate - label is in text + resourceId: resId, + className: className, + bounds: hasZeroArea ? nil : bounds, // Don't include bounds for zero-area elements + // Only include boolean fields when true (nil = false) + clickable: isClickable ? "true" : nil, + enabled: nil, // Don't include enabled - it's almost always true and implied by clickable + focusable: nil, // Don't include - almost all elements are focusable on iOS + focused: hasFocus ? "true" : nil, + accessibilityFocused: nil, + scrollable: isScrollable ? "true" : nil, + password: isPassword ? "true" : nil, + checkable: isCheckable ? "true" : nil, + checked: isChecked ? "true" : nil, + selected: isSelected ? "true" : nil, + longClickable: nil, // Don't include - same as clickable on iOS + testTag: nil, // Don't duplicate - identifier is in resourceId + role: mapRole(snapshot.elementType), + stateDescription: nil, + errorMessage: nil, + hintText: snapshot.placeholderValue, + actions: actions, + node: childNodes + ) + } + + // MARK: - Hierarchy Optimization + + /// Check if an element has meaningful content that should be preserved + private func meetsFilterCriteria(_ element: UIElementInfo) -> Bool { + // String criteria - element has useful identifying information + let hasStringCriteria = + element.text != nil || + element.resourceId != nil || + element.role != nil || + element.hintText != nil + + // Boolean criteria - element is interactive + let hasBooleanCriteria = + element.clickable == "true" || + element.scrollable == "true" || + element.focused == "true" || + element.selected == "true" || + element.checkable == "true" + + return hasStringCriteria || hasBooleanCriteria + } + + /// Check if a class name is a structural wrapper (no semantic meaning) + private func isStructuralWrapper(_ className: String?) -> Bool { + guard let className = className else { return false } + return ElementLocator.structuralClassNames.contains(className) + } + + /// Optimizes the hierarchy by: + /// 1. Promoting children of bounds-only wrapper nodes (structural nodes with only bounds) + /// 2. Filtering out empty structural nodes + /// 3. Preserving interactive elements and their children + /// + /// This significantly reduces hierarchy size for complex UIs. + private func optimizeHierarchy(_ element: UIElementInfo, isRoot: Bool = false) -> [UIElementInfo] { + // Check if this element is a bounds-only wrapper (has no useful properties) + let meetsCriteria = meetsFilterCriteria(element) + let isStructural = isStructuralWrapper(element.className) + let isBoundsOnlyWrapper = !meetsCriteria && isStructural + + // Never promote children of interactive elements + let isInteractive = element.clickable == "true" || + element.scrollable == "true" || + element.selected == "true" + + // First, recursively optimize children + var optimizedChildren: [UIElementInfo]? = nil + if let children = element.node { + let optimized = children.flatMap { child in + optimizeHierarchy(child, isRoot: false) + } + optimizedChildren = optimized.isEmpty ? nil : optimized + } + + // Root element is always kept + if isRoot { + return [UIElementInfo( + text: element.text, + textSize: element.textSize, + contentDesc: element.contentDesc, + resourceId: element.resourceId, + className: element.className, + bounds: element.bounds, + clickable: element.clickable, + enabled: element.enabled, + focusable: element.focusable, + focused: element.focused, + accessibilityFocused: element.accessibilityFocused, + scrollable: element.scrollable, + password: element.password, + checkable: element.checkable, + checked: element.checked, + selected: element.selected, + longClickable: element.longClickable, + testTag: element.testTag, + role: element.role, + stateDescription: element.stateDescription, + errorMessage: element.errorMessage, + hintText: element.hintText, + actions: element.actions, + node: optimizedChildren + )] + } + + // Only promote children (flatten hierarchy) if this is a bounds-only wrapper AND not interactive + if isBoundsOnlyWrapper && !isInteractive { + if let children = optimizedChildren { + // Promote children - flatten this wrapper node + return children + } + // No children and no content - filter out completely + return [] + } + + // Keep this element with optimized children + return [UIElementInfo( + text: element.text, + textSize: element.textSize, + contentDesc: element.contentDesc, + resourceId: element.resourceId, + className: element.className, + bounds: element.bounds, + clickable: element.clickable, + enabled: element.enabled, + focusable: element.focusable, + focused: element.focused, + accessibilityFocused: element.accessibilityFocused, + scrollable: element.scrollable, + password: element.password, + checkable: element.checkable, + checked: element.checked, + selected: element.selected, + longClickable: element.longClickable, + testTag: element.testTag, + role: element.role, + stateDescription: element.stateDescription, + errorMessage: element.errorMessage, + hintText: element.hintText, + actions: element.actions, + node: optimizedChildren + )] + } + + /// Legacy slow method - keeping for reference but not used + private func buildElementInfo(from _: XCUIElement, depth _: Int, maxDepth _: Int) -> UIElementInfo { + // This method accesses element properties individually which is SLOW + // Each property access is an IPC call to the accessibility service + // Use buildElementInfoFromSnapshot instead + fatalError("Use buildElementInfoFromSnapshot instead - this method is too slow") + } + + private func mapElementType(_ type: XCUIElement.ElementType) -> String { + switch type { + case .application: return "XCUIApplication" + case .window: return "UIWindow" + case .button: return "UIButton" + case .staticText: return "UILabel" + case .textField: return "UITextField" + case .secureTextField: return "UISecureTextField" + case .textView: return "UITextView" + case .image: return "UIImageView" + case .switch: return "UISwitch" + case .slider: return "UISlider" + case .picker: return "UIPickerView" + case .table: return "UITableView" + case .cell: return "UITableViewCell" + case .scrollView: return "UIScrollView" + case .collectionView: return "UICollectionView" + case .navigationBar: return "UINavigationBar" + case .tabBar: return "UITabBar" + case .toolbar: return "UIToolbar" + case .searchField: return "UISearchBar" + case .alert: return "UIAlertController" + case .sheet: return "UIActionSheet" + case .progressIndicator: return "UIProgressView" + case .activityIndicator: return "UIActivityIndicatorView" + case .segmentedControl: return "UISegmentedControl" + case .stepper: return "UIStepper" + case .datePicker: return "UIDatePicker" + case .webView: return "WKWebView" + case .link: return "UILink" + case .keyboard: return "UIKeyboard" + case .key: return "UIKeyboardKey" + default: return "UIView" + } + } + + private func mapRole(_ type: XCUIElement.ElementType) -> String? { + switch type { + case .button: return "button" + case .link: return "link" + case .switch: return "switch" + case .checkBox: return "checkbox" + case .radioButton: return "radio" + case .slider: return "slider" + case .textField, .textView, .secureTextField: return "textfield" + case .image: return "image" + case .staticText: return "text" + case .table, .collectionView: return "list" + case .cell: return "listitem" + case .tab: return "tab" + case .progressIndicator: return "progressbar" + default: return nil + } + } + + private func isScrollableType(_ type: XCUIElement.ElementType) -> Bool { + switch type { + case .scrollView, .table, .collectionView, .webView, .textView: + return true + default: + return false + } + } + + private func isCheckableType(_ type: XCUIElement.ElementType) -> Bool { + switch type { + case .switch, .checkBox, .radioButton: + return true + default: + return false + } + } + + /// Check if an element type is actually clickable (not just a generic container) + /// This prevents marking every UIView as clickable just because it's enabled + private func isActuallyClickableType(_ type: XCUIElement.ElementType) -> Bool { + switch type { + // Interactive controls + case .button, .link, .switch, .slider, .stepper, .segmentedControl: + return true + // Checkable items + case .checkBox, .radioButton: + return true + // Text input + case .textField, .textView, .secureTextField, .searchField: + return true + // List items (cells are tappable) + case .cell: + return true + // Tab and navigation items + case .tab, .tabBar: + return true + // Pickers + case .picker, .datePicker: + return true + // Alert/sheet buttons + case .alert, .sheet: + return true + // Keyboard keys + case .key: + return true + // Images can be tappable + case .image: + return true + // Everything else (UIView, window, staticText, etc.) is not inherently clickable + default: + return false + } + } + + // MARK: - Element Finding + + public func findElement(byResourceId resourceId: String) -> Any? { + if let cached = elementCache[resourceId] { + return cached + } + + let app = currentApplication + // Run on main thread since XCUITest APIs require it + let element: XCUIElement = runOnMainThread { + app.descendants(matching: .any).matching(identifier: resourceId).firstMatch + } + let exists = runOnMainThread { element.exists } + if exists { + elementCache[resourceId] = element + return element + } + return nil + } + + public func findElement(byText text: String) -> Any? { + let app = currentApplication + // Run on main thread since XCUITest APIs require it + let element: XCUIElement = runOnMainThread { + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", text)).firstMatch + } + let exists = runOnMainThread { element.exists } + return exists ? element : nil + } + + public func getCachedElement(_ resourceId: String) -> XCUIElement? { + return elementCache[resourceId] + } + + #else + /// Non-iOS stub implementation + public init() {} + + public func getViewHierarchy(disableAllFiltering _: Bool = false) throws -> ViewHierarchy { + return ViewHierarchy( + packageName: nil, + hierarchy: nil, + windowInfo: nil, + windows: nil, + error: "XCUITest only available on iOS" + ) + } + + public func findElement(byResourceId _: String) -> Any? { + return nil + } + + public func findElement(byText _: String) -> Any? { + return nil + } + + public func trackObservedBundleId(_ bundleId: String) { + // no-op on non-iOS + } + #endif +} diff --git a/ios/XCTestService/Sources/XCTestService/Fakes.swift b/ios/XCTestService/Sources/XCTestService/Fakes.swift new file mode 100644 index 000000000..117b228b0 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/Fakes.swift @@ -0,0 +1,544 @@ +import Foundation + +// MARK: - FakeElementLocator + +/// Fake implementation of ElementLocating for testing +public class FakeElementLocator: ElementLocating { + // MARK: - Configurable State + + private var hierarchyData: ViewHierarchy? + private var elements: [String: Any] = [:] + private var shouldThrow: Error? + + // MARK: - Call History + + private var getHierarchyCallCount = 0 + private var findByIdHistory: [String] = [] + private var findByTextHistory: [String] = [] + public private(set) var trackedBundleIds: [String] = [] + + /// Tracks the last value of disableAllFiltering passed to getViewHierarchy + public private(set) var lastDisableAllFiltering: Bool? + + public init() {} + + // MARK: - Configuration + + /// Set the hierarchy to return + public func setHierarchy(_ hierarchy: ViewHierarchy?) { + hierarchyData = hierarchy + } + + /// Set an element to be found by ID + public func setElement(id: String, element: Any) { + elements[id] = element + } + + /// Configure to throw an error + public func setShouldThrow(_ error: Error?) { + shouldThrow = error + } + + // MARK: - Assertions + + public var hierarchyRequestCount: Int { + getHierarchyCallCount + } + + public func getFindByIdHistory() -> [String] { + findByIdHistory + } + + public func getFindByTextHistory() -> [String] { + findByTextHistory + } + + public func clearHistory() { + getHierarchyCallCount = 0 + findByIdHistory.removeAll() + findByTextHistory.removeAll() + trackedBundleIds.removeAll() + lastDisableAllFiltering = nil + } + + // MARK: - ElementLocating + + public func getViewHierarchy(disableAllFiltering: Bool = false) throws -> ViewHierarchy { + getHierarchyCallCount += 1 + lastDisableAllFiltering = disableAllFiltering + + if let error = shouldThrow { + throw error + } + + return hierarchyData ?? ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Fake Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + } + + public func findElement(byResourceId resourceId: String) -> Any? { + findByIdHistory.append(resourceId) + return elements[resourceId] + } + + public func findElement(byText text: String) -> Any? { + findByTextHistory.append(text) + return elements.values.first + } + + public func trackObservedBundleId(_ bundleId: String) { + trackedBundleIds.append(bundleId) + } +} + +// MARK: - FakeGesturePerformer + +/// Fake implementation of GesturePerforming for testing +public class FakeGesturePerformer: GesturePerforming { + // MARK: - Configurable State + + private var screenshotData: Data? + private var currentOrientation = "portrait" + private var failureMap: [String: Error] = [:] + + // MARK: - Call History + + public struct TapCall { + public let x: Double + public let y: Double + public let duration: TimeInterval + } + + public struct SwipeCall { + public let startX: Double + public let startY: Double + public let endX: Double + public let endY: Double + public let duration: TimeInterval + } + + public struct DragCall { + public let startX: Double + public let startY: Double + public let endX: Double + public let endY: Double + public let pressDuration: TimeInterval + public let dragDuration: TimeInterval + public let holdDuration: TimeInterval + } + + public struct PinchCall { + public let centerX: Double + public let centerY: Double + public let scale: Double + public let duration: TimeInterval + } + + public struct TextCall { + public let text: String + public let resourceId: String? + } + + private var tapHistory: [TapCall] = [] + private var doubleTapHistory: [(x: Double, y: Double)] = [] + private var longPressHistory: [(x: Double, y: Double, duration: TimeInterval)] = [] + private var swipeHistory: [SwipeCall] = [] + private var dragHistory: [DragCall] = [] + private var pinchHistory: [PinchCall] = [] + private var typeTextHistory: [String] = [] + private var setTextHistory: [TextCall] = [] + private var clearTextHistory: [String?] = [] + private var imeActionHistory: [String] = [] + private var actionHistory: [(action: String, resourceId: String?)] = [] + private var screenshotCallCount = 0 + private var pressHomeCallCount = 0 + private var appLaunchHistory: [String] = [] + private var appTerminateHistory: [String] = [] + + public init() {} + + // MARK: - Configuration + + public func setScreenshotData(_ data: Data?) { + screenshotData = data + } + + public func setFailure(for operation: String, error: Error?) { + if let error = error { + failureMap[operation] = error + } else { + failureMap.removeValue(forKey: operation) + } + } + + // MARK: - Assertions + + public func getTapHistory() -> [TapCall] { + tapHistory + } + + public func getSwipeHistory() -> [SwipeCall] { + swipeHistory + } + + public func getDragHistory() -> [DragCall] { + dragHistory + } + + public func getPinchHistory() -> [PinchCall] { + pinchHistory + } + + public func getTypeTextHistory() -> [String] { + typeTextHistory + } + + public func getSetTextHistory() -> [TextCall] { + setTextHistory + } + + public func getImeActionHistory() -> [String] { + imeActionHistory + } + + public func getActionHistory() -> [(action: String, resourceId: String?)] { + actionHistory + } + + public func getScreenshotCallCount() -> Int { + screenshotCallCount + } + + public func getPressHomeCallCount() -> Int { + pressHomeCallCount + } + + public func getAppLaunchHistory() -> [String] { + appLaunchHistory + } + + public func getAppTerminateHistory() -> [String] { + appTerminateHistory + } + + public func clearHistory() { + tapHistory.removeAll() + doubleTapHistory.removeAll() + longPressHistory.removeAll() + swipeHistory.removeAll() + dragHistory.removeAll() + pinchHistory.removeAll() + typeTextHistory.removeAll() + setTextHistory.removeAll() + clearTextHistory.removeAll() + imeActionHistory.removeAll() + actionHistory.removeAll() + screenshotCallCount = 0 + pressHomeCallCount = 0 + appLaunchHistory.removeAll() + appTerminateHistory.removeAll() + } + + // MARK: - Private Helpers + + private func checkFailure(_ operation: String) throws { + if let error = failureMap[operation] { + throw error + } + } + + // MARK: - GesturePerforming + + public func tap(x: Double, y: Double, duration: TimeInterval) throws { + try checkFailure("tap") + tapHistory.append(TapCall(x: x, y: y, duration: duration)) + } + + public func doubleTap(x: Double, y: Double) throws { + try checkFailure("doubleTap") + doubleTapHistory.append((x: x, y: y)) + } + + public func longPress(x: Double, y: Double, duration: TimeInterval) throws { + try checkFailure("longPress") + longPressHistory.append((x: x, y: y, duration: duration)) + } + + public func swipe(startX: Double, startY: Double, endX: Double, endY: Double, duration: TimeInterval) throws { + try checkFailure("swipe") + swipeHistory.append(SwipeCall(startX: startX, startY: startY, endX: endX, endY: endY, duration: duration)) + } + + public func drag( + startX: Double, + startY: Double, + endX: Double, + endY: Double, + pressDuration: TimeInterval, + dragDuration: TimeInterval, + holdDuration: TimeInterval + ) + throws + { + try checkFailure("drag") + dragHistory.append(DragCall( + startX: startX, + startY: startY, + endX: endX, + endY: endY, + pressDuration: pressDuration, + dragDuration: dragDuration, + holdDuration: holdDuration + )) + } + + public func pinch(centerX: Double, centerY: Double, scale: Double, duration: TimeInterval) throws { + try checkFailure("pinch") + pinchHistory.append(PinchCall(centerX: centerX, centerY: centerY, scale: scale, duration: duration)) + } + + public func typeText(text: String) throws { + try checkFailure("typeText") + typeTextHistory.append(text) + } + + public func setText(resourceId: String, text: String) throws { + try checkFailure("setText") + setTextHistory.append(TextCall(text: text, resourceId: resourceId)) + } + + public func clearText(resourceId: String?) throws { + try checkFailure("clearText") + clearTextHistory.append(resourceId) + } + + public func selectAll() throws { + try checkFailure("selectAll") + } + + public func performImeAction(_ action: String) throws { + try checkFailure("imeAction") + imeActionHistory.append(action) + } + + public func performAction(_ action: String, resourceId: String?) throws { + try checkFailure("action") + actionHistory.append((action: action, resourceId: resourceId)) + } + + public func getScreenshot() throws -> Data { + try checkFailure("screenshot") + screenshotCallCount += 1 + return screenshotData ?? Data() + } + + public func setOrientation(_ orientation: String) throws { + try checkFailure("setOrientation") + currentOrientation = orientation + } + + public func getOrientation() -> String { + return currentOrientation + } + + public func pressHome() throws { + try checkFailure("pressHome") + pressHomeCallCount += 1 + } + + public func launchApp(bundleId: String) throws { + try checkFailure("launchApp") + appLaunchHistory.append(bundleId) + } + + public func terminateApp(bundleId: String) throws { + try checkFailure("terminateApp") + appTerminateHistory.append(bundleId) + } + + public func activateApp(bundleId _: String) throws { + try checkFailure("activateApp") + } +} + +// MARK: - FakeWebSocketServer + +/// Fake implementation of WebSocketServing for testing +public class FakeWebSocketServer: WebSocketServing { + // MARK: - State + + private var running = false + private var shouldStartFail = false + private var startError: Error? + + // MARK: - Call History + + private var broadcastHistory: [Data] = [] + private var startCallCount = 0 + private var stopCallCount = 0 + + public init() {} + + // MARK: - Configuration + + public func setShouldStartFail(_ shouldFail: Bool, error: Error? = nil) { + shouldStartFail = shouldFail + startError = error + } + + // MARK: - Assertions + + public func getBroadcastHistory() -> [Data] { + broadcastHistory + } + + public func getStartCallCount() -> Int { + startCallCount + } + + public func getStopCallCount() -> Int { + stopCallCount + } + + public func clearHistory() { + broadcastHistory.removeAll() + startCallCount = 0 + stopCallCount = 0 + } + + // MARK: - WebSocketServing + + public var isRunning: Bool { + running + } + + public func start() throws { + startCallCount += 1 + + if shouldStartFail { + throw startError ?? NSError( + domain: "FakeWebSocketServer", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Fake start failure"] + ) + } + + running = true + } + + public func stop() { + stopCallCount += 1 + running = false + } + + public func broadcast(_ data: Data) { + broadcastHistory.append(data) + } +} + +// MARK: - FakePerfProvider + +/// Fake implementation of PerfProvider for testing +public class FakePerfProvider { + // MARK: - State + + private var flushData: [PerfTiming]? + private let timeProvider: TimeProvider + + // MARK: - Call History + + private var serialHistory: [String] = [] + private var parallelHistory: [String] = [] + private var operationHistory: [(name: String, type: String)] = [] + private var endCallCount = 0 + private var flushCallCount = 0 + + public init(timeProvider: TimeProvider = FakeTimeProvider()) { + self.timeProvider = timeProvider + } + + // MARK: - Configuration + + public func setFlushData(_ data: [PerfTiming]?) { + flushData = data + } + + // MARK: - Assertions + + public func getSerialHistory() -> [String] { + serialHistory + } + + public func getParallelHistory() -> [String] { + parallelHistory + } + + public func getOperationHistory() -> [(name: String, type: String)] { + operationHistory + } + + public func getEndCallCount() -> Int { + endCallCount + } + + public func getFlushCallCount() -> Int { + flushCallCount + } + + public func clearHistory() { + serialHistory.removeAll() + parallelHistory.removeAll() + operationHistory.removeAll() + endCallCount = 0 + flushCallCount = 0 + } + + // MARK: - PerfProvider-like Methods + + public func serial(_ name: String) { + serialHistory.append(name) + operationHistory.append((name: name, type: "serial")) + } + + public func parallel(_ name: String) { + parallelHistory.append(name) + operationHistory.append((name: name, type: "parallel")) + } + + public func end() { + endCallCount += 1 + } + + @discardableResult + public func track(_ name: String, block: () throws -> T) rethrows -> T { + operationHistory.append((name: name, type: "track")) + return try block() + } + + public func startOperation(_ name: String) { + operationHistory.append((name: name, type: "startOperation")) + } + + public func endOperation(_ name: String) { + operationHistory.append((name: name, type: "endOperation")) + } + + public func flush() -> [PerfTiming]? { + flushCallCount += 1 + return flushData + } + + public var hasData: Bool { + return flushData != nil && !(flushData?.isEmpty ?? true) + } + + public func clear() { + flushData = nil + } +} diff --git a/ios/XCTestService/Sources/XCTestService/GesturePerformer.swift b/ios/XCTestService/Sources/XCTestService/GesturePerformer.swift new file mode 100644 index 000000000..a09e41845 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/GesturePerformer.swift @@ -0,0 +1,470 @@ +import Foundation +#if canImport(XCTest) && os(iOS) + import XCTest +#endif + +/// Performs gestures and interactions using XCUITest APIs +public class GesturePerformer: GesturePerforming { + public enum GestureError: LocalizedError { + case noApplication + case elementNotFound(String) + case gestureFailed(String) + case notSupported(String) + + public var errorDescription: String? { + switch self { + case .noApplication: + return "No application available for gestures" + case let .elementNotFound(id): + return "Element not found: \(id)" + case let .gestureFailed(reason): + return "Gesture failed: \(reason)" + case let .notSupported(feature): + return "Feature not supported: \(feature)" + } + } + } + + #if canImport(XCTest) && os(iOS) + private weak var application: XCUIApplication? + private let elementLocator: ElementLocating + + public init(application: XCUIApplication? = nil, elementLocator: ElementLocating) { + self.application = application + self.elementLocator = elementLocator + } + + public func setApplication(_ app: XCUIApplication) { + application = app + } + + // MARK: - Main Thread Helper + + /// Executes a throwing closure on the main thread and returns the result. + /// XCUITest APIs must be called on the main thread. + private func runOnMainThread(_ block: @escaping () throws -> T) throws -> T { + if Thread.isMainThread { + return try block() + } + + var result: Result! + DispatchQueue.main.sync { + do { + result = try .success(block()) + } catch { + result = .failure(error) + } + } + return try result.get() + } + + /// Executes a non-throwing closure on the main thread and returns the result. + private func runOnMainThread(_ block: @escaping () -> T) -> T { + if Thread.isMainThread { + return block() + } + + var result: T! + DispatchQueue.main.sync { + result = block() + } + return result + } + + // MARK: - Tap Gestures + + public func tap(x: Double, y: Double, duration: TimeInterval = 0) throws { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + let coordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: x, dy: y)) + + if duration > 0 { + coordinate.press(forDuration: duration) + } else { + coordinate.tap() + } + } + } + + public func doubleTap(x: Double, y: Double) throws { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + let coordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: x, dy: y)) + coordinate.doubleTap() + } + } + + public func longPress(x: Double, y: Double, duration: TimeInterval) throws { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + let coordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: x, dy: y)) + coordinate.press(forDuration: duration) + } + } + + // MARK: - Swipe Gestures + + public func swipe(startX: Double, startY: Double, endX: Double, endY: Double, duration _: TimeInterval) throws { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + let startCoordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: startX, dy: startY)) + let endCoordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: endX, dy: endY)) + + startCoordinate.press( + forDuration: 0.05, + thenDragTo: endCoordinate, + withVelocity: .default, + thenHoldForDuration: 0 + ) + } + } + + // MARK: - Drag Gestures + + public func drag( + startX: Double, startY: Double, + endX: Double, endY: Double, + pressDuration: TimeInterval, + dragDuration _: TimeInterval, + holdDuration: TimeInterval + ) + throws + { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + let startCoordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: startX, dy: startY)) + let endCoordinate = app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: endX, dy: endY)) + + // Press, drag, and hold + startCoordinate.press( + forDuration: pressDuration, + thenDragTo: endCoordinate, + withVelocity: .default, + thenHoldForDuration: holdDuration + ) + } + } + + // MARK: - Pinch Gestures + + public func pinch(centerX _: Double, centerY _: Double, scale _: Double, duration _: TimeInterval) throws { + // Pinch gesture using XCUITest is limited + // We can simulate by using the app's pinch method if an element is available + throw GestureError.notSupported("Coordinate-based pinch not yet implemented") + } + + // MARK: - Text Input + + public func typeText(text: String) throws { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + app.typeText(text) + } + } + + public func setText(resourceId: String, text: String) throws { + guard let element = elementLocator.findElement(byResourceId: resourceId) as? XCUIElement else { + throw GestureError.elementNotFound(resourceId) + } + + runOnMainThread { + // Clear existing text and type new text + element.tap() + + // Select all and delete + if let existingText = element.value as? String, !existingText.isEmpty { + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: existingText.count) + element.typeText(deleteString) + } + + element.typeText(text) + } + } + + public func clearText(resourceId: String? = nil) throws { + if let resourceId = resourceId { + guard let element = elementLocator.findElement(byResourceId: resourceId) as? XCUIElement else { + throw GestureError.elementNotFound(resourceId) + } + + runOnMainThread { + element.tap() + + if let existingText = element.value as? String, !existingText.isEmpty { + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: existingText.count) + element.typeText(deleteString) + } + } + } else { + guard let app = application else { + throw GestureError.noApplication + } + runOnMainThread { + // Clear focused element + app.typeText(XCUIKeyboardKey.delete.rawValue) + } + } + } + + public func selectAll() throws { + guard let app = application else { + throw GestureError.noApplication + } + + runOnMainThread { + // Try Cmd+A (select all) + app.typeText("a") // Note: This is simplified, real select all would need modifier keys + } + } + + public func performImeAction(_ action: String) throws { + guard let app = application else { + throw GestureError.noApplication + } + + switch action.lowercased() { + case "done", "go", "search", "send", "next": + runOnMainThread { + app.typeText("\n") + } + default: + throw GestureError.notSupported("IME action: \(action)") + } + } + + // MARK: - Actions + + public func performAction(_ action: String, resourceId: String? = nil) throws { + if let resourceId = resourceId { + guard let element = elementLocator.findElement(byResourceId: resourceId) as? XCUIElement else { + throw GestureError.elementNotFound(resourceId) + } + + try runOnMainThread { + switch action.lowercased() { + case "click", "tap": + element.tap() + case "long_click", "long_press": + element.press(forDuration: 1.0) + case "double_tap", "double_click": + element.doubleTap() + case "scroll_forward": + element.swipeUp() + case "scroll_backward": + element.swipeDown() + case "focus": + element.tap() + default: + throw GestureError.notSupported("Action: \(action)") + } + } + } else { + throw GestureError.elementNotFound("resourceId required for action") + } + } + + // MARK: - Screenshots + + public func getScreenshot() throws -> Data { + guard let app = application else { + throw GestureError.noApplication + } + + return runOnMainThread { + let screenshot = app.screenshot() + return screenshot.pngRepresentation + } + } + + // MARK: - Device Control + + public func setOrientation(_ orientation: String) throws { + try runOnMainThread { + let device = XCUIDevice.shared + + switch orientation.lowercased() { + case "portrait": + device.orientation = .portrait + case "portrait_upside_down", "portraitupsidedown": + device.orientation = .portraitUpsideDown + case "landscape_left", "landscapeleft": + device.orientation = .landscapeLeft + case "landscape_right", "landscaperight": + device.orientation = .landscapeRight + default: + throw GestureError.gestureFailed("Unknown orientation: \(orientation)") + } + } + } + + public func getOrientation() -> String { + return runOnMainThread { + let device = XCUIDevice.shared + + switch device.orientation { + case .portrait: return "portrait" + case .portraitUpsideDown: return "portrait_upside_down" + case .landscapeLeft: return "landscape_left" + case .landscapeRight: return "landscape_right" + default: return "unknown" + } + } + } + + public func pressHome() throws { + runOnMainThread { + XCUIDevice.shared.press(.home) + } + } + + // MARK: - App Control + + public func launchApp(bundleId: String) throws { + runOnMainThread { + let app = XCUIApplication(bundleIdentifier: bundleId) + app.launch() + } + } + + public func terminateApp(bundleId: String) throws { + runOnMainThread { + let app = XCUIApplication(bundleIdentifier: bundleId) + app.terminate() + } + } + + public func activateApp(bundleId: String) throws { + runOnMainThread { + let app = XCUIApplication(bundleIdentifier: bundleId) + app.activate() + } + } + + #else + /// Non-iOS stub implementation + private let elementLocator: ElementLocating + + public init(elementLocator: ElementLocating) { + self.elementLocator = elementLocator + } + + public func tap(x _: Double, y _: Double, duration _: TimeInterval = 0) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func doubleTap(x _: Double, y _: Double) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func longPress(x _: Double, y _: Double, duration _: TimeInterval) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func swipe( + startX _: Double, + startY _: Double, + endX _: Double, + endY _: Double, + duration _: TimeInterval + ) + throws + { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func drag( + startX _: Double, + startY _: Double, + endX _: Double, + endY _: Double, + pressDuration _: TimeInterval, + dragDuration _: TimeInterval, + holdDuration _: TimeInterval + ) + throws + { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func pinch(centerX _: Double, centerY _: Double, scale _: Double, duration _: TimeInterval) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func typeText(text _: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func setText(resourceId _: String, text _: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func clearText(resourceId _: String?) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func selectAll() throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func performImeAction(_: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func performAction(_: String, resourceId _: String?) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func getScreenshot() throws -> Data { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func setOrientation(_: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func getOrientation() -> String { + return "unknown" + } + + public func pressHome() throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func launchApp(bundleId _: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func terminateApp(bundleId _: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + + public func activateApp(bundleId _: String) throws { + throw GestureError.notSupported("XCUITest only available on iOS") + } + #endif +} diff --git a/ios/XCTestService/Sources/XCTestService/HierarchyDebouncer.swift b/ios/XCTestService/Sources/XCTestService/HierarchyDebouncer.swift new file mode 100644 index 000000000..38bf62c9d --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/HierarchyDebouncer.swift @@ -0,0 +1,499 @@ +import Foundation + +/// Result of hierarchy extraction with hash comparison. +public enum HierarchyResult { + /// New hierarchy extracted with different structural content. + case changed(hierarchy: ViewHierarchy, hash: Int, extractionTimeMs: Int64) + + /// Hierarchy extracted but structure unchanged (animation only). + case unchanged(hierarchy: ViewHierarchy, hash: Int, extractionTimeMs: Int64, skippedPollCount: Int) + + /// Failed to extract hierarchy. + case error(message: String) +} + +/// Protocol for hierarchy debouncing +public protocol HierarchyDebouncing { + /// Start polling for changes + func start() + + /// Stop polling for changes + func stop() + + /// Whether the debouncer is currently running + var isRunning: Bool { get } + + /// Perform an immediate extraction (bypasses debounce and animation mode) + func extractNow() + + /// Perform an immediate extraction and wait for it to complete (blocking) + func extractNowBlocking(skipFlowEmit: Bool) -> ViewHierarchy? + + /// Set callback for hierarchy results + func setOnResult(_ callback: @escaping (HierarchyResult) -> Void) + + /// Get the last extracted hierarchy without triggering a new extraction + func getLastHierarchy() -> ViewHierarchy? + + /// Reset all state + func reset() +} + +/// Smart debouncer for view hierarchy extraction on iOS. +/// +/// Uses structural hash comparison to detect when content actually changes vs. when only +/// bounds are changing during animations. +/// +/// Key optimization: During animations, many UI updates fire but only bounds change. +/// By comparing structural hashes, we can: +/// - Detect animation mode: Same hash = skip broadcasting, continue polling +/// - Detect real changes: Different hash = content changed, broadcast +/// +/// This reduces noise during animations while still detecting real changes quickly. +public class HierarchyDebouncer: HierarchyDebouncing { + // MARK: - Configuration + + /// How often to poll for changes (default 1s) + public static let defaultPollIntervalMs: Int64 = 1000 + + /// How long to skip broadcasts after detecting animation (default 100ms) + public static let animationSkipWindowMs: Int64 = 100 + + /// Minimum interval between broadcasts (debounce) + public static let broadcastDebounceMs: Int64 = 50 + + // MARK: - Dependencies + + private let elementLocator: ElementLocating + private let timer: Timer + private let pollIntervalMs: Int64 + + // MARK: - State + + private var lastStructuralHash = 0 + private var inAnimationMode = false + private var animationModeEndTime: Int64 = 0 + private var skippedPollCount = 0 + private var lastBroadcastTime: Int64 = 0 + private var lastHierarchy: ViewHierarchy? + + private let lock = NSLock() + private var _isRunning = false + private var pollScheduled = false + private var onResult: ((HierarchyResult) -> Void)? + + public var isRunning: Bool { + lock.lock() + defer { lock.unlock() } + return _isRunning + } + + // MARK: - Init + + public init( + elementLocator: ElementLocating, + timer: Timer = SystemTimer(), + pollIntervalMs: Int64 = HierarchyDebouncer.defaultPollIntervalMs + ) { + self.elementLocator = elementLocator + self.timer = timer + self.pollIntervalMs = pollIntervalMs + } + + // MARK: - Public Interface + + public func setOnResult(_ callback: @escaping (HierarchyResult) -> Void) { + lock.lock() + defer { lock.unlock() } + onResult = callback + } + + public func start() { + lock.lock() + guard !_isRunning else { + lock.unlock() + return + } + _isRunning = true + lock.unlock() + + // Capture initial state + captureInitialState() + + // Schedule first poll + scheduleNextPoll() + + print("[HierarchyDebouncer] Started with \(pollIntervalMs)ms polling interval") + } + + public func stop() { + lock.lock() + _isRunning = false + pollScheduled = false + lock.unlock() + + print("[HierarchyDebouncer] Stopped") + } + + public func extractNow() { + lock.lock() + inAnimationMode = false + lock.unlock() + + extractAndCompare(skipBroadcast: false) + } + + public func extractNowBlocking(skipFlowEmit: Bool = false) -> ViewHierarchy? { + lock.lock() + inAnimationMode = false + lock.unlock() + + extractAndCompare(skipBroadcast: skipFlowEmit) + + lock.lock() + let hierarchy = lastHierarchy + lock.unlock() + return hierarchy + } + + public func getLastHierarchy() -> ViewHierarchy? { + lock.lock() + defer { lock.unlock() } + return lastHierarchy + } + + public func reset() { + lock.lock() + lastStructuralHash = 0 + inAnimationMode = false + animationModeEndTime = 0 + skippedPollCount = 0 + lastBroadcastTime = 0 + lastHierarchy = nil + lock.unlock() + } + + // MARK: - Private + + private func scheduleNextPoll() { + lock.lock() + guard _isRunning, !pollScheduled else { + lock.unlock() + return + } + pollScheduled = true + lock.unlock() + + timer.schedule(after: pollIntervalMs) { [weak self] in + guard let self = self else { return } + + self.lock.lock() + self.pollScheduled = false + let shouldContinue = self._isRunning + self.lock.unlock() + + if shouldContinue { + self.pollAndCheck() + self.scheduleNextPoll() + } + } + } + + private func captureInitialState() { + do { + let startTime = timer.now() + let hierarchy = try elementLocator.getViewHierarchy(disableAllFiltering: false) + let extractionTime = timer.now() - startTime + let hash = StructuralHasher.computeHash(hierarchy) + + lock.lock() + lastStructuralHash = hash + lastHierarchy = hierarchy + lastBroadcastTime = timer.now() + let callback = onResult + lock.unlock() + + print("[HierarchyDebouncer] Initial state captured (hash=\(hash)), broadcasting") + + // Broadcast initial state so the IDE receives hierarchy immediately + let result = HierarchyResult.changed( + hierarchy: hierarchy, + hash: hash, + extractionTimeMs: extractionTime + ) + callback?(result) + } catch { + print("[HierarchyDebouncer] Failed to capture initial state: \(error)") + } + } + + private func pollAndCheck() { + let now = timer.now() + + lock.lock() + let running = _isRunning + let animationMode = inAnimationMode + let animationEnd = animationModeEndTime + lock.unlock() + + guard running else { return } + + // If we're in animation mode and within the skip window, skip extraction + if animationMode, now < animationEnd { + lock.lock() + skippedPollCount += 1 + lock.unlock() + return + } + + // Exit animation mode if window expired + if animationMode, now >= animationEnd { + lock.lock() + let skipped = skippedPollCount + inAnimationMode = false + lock.unlock() + print("[HierarchyDebouncer] Exiting animation mode after skipping \(skipped) polls") + } + + extractAndCompare(skipBroadcast: false) + } + + private func extractAndCompare(skipBroadcast: Bool) { + let startTime = timer.now() + + do { + let hierarchy = try elementLocator.getViewHierarchy(disableAllFiltering: false) + let extractionTime = timer.now() - startTime + let newHash = StructuralHasher.computeHash(hierarchy) + + lock.lock() + let oldHash = lastStructuralHash + let callback = onResult + let lastBroadcast = lastBroadcastTime + lock.unlock() + + if newHash == oldHash { + // Structure unchanged - likely animation + lock.lock() + inAnimationMode = true + animationModeEndTime = timer.now() + HierarchyDebouncer.animationSkipWindowMs + lastHierarchy = hierarchy + lock.unlock() + + // Don't broadcast unchanged results to reduce noise + // Structure unchanged = animation mode, just reset counter + lock.lock() + skippedPollCount = 0 + lock.unlock() + + } else { + // Structure changed - this is a real content change + let now = timer.now() + + lock.lock() + inAnimationMode = false + lastHierarchy = hierarchy + skippedPollCount = 0 + lock.unlock() + + // Debounce broadcasts + let timeSinceLastBroadcast = now - lastBroadcast + let shouldBroadcast = !skipBroadcast && timeSinceLastBroadcast >= HierarchyDebouncer.broadcastDebounceMs + + if shouldBroadcast { + // Only update the structural hash when we actually broadcast. + // This ensures that if a change is debounced, the next poll will + // re-detect it and broadcast once the debounce window has passed. + // Without this, the last change in a rapid sequence can be silently + // dropped (hash updated but never broadcast). + lock.lock() + lastStructuralHash = newHash + lastBroadcastTime = now + lock.unlock() + + let result = HierarchyResult.changed( + hierarchy: hierarchy, + hash: newHash, + extractionTimeMs: extractionTime + ) + + print( + "[HierarchyDebouncer] Structure changed (oldHash=\(oldHash), newHash=\(newHash)), broadcasting" + ) + callback?(result) + } + } + } catch { + // Log errors during polling for debuggability + // This can happen if the app is transitioning between states + print("[HierarchyDebouncer] Extraction error: \(error)") + } + } +} + +// MARK: - Structural Hasher + +/// Computes a structural hash of a ViewHierarchy for change detection. +/// Ignores bounds to focus on content changes vs. animation changes. +public enum StructuralHasher { + /// Compute a structural hash of the hierarchy. + /// Ignores bounds to differentiate content changes from animation/scroll changes. + public static func computeHash(_ hierarchy: ViewHierarchy) -> Int { + var hasher = Hasher() + + // Include package name + if let packageName = hierarchy.packageName { + hasher.combine(packageName) + } + + // Include hierarchy structure (but not bounds) + if let root = hierarchy.hierarchy { + hashElement(root, into: &hasher, depth: 0, maxDepth: 15) + } + + return hasher.finalize() + } + + private static func hashElement(_ element: UIElementInfo, into hasher: inout Hasher, depth: Int, maxDepth: Int) { + // Hash all identifying & state properties (NOT bounds/textSize - those change during animations) + hasher.combine(element.text) + hasher.combine(element.contentDesc) + hasher.combine(element.resourceId) + hasher.combine(element.className) + hasher.combine(element.role) + hasher.combine(element.testTag) + hasher.combine(element.hintText) + hasher.combine(element.stateDescription) + hasher.combine(element.errorMessage) + + // Hash interactive/state properties + hasher.combine(element.clickable) + hasher.combine(element.enabled) + hasher.combine(element.focusable) + hasher.combine(element.focused) + hasher.combine(element.accessibilityFocused) + hasher.combine(element.scrollable) + hasher.combine(element.password) + hasher.combine(element.checkable) + hasher.combine(element.checked) + hasher.combine(element.selected) + hasher.combine(element.longClickable) + + // Hash available actions + if let actions = element.actions { + hasher.combine(actions) + } + + // Hash children recursively (up to maxDepth) + if depth < maxDepth, let children = element.node { + hasher.combine(children.count) + for child in children { + hashElement(child, into: &hasher, depth: depth + 1, maxDepth: maxDepth) + } + } + } +} + +// MARK: - Fake for Testing + +/// Fake implementation for testing hierarchy debouncing +public class FakeHierarchyDebouncer: HierarchyDebouncing { + // Call tracking + public private(set) var startCallCount = 0 + public private(set) var stopCallCount = 0 + public private(set) var extractNowCallCount = 0 + public private(set) var extractNowBlockingCallCount = 0 + public private(set) var setOnResultCallCount = 0 + public private(set) var resetCallCount = 0 + + // State + private var _isRunning = false + private var onResult: ((HierarchyResult) -> Void)? + private var lastHierarchy: ViewHierarchy? + private let lock = NSLock() + + /// Configure what hierarchy to return from extractNowBlocking + public var hierarchyToReturn: ViewHierarchy? + + public var isRunning: Bool { + lock.lock() + defer { lock.unlock() } + return _isRunning + } + + public init() {} + + public func start() { + lock.lock() + startCallCount += 1 + _isRunning = true + lock.unlock() + } + + public func stop() { + lock.lock() + stopCallCount += 1 + _isRunning = false + lock.unlock() + } + + public func extractNow() { + lock.lock() + extractNowCallCount += 1 + lock.unlock() + } + + public func extractNowBlocking(skipFlowEmit _: Bool = false) -> ViewHierarchy? { + lock.lock() + extractNowBlockingCallCount += 1 + let hierarchy = hierarchyToReturn + lastHierarchy = hierarchy + lock.unlock() + return hierarchy + } + + public func setOnResult(_ callback: @escaping (HierarchyResult) -> Void) { + lock.lock() + setOnResultCallCount += 1 + onResult = callback + lock.unlock() + } + + public func getLastHierarchy() -> ViewHierarchy? { + lock.lock() + defer { lock.unlock() } + return lastHierarchy + } + + public func reset() { + lock.lock() + resetCallCount += 1 + lastHierarchy = nil + lock.unlock() + } + + /// Simulate a hierarchy change for testing + public func simulateChange(_ result: HierarchyResult) { + lock.lock() + let callback = onResult + if case let .changed(hierarchy, _, _) = result { + lastHierarchy = hierarchy + } else if case let .unchanged(hierarchy, _, _, _) = result { + lastHierarchy = hierarchy + } + lock.unlock() + callback?(result) + } + + /// Reset all call counts for fresh test assertions + public func resetCounts() { + lock.lock() + startCallCount = 0 + stopCallCount = 0 + extractNowCallCount = 0 + extractNowBlockingCallCount = 0 + setOnResultCallCount = 0 + resetCallCount = 0 + lock.unlock() + } +} diff --git a/ios/XCTestService/Sources/XCTestService/Models.swift b/ios/XCTestService/Sources/XCTestService/Models.swift new file mode 100644 index 000000000..6564cbfc5 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/Models.swift @@ -0,0 +1,601 @@ +import Foundation + +// MARK: - Request Models (matching Android AccessibilityService) + +/// WebSocket request from automation client +/// Matches Android's WebSocketRequest format +public struct WebSocketRequest: Codable { + public let type: String + public let requestId: String? + + // Tap parameters + public let x: Int? + public let y: Int? + public let duration: Int? + + // Swipe parameters + public let x1: Int? + public let y1: Int? + public let x2: Int? + public let y2: Int? + public let offset: Int? + + // Drag parameters + public let pressDurationMs: Int? + public let dragDurationMs: Int? + public let holdDurationMs: Int? + public let holdTime: Int? + + // Pinch parameters + public let centerX: Int? + public let centerY: Int? + public let distanceStart: Int? + public let distanceEnd: Int? + public let rotationDegrees: Float? + + // Text input parameters + public let text: String? + public let resourceId: String? + + // Action parameters + public let action: String? + public let bundleId: String? + + // Filtering control + public let sinceTimestamp: Int64? + public let disableAllFiltering: Bool? + + // Highlight parameters + public let id: String? + public let shape: HighlightShape? + + // Permission/settings + public let permission: String? + public let requestPermission: Bool? + public let enabled: Bool? + + public init( + type: String, + requestId: String? = nil, + x: Int? = nil, + y: Int? = nil, + duration: Int? = nil, + x1: Int? = nil, + y1: Int? = nil, + x2: Int? = nil, + y2: Int? = nil, + offset: Int? = nil, + pressDurationMs: Int? = nil, + dragDurationMs: Int? = nil, + holdDurationMs: Int? = nil, + holdTime: Int? = nil, + centerX: Int? = nil, + centerY: Int? = nil, + distanceStart: Int? = nil, + distanceEnd: Int? = nil, + rotationDegrees: Float? = nil, + text: String? = nil, + resourceId: String? = nil, + action: String? = nil, + bundleId: String? = nil, + sinceTimestamp: Int64? = nil, + disableAllFiltering: Bool? = nil, + id: String? = nil, + shape: HighlightShape? = nil, + permission: String? = nil, + requestPermission: Bool? = nil, + enabled: Bool? = nil + ) { + self.type = type + self.requestId = requestId + self.x = x + self.y = y + self.duration = duration + self.x1 = x1 + self.y1 = y1 + self.x2 = x2 + self.y2 = y2 + self.offset = offset + self.pressDurationMs = pressDurationMs + self.dragDurationMs = dragDurationMs + self.holdDurationMs = holdDurationMs + self.holdTime = holdTime + self.centerX = centerX + self.centerY = centerY + self.distanceStart = distanceStart + self.distanceEnd = distanceEnd + self.rotationDegrees = rotationDegrees + self.text = text + self.resourceId = resourceId + self.action = action + self.bundleId = bundleId + self.sinceTimestamp = sinceTimestamp + self.disableAllFiltering = disableAllFiltering + self.id = id + self.shape = shape + self.permission = permission + self.requestPermission = requestPermission + self.enabled = enabled + } +} + +// MARK: - Response Models (matching Android AccessibilityService) + +/// Base response structure +public struct WebSocketResponse: Codable { + public let type: String + public let timestamp: Int64 + public let requestId: String? + public let success: Bool? + public let totalTimeMs: Int64? + public let error: String? + public let perfTiming: PerfTiming? + + public init( + type: String, + timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000), + requestId: String? = nil, + success: Bool? = nil, + totalTimeMs: Int64? = nil, + error: String? = nil, + perfTiming: PerfTiming? = nil + ) { + self.type = type + self.timestamp = timestamp + self.requestId = requestId + self.success = success + self.totalTimeMs = totalTimeMs + self.error = error + self.perfTiming = perfTiming + } + + public static func success( + type: String, + requestId: String?, + totalTimeMs: Int64 + ) + -> WebSocketResponse + { + WebSocketResponse( + type: type, + requestId: requestId, + success: true, + totalTimeMs: totalTimeMs + ) + } + + public static func error( + type: String, + requestId: String?, + error: String, + totalTimeMs: Int64? = nil + ) + -> WebSocketResponse + { + WebSocketResponse( + type: type, + requestId: requestId, + success: false, + totalTimeMs: totalTimeMs, + error: error + ) + } +} + +/// Performance timing data - hierarchical format matching Android/TypeScript +public struct PerfTiming: Codable { + public let name: String + public let durationMs: Int64 + public let children: [PerfTiming]? + + public init(name: String, durationMs: Int64, children: [PerfTiming]? = nil) { + self.name = name + self.durationMs = durationMs + self.children = children + } + + /// Convenience for creating a simple timing with no children + public static func timing(_ name: String, durationMs: Int64) -> PerfTiming { + PerfTiming(name: name, durationMs: durationMs, children: nil) + } + + /// Convenience for creating a timing with children + public static func timing(_ name: String, durationMs: Int64, children: [PerfTiming]) -> PerfTiming { + PerfTiming(name: name, durationMs: durationMs, children: children.isEmpty ? nil : children) + } +} + +// MARK: - Hierarchy Response + +public struct HierarchyUpdateResponse: Codable { + public let type: String + public let timestamp: Int64 + public let requestId: String? + public let data: ViewHierarchy? + public let perfTiming: PerfTiming? + public let error: String? + + public init( + requestId: String? = nil, + data: ViewHierarchy? = nil, + perfTiming: PerfTiming? = nil, + error: String? = nil + ) { + type = "hierarchy_update" + timestamp = Int64(Date().timeIntervalSince1970 * 1000) + self.requestId = requestId + self.data = data + self.perfTiming = perfTiming + self.error = error + } +} + +/// View hierarchy structure (matching Android's ViewHierarchy) +public struct ViewHierarchy: Codable { + public let updatedAt: Int64 + public let packageName: String? + public let hierarchy: UIElementInfo? + public let windowInfo: WindowInfo? + public let windows: [WindowInfo]? + public let screenScale: Float? + public let screenWidth: Int? + public let screenHeight: Int? + public let error: String? + + public init( + updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000), + packageName: String? = nil, + hierarchy: UIElementInfo? = nil, + windowInfo: WindowInfo? = nil, + windows: [WindowInfo]? = nil, + screenScale: Float? = nil, + screenWidth: Int? = nil, + screenHeight: Int? = nil, + error: String? = nil + ) { + self.updatedAt = updatedAt + self.packageName = packageName + self.hierarchy = hierarchy + self.windowInfo = windowInfo + self.windows = windows + self.screenScale = screenScale + self.screenWidth = screenWidth + self.screenHeight = screenHeight + self.error = error + } +} + +/// Window information +public struct WindowInfo: Codable { + public let id: Int? + public let type: Int? + public let isActive: Bool + public let isFocused: Bool + public let bounds: ElementBounds? + + public init( + id: Int? = nil, + type: Int? = nil, + isActive: Bool = false, + isFocused: Bool = false, + bounds: ElementBounds? = nil + ) { + self.id = id + self.type = type + self.isActive = isActive + self.isFocused = isFocused + self.bounds = bounds + } +} + +// MARK: - Element Models (matching Android's UIElementInfo) + +/// UI Element information (matching Android's UIElementInfo) +public struct UIElementInfo: Codable { + public let text: String? + public let textSize: Float? + public let contentDesc: String? + public let resourceId: String? + public let className: String? + public let bounds: ElementBounds? + public let clickable: String? + public let enabled: String? + public let focusable: String? + public let focused: String? + public let accessibilityFocused: String? + public let scrollable: String? + public let password: String? + public let checkable: String? + public let checked: String? + public let selected: String? + public let longClickable: String? + public let testTag: String? + public let role: String? + public let stateDescription: String? + public let errorMessage: String? + public let hintText: String? + public let actions: [String]? + public let node: [UIElementInfo]? + + enum CodingKeys: String, CodingKey { + case text, textSize, className, bounds, clickable, enabled + case focusable, focused, scrollable, password, checkable, checked + case selected, actions, node, role, testTag + case contentDesc = "content-desc" + case resourceId = "resource-id" + case accessibilityFocused = "accessibility-focused" + case longClickable = "long-clickable" + case stateDescription = "state-description" + case errorMessage = "error-message" + case hintText = "hint-text" + } + + public init( + text: String? = nil, + textSize: Float? = nil, + contentDesc: String? = nil, + resourceId: String? = nil, + className: String? = nil, + bounds: ElementBounds? = nil, + clickable: String? = nil, + enabled: String? = nil, + focusable: String? = nil, + focused: String? = nil, + accessibilityFocused: String? = nil, + scrollable: String? = nil, + password: String? = nil, + checkable: String? = nil, + checked: String? = nil, + selected: String? = nil, + longClickable: String? = nil, + testTag: String? = nil, + role: String? = nil, + stateDescription: String? = nil, + errorMessage: String? = nil, + hintText: String? = nil, + actions: [String]? = nil, + node: [UIElementInfo]? = nil + ) { + self.text = text + self.textSize = textSize + self.contentDesc = contentDesc + self.resourceId = resourceId + self.className = className + self.bounds = bounds + self.clickable = clickable + self.enabled = enabled + self.focusable = focusable + self.focused = focused + self.accessibilityFocused = accessibilityFocused + self.scrollable = scrollable + self.password = password + self.checkable = checkable + self.checked = checked + self.selected = selected + self.longClickable = longClickable + self.testTag = testTag + self.role = role + self.stateDescription = stateDescription + self.errorMessage = errorMessage + self.hintText = hintText + self.actions = actions + self.node = node + } +} + +/// Element bounds (matching Android's ElementBounds) +public struct ElementBounds: Codable { + public let left: Int + public let top: Int + public let right: Int + public let bottom: Int + + public init(left: Int, top: Int, right: Int, bottom: Int) { + self.left = left + self.top = top + self.right = right + self.bottom = bottom + } + + public var width: Int { + right - left + } + + public var height: Int { + bottom - top + } + + public var centerX: Int { + left + width / 2 + } + + public var centerY: Int { + top + height / 2 + } +} + +// MARK: - Screenshot Response + +public struct ScreenshotResponse: Codable { + public let type: String + public let timestamp: Int64 + public let requestId: String? + public let format: String + public let data: String // Base64 encoded + + public init(requestId: String?, data: String, format: String = "png") { + type = "screenshot" + timestamp = Int64(Date().timeIntervalSince1970 * 1000) + self.requestId = requestId + self.format = format + self.data = data + } +} + +// MARK: - Highlight Models + +public struct HighlightShape: Codable { + public let type: String // "box" or "path" + public let bounds: HighlightBounds? + public let points: [HighlightPoint]? + public let style: HighlightStyle? + + public init( + type: String, + bounds: HighlightBounds? = nil, + points: [HighlightPoint]? = nil, + style: HighlightStyle? = nil + ) { + self.type = type + self.bounds = bounds + self.points = points + self.style = style + } +} + +public struct HighlightBounds: Codable { + public let x: Int + public let y: Int + public let width: Int + public let height: Int + public let sourceWidth: Int? + public let sourceHeight: Int? + + public init( + x: Int, + y: Int, + width: Int, + height: Int, + sourceWidth: Int? = nil, + sourceHeight: Int? = nil + ) { + self.x = x + self.y = y + self.width = width + self.height = height + self.sourceWidth = sourceWidth + self.sourceHeight = sourceHeight + } +} + +public struct HighlightPoint: Codable { + public let x: Float + public let y: Float + + public init(x: Float, y: Float) { + self.x = x + self.y = y + } +} + +public struct HighlightStyle: Codable { + public let strokeColor: String? + public let strokeWidth: Float? + public let dashPattern: [Float]? + public let smoothing: String? + public let tension: Float? + public let capStyle: String? + public let joinStyle: String? + + public init( + strokeColor: String? = nil, + strokeWidth: Float? = nil, + dashPattern: [Float]? = nil, + smoothing: String? = nil, + tension: Float? = nil, + capStyle: String? = nil, + joinStyle: String? = nil + ) { + self.strokeColor = strokeColor + self.strokeWidth = strokeWidth + self.dashPattern = dashPattern + self.smoothing = smoothing + self.tension = tension + self.capStyle = capStyle + self.joinStyle = joinStyle + } +} + +// MARK: - Performance Update Response + +/// Push notification for performance metrics (FPS, frame time, etc.) +public struct PerformanceUpdateResponse: Codable { + public let type: String + public let timestamp: Int64 + public let performanceData: PerformanceSnapshot + + public init(data: PerformanceSnapshot) { + self.type = "performance_update" + self.timestamp = Int64(Date().timeIntervalSince1970 * 1000) + self.performanceData = data + } +} + +// MARK: - Connected Event + +public struct ConnectedEvent: Codable { + public let type: String + public let id: Int + + public init(id: Int) { + type = "connected" + self.id = id + } +} + +// MARK: - Request Types (matching Android) + +public enum RequestType: String { + // View hierarchy + case requestHierarchy = "request_hierarchy" + case requestHierarchyIfStale = "request_hierarchy_if_stale" + case requestScreenshot = "request_screenshot" + + // Gestures + case requestTapCoordinates = "request_tap_coordinates" + case requestSwipe = "request_swipe" + case requestTwoFingerSwipe = "request_two_finger_swipe" + case requestDrag = "request_drag" + case requestPinch = "request_pinch" + + // Text input + case requestSetText = "request_set_text" + case requestImeAction = "request_ime_action" + case requestSelectAll = "request_select_all" + case requestPressHome = "request_press_home" + + // Node actions + case requestAction = "request_action" + case requestLaunchApp = "request_launch_app" + + /// Clipboard + case requestClipboard = "request_clipboard" + + // Accessibility features + case getCurrentFocus = "get_current_focus" + case getTraversalOrder = "get_traversal_order" + case addHighlight = "add_highlight" +} + +// MARK: - Response Types (matching Android) + +public enum ResponseType: String { + case hierarchyUpdate = "hierarchy_update" + case screenshot + case screenshotError = "screenshot_error" + case tapCoordinatesResult = "tap_coordinates_result" + case swipeResult = "swipe_result" + case dragResult = "drag_result" + case pinchResult = "pinch_result" + case setTextResult = "set_text_result" + case imeActionResult = "ime_action_result" + case selectAllResult = "select_all_result" + case pressHomeResult = "press_home_result" + case actionResult = "action_result" + case launchAppResult = "launch_app_result" + case clipboardResult = "clipboard_result" + case currentFocusResult = "current_focus_result" + case traversalOrderResult = "traversal_order_result" + case highlightResponse = "highlight_response" + case connected +} diff --git a/ios/XCTestService/Sources/XCTestService/PerfProvider.swift b/ios/XCTestService/Sources/XCTestService/PerfProvider.swift new file mode 100644 index 000000000..556688c93 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/PerfProvider.swift @@ -0,0 +1,574 @@ +import Foundation + +// MARK: - TimeProvider Protocol + +/// Protocol for providing current time in milliseconds. Uses injection for testability. +public protocol TimeProvider { + /// Get current time in milliseconds (epoch time) + func currentTimeMillis() -> Int64 +} + +/// Default implementation using system clock. +public class SystemTimeProvider: TimeProvider { + public init() {} + + public func currentTimeMillis() -> Int64 { + return Int64(Date().timeIntervalSince1970 * 1000) + } +} + +/// Fake implementation for testing with controllable time. +public class FakeTimeProvider: TimeProvider { + private var currentTime: Int64 + private let lock = NSLock() + + public init(initialTime: Int64 = 0) { + currentTime = initialTime + } + + public func currentTimeMillis() -> Int64 { + lock.lock() + defer { lock.unlock() } + return currentTime + } + + /// Set the current time to a specific value. + public func setTime(_ time: Int64) { + lock.lock() + defer { lock.unlock() } + currentTime = time + } + + /// Advance time by the specified number of milliseconds. + public func advance(by milliseconds: Int64) { + lock.lock() + defer { lock.unlock() } + currentTime += milliseconds + } + + /// Reset time to zero. + public func reset() { + lock.lock() + defer { lock.unlock() } + currentTime = 0 + } +} + +// MARK: - Timer Protocol (for delays and scheduling) + +/// Protocol for timer/delay operations. Uses injection for testability. +public protocol Timer { + /// Get current time in milliseconds + func now() -> Int64 + + /// Wait for specified milliseconds (async) + func wait(milliseconds: Int64) async + + /// Schedule a callback after specified milliseconds + func schedule(after milliseconds: Int64, callback: @escaping @Sendable () -> Void) +} + +/// Default implementation using real system time and delays. +public class SystemTimer: Timer, @unchecked Sendable { + public init() {} + + public func now() -> Int64 { + return Int64(Date().timeIntervalSince1970 * 1000) + } + + public func wait(milliseconds: Int64) async { + try? await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000) + } + + public func schedule(after milliseconds: Int64, callback: @escaping @Sendable () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(milliseconds))) { + callback() + } + } +} + +/// Fake implementation for testing with instant or controlled time. +public class FakeTimer: Timer, @unchecked Sendable { + public enum Mode { + case instant // All waits complete immediately + case manual // Waits only complete when manually advanced + case delayed(Int64) // Each wait takes a fixed duration + } + + private let mode: Mode + private var currentTime: Int64 + private let lock = NSLock() + private var pendingCallbacks: [(time: Int64, callback: @Sendable () -> Void)] = [] + private var pendingWaiters: [CheckedContinuation] = [] + + public init(mode: Mode = .instant, initialTime: Int64 = 0) { + self.mode = mode + currentTime = initialTime + } + + public func now() -> Int64 { + lock.lock() + defer { lock.unlock() } + return currentTime + } + + /// Helper to update time in a thread-safe manner (called from non-async contexts) + private func incrementTime(by milliseconds: Int64) { + lock.lock() + currentTime += milliseconds + lock.unlock() + } + + /// Helper to add a waiter (called from withCheckedContinuation) + private func addWaiter(_ continuation: CheckedContinuation) { + lock.lock() + pendingWaiters.append(continuation) + lock.unlock() + } + + public func wait(milliseconds: Int64) async { + switch mode { + case .instant: + incrementTime(by: milliseconds) + return + + case .manual: + await withCheckedContinuation { continuation in + self.addWaiter(continuation) + } + + case let .delayed(delay): + try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000) + incrementTime(by: milliseconds) + } + } + + public func schedule(after milliseconds: Int64, callback: @escaping @Sendable () -> Void) { + lock.lock() + let targetTime = currentTime + milliseconds + pendingCallbacks.append((time: targetTime, callback: callback)) + lock.unlock() + + if case .instant = mode { + advance(by: milliseconds) + } + } + + /// Advance time by the specified number of milliseconds. + /// Triggers any scheduled callbacks that should fire. + public func advance(by milliseconds: Int64) { + lock.lock() + currentTime += milliseconds + + // Find and execute callbacks that should fire + let toExecute = pendingCallbacks.filter { $0.time <= currentTime } + pendingCallbacks.removeAll { $0.time <= currentTime } + + // Resume any pending waiters + let waiters = pendingWaiters + pendingWaiters.removeAll() + lock.unlock() + + for item in toExecute.sorted(by: { $0.time < $1.time }) { + item.callback() + } + + for waiter in waiters { + waiter.resume() + } + } + + /// Set the current time to a specific value. + public func setTime(_ time: Int64) { + lock.lock() + currentTime = time + lock.unlock() + } + + /// Reset time to zero and clear pending callbacks. + public func reset() { + lock.lock() + currentTime = 0 + pendingCallbacks.removeAll() + let waiters = pendingWaiters + pendingWaiters.removeAll() + lock.unlock() + + for waiter in waiters { + waiter.resume() + } + } + + /// Get count of pending scheduled callbacks. + public var pendingCallbackCount: Int { + lock.lock() + defer { lock.unlock() } + return pendingCallbacks.count + } + + /// Get count of pending waiters. + public var pendingWaiterCount: Int { + lock.lock() + defer { lock.unlock() } + return pendingWaiters.count + } +} + +// MARK: - Mutable Perf Entry + +/// Internal mutable timing entry for building up timing data. +class MutablePerfEntry { + let name: String + let startTime: Int64 + var endTime: Int64? + var children: [MutablePerfEntry] = [] + let isParallel: Bool + + init(name: String, startTime: Int64, isParallel: Bool = false) { + self.name = name + self.startTime = startTime + self.isParallel = isParallel + } + + func toTiming(timeProvider: TimeProvider) -> PerfTiming { + let duration = (endTime ?? timeProvider.currentTimeMillis()) - startTime + let childTimings: [PerfTiming]? = children.isEmpty ? nil : children + .map { $0.toTiming(timeProvider: timeProvider) } + return PerfTiming(name: name, durationMs: duration, children: childTimings) + } +} + +// MARK: - PerfProvider + +/// Thread-safe provider for accumulating performance timing data. +/// +/// Usage: +/// ```swift +/// let perf = PerfProvider.instance +/// +/// // Track an operation +/// let result = perf.track("operationName") { +/// // do work +/// return someValue +/// } +/// +/// // Or manually track +/// perf.startOperation("operationName") +/// // do work +/// perf.endOperation("operationName") +/// +/// // When sending a WebSocket message, flush all timing data +/// let timings = perf.flush() +/// ``` +public class PerfProvider { + // MARK: - Singleton + + // Using nonisolated(unsafe) because thread safety is managed manually via instanceLock + private nonisolated(unsafe) static var _instance: PerfProvider? + private static let instanceLock = NSLock() + + public static var instance: PerfProvider { + instanceLock.lock() + defer { instanceLock.unlock() } + + if _instance == nil { + _instance = PerfProvider() + } + return _instance! + } + + /// For testing - allows injecting a custom TimeProvider. + public static func createForTesting(timeProvider: TimeProvider) -> PerfProvider { + return PerfProvider(timeProvider: timeProvider) + } + + /// Reset the singleton instance (for testing). + public static func resetInstance() { + instanceLock.lock() + defer { instanceLock.unlock() } + _instance = nil + } + + // MARK: - Properties + + private let timeProvider: TimeProvider + private let lock = NSLock() + + /// Stack of active timing entries (for nested operations) + private var entryStack: [MutablePerfEntry] = [] + + /// Root entries that have been completed + private var completedEntries: [MutablePerfEntry] = [] + + /// Current active root entry + private var currentRoot: MutablePerfEntry? + + // Debounce tracking + private var debounceCount = 0 + private var lastDebounceTime: Int64? + + // MARK: - Init + + private init(timeProvider: TimeProvider = SystemTimeProvider()) { + self.timeProvider = timeProvider + } + + // MARK: - Serial/Parallel Blocks + + /// Start a serial block (operations run sequentially). + public func serial(_ name: String) { + lock.lock() + defer { lock.unlock() } + + let now = timeProvider.currentTimeMillis() + let entry = MutablePerfEntry(name: name, startTime: now, isParallel: false) + + if let parent = entryStack.last { + parent.children.append(entry) + } else { + currentRoot = entry + } + entryStack.append(entry) + + #if DEBUG + print("[PerfProvider] Started serial block: \(name)") + #endif + } + + /// Start a new independent root block, ending any currently open blocks first. + /// Use this for operations that may run concurrently and should be tracked as + /// parallel/sibling entries rather than nested within each other. + public func independentRoot(_ name: String) { + lock.lock() + defer { lock.unlock() } + + // End all open entries - they become completed entries (parallel siblings) + while !entryStack.isEmpty { + endInternal() + } + + // Start fresh root + let now = timeProvider.currentTimeMillis() + let entry = MutablePerfEntry(name: name, startTime: now, isParallel: false) + currentRoot = entry + entryStack.append(entry) + + #if DEBUG + print("[PerfProvider] Started independent root: \(name)") + #endif + } + + /// Start a parallel block (operations run concurrently). + public func parallel(_ name: String) { + lock.lock() + defer { lock.unlock() } + + let now = timeProvider.currentTimeMillis() + let entry = MutablePerfEntry(name: name, startTime: now, isParallel: true) + + if let parent = entryStack.last { + parent.children.append(entry) + } else { + currentRoot = entry + } + entryStack.append(entry) + + #if DEBUG + print("[PerfProvider] Started parallel block: \(name)") + #endif + } + + /// End the current block. + public func end() { + lock.lock() + defer { lock.unlock() } + endInternal() + } + + /// Internal end without locking (called by other locked methods). + private func endInternal() { + let now = timeProvider.currentTimeMillis() + + guard let entry = entryStack.popLast() else { + #if DEBUG + print("[PerfProvider] end() called with no active block") + #endif + return + } + + entry.endTime = now + + #if DEBUG + print("[PerfProvider] Ended block: \(entry.name) (\(now - entry.startTime)ms)") + #endif + + // If this was the root entry, move it to completed + if entryStack.isEmpty, currentRoot === entry { + completedEntries.append(entry) + currentRoot = nil + } + } + + // MARK: - Track Operations + + /// Track an operation with automatic start/end timing. Returns the result of the block. + @discardableResult + public func track(_ name: String, block: () throws -> T) rethrows -> T { + startOperation(name) + defer { endOperation(name) } + return try block() + } + + /// Track an async operation with automatic start/end timing. + @discardableResult + public func trackAsync(_ name: String, block: () async throws -> T) async rethrows -> T { + startOperation(name) + defer { endOperation(name) } + return try await block() + } + + /// Start tracking an operation manually. + public func startOperation(_ name: String) { + lock.lock() + defer { lock.unlock() } + + let now = timeProvider.currentTimeMillis() + let entry = MutablePerfEntry(name: name, startTime: now) + + if let parent = entryStack.last { + parent.children.append(entry) + entryStack.append(entry) + } else { + // No active block, this becomes a root entry + currentRoot = entry + entryStack.append(entry) + } + + #if DEBUG + print("[PerfProvider] Started operation: \(name)") + #endif + } + + /// End tracking an operation manually. + public func endOperation(_ name: String) { + lock.lock() + defer { lock.unlock() } + + let now = timeProvider.currentTimeMillis() + + // Find the matching entry in the stack + guard let entry = entryStack.last, entry.name == name else { + #if DEBUG + print( + "[PerfProvider] endOperation(\(name)) called but current entry is \(entryStack.last?.name ?? "nil")" + ) + #endif + return + } + + entry.endTime = now + _ = entryStack.popLast() + + #if DEBUG + print("[PerfProvider] Ended operation: \(name) (\(now - entry.startTime)ms)") + #endif + + // If this was the root entry, move it to completed + if entryStack.isEmpty, currentRoot === entry { + completedEntries.append(entry) + currentRoot = nil + } + } + + // MARK: - Debounce Tracking + + /// Record a debounce event (when hierarchy updates are debounced). + public func recordDebounce() { + lock.lock() + defer { lock.unlock() } + + debounceCount += 1 + lastDebounceTime = timeProvider.currentTimeMillis() + + #if DEBUG + print("[PerfProvider] Debounce recorded (total: \(debounceCount))") + #endif + } + + // MARK: - Flush and Query + + /// Flush all accumulated timing data and reset. + /// Returns the timing data as an array for inclusion in WebSocket messages. + public func flush() -> [PerfTiming]? { + lock.lock() + defer { lock.unlock() } + + // End any incomplete entries + while !entryStack.isEmpty { + endInternal() + } + + // Collect all completed entries + var entries: [PerfTiming] = [] + for entry in completedEntries { + entries.append(entry.toTiming(timeProvider: timeProvider)) + } + completedEntries.removeAll() + + // Include debounce info if any + if debounceCount > 0 { + let debounceInfo = PerfTiming( + name: "debounce", + durationMs: 0, + children: [ + PerfTiming.timing("count", durationMs: Int64(debounceCount)), + PerfTiming.timing("lastTime", durationMs: lastDebounceTime ?? 0), + ] + ) + entries.append(debounceInfo) + debounceCount = 0 + lastDebounceTime = nil + } + + return entries.isEmpty ? nil : entries + } + + /// Get current timing data without clearing (for debugging). + public func peek() -> [PerfTiming] { + lock.lock() + defer { lock.unlock() } + + var entries: [PerfTiming] = [] + + // Include current root if any + if let root = currentRoot { + entries.append(root.toTiming(timeProvider: timeProvider)) + } + + // Include completed entries + for entry in completedEntries { + entries.append(entry.toTiming(timeProvider: timeProvider)) + } + + return entries + } + + /// Check if there's any accumulated timing data. + public var hasData: Bool { + lock.lock() + defer { lock.unlock() } + return !completedEntries.isEmpty || currentRoot != nil || debounceCount > 0 + } + + /// Clear all timing data without returning it. + public func clear() { + lock.lock() + defer { lock.unlock() } + + entryStack.removeAll() + completedEntries.removeAll() + currentRoot = nil + debounceCount = 0 + lastDebounceTime = nil + } +} diff --git a/ios/XCTestService/Sources/XCTestService/PerformanceMetrics.swift b/ios/XCTestService/Sources/XCTestService/PerformanceMetrics.swift new file mode 100644 index 000000000..e7b4d9b03 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/PerformanceMetrics.swift @@ -0,0 +1,213 @@ +import Foundation + +// MARK: - Performance Snapshot + +/// A snapshot of performance metrics at a point in time. +public struct PerformanceSnapshot: Codable, Sendable { + /// Timestamp in milliseconds (epoch time) + public let timestamp: Int64 + + /// Frames per second (if available) + public let fps: Float? + + /// Frame time in milliseconds (if available) + public let frameTimeMs: Float? + + /// Number of janky frames (frames that took longer than expected) + public let jankFrames: Int? + + /// Touch response latency in milliseconds + public let touchLatencyMs: Float? + + /// Time to first frame in milliseconds (from app launch) + public let ttffMs: Float? + + /// Time to interactive in milliseconds + public let ttiMs: Float? + + /// CPU usage percentage (0-100) + public let cpuUsagePercent: Float? + + /// Memory usage in MB + public let memoryUsageMb: Float? + + /// Current screen/view controller name + public let screenName: String? + + public init( + timestamp: Int64, + fps: Float? = nil, + frameTimeMs: Float? = nil, + jankFrames: Int? = nil, + touchLatencyMs: Float? = nil, + ttffMs: Float? = nil, + ttiMs: Float? = nil, + cpuUsagePercent: Float? = nil, + memoryUsageMb: Float? = nil, + screenName: String? = nil + ) { + self.timestamp = timestamp + self.fps = fps + self.frameTimeMs = frameTimeMs + self.jankFrames = jankFrames + self.touchLatencyMs = touchLatencyMs + self.ttffMs = ttffMs + self.ttiMs = ttiMs + self.cpuUsagePercent = cpuUsagePercent + self.memoryUsageMb = memoryUsageMb + self.screenName = screenName + } +} + +// MARK: - Performance Metrics Provider Protocol + +/// Protocol for collecting performance metrics. +/// Implementations provide platform-specific metric collection. +public protocol PerformanceMetricsProvider { + /// Collect a snapshot of current performance metrics. + /// Returns nil if metrics cannot be collected. + func collectMetrics() async -> PerformanceSnapshot? + + /// Start continuous monitoring with periodic callbacks. + /// The callback will be invoked at regular intervals with new metrics. + func startMonitoring(callback: @escaping @Sendable (PerformanceSnapshot) -> Void) + + /// Stop continuous monitoring. + func stopMonitoring() + + /// Check if monitoring is currently active. + var isMonitoring: Bool { get } +} + +// MARK: - No-Op Implementation + +/// No-op implementation of PerformanceMetricsProvider. +/// Returns empty/nil results for all operations. +/// Use this as a placeholder until platform-specific implementation is available. +public class NoOpPerformanceMetricsProvider: PerformanceMetricsProvider { + private var _isMonitoring = false + private let lock = NSLock() + + public init() {} + + public func collectMetrics() async -> PerformanceSnapshot? { + // No-op: Return nil to indicate metrics are not available + return nil + } + + public func startMonitoring(callback _: @escaping @Sendable (PerformanceSnapshot) -> Void) { + lock.lock() + defer { lock.unlock() } + // No-op: Set flag but don't actually monitor + _isMonitoring = true + } + + public func stopMonitoring() { + lock.lock() + defer { lock.unlock() } + _isMonitoring = false + } + + public var isMonitoring: Bool { + lock.lock() + defer { lock.unlock() } + return _isMonitoring + } +} + +// MARK: - Fake Implementation for Testing + +/// Fake implementation for testing that returns configurable metrics. +public class FakePerformanceMetricsProvider: PerformanceMetricsProvider { + private var _isMonitoring = false + private var monitoringCallback: (@Sendable (PerformanceSnapshot) -> Void)? + private var monitoringTask: Task? + private let lock = NSLock() + + /// The snapshot to return from collectMetrics() + public var nextSnapshot: PerformanceSnapshot? + + /// Interval between monitoring callbacks in milliseconds + public var monitoringIntervalMs: Int64 = 1000 + + /// Time provider for generating timestamps + private let timeProvider: TimeProvider + + public init(timeProvider: TimeProvider = SystemTimeProvider()) { + self.timeProvider = timeProvider + } + + public func collectMetrics() async -> PerformanceSnapshot? { + return nextSnapshot ?? PerformanceSnapshot( + timestamp: timeProvider.currentTimeMillis(), + fps: 60, + frameTimeMs: 16.67, + jankFrames: 0, + cpuUsagePercent: 10, + memoryUsageMb: 100 + ) + } + + public func startMonitoring(callback: @escaping @Sendable (PerformanceSnapshot) -> Void) { + lock.lock() + _isMonitoring = true + monitoringCallback = callback + lock.unlock() + + // Start a background task to emit metrics + monitoringTask = Task { [weak self] in + while !Task.isCancelled { + guard let self = self else { break } + + // Read monitoring state synchronously (outside async context) + let (shouldContinue, cb, intervalMs) = self.getMonitoringState() + + guard shouldContinue else { break } + + let snapshot = await self.collectMetrics() + if let snapshot = snapshot, let cb = cb { + cb(snapshot) + } + + try? await Task.sleep(nanoseconds: UInt64(intervalMs) * 1_000_000) + } + } + } + + /// Helper to get monitoring state synchronously. + private func getMonitoringState() + -> (isMonitoring: Bool, callback: (@Sendable (PerformanceSnapshot) -> Void)?, intervalMs: Int64) + { + lock.lock() + let monitoring = _isMonitoring + let cb = monitoringCallback + let interval = monitoringIntervalMs + lock.unlock() + return (monitoring, cb, interval) + } + + public func stopMonitoring() { + lock.lock() + _isMonitoring = false + monitoringCallback = nil + lock.unlock() + + monitoringTask?.cancel() + monitoringTask = nil + } + + public var isMonitoring: Bool { + lock.lock() + defer { lock.unlock() } + return _isMonitoring + } + + /// Manually emit a snapshot to the monitoring callback. + /// Useful for testing specific scenarios. + public func emitSnapshot(_ snapshot: PerformanceSnapshot) { + lock.lock() + let cb = monitoringCallback + lock.unlock() + cb?(snapshot) + } +} diff --git a/ios/XCTestService/Sources/XCTestService/Protocols.swift b/ios/XCTestService/Sources/XCTestService/Protocols.swift new file mode 100644 index 000000000..1a9ed7fdb --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/Protocols.swift @@ -0,0 +1,131 @@ +import Foundation + +// MARK: - ElementLocator Protocol + +/// Protocol for locating UI elements and building view hierarchies +public protocol ElementLocating { + /// Get the full view hierarchy + /// - Parameter disableAllFiltering: If true, skip hierarchy optimization and return raw hierarchy + func getViewHierarchy(disableAllFiltering: Bool) throws -> ViewHierarchy + + /// Find element by resource ID / accessibility identifier + func findElement(byResourceId resourceId: String) -> Any? + + /// Find element by text content + func findElement(byText text: String) -> Any? + + /// Track a bundle ID so the foreground app detector can find it + func trackObservedBundleId(_ bundleId: String) +} + +// MARK: - GesturePerformer Protocol + +/// Protocol for performing gestures and interactions +public protocol GesturePerforming { + // MARK: - Tap Gestures + + /// Tap at coordinates with optional duration for long press + func tap(x: Double, y: Double, duration: TimeInterval) throws + + /// Double tap at coordinates + func doubleTap(x: Double, y: Double) throws + + /// Long press at coordinates + func longPress(x: Double, y: Double, duration: TimeInterval) throws + + // MARK: - Swipe Gestures + + /// Swipe from start to end coordinates + func swipe(startX: Double, startY: Double, endX: Double, endY: Double, duration: TimeInterval) throws + + // MARK: - Drag Gestures + + /// Drag with press, drag, and hold durations + func drag( + startX: Double, startY: Double, + endX: Double, endY: Double, + pressDuration: TimeInterval, + dragDuration: TimeInterval, + holdDuration: TimeInterval + ) + throws + + // MARK: - Pinch Gestures + + /// Pinch at center with scale + func pinch(centerX: Double, centerY: Double, scale: Double, duration: TimeInterval) throws + + // MARK: - Text Input + + /// Type text using keyboard + func typeText(text: String) throws + + /// Set text on a specific element + func setText(resourceId: String, text: String) throws + + /// Clear text from element or focused field + func clearText(resourceId: String?) throws + + /// Select all text + func selectAll() throws + + /// Perform IME action (done, next, search, etc.) + func performImeAction(_ action: String) throws + + // MARK: - Actions + + /// Perform action on element + func performAction(_ action: String, resourceId: String?) throws + + // MARK: - Screenshots + + /// Capture screenshot + func getScreenshot() throws -> Data + + // MARK: - Device Control + + /// Set device orientation + func setOrientation(_ orientation: String) throws + + /// Get current orientation + func getOrientation() -> String + + /// Press home button + func pressHome() throws + + // MARK: - App Control + + /// Launch app by bundle ID + func launchApp(bundleId: String) throws + + /// Terminate app by bundle ID + func terminateApp(bundleId: String) throws + + /// Activate app by bundle ID + func activateApp(bundleId: String) throws +} + +// MARK: - WebSocket Server Protocol + +/// Protocol for WebSocket server operations +public protocol WebSocketServing { + /// Whether the server is running + var isRunning: Bool { get } + + /// Start the server + func start() throws + + /// Stop the server + func stop() + + /// Broadcast data to all connected clients + func broadcast(_ data: Data) +} + +// MARK: - Command Handler Protocol + +/// Protocol for handling WebSocket commands +public protocol CommandHandling { + /// Handle a request and return response + func handle(_ request: WebSocketRequest) -> Any +} diff --git a/ios/XCTestService/Sources/XCTestService/WebSocketServer.swift b/ios/XCTestService/Sources/XCTestService/WebSocketServer.swift new file mode 100644 index 000000000..dd83f4ebe --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/WebSocketServer.swift @@ -0,0 +1,564 @@ +import Foundation +import Network + +/// WebSocket server for XCTestService +/// Implements RFC 6455 WebSocket protocol over TCP +public class WebSocketServer: WebSocketServing { + public enum ServerError: Error { + case alreadyRunning + case failedToStart(Error) + case encodingError + } + + private var listener: NWListener? + private var connections: [Int: WebSocketConnection] = [:] + private var nextConnectionId = 1 + private let port: UInt16 + private let commandHandler: CommandHandler + private let perfProvider: PerfProvider + private let queue = DispatchQueue(label: "com.xctestservice.server") + + public var isRunning: Bool { + listener != nil + } + + public init( + port: UInt16 = 8765, + commandHandler: CommandHandler, + perfProvider: PerfProvider = PerfProvider.instance + ) { + self.port = port + self.commandHandler = commandHandler + self.perfProvider = perfProvider + } + + /// Starts the server + public func start() throws { + guard listener == nil else { + throw ServerError.alreadyRunning + } + + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + + do { + listener = try NWListener(using: parameters, on: NWEndpoint.Port(integerLiteral: port)) + } catch { + throw ServerError.failedToStart(error) + } + + listener?.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("[WebSocketServer] Server ready on port \(self?.port ?? 0)") + case let .failed(error): + print("[WebSocketServer] Server failed: \(error)") + self?.stop() + case .cancelled: + print("[WebSocketServer] Server cancelled") + default: + break + } + } + + listener?.newConnectionHandler = { [weak self] connection in + self?.handleNewConnection(connection) + } + + listener?.start(queue: queue) + print("[WebSocketServer] Starting server on port \(port)...") + } + + /// Stops the server + public func stop() { + connections.values.forEach { $0.close() } + connections.removeAll() + listener?.cancel() + listener = nil + print("[WebSocketServer] Server stopped") + } + + // MARK: - Connection Handling + + private func handleNewConnection(_ nwConnection: NWConnection) { + let connectionId = nextConnectionId + nextConnectionId += 1 + + print("[WebSocketServer] New connection #\(connectionId) from \(nwConnection.endpoint)") + + let connection = WebSocketConnection( + id: connectionId, + connection: nwConnection, + queue: queue + ) { [weak self] message in + self?.handleMessage(message, connectionId: connectionId) + } onClose: { [weak self] in + self?.connections.removeValue(forKey: connectionId) + print("[WebSocketServer] Connection #\(connectionId) closed") + } + + connections[connectionId] = connection + connection.start() + } + + private func handleMessage(_ data: Data, connectionId: Int) { + guard let connection = connections[connectionId] else { return } + + do { + let request = try JSONDecoder().decode(WebSocketRequest.self, from: data) + print("[WebSocketServer] Received: \(request.type)") + + // Track the entire request handling with PerfProvider + perfProvider.serial("handleRequest:\(request.type)") + let startTime = Date() + + let response = commandHandler.handle(request) + let totalTimeMs = Int64(Date().timeIntervalSince(startTime) * 1000) + + perfProvider.end() + + // Flush perf timing data and encode response + let perfTiming = flushPerfTiming() + let responseData = try encodeResponse(response, totalTimeMs: totalTimeMs, perfTiming: perfTiming) + connection.send(responseData) + + } catch { + print("[WebSocketServer] Error handling message: \(error)") + perfProvider.clear() // Clear any partial timing data on error + let errorResponse = WebSocketResponse.error( + type: "error", + requestId: nil, + error: error.localizedDescription + ) + if let data = try? JSONEncoder().encode(errorResponse) { + connection.send(data) + } + } + } + + /// Flush accumulated perf timing data and return as a single PerfTiming entry + private func flushPerfTiming() -> PerfTiming? { + guard let timings = perfProvider.flush(), !timings.isEmpty else { + return nil + } + + // If there's only one entry, return it directly + if timings.count == 1 { + return timings[0] + } + + // If multiple entries, wrap them in a parent + let totalDuration = timings.reduce(0) { $0 + $1.durationMs } + return PerfTiming(name: "total", durationMs: totalDuration, children: timings) + } + + private func encodeResponse(_ response: Any, totalTimeMs: Int64, perfTiming: PerfTiming?) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + + if var wsResponse = response as? WebSocketResponse { + // Inject perfTiming if present and response doesn't already have it + if perfTiming != nil && wsResponse.perfTiming == nil { + wsResponse = WebSocketResponse( + type: wsResponse.type, + timestamp: wsResponse.timestamp, + requestId: wsResponse.requestId, + success: wsResponse.success, + totalTimeMs: wsResponse.totalTimeMs ?? totalTimeMs, + error: wsResponse.error, + perfTiming: perfTiming + ) + } + return try encoder.encode(wsResponse) + } else if var hierarchyResponse = response as? HierarchyUpdateResponse { + // Inject perfTiming if present and response doesn't already have it + if perfTiming != nil && hierarchyResponse.perfTiming == nil { + hierarchyResponse = HierarchyUpdateResponse( + requestId: hierarchyResponse.requestId, + data: hierarchyResponse.data, + perfTiming: perfTiming, + error: hierarchyResponse.error + ) + } + return try encoder.encode(hierarchyResponse) + } else if let screenshotResponse = response as? ScreenshotResponse { + return try encoder.encode(screenshotResponse) + } else if let encodable = response as? Encodable { + return try encoder.encode(AnyEncodable(encodable)) + } else { + throw ServerError.encodingError + } + } + + /// Broadcast a message to all connected clients + public func broadcast(_ data: Data) { + for connection in connections.values { + connection.send(data) + } + } + + /// Broadcast a hierarchy update to all connected clients (push notification) + public func broadcastHierarchyUpdate(_ hierarchy: ViewHierarchy) { + let response = HierarchyUpdateResponse( + requestId: nil, // No requestId for push updates + data: hierarchy, + perfTiming: nil + ) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(response) + broadcast(data) + print("[WebSocketServer] Broadcast hierarchy update to \(connections.count) client(s)") + } catch { + print("[WebSocketServer] Failed to encode hierarchy update: \(error)") + } + } + + /// Broadcast a performance update to all connected clients (push notification) + public func broadcastPerformanceUpdate(_ snapshot: PerformanceSnapshot) { + guard connections.count > 0 else { return } + + let response = PerformanceUpdateResponse(data: snapshot) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(response) + broadcast(data) + // Only log occasionally to avoid spam (snapshot contains timestamp) + if Int(snapshot.timestamp) % 5000 < 500 { + print("[WebSocketServer] Broadcast performance update: fps=\(snapshot.fps ?? 0), frameTime=\(snapshot.frameTimeMs ?? 0)ms") + } + } catch { + print("[WebSocketServer] Failed to encode performance update: \(error)") + } + } + + /// Broadcast a message with perf timing data included. + /// Flushes accumulated perf data and injects it into the message via the builder. + /// - Parameter messageBuilder: Function that takes optional perfTiming and returns message data + public func broadcastWithPerf(_ messageBuilder: (PerfTiming?) throws -> Data) rethrows { + let perfTiming = flushPerfTiming() + let data = try messageBuilder(perfTiming) + broadcast(data) + } + + /// Get access to the perf provider for tracking operations + public var perf: PerfProvider { + perfProvider + } +} + +// MARK: - WebSocket Connection + +/// Handles a single WebSocket connection with handshake and framing +class WebSocketConnection { + let id: Int + private let connection: NWConnection + private let queue: DispatchQueue + private let onMessage: (Data) -> Void + private let onClose: () -> Void + private var isWebSocketUpgraded = false + + init( + id: Int, + connection: NWConnection, + queue: DispatchQueue, + onMessage: @escaping (Data) -> Void, + onClose: @escaping () -> Void + ) { + self.id = id + self.connection = connection + self.queue = queue + self.onMessage = onMessage + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.receiveHTTPUpgrade() + case .failed, .cancelled: + self?.onClose() + default: + break + } + } + + connection.start(queue: queue) + } + + func close() { + connection.cancel() + } + + func send(_ data: Data) { + let frame = createWebSocketFrame(data: data, opcode: 0x01) // Text frame + connection.send(content: frame, completion: .contentProcessed { error in + if let error = error { + print("[WebSocketConnection] Send error: \(error)") + } + }) + } + + // MARK: - WebSocket Handshake + + private func receiveHTTPUpgrade() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in + guard let self = self else { return } + + if let error = error { + print("[WebSocketConnection] Error: \(error)") + self.onClose() + return + } + + if isComplete { + self.onClose() + return + } + + guard let data = data, let request = String(data: data, encoding: .utf8) else { + self.receiveHTTPUpgrade() + return + } + + if request.contains("Upgrade: websocket") || request.contains("upgrade: websocket") { + self.handleWebSocketUpgrade(request) + } else if request.contains("GET /health") { + self.handleHealthCheck() + } else { + // Not a WebSocket request, try again + self.receiveHTTPUpgrade() + } + } + } + + private func handleHealthCheck() { + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}" + connection.send(content: response.data(using: .utf8), completion: .contentProcessed { [weak self] _ in + self?.connection.cancel() + }) + } + + private func handleWebSocketUpgrade(_ request: String) { + // Extract Sec-WebSocket-Key + guard let keyLine = request.split(separator: "\r\n") + .first(where: { $0.lowercased().hasPrefix("sec-websocket-key:") }), + let key = keyLine.split(separator: ":").last?.trimmingCharacters(in: .whitespaces) + else { + print("[WebSocketConnection] Missing Sec-WebSocket-Key") + connection.cancel() + return + } + + // Calculate accept key + let magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + let acceptKey = (key + magic).data(using: .utf8)!.sha1().base64EncodedString() + + // Send upgrade response + let response = """ + HTTP/1.1 101 Switching Protocols\r + Upgrade: websocket\r + Connection: Upgrade\r + Sec-WebSocket-Accept: \(acceptKey)\r + \r + + """ + + connection.send(content: response.data(using: .utf8), completion: .contentProcessed { [weak self] error in + if let error = error { + print("[WebSocketConnection] Upgrade send error: \(error)") + self?.onClose() + return + } + + self?.isWebSocketUpgraded = true + self?.sendConnectedEvent() + self?.receiveWebSocketFrame() + }) + } + + private func sendConnectedEvent() { + let event = ConnectedEvent(id: id) + if let data = try? JSONEncoder().encode(event) { + send(data) + } + } + + // MARK: - WebSocket Frame Handling + + private func receiveWebSocketFrame() { + // Read first 2 bytes (header) + connection.receive(minimumIncompleteLength: 2, maximumLength: 2) { [weak self] data, _, isComplete, error in + guard let self = self else { return } + + if let error = error { + print("[WebSocketConnection] Frame error: \(error)") + self.onClose() + return + } + + if isComplete { + self.onClose() + return + } + + guard let headerData = data, headerData.count == 2 else { + self.receiveWebSocketFrame() + return + } + + self.parseWebSocketFrame(headerData) + } + } + + private func parseWebSocketFrame(_ header: Data) { + let byte0 = header[0] + let byte1 = header[1] + + let opcode = byte0 & 0x0F + let isMasked = (byte1 & 0x80) != 0 + let payloadLength = UInt64(byte1 & 0x7F) + + // Handle close frame + if opcode == 0x08 { + sendCloseFrame() + onClose() + return + } + + // Handle ping + if opcode == 0x09 { + // Send pong + let pongFrame = createWebSocketFrame(data: Data(), opcode: 0x0A) + connection.send(content: pongFrame, completion: .contentProcessed { _ in }) + receiveWebSocketFrame() + return + } + + // Read extended length if needed + if payloadLength == 126 { + readExtendedLength(2, isMasked: isMasked, opcode: opcode) + } else if payloadLength == 127 { + readExtendedLength(8, isMasked: isMasked, opcode: opcode) + } else { + readPayload(length: payloadLength, isMasked: isMasked, opcode: opcode) + } + } + + private func readExtendedLength(_ bytes: Int, isMasked: Bool, opcode: UInt8) { + connection.receive(minimumIncompleteLength: bytes, maximumLength: bytes) { [weak self] data, _, _, _ in + guard let self = self, let data = data else { + self?.onClose() + return + } + + var length: UInt64 = 0 + for byte in data { + length = length << 8 | UInt64(byte) + } + + self.readPayload(length: length, isMasked: isMasked, opcode: opcode) + } + } + + private func readPayload(length: UInt64, isMasked: Bool, opcode: UInt8) { + let maskLength = isMasked ? 4 : 0 + let totalLength = Int(length) + maskLength + + guard totalLength > 0 else { + receiveWebSocketFrame() + return + } + + connection + .receive(minimumIncompleteLength: totalLength, maximumLength: totalLength) { [weak self] data, _, _, _ in + guard let self = self, let data = data else { + self?.onClose() + return + } + + var payload: Data + if isMasked { + let mask = Array(data.prefix(4)) + let maskedData = data.suffix(from: 4) + var unmasked = Data() + for (i, byte) in maskedData.enumerated() { + unmasked.append(byte ^ mask[i % 4]) + } + payload = unmasked + } else { + payload = data + } + + // Handle text or binary frame + if opcode == 0x01 || opcode == 0x02 { + self.onMessage(payload) + } + + self.receiveWebSocketFrame() + } + } + + private func createWebSocketFrame(data: Data, opcode: UInt8) -> Data { + var frame = Data() + + // FIN + opcode + frame.append(0x80 | opcode) + + // Payload length (server doesn't mask) + if data.count < 126 { + frame.append(UInt8(data.count)) + } else if data.count < 65536 { + frame.append(126) + frame.append(UInt8((data.count >> 8) & 0xFF)) + frame.append(UInt8(data.count & 0xFF)) + } else { + frame.append(127) + for i in (0 ..< 8).reversed() { + frame.append(UInt8((data.count >> (i * 8)) & 0xFF)) + } + } + + frame.append(data) + return frame + } + + private func sendCloseFrame() { + let frame = createWebSocketFrame(data: Data(), opcode: 0x08) + connection.send(content: frame, completion: .contentProcessed { _ in }) + } +} + +// MARK: - SHA1 Extension + +extension Data { + func sha1() -> Data { + var digest = [UInt8](repeating: 0, count: 20) + _ = withUnsafeBytes { bytes in + CC_SHA1(bytes.baseAddress, CC_LONG(self.count), &digest) + } + return Data(digest) + } +} + +// CommonCrypto import for SHA1 +import CommonCrypto + +// MARK: - AnyEncodable Helper + +struct AnyEncodable: Encodable { + private let _encode: (Encoder) throws -> Void + + init(_ wrapped: T) { + _encode = wrapped.encode + } + + func encode(to encoder: Encoder) throws { + try _encode(encoder) + } +} diff --git a/ios/XCTestService/Sources/XCTestService/XCTestService.swift b/ios/XCTestService/Sources/XCTestService/XCTestService.swift new file mode 100644 index 000000000..74c3b1db1 --- /dev/null +++ b/ios/XCTestService/Sources/XCTestService/XCTestService.swift @@ -0,0 +1,129 @@ +import Foundation +#if canImport(XCTest) && os(iOS) + import XCTest +#endif + +/// Main XCTestService that coordinates WebSocket server, element locator, and gesture performer +/// Similar to Appium's WebDriverAgent but matching Android AccessibilityService protocol +public class XCTestService { + public static let defaultPort: UInt16 = 8765 + + private let server: WebSocketServer + private let elementLocator: ElementLocator + private let gesturePerformer: GesturePerformer + private let commandHandler: CommandHandler + private let hierarchyDebouncer: HierarchyDebouncer + private let fpsMonitor: DisplayLinkFPSMonitor + + #if canImport(XCTest) && os(iOS) + private var application: XCUIApplication? + #endif + + /// Creates the service with specified port + public init(port: UInt16 = defaultPort, timer: Timer = SystemTimer()) { + elementLocator = ElementLocator() + gesturePerformer = GesturePerformer(elementLocator: elementLocator) + commandHandler = CommandHandler( + elementLocator: elementLocator, + gesturePerformer: gesturePerformer + ) + server = WebSocketServer(port: port, commandHandler: commandHandler) + hierarchyDebouncer = HierarchyDebouncer(elementLocator: elementLocator, timer: timer) + fpsMonitor = DisplayLinkFPSMonitor() + } + + #if canImport(XCTest) && os(iOS) + /// Default bundle ID to use when none is specified (iOS Springboard/home screen) + public static let defaultBundleId = "com.apple.springboard" + + /// Sets the application under test with its bundle ID + public func setApplication(_ app: XCUIApplication, bundleId: String? = nil) { + application = app + if let bundleId = bundleId { + elementLocator.setApplication(app, bundleId: bundleId) + } else { + elementLocator.setApplication(app) + } + gesturePerformer.setApplication(app) + } + + /// Activates the target application and starts the service + public func start(bundleId: String? = nil) throws { + // Activate or connect to target app + // Use provided bundleId, or default to Springboard (home screen) + let targetBundleId = bundleId ?? Self.defaultBundleId + let app = XCUIApplication(bundleIdentifier: targetBundleId) + app.activate() + setApplication(app, bundleId: targetBundleId) + print("[XCTestService] Activated app: \(targetBundleId)") + + // Start the server + try server.start() + + // Wire up hierarchy debouncer to broadcast updates when content changes + hierarchyDebouncer.setOnResult { [weak self] result in + switch result { + case let .changed(hierarchy, hash, extractionTimeMs): + print( + "[XCTestService] Hierarchy changed (hash=\(hash), extraction=\(extractionTimeMs)ms), broadcasting" + ) + self?.server.broadcastHierarchyUpdate(hierarchy) + case .unchanged: + // Don't broadcast unchanged results (animation mode) + break + case let .error(message): + print("[XCTestService] Hierarchy extraction error: \(message)") + } + } + hierarchyDebouncer.start() + + // Start FPS monitoring and broadcast updates to connected clients + fpsMonitor.startMonitoring { [weak self] snapshot in + self?.server.broadcastPerformanceUpdate(snapshot) + } + + print("[XCTestService] Service started") + print("[XCTestService] WebSocket server listening on port \(Self.defaultPort)") + print("[XCTestService] Endpoint: ws://localhost:\(Self.defaultPort)/ws") + print("[XCTestService] Health check: http://localhost:\(Self.defaultPort)/health") + print( + "[XCTestService] Hierarchy debouncer active (polling every \(HierarchyDebouncer.defaultPollIntervalMs)ms)" + ) + print("[XCTestService] FPS monitor active (reporting every \(DisplayLinkFPSMonitor.defaultReportIntervalSeconds)s)") + print("[XCTestService] Ready to accept connections") + } + #else + public func start(bundleId _: String? = nil) throws { + try server.start() + print("[XCTestService] Service started (non-iOS mode - limited functionality)") + } + #endif + + /// Stops the service + public func stop() { + fpsMonitor.stopMonitoring() + hierarchyDebouncer.stop() + server.stop() + print("[XCTestService] Service stopped") + } + + +} + +// MARK: - Convenience Extensions + +extension XCTestService { + /// Creates and starts a service with default configuration + public static func startDefault() throws -> XCTestService { + let service = XCTestService() + try service.start() + return service + } + + /// Creates and starts a service for a specific app + public static func start(bundleId: String, port: UInt16 = defaultPort) throws -> XCTestService { + let service = XCTestService(port: port) + try service.start(bundleId: bundleId) + return service + } +} diff --git a/ios/XCTestService/Tests/XCTestServiceTests/CommandHandlerTests.swift b/ios/XCTestService/Tests/XCTestServiceTests/CommandHandlerTests.swift new file mode 100644 index 000000000..c7a4d90f0 --- /dev/null +++ b/ios/XCTestService/Tests/XCTestServiceTests/CommandHandlerTests.swift @@ -0,0 +1,312 @@ +import XCTest +@testable import XCTestService + +final class CommandHandlerTests: XCTestCase { + var fakeTimeProvider: FakeTimeProvider! + var perfProvider: PerfProvider! + var fakeElementLocator: FakeElementLocator! + var fakeGesturePerformer: FakeGesturePerformer! + var commandHandler: CommandHandler! + + override func setUp() { + super.setUp() + fakeTimeProvider = FakeTimeProvider(initialTime: 1000) + perfProvider = PerfProvider.createForTesting(timeProvider: fakeTimeProvider) + fakeElementLocator = FakeElementLocator() + fakeGesturePerformer = FakeGesturePerformer() + commandHandler = CommandHandler.createForTesting( + elementLocator: fakeElementLocator, + gesturePerformer: fakeGesturePerformer, + perfProvider: perfProvider + ) + } + + override func tearDown() { + perfProvider.clear() + PerfProvider.resetInstance() + super.tearDown() + } + + // MARK: - Hierarchy Request Tests + + func testRequestHierarchyIncludesPerfTiming() { + // Configure fake hierarchy + let testHierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Test Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeElementLocator.setHierarchy(testHierarchy) + + // Create request + let request = WebSocketRequest( + type: "request_hierarchy", + requestId: "test-123" + ) + + // Simulate time passing during extraction + fakeTimeProvider.setTime(1000) + + // Handle request + let response = commandHandler.handle(request) + + // Verify response includes perf timing + guard let hierarchyResponse = response as? HierarchyUpdateResponse else { + XCTFail("Expected HierarchyUpdateResponse, got \(type(of: response))") + return + } + + XCTAssertEqual(hierarchyResponse.requestId, "test-123") + XCTAssertNotNil(hierarchyResponse.data) + XCTAssertEqual(hierarchyResponse.data?.packageName, "com.test.app") + + // Verify perf timing was captured + // Note: The timing will be 0ms since we're using fakes, but the structure should be there + XCTAssertNotNil(hierarchyResponse.perfTiming) + XCTAssertEqual(hierarchyResponse.perfTiming?.name, "handleRequestHierarchy") + } + + func testRequestHierarchyPerfTimingHasExtractionChild() { + // Configure fake to simulate time passage + fakeTimeProvider.setTime(1000) + + let testHierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Test Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeElementLocator.setHierarchy(testHierarchy) + + let request = WebSocketRequest( + type: "request_hierarchy", + requestId: "test-456" + ) + + let response = commandHandler.handle(request) + + guard let hierarchyResponse = response as? HierarchyUpdateResponse else { + XCTFail("Expected HierarchyUpdateResponse") + return + } + + // Verify perf timing structure includes extraction child + let perfTiming = hierarchyResponse.perfTiming + XCTAssertNotNil(perfTiming) + XCTAssertEqual(perfTiming?.name, "handleRequestHierarchy") + + // Should have extraction as a child + let extractionChild = perfTiming?.children?.first { $0.name == "extraction" } + XCTAssertNotNil(extractionChild, "Expected 'extraction' child in perf timing") + } + + func testRequestHierarchyError() { + // Configure fake to throw error + fakeElementLocator.setShouldThrow(CommandError.executionFailed("Test error")) + + let request = WebSocketRequest( + type: "request_hierarchy", + requestId: "test-error" + ) + + let response = commandHandler.handle(request) + + guard let errorResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse error") + return + } + + XCTAssertFalse(errorResponse.success ?? true) + XCTAssertNotNil(errorResponse.error) + XCTAssertTrue(errorResponse.error?.contains("Test error") ?? false) + } + + // MARK: - Tap Tests + + func testTapCoordinatesSuccess() { + let request = WebSocketRequest( + type: "request_tap_coordinates", + requestId: "tap-123", + x: 100, + y: 200 + ) + + let response = commandHandler.handle(request) + + guard let tapResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertEqual(tapResponse.success, true) + XCTAssertEqual(tapResponse.type, "tap_coordinates_result") + + // Verify tap was performed + let tapHistory = fakeGesturePerformer.getTapHistory() + XCTAssertEqual(tapHistory.count, 1) + XCTAssertEqual(tapHistory.first?.x, 100) + XCTAssertEqual(tapHistory.first?.y, 200) + } + + func testTapCoordinatesMissingParameters() { + let request = WebSocketRequest( + type: "request_tap_coordinates", + requestId: "tap-error" + // Missing x, y + ) + + let response = commandHandler.handle(request) + + guard let errorResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertFalse(errorResponse.success ?? true) + XCTAssertNotNil(errorResponse.error) + XCTAssertTrue(errorResponse.error?.contains("x and y") ?? false) + } + + // MARK: - Swipe Tests + + func testSwipeSuccess() { + let request = WebSocketRequest( + type: "request_swipe", + requestId: "swipe-123", + duration: 300, + x1: 100, + y1: 200, + x2: 100, + y2: 500 + ) + + let response = commandHandler.handle(request) + + guard let swipeResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertEqual(swipeResponse.success, true) + + // Verify swipe was performed + let swipeHistory = fakeGesturePerformer.getSwipeHistory() + XCTAssertEqual(swipeHistory.count, 1) + XCTAssertEqual(swipeHistory.first?.startY, 200) + XCTAssertEqual(swipeHistory.first?.endY, 500) + } + + // MARK: - Text Input Tests + + func testSetTextSuccess() { + let request = WebSocketRequest( + type: "request_set_text", + requestId: "text-123", + text: "Hello World" + ) + + let response = commandHandler.handle(request) + + guard let textResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertEqual(textResponse.success, true) + + // Verify text was typed + let textHistory = fakeGesturePerformer.getTypeTextHistory() + XCTAssertEqual(textHistory.count, 1) + XCTAssertEqual(textHistory.first, "Hello World") + } + + func testSetTextWithResourceId() { + let request = WebSocketRequest( + type: "request_set_text", + requestId: "text-456", + text: "Field Text", + resourceId: "input_field" + ) + + let response = commandHandler.handle(request) + + guard let textResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertEqual(textResponse.success, true) + + // Verify setText was called (not typeText) + let setTextHistory = fakeGesturePerformer.getSetTextHistory() + XCTAssertEqual(setTextHistory.count, 1) + XCTAssertEqual(setTextHistory.first?.text, "Field Text") + XCTAssertEqual(setTextHistory.first?.resourceId, "input_field") + } + + // MARK: - Device Control Tests + + func testPressHomeSuccess() { + let request = WebSocketRequest( + type: "request_press_home", + requestId: "home-123" + ) + + let response = commandHandler.handle(request) + + guard let homeResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertEqual(homeResponse.success, true) + XCTAssertEqual(homeResponse.type, "press_home_result") + XCTAssertEqual(fakeGesturePerformer.getPressHomeCallCount(), 1) + } + + func testLaunchAppSuccess() { + let request = WebSocketRequest( + type: "request_launch_app", + requestId: "launch-123", + bundleId: "com.apple.Preferences" + ) + + let response = commandHandler.handle(request) + + guard let launchResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertEqual(launchResponse.success, true) + XCTAssertEqual(launchResponse.type, "launch_app_result") + XCTAssertEqual(fakeGesturePerformer.getAppLaunchHistory(), ["com.apple.Preferences"]) + XCTAssertEqual(fakeElementLocator.trackedBundleIds, ["com.apple.Preferences"]) + } + + // MARK: - Unknown Command Tests + + func testUnknownCommand() { + let request = WebSocketRequest( + type: "unknown_command", + requestId: "unknown-123" + ) + + let response = commandHandler.handle(request) + + guard let errorResponse = response as? WebSocketResponse else { + XCTFail("Expected WebSocketResponse") + return + } + + XCTAssertFalse(errorResponse.success ?? true) + XCTAssertTrue(errorResponse.error?.contains("Unknown command") ?? false) + } +} diff --git a/ios/XCTestService/Tests/XCTestServiceTests/HierarchyDebouncerTests.swift b/ios/XCTestService/Tests/XCTestServiceTests/HierarchyDebouncerTests.swift new file mode 100644 index 000000000..aa062b5d7 --- /dev/null +++ b/ios/XCTestService/Tests/XCTestServiceTests/HierarchyDebouncerTests.swift @@ -0,0 +1,531 @@ +import XCTest +@testable import XCTestService + +/// Simple reference wrapper for use in test closures to avoid Swift concurrency warnings. +private final class Box: @unchecked Sendable { + var value: T + init(_ value: T) { + self.value = value + } +} + +final class HierarchyDebouncerTests: XCTestCase { + var fakeLocator: FakeElementLocator! + var fakeTimer: FakeTimer! + var debouncer: HierarchyDebouncer! + + override func setUp() { + super.setUp() + fakeLocator = FakeElementLocator() + fakeTimer = FakeTimer(mode: .manual, initialTime: 0) + debouncer = HierarchyDebouncer( + elementLocator: fakeLocator, + timer: fakeTimer, + pollIntervalMs: 10 + ) + } + + override func tearDown() { + debouncer.stop() + fakeTimer.reset() + super.tearDown() + } + + // MARK: - Polling Resilience Tests + + func testContinuesPollingAfterExtractionError() { + // Configure locator to throw on first calls + let testError = NSError(domain: "test", code: 1, userInfo: nil) + fakeLocator.setShouldThrow(testError) + + let results = Box<[HierarchyResult]>([]) + debouncer.setOnResult { result in + results.value.append(result) + } + + debouncer.start() + + // Initial capture should fail (throws), but debouncer should still be running + XCTAssertTrue(debouncer.isRunning) + + // Advance past the poll interval to trigger a poll cycle - still throwing + fakeTimer.advance(by: 10) + XCTAssertTrue(debouncer.isRunning, "Debouncer should keep running after extraction error") + XCTAssertEqual(results.value.count, 0, "No results should be emitted during errors") + + // Now stop throwing and provide a hierarchy + fakeLocator.setShouldThrow(nil) + let hierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy) + + // Advance past debounce window so broadcast is allowed + fakeTimer.advance(by: HierarchyDebouncer.broadcastDebounceMs + 10) + + // Should have recovered and emitted a result + XCTAssertTrue(debouncer.isRunning, "Debouncer should still be running after recovery") + XCTAssertEqual(results.value.count, 1, "Should emit result after recovery from error") + } + + func testStartCapturesInitialState() { + let hierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy) + + debouncer.start() + + XCTAssertTrue(debouncer.isRunning) + let lastHierarchy = debouncer.getLastHierarchy() + XCTAssertNotNil(lastHierarchy) + XCTAssertEqual(lastHierarchy?.packageName, "com.test.app") + } + + func testStartBroadcastsInitialState() { + let hierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy) + + let results = Box<[HierarchyResult]>([]) + debouncer.setOnResult { result in + results.value.append(result) + } + + debouncer.start() + + // Initial state should be broadcast immediately via onResult + XCTAssertEqual(results.value.count, 1, "Initial state should be broadcast on start") + if case let .changed(h, _, _) = results.value.first { + XCTAssertEqual(h.packageName, "com.test.app") + } else { + XCTFail("Expected .changed result for initial broadcast") + } + } + + func testStopPreventsPolling() { + debouncer.start() + XCTAssertTrue(debouncer.isRunning) + + debouncer.stop() + XCTAssertFalse(debouncer.isRunning) + + // Advancing timer should not trigger any polling + let initialCount = fakeLocator.hierarchyRequestCount + fakeTimer.advance(by: 100) + XCTAssertEqual(fakeLocator.hierarchyRequestCount, initialCount) + } + + func testExtractNowBlockingReturnsHierarchy() { + let hierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy) + + debouncer.start() + let result = debouncer.extractNowBlocking(skipFlowEmit: true) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.packageName, "com.test.app") + } + + func testDetectsStructuralChange() { + let hierarchy1 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy1) + + let results = Box<[HierarchyResult]>([]) + debouncer.setOnResult { result in + results.value.append(result) + } + + debouncer.start() + + // Change hierarchy to include a new element (structural change) + let hierarchy2 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "New Button", + className: "UIButton", + bounds: ElementBounds(left: 10, top: 10, right: 100, bottom: 50), + clickable: "true", + role: "button" + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy2) + + // Advance past debounce window + fakeTimer.advance(by: HierarchyDebouncer.broadcastDebounceMs + 10) + + // 2 results: initial broadcast + structural change + XCTAssertEqual(results.value.count, 2) + if case let .changed(hierarchy, _, _) = results.value.last { + XCTAssertEqual(hierarchy.packageName, "com.test.app") + } else { + XCTFail("Expected .changed result") + } + } + + func testResetClearsState() { + let hierarchy = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy) + + debouncer.start() + XCTAssertNotNil(debouncer.getLastHierarchy()) + + debouncer.reset() + XCTAssertNil(debouncer.getLastHierarchy()) + } + + // MARK: - Debounce Resilience Tests + + func testDebouncedChangeIsEventuallyBroadcast() { + // This tests the fix for a bug where the last change in a rapid sequence + // could be silently dropped: the hash was updated but the broadcast was + // debounced, so subsequent polls saw no change and entered animation mode. + + let hierarchy1 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812) + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy1) + + let results = Box<[HierarchyResult]>([]) + debouncer.setOnResult { result in + results.value.append(result) + } + + debouncer.start() + + // First change - advance past debounce to broadcast + let hierarchy2 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "Button A", + className: "UIButton", + bounds: ElementBounds(left: 10, top: 10, right: 100, bottom: 50), + clickable: "true", + role: "button" + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy2) + fakeTimer.advance(by: HierarchyDebouncer.broadcastDebounceMs + 10) + XCTAssertEqual(results.value.count, 2, "Initial broadcast + first change should be broadcast") + + // Second change immediately after - within debounce window + // This simulates a permission dialog appearing right after a UI change + let hierarchy3 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "Button A", + className: "UIButton", + bounds: ElementBounds(left: 10, top: 10, right: 100, bottom: 50), + clickable: "true", + role: "button" + ), + UIElementInfo( + text: "Permission Dialog", + className: "UIAlertController", + bounds: ElementBounds(left: 20, top: 300, right: 355, bottom: 500), + clickable: "true" + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + fakeLocator.setHierarchy(hierarchy3) + + // Advance just past poll interval but within debounce window (should detect but not broadcast yet) + fakeTimer.advance(by: 10) + XCTAssertEqual(results.value.count, 2, "Change within debounce window should not broadcast yet") + + // Now advance past the debounce window - the change MUST eventually be broadcast + fakeTimer.advance(by: HierarchyDebouncer.broadcastDebounceMs + 10) + XCTAssertEqual(results.value.count, 3, "Debounced change must eventually be broadcast") + + // Verify the broadcast contains the dialog + if case let .changed(hierarchy, _, _) = results.value.last { + let hasDialog = hierarchy.hierarchy?.node?.contains { $0.text == "Permission Dialog" } ?? false + XCTAssertTrue(hasDialog, "Broadcast should contain the permission dialog") + } else { + XCTFail("Expected .changed result") + } + } + + // MARK: - StructuralHasher Tests + + func testHashChangesWhenAlertNodesAdded() { + let hierarchyWithoutAlert = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "XCUIApplication", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "Hello", + className: "UILabel", + bounds: ElementBounds(left: 10, top: 10, right: 200, bottom: 30), + role: "text" + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hierarchyWithAlert = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "XCUIApplication", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "Hello", + className: "UILabel", + bounds: ElementBounds(left: 10, top: 10, right: 200, bottom: 30), + role: "text" + ), + UIElementInfo( + text: "\u{201c}App\u{201d} Would Like to Send You Notifications", + className: "UIAlertController", + bounds: ElementBounds(left: 20, top: 300, right: 355, bottom: 500), + node: [ + UIElementInfo( + text: "Allow", + className: "UIButton", + bounds: ElementBounds(left: 20, top: 450, right: 180, bottom: 490), + clickable: "true", + role: "button" + ), + UIElementInfo( + text: "Don\u{2019}t Allow", + className: "UIButton", + bounds: ElementBounds(left: 190, top: 450, right: 355, bottom: 490), + clickable: "true", + role: "button" + ), + ] + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hashWithout = StructuralHasher.computeHash(hierarchyWithoutAlert) + let hashWith = StructuralHasher.computeHash(hierarchyWithAlert) + + XCTAssertNotEqual(hashWithout, hashWith, "Hash should change when alert nodes are added to hierarchy") + } + + func testHashUnchangedForBoundsOnlyDifference() { + let hierarchy1 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "Hello", + className: "UILabel", + bounds: ElementBounds(left: 10, top: 10, right: 200, bottom: 30) + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + // Same structure, different bounds (simulating animation) + let hierarchy2 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + text: "Hello", + className: "UILabel", + bounds: ElementBounds(left: 15, top: 15, right: 205, bottom: 35) + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hash1 = StructuralHasher.computeHash(hierarchy1) + let hash2 = StructuralHasher.computeHash(hierarchy2) + + XCTAssertEqual(hash1, hash2, "Hash should be the same when only bounds differ (animation)") + } + + func testHashChangesWhenContentDescChanges() { + let hierarchy1 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + contentDesc: "Balance: $100", + className: "UILabel", + bounds: ElementBounds(left: 10, top: 10, right: 200, bottom: 30) + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hierarchy2 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Root", + className: "UIView", + bounds: ElementBounds(left: 0, top: 0, right: 375, bottom: 812), + node: [ + UIElementInfo( + contentDesc: "Balance: $200", + className: "UILabel", + bounds: ElementBounds(left: 10, top: 10, right: 200, bottom: 30) + ), + ] + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hash1 = StructuralHasher.computeHash(hierarchy1) + let hash2 = StructuralHasher.computeHash(hierarchy2) + + XCTAssertNotEqual(hash1, hash2, "Hash should change when contentDesc changes") + } + + func testHashChangesWhenClickableStateChanges() { + let hierarchy1 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Submit", + className: "UIButton", + bounds: ElementBounds(left: 10, top: 10, right: 100, bottom: 50), + clickable: "true", + enabled: "true" + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hierarchy2 = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Submit", + className: "UIButton", + bounds: ElementBounds(left: 10, top: 10, right: 100, bottom: 50), + clickable: "false", + enabled: "false" + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hash1 = StructuralHasher.computeHash(hierarchy1) + let hash2 = StructuralHasher.computeHash(hierarchy2) + + XCTAssertNotEqual(hash1, hash2, "Hash should change when clickable/enabled state changes") + } + + func testHashChangesWhenCheckedStateChanges() { + let hierarchyUnchecked = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Wi-Fi", + className: "UISwitch", + bounds: ElementBounds(left: 280, top: 100, right: 340, bottom: 130), + checkable: "true", + checked: nil + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hierarchyChecked = ViewHierarchy( + packageName: "com.test.app", + hierarchy: UIElementInfo( + text: "Wi-Fi", + className: "UISwitch", + bounds: ElementBounds(left: 280, top: 100, right: 340, bottom: 130), + checkable: "true", + checked: "true" + ), + windowInfo: WindowInfo(id: 0, type: 1, isActive: true, isFocused: true) + ) + + let hashUnchecked = StructuralHasher.computeHash(hierarchyUnchecked) + let hashChecked = StructuralHasher.computeHash(hierarchyChecked) + + XCTAssertNotEqual(hashUnchecked, hashChecked, "Hash should change when checked state changes") + } +} diff --git a/ios/XCTestService/Tests/XCTestServiceTests/ModelsTests.swift b/ios/XCTestService/Tests/XCTestServiceTests/ModelsTests.swift new file mode 100644 index 000000000..ace6c44aa --- /dev/null +++ b/ios/XCTestService/Tests/XCTestServiceTests/ModelsTests.swift @@ -0,0 +1,307 @@ +import XCTest +@testable import XCTestService + +final class ModelsTests: XCTestCase { + // MARK: - PerfTiming Tests + + func testPerfTimingEncoding() throws { + let timing = PerfTiming(name: "test", durationMs: 100) + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(timing) + let json = String(data: data, encoding: .utf8) + + XCTAssertEqual(json, #"{"durationMs":100,"name":"test"}"#) + } + + func testPerfTimingWithChildren() throws { + let child1 = PerfTiming(name: "child1", durationMs: 30) + let child2 = PerfTiming(name: "child2", durationMs: 20) + let parent = PerfTiming(name: "parent", durationMs: 100, children: [child1, child2]) + + let encoder = JSONEncoder() + let data = try encoder.encode(parent) + let decoded = try JSONDecoder().decode(PerfTiming.self, from: data) + + XCTAssertEqual(decoded.name, "parent") + XCTAssertEqual(decoded.durationMs, 100) + XCTAssertEqual(decoded.children?.count, 2) + XCTAssertEqual(decoded.children?[0].name, "child1") + XCTAssertEqual(decoded.children?[1].name, "child2") + } + + func testPerfTimingConvenienceMethods() { + let simple = PerfTiming.timing("simple", durationMs: 50) + XCTAssertNil(simple.children) + + let withChildren = PerfTiming.timing("parent", durationMs: 100, children: [ + PerfTiming.timing("child", durationMs: 30), + ]) + XCTAssertNotNil(withChildren.children) + XCTAssertEqual(withChildren.children?.count, 1) + } + + // MARK: - WebSocketRequest Tests + + func testWebSocketRequestDecoding() throws { + let json = """ + { + "type": "request_tap_coordinates", + "requestId": "req-123", + "x": 100, + "y": 200, + "duration": 50 + } + """ + + let request = try JSONDecoder().decode(WebSocketRequest.self, from: XCTUnwrap(json.data(using: .utf8))) + + XCTAssertEqual(request.type, "request_tap_coordinates") + XCTAssertEqual(request.requestId, "req-123") + XCTAssertEqual(request.x, 100) + XCTAssertEqual(request.y, 200) + XCTAssertEqual(request.duration, 50) + } + + func testWebSocketRequestSwipeDecoding() throws { + let json = """ + { + "type": "request_swipe", + "x1": 100, + "y1": 200, + "x2": 300, + "y2": 400, + "duration": 300 + } + """ + + let request = try JSONDecoder().decode(WebSocketRequest.self, from: XCTUnwrap(json.data(using: .utf8))) + + XCTAssertEqual(request.type, "request_swipe") + XCTAssertEqual(request.x1, 100) + XCTAssertEqual(request.y1, 200) + XCTAssertEqual(request.x2, 300) + XCTAssertEqual(request.y2, 400) + XCTAssertEqual(request.duration, 300) + } + + func testWebSocketRequestDragDecoding() throws { + let json = """ + { + "type": "request_drag", + "x1": 100, + "y1": 200, + "x2": 300, + "y2": 400, + "pressDurationMs": 600, + "dragDurationMs": 300, + "holdDurationMs": 100 + } + """ + + let request = try JSONDecoder().decode(WebSocketRequest.self, from: XCTUnwrap(json.data(using: .utf8))) + + XCTAssertEqual(request.type, "request_drag") + XCTAssertEqual(request.pressDurationMs, 600) + XCTAssertEqual(request.dragDurationMs, 300) + XCTAssertEqual(request.holdDurationMs, 100) + } + + // MARK: - WebSocketResponse Tests + + func testWebSocketResponseSuccess() { + let response = WebSocketResponse.success( + type: "tap_coordinates_result", + requestId: "req-123", + totalTimeMs: 50 + ) + + XCTAssertEqual(response.type, "tap_coordinates_result") + XCTAssertEqual(response.requestId, "req-123") + XCTAssertEqual(response.success, true) + XCTAssertEqual(response.totalTimeMs, 50) + XCTAssertNil(response.error) + } + + func testWebSocketResponseError() { + let response = WebSocketResponse.error( + type: "tap_coordinates_result", + requestId: "req-123", + error: "Element not found" + ) + + XCTAssertEqual(response.type, "tap_coordinates_result") + XCTAssertEqual(response.success, false) + XCTAssertEqual(response.error, "Element not found") + } + + func testWebSocketResponseWithPerfTiming() throws { + let perfTiming = PerfTiming(name: "total", durationMs: 100, children: [ + PerfTiming(name: "find", durationMs: 30), + PerfTiming(name: "tap", durationMs: 70), + ]) + + let response = WebSocketResponse( + type: "tap_coordinates_result", + requestId: "req-123", + success: true, + totalTimeMs: 100, + perfTiming: perfTiming + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(response) + let decoded = try JSONDecoder().decode(WebSocketResponse.self, from: data) + + XCTAssertNotNil(decoded.perfTiming) + XCTAssertEqual(decoded.perfTiming?.name, "total") + XCTAssertEqual(decoded.perfTiming?.children?.count, 2) + } + + // MARK: - HierarchyUpdateResponse Tests + + func testHierarchyUpdateResponseEncoding() throws { + let hierarchy = ViewHierarchy( + packageName: "com.example.app", + hierarchy: UIElementInfo( + text: "Hello", + className: "UILabel", + bounds: ElementBounds(left: 0, top: 0, right: 100, bottom: 50) + ) + ) + + let response = HierarchyUpdateResponse( + requestId: "req-456", + data: hierarchy + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(response) + let decoded = try JSONDecoder().decode(HierarchyUpdateResponse.self, from: data) + + XCTAssertEqual(decoded.type, "hierarchy_update") + XCTAssertEqual(decoded.requestId, "req-456") + XCTAssertEqual(decoded.data?.packageName, "com.example.app") + XCTAssertEqual(decoded.data?.hierarchy?.text, "Hello") + } + + // MARK: - UIElementInfo Tests + + func testUIElementInfoEncoding() throws { + let element = UIElementInfo( + text: "Button", + contentDesc: "Submit button", + resourceId: "com.example:id/submit", + className: "UIButton", + bounds: ElementBounds(left: 10, top: 20, right: 110, bottom: 70), + clickable: "true", + enabled: "true" + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(element) + let jsonObject = try JSONSerialization.jsonObject(with: data) + let json = try XCTUnwrap(jsonObject as? [String: Any]) + + // Check that content-desc uses hyphenated key (Android format) + XCTAssertNotNil(json["content-desc"]) + XCTAssertEqual(json["content-desc"] as? String, "Submit button") + + // Check that resource-id uses hyphenated key + XCTAssertNotNil(json["resource-id"]) + XCTAssertEqual(json["resource-id"] as? String, "com.example:id/submit") + } + + func testUIElementInfoDecoding() throws { + let json = """ + { + "text": "Label", + "content-desc": "Description", + "resource-id": "com.example:id/label", + "className": "UILabel", + "bounds": {"left": 0, "top": 0, "right": 100, "bottom": 50}, + "clickable": "false", + "enabled": "true" + } + """ + + let element = try JSONDecoder().decode(UIElementInfo.self, from: XCTUnwrap(json.data(using: .utf8))) + + XCTAssertEqual(element.text, "Label") + XCTAssertEqual(element.contentDesc, "Description") + XCTAssertEqual(element.resourceId, "com.example:id/label") + XCTAssertEqual(element.clickable, "false") + } + + // MARK: - ElementBounds Tests + + func testElementBoundsComputedProperties() { + let bounds = ElementBounds(left: 10, top: 20, right: 110, bottom: 70) + + XCTAssertEqual(bounds.width, 100) + XCTAssertEqual(bounds.height, 50) + XCTAssertEqual(bounds.centerX, 60) + XCTAssertEqual(bounds.centerY, 45) + } + + // MARK: - HighlightShape Tests + + func testHighlightShapeBox() throws { + let shape = HighlightShape( + type: "box", + bounds: HighlightBounds(x: 10, y: 20, width: 100, height: 50), + style: HighlightStyle(strokeColor: "#FF0000", strokeWidth: 2.0) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(shape) + let decoded = try JSONDecoder().decode(HighlightShape.self, from: data) + + XCTAssertEqual(decoded.type, "box") + XCTAssertEqual(decoded.bounds?.x, 10) + XCTAssertEqual(decoded.bounds?.width, 100) + XCTAssertEqual(decoded.style?.strokeColor, "#FF0000") + } + + func testHighlightShapePath() throws { + let shape = HighlightShape( + type: "path", + points: [ + HighlightPoint(x: 0, y: 0), + HighlightPoint(x: 100, y: 100), + HighlightPoint(x: 200, y: 50), + ] + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(shape) + let decoded = try JSONDecoder().decode(HighlightShape.self, from: data) + + XCTAssertEqual(decoded.type, "path") + XCTAssertEqual(decoded.points?.count, 3) + XCTAssertEqual(decoded.points?[1].x, 100) + XCTAssertEqual(decoded.points?[1].y, 100) + } + + // MARK: - RequestType Tests + + func testRequestTypeRawValues() { + XCTAssertEqual(RequestType.requestHierarchy.rawValue, "request_hierarchy") + XCTAssertEqual(RequestType.requestTapCoordinates.rawValue, "request_tap_coordinates") + XCTAssertEqual(RequestType.requestSwipe.rawValue, "request_swipe") + XCTAssertEqual(RequestType.requestDrag.rawValue, "request_drag") + XCTAssertEqual(RequestType.requestSetText.rawValue, "request_set_text") + XCTAssertEqual(RequestType.requestLaunchApp.rawValue, "request_launch_app") + } + + // MARK: - ResponseType Tests + + func testResponseTypeRawValues() { + XCTAssertEqual(ResponseType.hierarchyUpdate.rawValue, "hierarchy_update") + XCTAssertEqual(ResponseType.tapCoordinatesResult.rawValue, "tap_coordinates_result") + XCTAssertEqual(ResponseType.swipeResult.rawValue, "swipe_result") + XCTAssertEqual(ResponseType.screenshot.rawValue, "screenshot") + XCTAssertEqual(ResponseType.launchAppResult.rawValue, "launch_app_result") + } +} diff --git a/ios/XCTestService/Tests/XCTestServiceTests/PerfProviderTests.swift b/ios/XCTestService/Tests/XCTestServiceTests/PerfProviderTests.swift new file mode 100644 index 000000000..8054971d1 --- /dev/null +++ b/ios/XCTestService/Tests/XCTestServiceTests/PerfProviderTests.swift @@ -0,0 +1,314 @@ +import XCTest +@testable import XCTestService + +/// Simple reference wrapper for use in test closures to avoid Swift concurrency warnings +/// about mutation of captured variables. +private final class Box: @unchecked Sendable { + var value: T + init(_ value: T) { + self.value = value + } +} + +final class PerfProviderTests: XCTestCase { + var fakeTimeProvider: FakeTimeProvider! + var perfProvider: PerfProvider! + + override func setUp() { + super.setUp() + fakeTimeProvider = FakeTimeProvider(initialTime: 1000) + perfProvider = PerfProvider.createForTesting(timeProvider: fakeTimeProvider) + } + + override func tearDown() { + perfProvider.clear() + PerfProvider.resetInstance() + super.tearDown() + } + + // MARK: - Basic Operation Tests + + func testTrackSingleOperation() { + fakeTimeProvider.setTime(1000) + + perfProvider.startOperation("testOp") + + fakeTimeProvider.setTime(1050) + + perfProvider.endOperation("testOp") + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + XCTAssertEqual(timings?.count, 1) + XCTAssertEqual(timings?.first?.name, "testOp") + XCTAssertEqual(timings?.first?.durationMs, 50) + } + + func testTrackWithBlock() { + fakeTimeProvider.setTime(1000) + + let result = perfProvider.track("operation") { + fakeTimeProvider.advance(by: 100) + return "result" + } + + XCTAssertEqual(result, "result") + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + XCTAssertEqual(timings?.first?.durationMs, 100) + } + + func testNestedOperations() { + fakeTimeProvider.setTime(1000) + + perfProvider.startOperation("outer") + fakeTimeProvider.advance(by: 10) + + perfProvider.startOperation("inner") + fakeTimeProvider.advance(by: 50) + perfProvider.endOperation("inner") + + fakeTimeProvider.advance(by: 10) + perfProvider.endOperation("outer") + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + XCTAssertEqual(timings?.count, 1) + + let outer = timings?.first + XCTAssertEqual(outer?.name, "outer") + XCTAssertEqual(outer?.durationMs, 70) + XCTAssertEqual(outer?.children?.count, 1) + + let inner = outer?.children?.first + XCTAssertEqual(inner?.name, "inner") + XCTAssertEqual(inner?.durationMs, 50) + } + + // MARK: - Serial/Parallel Block Tests + + func testSerialBlock() { + fakeTimeProvider.setTime(1000) + + perfProvider.serial("serialBlock") + fakeTimeProvider.advance(by: 100) + perfProvider.end() + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + XCTAssertEqual(timings?.first?.name, "serialBlock") + XCTAssertEqual(timings?.first?.durationMs, 100) + } + + func testParallelBlock() { + fakeTimeProvider.setTime(1000) + + perfProvider.parallel("parallelBlock") + fakeTimeProvider.advance(by: 50) + perfProvider.end() + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + XCTAssertEqual(timings?.first?.name, "parallelBlock") + XCTAssertEqual(timings?.first?.durationMs, 50) + } + + func testIndependentRoot() { + fakeTimeProvider.setTime(1000) + + // Start first root + perfProvider.serial("first") + fakeTimeProvider.advance(by: 50) + + // Start independent root - should close first and start new + perfProvider.independentRoot("second") + fakeTimeProvider.advance(by: 30) + perfProvider.end() + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + XCTAssertEqual(timings?.count, 2) + + // First should be completed with 50ms + let first = timings?.first(where: { $0.name == "first" }) + XCTAssertNotNil(first) + XCTAssertEqual(first?.durationMs, 50) + + // Second should have 30ms + let second = timings?.first(where: { $0.name == "second" }) + XCTAssertNotNil(second) + XCTAssertEqual(second?.durationMs, 30) + } + + // MARK: - Debounce Tests + + func testDebounceTracking() { + perfProvider.recordDebounce() + perfProvider.recordDebounce() + perfProvider.recordDebounce() + + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + + let debounceInfo = timings?.first(where: { $0.name == "debounce" }) + XCTAssertNotNil(debounceInfo) + + let countChild = debounceInfo?.children?.first(where: { $0.name == "count" }) + XCTAssertEqual(countChild?.durationMs, 3) // 3 debounces recorded + } + + // MARK: - Flush and Clear Tests + + func testFlushReturnsNilWhenEmpty() { + let timings = perfProvider.flush() + XCTAssertNil(timings) + } + + func testFlushClearsData() { + perfProvider.startOperation("op") + fakeTimeProvider.advance(by: 10) + perfProvider.endOperation("op") + + _ = perfProvider.flush() + let secondFlush = perfProvider.flush() + XCTAssertNil(secondFlush) + } + + func testClear() { + perfProvider.startOperation("op") + perfProvider.clear() + + XCTAssertFalse(perfProvider.hasData) + XCTAssertNil(perfProvider.flush()) + } + + func testHasData() { + XCTAssertFalse(perfProvider.hasData) + + perfProvider.startOperation("op") + // hasData might be true during operation or after completion + perfProvider.endOperation("op") + + XCTAssertTrue(perfProvider.hasData) + } + + // MARK: - Peek Tests + + func testPeekDoesNotClearData() { + perfProvider.startOperation("op") + fakeTimeProvider.advance(by: 50) + perfProvider.endOperation("op") + + let peeked = perfProvider.peek() + XCTAssertEqual(peeked.count, 1) + + // Data should still be there + XCTAssertTrue(perfProvider.hasData) + let flushed = perfProvider.flush() + XCTAssertNotNil(flushed) + } + + // MARK: - Thread Safety Tests + + func testConcurrentOperations() { + let expectation = self.expectation(description: "concurrent operations") + expectation.expectedFulfillmentCount = 10 + + for i in 0 ..< 10 { + DispatchQueue.global().async { + self.perfProvider.startOperation("op\(i)") + Thread.sleep(forTimeInterval: 0.001) + self.perfProvider.endOperation("op\(i)") + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + + // Should not crash and should have data + let timings = perfProvider.flush() + XCTAssertNotNil(timings) + } +} + +// MARK: - FakeTimeProvider Tests + +final class FakeTimeProviderTests: XCTestCase { + func testInitialTime() { + let provider = FakeTimeProvider(initialTime: 5000) + XCTAssertEqual(provider.currentTimeMillis(), 5000) + } + + func testSetTime() { + let provider = FakeTimeProvider() + provider.setTime(10000) + XCTAssertEqual(provider.currentTimeMillis(), 10000) + } + + func testAdvance() { + let provider = FakeTimeProvider(initialTime: 1000) + provider.advance(by: 500) + XCTAssertEqual(provider.currentTimeMillis(), 1500) + } + + func testReset() { + let provider = FakeTimeProvider(initialTime: 5000) + provider.reset() + XCTAssertEqual(provider.currentTimeMillis(), 0) + } +} + +// MARK: - FakeTimer Tests + +final class FakeTimerTests: XCTestCase { + func testInstantMode() async { + let timer = FakeTimer(mode: .instant, initialTime: 1000) + + let before = timer.now() + await timer.wait(milliseconds: 100) + let after = timer.now() + + // Time should advance by the wait amount + XCTAssertEqual(after - before, 100) + } + + func testManualModeAdvance() { + let timer = FakeTimer(mode: .manual, initialTime: 0) + + let callbackFired = Box(false) + timer.schedule(after: 50) { + callbackFired.value = true + } + + XCTAssertFalse(callbackFired.value) + + timer.advance(by: 50) + XCTAssertTrue(callbackFired.value) + } + + func testScheduleMultipleCallbacks() { + let timer = FakeTimer(mode: .instant, initialTime: 0) + + let order = Box<[Int]>([]) + timer.schedule(after: 30) { order.value.append(2) } + timer.schedule(after: 10) { order.value.append(1) } + timer.schedule(after: 50) { order.value.append(3) } + + // In instant mode, callbacks fire immediately when scheduled + // They fire in schedule order, not target time order + XCTAssertEqual(order.value, [2, 1, 3]) + } + + func testReset() { + let timer = FakeTimer(mode: .manual, initialTime: 1000) + timer.schedule(after: 50) {} + + XCTAssertEqual(timer.pendingCallbackCount, 1) + + timer.reset() + + XCTAssertEqual(timer.now(), 0) + XCTAssertEqual(timer.pendingCallbackCount, 0) + } +} diff --git a/ios/XCTestService/Tests/XCTestServiceUITests/XCTestServiceUITests.swift b/ios/XCTestService/Tests/XCTestServiceUITests/XCTestServiceUITests.swift new file mode 100644 index 000000000..10c985c7f --- /dev/null +++ b/ios/XCTestService/Tests/XCTestServiceUITests/XCTestServiceUITests.swift @@ -0,0 +1,121 @@ +import XCTest + +// Note: XCTestService sources are compiled directly into this target (not imported as framework) +// This gives XCTest access for XCUIApplication support + +/// XCUITest runner that starts the XCTestService WebSocket server +/// Similar to Appium's WebDriverAgent, but matching Android AccessibilityService protocol +/// +/// Usage: +/// 1. Build and run this test target on a device/simulator +/// 2. The test will start the WebSocket server on port 8765 +/// 3. Connect your automation client to ws://localhost:8765/ws +/// 4. Send commands matching Android AccessibilityService protocol +/// +/// Environment Variables: +/// - XCTESTSERVICE_PORT: Server port (default: 8765) +/// - XCTESTSERVICE_BUNDLE_ID: Target app bundle ID (optional) +/// - XCTESTSERVICE_TIMEOUT: How long to keep server running in seconds (default: forever) +/// +final class XCTestServiceUITests: XCTestCase { + private var service: XCTestService? + + override func setUpWithError() throws { + continueAfterFailure = true + } + + override func tearDownWithError() throws { + service?.stop() + } + + /// Main test that starts the WebSocket server + /// This test runs indefinitely (or until timeout) to keep the server alive + func testRunService() throws { + // Get configuration from environment + let port = getPort() + let bundleId = getBundleId() + + print("========================================") + print(" XCTestService") + print("========================================") + print("Port: \(port)") + print("Bundle ID: \(bundleId ?? "default")") + print("Timeout: \(getTimeout().map { "\($0)s" } ?? "forever")") + print("========================================") + print("") + print("WebSocket: ws://localhost:\(port)/ws") + print("Health: http://localhost:\(port)/health") + print("") + print("Protocol: Android AccessibilityService compatible") + print("========================================") + + // Create and start service + service = XCTestService(port: port) + + if let bundleId = bundleId { + try service?.start(bundleId: bundleId) + } else { + try service?.start() + } + + // Keep the test alive using XCTWaiter instead of RunLoop spinning. + // The expectation is intentionally never fulfilled; .timedOut is the + // expected result for a normal timed shutdown. The process is killed + // externally by the MCP stop() path before the timeout elapses. + let keepAlive = expectation(description: "XCTestService keep-alive") + let result = XCTWaiter().wait(for: [keepAlive], timeout: getTimeout() ?? 86400) + XCTAssertEqual(result, .timedOut, "Expected service to run until timeout") + } + + /// Test that just verifies the service can start + func testServiceStarts() throws { + let service = XCTestService(port: 8766) + try service.start() + + // Give it a moment + Thread.sleep(forTimeInterval: 1.0) + + XCTAssertTrue(true, "Service started successfully") + + service.stop() + } + + /// Test that launches a specific app and starts the service + func testLaunchAppAndRunService() throws { + guard let bundleId = getBundleId() else { + throw XCTSkip("XCTESTSERVICE_BUNDLE_ID environment variable not set") + } + + service = XCTestService(port: getPort()) + try service?.start(bundleId: bundleId) + + let keepAlive = expectation(description: "XCTestService keep-alive") + let result = XCTWaiter().wait(for: [keepAlive], timeout: getTimeout() ?? 300) + XCTAssertEqual(result, .timedOut, "Expected service to run until timeout") + } + + // MARK: - Configuration Helpers + + private func getPort() -> UInt16 { + if let portString = ProcessInfo.processInfo.environment["XCTESTSERVICE_PORT"], + let port = UInt16(portString) + { + return port + } + return XCTestService.defaultPort + } + + private func getBundleId() -> String? { + return ProcessInfo.processInfo.environment["XCTESTSERVICE_BUNDLE_ID"] + } + + private func getTimeout() -> TimeInterval? { + if let timeoutString = ProcessInfo.processInfo.environment["XCTESTSERVICE_TIMEOUT"], + let timeout = TimeInterval(timeoutString) + { + return timeout + } + return nil + } +} + diff --git a/ios/XCTestService/XCTestService.xcodeproj/project.pbxproj b/ios/XCTestService/XCTestService.xcodeproj/project.pbxproj new file mode 100644 index 000000000..19920375e --- /dev/null +++ b/ios/XCTestService/XCTestService.xcodeproj/project.pbxproj @@ -0,0 +1,703 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 037DA42AFA18F5976D19A594 /* XCTestServiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0033B69189AE1222D06ACA8 /* XCTestServiceUITests.swift */; }; + 052E12A5C50F95E644253CEB /* Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BA6768C288E782FD4BF007 /* Protocols.swift */; }; + 0C0DA069D9B774797B08050D /* Fakes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3CE124F2A6CD97A0B61A20 /* Fakes.swift */; }; + 196923B8D693CBFA0F64FBE8 /* ElementLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA85B8D1F76B85F7A40A9AC1 /* ElementLocator.swift */; }; + 1CBED464F7D1B710B643881E /* DisplayLinkFPSMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA14AF39E5922831951BB12 /* DisplayLinkFPSMonitor.swift */; }; + 26BE4CDC25C50AF275B5D62D /* ElementLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA85B8D1F76B85F7A40A9AC1 /* ElementLocator.swift */; }; + 2CF1BFE1F9C343DDC3327B62 /* XCTestService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56827EE4350DFB34C181E6F9 /* XCTestService.swift */; }; + 2E3CACA8F6B100AA92191D14 /* PerfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2405456E61E2BE2AB9837DD /* PerfProvider.swift */; }; + 2EB2329D4CC93BCE10835479 /* GesturePerformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B65ACD5F3352EAF7DF305F8 /* GesturePerformer.swift */; }; + 3949205C0E67D8B10D030C9B /* CommandHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3129C5A03D1A6FDA2BA16205 /* CommandHandlerTests.swift */; }; + 3E431C908DCDEFB2E52F4564 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E0ED91DB46F9FC23B3868E /* Models.swift */; }; + 471BDC31826855445CB0FF30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B265512A37308C098B3E01 /* AppDelegate.swift */; }; + 5151C4235C549690408DE674 /* PerfProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3ADC91E330FEFCD9882A30A /* PerfProviderTests.swift */; }; + 551E811C748D7EB78D4353FD /* PerformanceMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7998D5E41C12A3CF4AEC9BD6 /* PerformanceMetrics.swift */; }; + 66FB888E218A443091A2DD8B /* XCTestService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56827EE4350DFB34C181E6F9 /* XCTestService.swift */; }; + 682BCA31038636F8DDBFEBA7 /* HierarchyDebouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86213D75803C8AA93B9583F /* HierarchyDebouncer.swift */; }; + 6BE1A8BAB50B6EC54287A6C6 /* ModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6802366B39546509F431F39 /* ModelsTests.swift */; }; + 76C282F48CB5169159D6A6D9 /* PerfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2405456E61E2BE2AB9837DD /* PerfProvider.swift */; }; + 99C289B4D07B9C159E98DB4E /* PerformanceMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7998D5E41C12A3CF4AEC9BD6 /* PerformanceMetrics.swift */; }; + 9CA64759740038CD0BACC740 /* WebSocketServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36DE1E4D00CEF199F076EBE /* WebSocketServer.swift */; }; + A056201FD1164D5BD940F478 /* CommandHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E6580240FAAEF11D3AE486 /* CommandHandler.swift */; }; + A296D70736925E1B71FCE929 /* XCTestService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9788C24D07660B4E2960BC91 /* XCTestService.framework */; }; + AC84EB7D0DB0475DCB749CEC /* XCTestService.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9788C24D07660B4E2960BC91 /* XCTestService.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + AFF0F4FB01A3EF5919FEDA22 /* GesturePerformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B65ACD5F3352EAF7DF305F8 /* GesturePerformer.swift */; }; + B52D39007BB9B35F65DA162B /* Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BA6768C288E782FD4BF007 /* Protocols.swift */; }; + B542B471C14DBC8AFD9DB912 /* HierarchyDebouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86213D75803C8AA93B9583F /* HierarchyDebouncer.swift */; }; + D6F565155DCCD47DE1D137F0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCBC1B4FA0889E01E6670015 /* Assets.xcassets */; }; + E4386D208A9A2B600DD0C5F0 /* CommandHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E6580240FAAEF11D3AE486 /* CommandHandler.swift */; }; + EFF6AEFF4C9BA793D21D1FE0 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E0ED91DB46F9FC23B3868E /* Models.swift */; }; + F16F89D0CE46B3F7EA09DC0E /* WebSocketServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36DE1E4D00CEF199F076EBE /* WebSocketServer.swift */; }; + F2C455F1A1F5B8F75E0A80EC /* XCTestService.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9788C24D07660B4E2960BC91 /* XCTestService.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + F7919FB4A4FA6F3332E736B4 /* DisplayLinkFPSMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA14AF39E5922831951BB12 /* DisplayLinkFPSMonitor.swift */; }; + FCEF451F5F925373E2FB5A2C /* XCTestService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9788C24D07660B4E2960BC91 /* XCTestService.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2B06022F7B31D6647E1A4DC1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA9C5717DADAEC2EAC38F4C6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 78D8EB901BF6BD6054C005AC; + remoteInfo = XCTestService; + }; + 5977F37AF1AAC4483E9B918F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA9C5717DADAEC2EAC38F4C6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 64ABA10AC1845842C74A47EF; + remoteInfo = XCTestServiceApp; + }; + 8788907F136A078FAF51E6E1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA9C5717DADAEC2EAC38F4C6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 78D8EB901BF6BD6054C005AC; + remoteInfo = XCTestService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 48F351B420D9ACF2359997B8 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + AC84EB7D0DB0475DCB749CEC /* XCTestService.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 8A21574994AAA70CC279B103 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + F2C455F1A1F5B8F75E0A80EC /* XCTestService.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1DB65B3413204854959BE7FF /* XCTestServiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = XCTestServiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 29DDC30D37A5903F163DD4E5 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 3129C5A03D1A6FDA2BA16205 /* CommandHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandHandlerTests.swift; sourceTree = ""; }; + 38BA6768C288E782FD4BF007 /* Protocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Protocols.swift; sourceTree = ""; }; + 3E3ECFB96E6F3A2EC111980D /* XCTestServiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = XCTestServiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 56827EE4350DFB34C181E6F9 /* XCTestService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestService.swift; sourceTree = ""; }; + 68B265512A37308C098B3E01 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7998D5E41C12A3CF4AEC9BD6 /* PerformanceMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetrics.swift; sourceTree = ""; }; + 7B65ACD5F3352EAF7DF305F8 /* GesturePerformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturePerformer.swift; sourceTree = ""; }; + 8098B3F86909EF6EDFF01D23 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 952BC86A5C17183D97D8BD16 /* XCTestServiceApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = XCTestServiceApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9788C24D07660B4E2960BC91 /* XCTestService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = XCTestService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9EA14AF39E5922831951BB12 /* DisplayLinkFPSMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLinkFPSMonitor.swift; sourceTree = ""; }; + A36DE1E4D00CEF199F076EBE /* WebSocketServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketServer.swift; sourceTree = ""; }; + B2405456E61E2BE2AB9837DD /* PerfProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfProvider.swift; sourceTree = ""; }; + B3ADC91E330FEFCD9882A30A /* PerfProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfProviderTests.swift; sourceTree = ""; }; + B6802366B39546509F431F39 /* ModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelsTests.swift; sourceTree = ""; }; + BF3CE124F2A6CD97A0B61A20 /* Fakes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fakes.swift; sourceTree = ""; }; + D86213D75803C8AA93B9583F /* HierarchyDebouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HierarchyDebouncer.swift; sourceTree = ""; }; + DCBC1B4FA0889E01E6670015 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets.xcassets; path = XCTestServiceApp/Assets.xcassets; sourceTree = SOURCE_ROOT; }; + E0033B69189AE1222D06ACA8 /* XCTestServiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestServiceUITests.swift; sourceTree = ""; }; + F0E0ED91DB46F9FC23B3868E /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + F7E6580240FAAEF11D3AE486 /* CommandHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandHandler.swift; sourceTree = ""; }; + FA85B8D1F76B85F7A40A9AC1 /* ElementLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementLocator.swift; sourceTree = ""; }; + FC0A6D748551BD93FA185962 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9D8497137D16D3914AC0B656 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A296D70736925E1B71FCE929 /* XCTestService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B11880505A08779A2A23B34E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FCEF451F5F925373E2FB5A2C /* XCTestService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 44A49ABA87C2EAAFDE2070BB /* Configurations */ = { + isa = PBXGroup; + children = ( + 29DDC30D37A5903F163DD4E5 /* Debug.xcconfig */, + 8098B3F86909EF6EDFF01D23 /* Release.xcconfig */, + ); + path = Configurations; + sourceTree = ""; + }; + A5FA5FAB58C57CFF316C0AEC = { + isa = PBXGroup; + children = ( + DCBC1B4FA0889E01E6670015 /* Assets.xcassets */, + 44A49ABA87C2EAAFDE2070BB /* Configurations */, + B2CE549121F095DF2D1DB9D4 /* XCTestService */, + FFCC9A6540DDA3D9FD505EB5 /* XCTestServiceApp */, + D1CD7B069AF7DC2F38D61B26 /* XCTestServiceTests */, + E1D296EDC3BEB4DD69F3EA7B /* XCTestServiceUITests */, + A8E97C96C2A803D4D0E756AB /* Products */, + ); + sourceTree = ""; + }; + A8E97C96C2A803D4D0E756AB /* Products */ = { + isa = PBXGroup; + children = ( + 9788C24D07660B4E2960BC91 /* XCTestService.framework */, + 952BC86A5C17183D97D8BD16 /* XCTestServiceApp.app */, + 1DB65B3413204854959BE7FF /* XCTestServiceTests.xctest */, + 3E3ECFB96E6F3A2EC111980D /* XCTestServiceUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + B2CE549121F095DF2D1DB9D4 /* XCTestService */ = { + isa = PBXGroup; + children = ( + F7E6580240FAAEF11D3AE486 /* CommandHandler.swift */, + 9EA14AF39E5922831951BB12 /* DisplayLinkFPSMonitor.swift */, + FA85B8D1F76B85F7A40A9AC1 /* ElementLocator.swift */, + BF3CE124F2A6CD97A0B61A20 /* Fakes.swift */, + 7B65ACD5F3352EAF7DF305F8 /* GesturePerformer.swift */, + D86213D75803C8AA93B9583F /* HierarchyDebouncer.swift */, + F0E0ED91DB46F9FC23B3868E /* Models.swift */, + 7998D5E41C12A3CF4AEC9BD6 /* PerformanceMetrics.swift */, + B2405456E61E2BE2AB9837DD /* PerfProvider.swift */, + 38BA6768C288E782FD4BF007 /* Protocols.swift */, + A36DE1E4D00CEF199F076EBE /* WebSocketServer.swift */, + 56827EE4350DFB34C181E6F9 /* XCTestService.swift */, + ); + name = XCTestService; + path = Sources/XCTestService; + sourceTree = ""; + }; + D1CD7B069AF7DC2F38D61B26 /* XCTestServiceTests */ = { + isa = PBXGroup; + children = ( + 3129C5A03D1A6FDA2BA16205 /* CommandHandlerTests.swift */, + B6802366B39546509F431F39 /* ModelsTests.swift */, + B3ADC91E330FEFCD9882A30A /* PerfProviderTests.swift */, + ); + name = XCTestServiceTests; + path = Tests/XCTestServiceTests; + sourceTree = ""; + }; + E1D296EDC3BEB4DD69F3EA7B /* XCTestServiceUITests */ = { + isa = PBXGroup; + children = ( + E0033B69189AE1222D06ACA8 /* XCTestServiceUITests.swift */, + ); + name = XCTestServiceUITests; + path = Tests/XCTestServiceUITests; + sourceTree = ""; + }; + FFCC9A6540DDA3D9FD505EB5 /* XCTestServiceApp */ = { + isa = PBXGroup; + children = ( + 68B265512A37308C098B3E01 /* AppDelegate.swift */, + FC0A6D748551BD93FA185962 /* Info.plist */, + ); + path = XCTestServiceApp; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 14E9F6D055EFE0BC4CE6073B /* XCTestServiceUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6432610C1E5574DF61149E41 /* Build configuration list for PBXNativeTarget "XCTestServiceUITests" */; + buildPhases = ( + E0BA1894423433D5DE8AD3DF /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 96BB0E3BE82982AAB4BC8881 /* PBXTargetDependency */, + ); + name = XCTestServiceUITests; + packageProductDependencies = ( + ); + productName = XCTestServiceUITests; + productReference = 3E3ECFB96E6F3A2EC111980D /* XCTestServiceUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 5C25456B69D4B3C0F5619AF4 /* XCTestServiceTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 36B3E4AC025706695BF4DB76 /* Build configuration list for PBXNativeTarget "XCTestServiceTests" */; + buildPhases = ( + 31F189BB4F5C36260E86A5F2 /* Sources */, + B11880505A08779A2A23B34E /* Frameworks */, + 48F351B420D9ACF2359997B8 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + EBE0CC4D6D7E8FCE374400C8 /* PBXTargetDependency */, + ); + name = XCTestServiceTests; + packageProductDependencies = ( + ); + productName = XCTestServiceTests; + productReference = 1DB65B3413204854959BE7FF /* XCTestServiceTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 64ABA10AC1845842C74A47EF /* XCTestServiceApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = DB120CF9F67C777E9E91625F /* Build configuration list for PBXNativeTarget "XCTestServiceApp" */; + buildPhases = ( + B4A01F621218A61347BCFA47 /* Sources */, + 15377F97C0089E48BD526BDE /* Resources */, + 9D8497137D16D3914AC0B656 /* Frameworks */, + 8A21574994AAA70CC279B103 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + CE26F3B5129D4DAD7ACDC79A /* PBXTargetDependency */, + ); + name = XCTestServiceApp; + packageProductDependencies = ( + ); + productName = XCTestServiceApp; + productReference = 952BC86A5C17183D97D8BD16 /* XCTestServiceApp.app */; + productType = "com.apple.product-type.application"; + }; + 78D8EB901BF6BD6054C005AC /* XCTestService */ = { + isa = PBXNativeTarget; + buildConfigurationList = A2D9CDB098C4783A50F9FF04 /* Build configuration list for PBXNativeTarget "XCTestService" */; + buildPhases = ( + 431C1C0EA8CEE5849859070F /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = XCTestService; + packageProductDependencies = ( + ); + productName = XCTestService; + productReference = 9788C24D07660B4E2960BC91 /* XCTestService.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DA9C5717DADAEC2EAC38F4C6 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 14E9F6D055EFE0BC4CE6073B = { + ProvisioningStyle = Automatic; + TestTargetID = 64ABA10AC1845842C74A47EF; + }; + 5C25456B69D4B3C0F5619AF4 = { + ProvisioningStyle = Automatic; + }; + 64ABA10AC1845842C74A47EF = { + ProvisioningStyle = Automatic; + }; + 78D8EB901BF6BD6054C005AC = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 2386CB692F7D3AFE3AAC6B37 /* Build configuration list for PBXProject "XCTestService" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = A5FA5FAB58C57CFF316C0AEC; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 78D8EB901BF6BD6054C005AC /* XCTestService */, + 64ABA10AC1845842C74A47EF /* XCTestServiceApp */, + 5C25456B69D4B3C0F5619AF4 /* XCTestServiceTests */, + 14E9F6D055EFE0BC4CE6073B /* XCTestServiceUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 15377F97C0089E48BD526BDE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6F565155DCCD47DE1D137F0 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 31F189BB4F5C36260E86A5F2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3949205C0E67D8B10D030C9B /* CommandHandlerTests.swift in Sources */, + 6BE1A8BAB50B6EC54287A6C6 /* ModelsTests.swift in Sources */, + 5151C4235C549690408DE674 /* PerfProviderTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 431C1C0EA8CEE5849859070F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E4386D208A9A2B600DD0C5F0 /* CommandHandler.swift in Sources */, + 1CBED464F7D1B710B643881E /* DisplayLinkFPSMonitor.swift in Sources */, + 26BE4CDC25C50AF275B5D62D /* ElementLocator.swift in Sources */, + 0C0DA069D9B774797B08050D /* Fakes.swift in Sources */, + AFF0F4FB01A3EF5919FEDA22 /* GesturePerformer.swift in Sources */, + B542B471C14DBC8AFD9DB912 /* HierarchyDebouncer.swift in Sources */, + 3E431C908DCDEFB2E52F4564 /* Models.swift in Sources */, + 76C282F48CB5169159D6A6D9 /* PerfProvider.swift in Sources */, + 99C289B4D07B9C159E98DB4E /* PerformanceMetrics.swift in Sources */, + 052E12A5C50F95E644253CEB /* Protocols.swift in Sources */, + 9CA64759740038CD0BACC740 /* WebSocketServer.swift in Sources */, + 2CF1BFE1F9C343DDC3327B62 /* XCTestService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B4A01F621218A61347BCFA47 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 471BDC31826855445CB0FF30 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0BA1894423433D5DE8AD3DF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A056201FD1164D5BD940F478 /* CommandHandler.swift in Sources */, + F7919FB4A4FA6F3332E736B4 /* DisplayLinkFPSMonitor.swift in Sources */, + 196923B8D693CBFA0F64FBE8 /* ElementLocator.swift in Sources */, + 2EB2329D4CC93BCE10835479 /* GesturePerformer.swift in Sources */, + 682BCA31038636F8DDBFEBA7 /* HierarchyDebouncer.swift in Sources */, + EFF6AEFF4C9BA793D21D1FE0 /* Models.swift in Sources */, + 2E3CACA8F6B100AA92191D14 /* PerfProvider.swift in Sources */, + 551E811C748D7EB78D4353FD /* PerformanceMetrics.swift in Sources */, + B52D39007BB9B35F65DA162B /* Protocols.swift in Sources */, + F16F89D0CE46B3F7EA09DC0E /* WebSocketServer.swift in Sources */, + 66FB888E218A443091A2DD8B /* XCTestService.swift in Sources */, + 037DA42AFA18F5976D19A594 /* XCTestServiceUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 96BB0E3BE82982AAB4BC8881 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 64ABA10AC1845842C74A47EF /* XCTestServiceApp */; + targetProxy = 5977F37AF1AAC4483E9B918F /* PBXContainerItemProxy */; + }; + CE26F3B5129D4DAD7ACDC79A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 78D8EB901BF6BD6054C005AC /* XCTestService */; + targetProxy = 8788907F136A078FAF51E6E1 /* PBXContainerItemProxy */; + }; + EBE0CC4D6D7E8FCE374400C8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 78D8EB901BF6BD6054C005AC /* XCTestService */; + targetProxy = 2B06022F7B31D6647E1A4DC1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 072E8246C2D4D7432EE6C1C5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 12E5420BAB523846CFDDE939 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = XCTestServiceApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestServiceApp; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 42AAB73690231B065121DCD1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = XCTestServiceApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestServiceApp; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 5838E1A1C5ACD927BDEFF774 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestServiceTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 6D944E72A2CC6804549373C0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestServiceTests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 89E62AAD6F90DBDBE69DA15B /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 29DDC30D37A5903F163DD4E5 /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + CD4A7997CC70095CE9ECBE14 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestServiceUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = XCTestServiceApp; + }; + name = Release; + }; + CF4281BA6C87512F48E0F2FE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestServiceUITests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = XCTestServiceApp; + }; + name = Debug; + }; + E7A9E9DB727CA73081625FE8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.jasonpearson.automobile.XCTestService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + EC40EEB4F8D8525FE22F4168 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8098B3F86909EF6EDFF01D23 /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2386CB692F7D3AFE3AAC6B37 /* Build configuration list for PBXProject "XCTestService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 89E62AAD6F90DBDBE69DA15B /* Debug */, + EC40EEB4F8D8525FE22F4168 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 36B3E4AC025706695BF4DB76 /* Build configuration list for PBXNativeTarget "XCTestServiceTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6D944E72A2CC6804549373C0 /* Debug */, + 5838E1A1C5ACD927BDEFF774 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6432610C1E5574DF61149E41 /* Build configuration list for PBXNativeTarget "XCTestServiceUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CF4281BA6C87512F48E0F2FE /* Debug */, + CD4A7997CC70095CE9ECBE14 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A2D9CDB098C4783A50F9FF04 /* Build configuration list for PBXNativeTarget "XCTestService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7A9E9DB727CA73081625FE8 /* Debug */, + 072E8246C2D4D7432EE6C1C5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + DB120CF9F67C777E9E91625F /* Build configuration list for PBXNativeTarget "XCTestServiceApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 12E5420BAB523846CFDDE939 /* Debug */, + 42AAB73690231B065121DCD1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = DA9C5717DADAEC2EAC38F4C6 /* Project object */; +} diff --git a/ios/XCTestService/XCTestService.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/XCTestService/XCTestService.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/ios/XCTestService/XCTestService.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/XCTestService/XCTestService.xcodeproj/xcshareddata/xcschemes/XCTestServiceApp.xcscheme b/ios/XCTestService/XCTestService.xcodeproj/xcshareddata/xcschemes/XCTestServiceApp.xcscheme new file mode 100644 index 000000000..baf41ae91 --- /dev/null +++ b/ios/XCTestService/XCTestService.xcodeproj/xcshareddata/xcschemes/XCTestServiceApp.xcscheme @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/XCTestService/XCTestServiceApp/AppDelegate.swift b/ios/XCTestService/XCTestServiceApp/AppDelegate.swift new file mode 100644 index 000000000..612eac40d --- /dev/null +++ b/ios/XCTestService/XCTestServiceApp/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? + ) + -> Bool + { + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = UIViewController() + window?.rootViewController?.view.backgroundColor = .systemBackground + window?.makeKeyAndVisible() + return true + } +} diff --git a/ios/XCTestService/XCTestServiceApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/XCTestService/XCTestServiceApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 000000000..5789fe107 Binary files /dev/null and b/ios/XCTestService/XCTestServiceApp/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/XCTestService/XCTestServiceApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/XCTestService/XCTestServiceApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..cefcc878e --- /dev/null +++ b/ios/XCTestService/XCTestServiceApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/XCTestService/XCTestServiceApp/Assets.xcassets/Contents.json b/ios/XCTestService/XCTestServiceApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ios/XCTestService/XCTestServiceApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/XCTestService/XCTestServiceApp/Info.plist b/ios/XCTestService/XCTestServiceApp/Info.plist new file mode 100644 index 000000000..f06ec1b50 --- /dev/null +++ b/ios/XCTestService/XCTestServiceApp/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/XCTestService/project.yml b/ios/XCTestService/project.yml new file mode 100644 index 000000000..550b791ab --- /dev/null +++ b/ios/XCTestService/project.yml @@ -0,0 +1,100 @@ +name: XCTestService +options: + bundleIdPrefix: dev.jasonpearson.automobile + deploymentTarget: + iOS: "15.0" + xcodeVersion: "15.0" + +configFiles: + Debug: Configurations/Debug.xcconfig + Release: Configurations/Release.xcconfig + +settings: + base: + SWIFT_VERSION: "5.0" + +targets: + XCTestServiceApp: + type: application + platform: iOS + sources: + - path: XCTestServiceApp + excludes: + - "*.xcassets" + - path: XCTestServiceApp/Assets.xcassets + type: folder + settings: + base: + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + PRODUCT_BUNDLE_IDENTIFIER: dev.jasonpearson.automobile.XCTestServiceApp + INFOPLIST_FILE: XCTestServiceApp/Info.plist + CODE_SIGN_STYLE: Automatic + dependencies: + - target: XCTestService + embed: true + + XCTestService: + type: framework + platform: iOS + sources: + - path: Sources/XCTestService + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.jasonpearson.automobile.XCTestService + DEFINES_MODULE: YES + CODE_SIGN_STYLE: Automatic + GENERATE_INFOPLIST_FILE: YES + + XCTestServiceTests: + type: bundle.unit-test + platform: iOS + sources: + - path: Tests/XCTestServiceTests + dependencies: + - target: XCTestService + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.jasonpearson.automobile.XCTestServiceTests + CODE_SIGN_STYLE: Automatic + GENERATE_INFOPLIST_FILE: YES + + XCTestServiceUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: Tests/XCTestServiceUITests + # Include XCTestService sources directly so XCTest is available during compilation + - path: Sources/XCTestService + excludes: + - "Fakes.swift" # Exclude test-only fakes + dependencies: + - target: XCTestServiceApp + # Note: We don't depend on XCTestService framework - sources are compiled directly into UI tests + # to give XCTest access for XCUIApplication + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.jasonpearson.automobile.XCTestServiceUITests + TEST_TARGET_NAME: XCTestServiceApp + CODE_SIGN_STYLE: Automatic + GENERATE_INFOPLIST_FILE: YES + +schemes: + XCTestServiceApp: + build: + targets: + XCTestServiceApp: all + XCTestService: all + XCTestServiceTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - XCTestServiceTests + - XCTestServiceUITests + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/ios/XcodeCompanion/Package.swift b/ios/XcodeCompanion/Package.swift new file mode 100644 index 000000000..276e0cd9c --- /dev/null +++ b/ios/XcodeCompanion/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "XcodeCompanion", + platforms: [ + .macOS(.v13), + ], + products: [ + .executable( + name: "AutoMobileCompanion", + targets: ["AutoMobileCompanion"] + ), + ], + dependencies: [ + // SwiftUI and Combine are built-in + ], + targets: [ + .executableTarget( + name: "AutoMobileCompanion", + dependencies: [], + resources: [ + .process("Resources"), + ] + ), + .testTarget( + name: "AutoMobileCompanionTests", + dependencies: ["AutoMobileCompanion"] + ), + ] +) diff --git a/ios/XcodeCompanion/README.md b/ios/XcodeCompanion/README.md new file mode 100644 index 000000000..b9989467a --- /dev/null +++ b/ios/XcodeCompanion/README.md @@ -0,0 +1,119 @@ +# AutoMobile Xcode Companion + +macOS companion app for AutoMobile IDE integration with Xcode. + +## Overview + +The Xcode Companion is a native macOS application that provides a rich UI for iOS automation development: + +- Device and simulator management +- Test plan recording workflow +- Plan execution with live logs +- Performance metrics and graphs +- Feature flags configuration +- Menu bar quick actions +- MCP transport management + +## Architecture + +Based on the design documented in `docs/design-docs/plat/ios/ide-plugin/overview.md`, this app: + +1. Runs as a standalone macOS application +2. Provides SwiftUI-based UI for all automation features +3. Manages MCP connection via multiple transport options +4. Integrates with Xcode Source Editor Extension +5. Supports test recording and YAML plan generation + +## Features + +### Device Management +- List iOS simulators and devices +- View device status and runtime information +- Boot/shutdown simulators + +### Test Recording +- Start/stop recording workflow +- Capture taps, swipes, and input events +- Generate executable YAML plans +- Export plans to files + +### Plan Execution +- Execute YAML plans via MCP +- View live execution logs +- Track step-by-step progress + +### Performance Monitoring +- Visualize test performance metrics +- Track timing history +- Identify bottlenecks + +### MCP Transport +Transport priority order: +1. `AUTOMOBILE_MCP_STDIO_COMMAND` environment variable (stdio) +2. Unix socket fallback at `/tmp/auto-mobile-daemon-.sock` + +## Building + +```bash +# Build the application +swift build + +# Run tests +swift test + +# Build for macOS +xcodebuild -scheme AutoMobileCompanion -destination 'platform=macOS' +``` + +## Running + +```bash +# Run the app +swift run AutoMobileCompanion + +# Or build and run via Xcode +open XcodeCompanion.xcodeproj +``` + +## Configuration + +Settings are available via macOS Settings panel: + +- **MCP Endpoint**: Configure MCP server URL +- **Auto-connect**: Connect to MCP on launch +- **Recording Options**: Configure recording behavior +- **Execution Options**: Configure execution logging + +## Menu Bar Integration + +The app includes a menu bar icon for quick access: + +- Show/hide companion window +- Start/stop recording +- Quick actions +- Quit application + +## Development Status + +**MVP Scaffold** - This is a minimal viable product scaffold with: +- Complete SwiftUI application structure +- Navigation and tab-based UI +- Device management view +- Recording view with event capture +- Execution view with logs +- Performance view placeholder +- Feature flags view placeholder +- Settings panel +- Menu bar integration +- MCP connection manager +- Test scaffolding + +**Next Steps:** +- Implement MCP client integration +- Add real device listing via simctl +- Implement recording capture logic +- Add YAML plan generation +- Implement plan execution +- Add performance graph rendering +- Add comprehensive test coverage +- Create Xcode project for app distribution diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/AutoMobileCompanionApp.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/AutoMobileCompanionApp.swift new file mode 100644 index 000000000..989573499 --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/AutoMobileCompanionApp.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Main application entry point for AutoMobile Xcode Companion +@main +struct AutoMobileCompanionApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 800, minHeight: 600) + } + .commands { + CommandGroup(replacing: .appInfo) { + Button("About AutoMobile Companion") { + // Show about window + } + } + } + + Settings { + SettingsView() + } + + MenuBarExtra("AutoMobile", systemImage: "wrench.and.screwdriver") { + MenuBarView() + } + } +} + +/// Application delegate for menu bar and lifecycle management +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_: Notification) { + print("AutoMobile Companion launched") + + // Initialize MCP connection + MCPConnectionManager.shared.initialize() + } + + func applicationWillTerminate(_: Notification) { + print("AutoMobile Companion terminating") + + // Cleanup MCP connection + MCPConnectionManager.shared.disconnect() + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Services/MCPConnectionManager.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Services/MCPConnectionManager.swift new file mode 100644 index 000000000..21a8588ea --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Services/MCPConnectionManager.swift @@ -0,0 +1,68 @@ +import Foundation +import Network + +/// Manages MCP connection and transport selection +class MCPConnectionManager: ObservableObject { + static let shared = MCPConnectionManager() + + @Published var isConnected = false + @Published var transportType: TransportType = .http + + enum TransportType { + case http + case stdio + case unixSocket + case environmentVariables + } + + private var connection: NWConnection? + + private init() {} + + /// Initialize MCP connection with transport priority + func initialize() { + // Transport selection priority: + // 1. HTTP dev server (if available) + // 2. Environment variables + // 3. stdio + // 4. Unix socket fallback + + if tryHTTPConnection() { + transportType = .http + } else if tryEnvironmentVariables() { + transportType = .environmentVariables + } else if tryUnixSocket() { + transportType = .unixSocket + } else { + transportType = .stdio + } + + isConnected = true + } + + /// Disconnect from MCP server + func disconnect() { + connection?.cancel() + connection = nil + isConnected = false + } + + // MARK: - Transport Methods + + private func tryHTTPConnection() -> Bool { + // Try to connect to HTTP dev server + // TODO: Implement HTTP connection + return false + } + + private func tryEnvironmentVariables() -> Bool { + // Check for MCP environment variables + return ProcessInfo.processInfo.environment["MCP_ENDPOINT"] != nil + } + + private func tryUnixSocket() -> Bool { + // Try to connect via Unix socket + // TODO: Implement Unix socket connection + return false + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/ContentView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/ContentView.swift new file mode 100644 index 000000000..639e01e3c --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/ContentView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Main content view for AutoMobile Companion +struct ContentView: View { + @StateObject private var navigationState = NavigationState() + + var body: some View { + NavigationSplitView { + SidebarView(selection: $navigationState.selectedTab) + } detail: { + DetailView(selectedTab: navigationState.selectedTab) + } + .navigationTitle("AutoMobile Companion") + } +} + +/// Navigation state manager +class NavigationState: ObservableObject { + @Published var selectedTab: SidebarTab = .devices +} + +/// Sidebar tabs +enum SidebarTab: String, CaseIterable, Identifiable { + case devices = "Devices" + case recording = "Recording" + case execution = "Execution" + case performance = "Performance" + case flags = "Feature Flags" + + var id: String { + rawValue + } + + var icon: String { + switch self { + case .devices: return "iphone" + case .recording: return "record.circle" + case .execution: return "play.circle" + case .performance: return "chart.line.uptrend.xyaxis" + case .flags: return "flag" + } + } +} + +/// Sidebar view +struct SidebarView: View { + @Binding var selection: SidebarTab + + var body: some View { + List(SidebarTab.allCases, selection: $selection) { tab in + Label(tab.rawValue, systemImage: tab.icon) + .tag(tab) + } + .listStyle(.sidebar) + } +} + +/// Detail view router +struct DetailView: View { + let selectedTab: SidebarTab + + var body: some View { + switch selectedTab { + case .devices: + DevicesView() + case .recording: + RecordingView() + case .execution: + ExecutionView() + case .performance: + PerformanceView() + case .flags: + FeatureFlagsView() + } + } +} + +/// Preview provider +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/DevicesView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/DevicesView.swift new file mode 100644 index 000000000..3ccd55618 --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/DevicesView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +/// Devices view for managing iOS simulators and devices +struct DevicesView: View { + @StateObject private var viewModel = DevicesViewModel() + + var body: some View { + VStack { + HStack { + Text("iOS Devices & Simulators") + .font(.title) + + Spacer() + + Button("Refresh") { + viewModel.refreshDevices() + } + } + .padding() + + if viewModel.isLoading { + ProgressView("Loading devices...") + } else if viewModel.devices.isEmpty { + Text("No devices found") + .foregroundColor(.secondary) + } else { + List(viewModel.devices) { device in + DeviceRow(device: device) + } + } + } + .onAppear { + viewModel.refreshDevices() + } + } +} + +/// Device row view +struct DeviceRow: View { + let device: SimulatorDevice + + var body: some View { + HStack { + Image(systemName: deviceIcon) + .foregroundColor(.blue) + + VStack(alignment: .leading) { + Text(device.name) + .font(.headline) + + Text(device.runtime) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + StateLabel(state: device.state) + } + .padding(.vertical, 4) + } + + private var deviceIcon: String { + if device.name.contains("iPad") { + return "ipad" + } else { + return "iphone" + } + } +} + +/// State label view +struct StateLabel: View { + let state: String + + var body: some View { + Text(state) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(stateColor.opacity(0.2)) + .foregroundColor(stateColor) + .cornerRadius(4) + } + + private var stateColor: Color { + switch state { + case "Booted": return .green + case "Shutdown": return .gray + case "Booting", "ShuttingDown": return .orange + default: return .secondary + } + } +} + +/// Devices view model +class DevicesViewModel: ObservableObject { + @Published var devices: [SimulatorDevice] = [] + @Published var isLoading = false + + func refreshDevices() { + isLoading = true + + // TODO: Integrate with Simctl to fetch real devices + // For MVP, use mock data + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.devices = [ + SimulatorDevice( + id: "1", + udid: "ABC123", + name: "iPhone 15 Pro", + state: "Booted", + runtime: "iOS 17.0" + ), + SimulatorDevice( + id: "2", + udid: "DEF456", + name: "iPad Pro (12.9-inch)", + state: "Shutdown", + runtime: "iOS 17.0" + ), + ] + self.isLoading = false + } + } +} + +/// Simulator device model +struct SimulatorDevice: Identifiable { + let id: String + let udid: String + let name: String + let state: String + let runtime: String +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/ExecutionView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/ExecutionView.swift new file mode 100644 index 000000000..b937e5f10 --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/ExecutionView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +/// Execution view for running automation plans +struct ExecutionView: View { + @StateObject private var viewModel = ExecutionViewModel() + + var body: some View { + VStack { + HStack { + Text("Plan Execution") + .font(.title) + + Spacer() + + Button("Run Plan") { + viewModel.executePlan() + } + .disabled(viewModel.isExecuting) + } + .padding() + + if viewModel.isExecuting { + ProgressView("Executing plan...") + .padding() + } + + if !viewModel.executionLog.isEmpty { + List(viewModel.executionLog) { entry in + LogEntryRow(entry: entry) + } + } else { + Text("No execution logs") + .foregroundColor(.secondary) + } + } + } +} + +/// Log entry row +struct LogEntryRow: View { + let entry: LogEntry + + var body: some View { + HStack { + Image(systemName: entry.level.icon) + .foregroundColor(entry.level.color) + + VStack(alignment: .leading) { + Text(entry.message) + .font(.body) + + Text(entry.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 2) + } +} + +/// Execution view model +class ExecutionViewModel: ObservableObject { + @Published var isExecuting = false + @Published var executionLog: [LogEntry] = [] + + func executePlan() { + isExecuting = true + executionLog = [] + + // TODO: Execute plan via MCP + // For MVP, simulate execution + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.executionLog.append(LogEntry( + level: .info, + message: "Plan execution started" + )) + self.isExecuting = false + } + } +} + +/// Log entry model +struct LogEntry: Identifiable { + let id = UUID() + let level: Level + let message: String + let timestamp = Date() + + enum Level { + case info, success, warning, error + + var icon: String { + switch self { + case .info: return "info.circle" + case .success: return "checkmark.circle" + case .warning: return "exclamationmark.triangle" + case .error: return "xmark.circle" + } + } + + var color: Color { + switch self { + case .info: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/FeatureFlagsView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/FeatureFlagsView.swift new file mode 100644 index 000000000..3f9cb268c --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/FeatureFlagsView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Feature flags view for managing runtime flags +struct FeatureFlagsView: View { + var body: some View { + VStack { + Text("Feature Flags") + .font(.title) + .padding() + + Text("Feature flags configuration will be displayed here") + .foregroundColor(.secondary) + + Spacer() + } + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/MenuBarView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/MenuBarView.swift new file mode 100644 index 000000000..7c5f0b22d --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/MenuBarView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Menu bar view for quick actions +struct MenuBarView: View { + var body: some View { + VStack { + Button("Show Companion") { + // Bring main window to front + NSApp.activate(ignoringOtherApps: true) + } + + Divider() + + Button("Start Recording") { + // Start recording + } + + Button("Stop Recording") { + // Stop recording + } + + Divider() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + } + .padding(4) + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/PerformanceView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/PerformanceView.swift new file mode 100644 index 000000000..3e9061e3e --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/PerformanceView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Performance view for displaying metrics and graphs +struct PerformanceView: View { + var body: some View { + VStack { + Text("Performance Metrics") + .font(.title) + .padding() + + Text("Performance graphs will be rendered here") + .foregroundColor(.secondary) + + Spacer() + } + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/RecordingView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/RecordingView.swift new file mode 100644 index 000000000..e85c2b6bc --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/RecordingView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +/// Recording view for capturing automation plans +struct RecordingView: View { + @StateObject private var viewModel = RecordingViewModel() + + var body: some View { + VStack { + HStack { + Text("Test Recording") + .font(.title) + + Spacer() + + if viewModel.isRecording { + Button("Stop Recording") { + viewModel.stopRecording() + } + .buttonStyle(.borderedProminent) + .tint(.red) + } else { + Button("Start Recording") { + viewModel.startRecording() + } + .buttonStyle(.borderedProminent) + } + } + .padding() + + if viewModel.isRecording { + RecordingSessionView(events: viewModel.recordedEvents) + } else if let plan = viewModel.generatedPlan { + PlanPreviewView(plan: plan) + } else { + EmptyRecordingView() + } + } + } +} + +/// Recording session view +struct RecordingSessionView: View { + let events: [RecordedEvent] + + var body: some View { + VStack { + Text("Recording in progress...") + .font(.headline) + .foregroundColor(.red) + + List(events) { event in + HStack { + Image(systemName: event.icon) + Text(event.description) + Spacer() + Text(event.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } +} + +/// Plan preview view +struct PlanPreviewView: View { + let plan: String + + var body: some View { + VStack { + Text("Generated YAML Plan") + .font(.headline) + + TextEditor(text: .constant(plan)) + .font(.system(.body, design: .monospaced)) + .padding() + + HStack { + Button("Copy to Clipboard") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(plan, forType: .string) + } + + Button("Save to File") { + // Save plan to file + } + .buttonStyle(.borderedProminent) + } + .padding() + } + } +} + +/// Empty recording view +struct EmptyRecordingView: View { + var body: some View { + VStack { + Image(systemName: "record.circle") + .font(.system(size: 64)) + .foregroundColor(.secondary) + + Text("No recording in progress") + .font(.headline) + .padding(.top) + + Text("Click 'Start Recording' to begin capturing a test plan") + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +/// Recording view model +class RecordingViewModel: ObservableObject { + @Published var isRecording = false + @Published var recordedEvents: [RecordedEvent] = [] + @Published var generatedPlan: String? + + func startRecording() { + isRecording = true + recordedEvents = [] + generatedPlan = nil + } + + func stopRecording() { + isRecording = false + generatePlan() + } + + private func generatePlan() { + // TODO: Generate YAML plan from recorded events + generatedPlan = """ + # Generated AutoMobile Test Plan + name: Recorded Test + steps: + - action: tapOn + params: + text: "Login" + - action: inputText + params: + text: "user@example.com" + """ + } +} + +/// Recorded event model +struct RecordedEvent: Identifiable { + let id = UUID() + let type: EventType + let description: String + let timestamp: Date + + enum EventType { + case tap, swipe, input + + var icon: String { + switch self { + case .tap: return "hand.tap" + case .swipe: return "hand.draw" + case .input: return "keyboard" + } + } + } + + var icon: String { + type.icon + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/SettingsView.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/SettingsView.swift new file mode 100644 index 000000000..0f98bff1f --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanion/Views/SettingsView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Settings view for configuring the companion app +struct SettingsView: View { + @AppStorage("mcpEndpoint") private var mcpEndpoint = "http://localhost:3000" + @AppStorage("autoConnect") private var autoConnect = true + + var body: some View { + Form { + Section("MCP Connection") { + TextField("MCP Endpoint", text: $mcpEndpoint) + Toggle("Auto-connect on launch", isOn: $autoConnect) + } + + Section("Recording") { + Toggle("Auto-generate element IDs", isOn: .constant(true)) + Toggle("Capture screenshots", isOn: .constant(true)) + } + + Section("Execution") { + Toggle("Show detailed logs", isOn: .constant(true)) + Toggle("Auto-retry failed steps", isOn: .constant(false)) + } + } + .padding() + .frame(width: 450, height: 350) + } +} diff --git a/ios/XcodeCompanion/Sources/AutoMobileCompanionTests/AutoMobileCompanionTests.swift b/ios/XcodeCompanion/Sources/AutoMobileCompanionTests/AutoMobileCompanionTests.swift new file mode 100644 index 000000000..8c4439e1c --- /dev/null +++ b/ios/XcodeCompanion/Sources/AutoMobileCompanionTests/AutoMobileCompanionTests.swift @@ -0,0 +1,11 @@ +@testable import AutoMobileCompanion +import XCTest + +final class AutoMobileCompanionTests: XCTestCase { + func testMCPConnectionManagerInitialization() { + let manager = MCPConnectionManager.shared + XCTAssertNotNil(manager) + } + + // Add more tests as implementation progresses +} diff --git a/ios/XcodeExtension/Package.swift b/ios/XcodeExtension/Package.swift new file mode 100644 index 000000000..da89ea93d --- /dev/null +++ b/ios/XcodeExtension/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "XcodeExtension", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "XcodeExtension", + targets: ["XcodeExtension"] + ), + ], + dependencies: [ + // XcodeKit is provided by Xcode + ], + targets: [ + .target( + name: "XcodeExtension", + dependencies: [] + ), + .testTarget( + name: "XcodeExtensionTests", + dependencies: ["XcodeExtension"] + ), + ] +) diff --git a/ios/XcodeExtension/README.md b/ios/XcodeExtension/README.md new file mode 100644 index 000000000..fd7e8f172 --- /dev/null +++ b/ios/XcodeExtension/README.md @@ -0,0 +1,104 @@ +# AutoMobile Xcode Source Editor Extension + +Xcode Source Editor Extension for AutoMobile automation integration. + +## Overview + +The Xcode Extension provides editor commands for working with AutoMobile automation plans directly within Xcode: + +- Generate YAML plan templates +- Execute plans from editor +- Open AutoMobile Companion app +- Start/stop test recording +- Quick access to automation features + +## Architecture + +Based on the design documented in `docs/design-docs/plat/ios/ide-plugin/overview.md`, this extension: + +1. Runs as an Xcode Source Editor Extension +2. Provides menu commands in Xcode's Editor menu +3. Communicates with Companion app via distributed notifications +4. Supports plan template generation and execution +5. Integrates with recording workflow + +## Commands + +### Generate Plan Template +- **Command**: Generate AutoMobile Plan Template +- **Action**: Inserts a YAML plan template at cursor position +- **Usage**: Place cursor where you want the template, then invoke command + +### Execute Plan +- **Command**: Execute AutoMobile Plan +- **Action**: Sends current file to Companion app for execution +- **Usage**: Open a YAML plan file, then invoke command + +### Open Companion +- **Command**: Open AutoMobile Companion +- **Action**: Launches or activates the Companion app +- **Usage**: Quick access to Companion app from Xcode + +### Start/Stop Recording +- **Commands**: Start/Stop AutoMobile Recording +- **Action**: Controls test recording via Companion app +- **Usage**: Start recording, perform actions in simulator, stop to generate plan + +## Communication + +The extension communicates with the Companion app using macOS distributed notifications: + +- `com.automobile.execute-plan`: Execute a plan file +- `com.automobile.start-recording`: Start recording +- `com.automobile.stop-recording`: Stop recording + +## Installation + +1. Build the extension as part of the Companion app bundle +2. Enable the extension in System Preferences → Extensions → Xcode Source Editor +3. Restart Xcode to see the commands in Editor menu + +## Building + +```bash +# Build the extension +swift build + +# Run tests +swift test +``` + +Note: The extension must be code-signed and bundled within a macOS app (the Companion app) to be used in Xcode. + +## Usage in Xcode + +1. Open Xcode +2. Navigate to Editor menu +3. Look for AutoMobile commands +4. Select desired command + +Keyboard shortcuts can be configured in Xcode → Preferences → Key Bindings. + +## Development Status + +**MVP Scaffold** - This is a minimal viable product scaffold with: +- Complete Xcode extension structure +- Five editor commands implemented +- Communication with Companion app +- Plan template generation +- Test scaffolding + +**Next Steps:** +- Bundle extension with Companion app +- Add code signing configuration +- Implement YAML syntax validation +- Add context-aware command availability +- Add keyboard shortcut defaults +- Add comprehensive test coverage +- Create Xcode project for extension distribution + +## Requirements + +- macOS 13.0 or later +- Xcode 15.0 or later +- AutoMobile Companion app installed diff --git a/ios/XcodeExtension/Sources/XcodeExtension/Commands/ExecutePlanCommand.swift b/ios/XcodeExtension/Sources/XcodeExtension/Commands/ExecutePlanCommand.swift new file mode 100644 index 000000000..2fdc0ef5c --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtension/Commands/ExecutePlanCommand.swift @@ -0,0 +1,43 @@ +import Foundation +#if canImport(XcodeKit) + import XcodeKit + + /// Command to execute the current AutoMobile plan + class ExecutePlanCommand: NSObject, XCSourceEditorCommand { + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + let buffer = invocation.buffer + + // Get the file path + guard let fileURL = buffer.contentUTI as? URL else { + completionHandler(NSError( + domain: "AutoMobile", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Could not determine file path"] + )) + return + } + + // Execute plan via companion app or MCP + executePlan(at: fileURL.path) { error in + completionHandler(error) + } + } + + private func executePlan(at path: String, completion: @escaping (Error?) -> Void) { + // TODO: Communicate with companion app to execute plan + // For MVP, just log the action + print("Executing plan at: \(path)") + + // Send notification to companion app + DistributedNotificationCenter.default().post( + name: NSNotification.Name("com.automobile.execute-plan"), + object: path + ) + + completion(nil) + } + } +#endif diff --git a/ios/XcodeExtension/Sources/XcodeExtension/Commands/GeneratePlanTemplateCommand.swift b/ios/XcodeExtension/Sources/XcodeExtension/Commands/GeneratePlanTemplateCommand.swift new file mode 100644 index 000000000..aa5a3e088 --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtension/Commands/GeneratePlanTemplateCommand.swift @@ -0,0 +1,64 @@ +import Foundation +#if canImport(XcodeKit) + import XcodeKit + + /// Command to generate an AutoMobile plan template + class GeneratePlanTemplateCommand: NSObject, XCSourceEditorCommand { + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + let buffer = invocation.buffer + + // Generate plan template + let template = generatePlanTemplate() + + // Insert template at cursor or beginning of file + let insertionLine = buffer.selections.firstObject as? XCSourceTextRange + let lineIndex = insertionLine?.start.line ?? 0 + + for (index, line) in template.enumerated() { + buffer.lines.insert(line, at: lineIndex + index) + } + + completionHandler(nil) + } + + private func generatePlanTemplate() -> [String] { + return [ + "# AutoMobile Test Plan", + "name: Test Plan", + "description: Automated test plan", + "", + "setup:", + " - action: launchApp", + " params:", + " bundleId: com.example.app", + "", + "steps:", + " - action: tapOn", + " params:", + " text: \"Button Text\"", + "", + " - action: inputText", + " params:", + " text: \"Input text here\"", + "", + " - action: swipe", + " params:", + " direction: up", + "", + "assertions:", + " - element:", + " text: \"Expected Text\"", + " visible: true", + "", + "teardown:", + " - action: terminateApp", + " params:", + " bundleId: com.example.app", + "", + ] + } + } +#endif diff --git a/ios/XcodeExtension/Sources/XcodeExtension/Commands/OpenCompanionCommand.swift b/ios/XcodeExtension/Sources/XcodeExtension/Commands/OpenCompanionCommand.swift new file mode 100644 index 000000000..c2565416e --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtension/Commands/OpenCompanionCommand.swift @@ -0,0 +1,31 @@ +import Foundation +#if canImport(XcodeKit) + import AppKit + import XcodeKit + + /// Command to open the AutoMobile Companion app + class OpenCompanionCommand: NSObject, XCSourceEditorCommand { + func perform( + with _: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + // Launch or activate the companion app + let bundleIdentifier = "com.automobile.companion" + + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) { + NSWorkspace.shared.openApplication( + at: appURL, + configuration: NSWorkspace.OpenConfiguration() + ) { _, error in + completionHandler(error) + } + } else { + completionHandler(NSError( + domain: "AutoMobile", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "AutoMobile Companion app not found"] + )) + } + } + } +#endif diff --git a/ios/XcodeExtension/Sources/XcodeExtension/Commands/StartRecordingCommand.swift b/ios/XcodeExtension/Sources/XcodeExtension/Commands/StartRecordingCommand.swift new file mode 100644 index 000000000..b2388e3a8 --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtension/Commands/StartRecordingCommand.swift @@ -0,0 +1,21 @@ +import Foundation +#if canImport(XcodeKit) + import XcodeKit + + /// Command to start test recording + class StartRecordingCommand: NSObject, XCSourceEditorCommand { + func perform( + with _: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + // Send notification to companion app to start recording + DistributedNotificationCenter.default().post( + name: NSNotification.Name("com.automobile.start-recording"), + object: nil + ) + + print("Started AutoMobile recording") + completionHandler(nil) + } + } +#endif diff --git a/ios/XcodeExtension/Sources/XcodeExtension/Commands/StopRecordingCommand.swift b/ios/XcodeExtension/Sources/XcodeExtension/Commands/StopRecordingCommand.swift new file mode 100644 index 000000000..dc3256d14 --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtension/Commands/StopRecordingCommand.swift @@ -0,0 +1,21 @@ +import Foundation +#if canImport(XcodeKit) + import XcodeKit + + /// Command to stop test recording + class StopRecordingCommand: NSObject, XCSourceEditorCommand { + func perform( + with _: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + // Send notification to companion app to stop recording + DistributedNotificationCenter.default().post( + name: NSNotification.Name("com.automobile.stop-recording"), + object: nil + ) + + print("Stopped AutoMobile recording") + completionHandler(nil) + } + } +#endif diff --git a/ios/XcodeExtension/Sources/XcodeExtension/SourceEditorExtension.swift b/ios/XcodeExtension/Sources/XcodeExtension/SourceEditorExtension.swift new file mode 100644 index 000000000..8dd4818b1 --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtension/SourceEditorExtension.swift @@ -0,0 +1,43 @@ +import Foundation +#if canImport(XcodeKit) + import XcodeKit + + /// Main source editor extension for AutoMobile + class SourceEditorExtension: NSObject, XCSourceEditorExtension { + /// Called when the extension is initialized + func extensionDidFinishLaunching() { + print("AutoMobile Xcode Extension loaded") + } + + /// Returns the command definitions for this extension + var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { + return [ + [ + .identifierKey: "com.automobile.xcode-extension.generate-plan-template", + .classNameKey: GeneratePlanTemplateCommand.className(), + .nameKey: "Generate AutoMobile Plan Template", + ], + [ + .identifierKey: "com.automobile.xcode-extension.execute-plan", + .classNameKey: ExecutePlanCommand.className(), + .nameKey: "Execute AutoMobile Plan", + ], + [ + .identifierKey: "com.automobile.xcode-extension.open-companion", + .classNameKey: OpenCompanionCommand.className(), + .nameKey: "Open AutoMobile Companion", + ], + [ + .identifierKey: "com.automobile.xcode-extension.start-recording", + .classNameKey: StartRecordingCommand.className(), + .nameKey: "Start AutoMobile Recording", + ], + [ + .identifierKey: "com.automobile.xcode-extension.stop-recording", + .classNameKey: StopRecordingCommand.className(), + .nameKey: "Stop AutoMobile Recording", + ], + ] + } + } +#endif diff --git a/ios/XcodeExtension/Sources/XcodeExtensionTests/XcodeExtensionTests.swift b/ios/XcodeExtension/Sources/XcodeExtensionTests/XcodeExtensionTests.swift new file mode 100644 index 000000000..df485461a --- /dev/null +++ b/ios/XcodeExtension/Sources/XcodeExtensionTests/XcodeExtensionTests.swift @@ -0,0 +1,12 @@ +@testable import XcodeExtension +import XCTest + +final class XcodeExtensionTests: XCTestCase { + func testExtensionInitialization() { + // Basic test to ensure the extension compiles + XCTAssertTrue(true) + } + + // Add more tests as implementation progresses + // Note: Testing Xcode extensions requires special setup +} diff --git a/ios/docs/ai/validation.md b/ios/docs/ai/validation.md deleted file mode 100644 index 506b63cdc..000000000 --- a/ios/docs/ai/validation.md +++ /dev/null @@ -1,101 +0,0 @@ -# Project Validation - -This document provides instructions for AI agents to validate the iOS project builds correctly -and all tests pass. After writing some implementation you should select the most relevant checks given the changes made. - -## SwiftLint - -```shell -# SwiftLint validation (if configured) -# Check if SwiftLint is available, install if needed, then run -if ! command -v swiftlint &> /dev/null; then - echo "ℹ️ SwiftLint not installed, installing via Homebrew..." - if [[ "$OSTYPE" == "darwin"* ]]; then - brew install swiftlint - else - echo "ℹ️ Installing SwiftLint for Linux..." - # Try common Linux package managers - if command -v apt-get &> /dev/null; then - # Ubuntu/Debian - install via Swift toolchain - echo "Installing SwiftLint via Swift Package Manager..." - swift build -c release --package-path /tmp/swiftlint --product swiftlint 2>/dev/null || echo "❌ SwiftLint installation failed" - elif command -v yum &> /dev/null || command -v dnf &> /dev/null; then - # RHEL/CentOS/Fedora - echo "Please install SwiftLint manually for your Linux distribution" - else - echo "❌ No supported package manager found, skipping SwiftLint" - fi - fi -fi - -# Run SwiftLint -swiftlint -``` - -## Project Structure - -```bash -# Verify project structure (run from project root) -ls -la ios/playground/*.xcodeproj 2>/dev/null || echo "No workspace files in playground/" -find . -name "*.swift" -type f | wc -l -``` - -## Build Validation - -```bash -# Auto-detect and build (with error handling) -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' build - -``` - -## Test Validation - -```bash -# Run all tests with error handling -echo "🧪 Running tests for scheme Playground" - -# Run all unit tests -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' test - -# Run tests with coverage (optional) -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' -enableCodeCoverage YES test 2>/dev/null || echo "Coverage test failed" - -# Run specific test suite (if exists) -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:"PlaygroundTests" test 2>/dev/null || echo "Specific tests not found" -``` - -## Code Quality Validation - -```bash -# Check for compilation warnings (with error handling) -echo "🔍 Checking for warnings..." -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep -i warning || echo "No warnings found" - -# Check for deprecated APIs -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep -i deprecat || echo "No deprecated API usage found" -``` - -When validation fails, capture output to scratch directory: - -```bash -# Create scratch directory if it doesn't exist -mkdir -p scratch - -# Log build output with timestamp -echo "$(date): Build started" >> ../scratch/build_output.log 2>/dev/null || echo "$(date): Build started" >> scratch/build_output.log -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | tee -a ../scratch/build_output.log 2>/dev/null || tee -a scratch/build_output.log - -# Log test output with timestamp -echo "$(date): Tests started" >> ../scratch/test_output.log 2>/dev/null || echo "$(date): Tests started" >> scratch/test_output.log -xcodebuild -project playground/Playground.xcodeproj -scheme "Playground" -destination 'platform=iOS Simulator,name=iPhone 16' test 2>&1 | tee -a ../scratch/test_output.log 2>/dev/null || tee -a scratch/test_output.log -``` - -## Error Recovery - -If validation fails: - -1. **Directory does not contain an XCode project**: Use `rg --files --glob "**/*.pbxproj" . | sed -e "s/\/project.pbxproj//"` -2. **Scheme not found**: Use `xcodebuild -project playground/Playground.xcodeproj -list` to see available schemes -3. **Simulator not found**: Use `xcrun simctl list devices` to see available simulators -4. **Build failures**: Check logs in scratch directory -5. **Test failures**: Run individual test targets to isolate issues diff --git a/ios/firebender.json b/ios/firebender.json deleted file mode 100644 index a84fef757..000000000 --- a/ios/firebender.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "rules": [ - "", - "Speed is one of the highest priorities of this project", - "Whenever terminal output isn't showing, write output to a file in the scratch directory", - { - "filePathMatches": "*", - "rulesPaths": [ - "README.md", - "docs/ai/validation.md", - "roadmap/README.md" - ] - } - ] -} diff --git a/ios/playground/Playground.xcodeproj/project.pbxproj b/ios/playground/Playground.xcodeproj/project.pbxproj deleted file mode 100644 index d1100e7ab..000000000 --- a/ios/playground/Playground.xcodeproj/project.pbxproj +++ /dev/null @@ -1,563 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXContainerItemProxy section */ - 894689362E2E6AD2001113EB /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 894689202E2E6AD0001113EB /* Project object */; - proxyType = 1; - remoteGlobalIDString = 894689272E2E6AD0001113EB; - remoteInfo = Playground; - }; - 894689402E2E6AD2001113EB /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 894689202E2E6AD0001113EB /* Project object */; - proxyType = 1; - remoteGlobalIDString = 894689272E2E6AD0001113EB; - remoteInfo = Playground; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 894689282E2E6AD0001113EB /* Playground.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Playground.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 894689352E2E6AD2001113EB /* PlaygroundTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlaygroundTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 8946893F2E2E6AD2001113EB /* PlaygroundUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlaygroundUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 8946892A2E2E6AD0001113EB /* Playground */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Playground; - sourceTree = ""; - }; - 894689382E2E6AD2001113EB /* PlaygroundTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = PlaygroundTests; - sourceTree = ""; - }; - 894689422E2E6AD2001113EB /* PlaygroundUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = PlaygroundUITests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 894689252E2E6AD0001113EB /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 894689322E2E6AD2001113EB /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8946893C2E2E6AD2001113EB /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 8946891F2E2E6AD0001113EB = { - isa = PBXGroup; - children = ( - 8946892A2E2E6AD0001113EB /* Playground */, - 894689382E2E6AD2001113EB /* PlaygroundTests */, - 894689422E2E6AD2001113EB /* PlaygroundUITests */, - 894689292E2E6AD0001113EB /* Products */, - ); - sourceTree = ""; - }; - 894689292E2E6AD0001113EB /* Products */ = { - isa = PBXGroup; - children = ( - 894689282E2E6AD0001113EB /* Playground.app */, - 894689352E2E6AD2001113EB /* PlaygroundTests.xctest */, - 8946893F2E2E6AD2001113EB /* PlaygroundUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 894689272E2E6AD0001113EB /* Playground */ = { - isa = PBXNativeTarget; - buildConfigurationList = 894689492E2E6AD2001113EB /* Build configuration list for PBXNativeTarget "Playground" */; - buildPhases = ( - 894689242E2E6AD0001113EB /* Sources */, - 894689252E2E6AD0001113EB /* Frameworks */, - 894689262E2E6AD0001113EB /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 8946892A2E2E6AD0001113EB /* Playground */, - ); - name = Playground; - packageProductDependencies = ( - ); - productName = Playground; - productReference = 894689282E2E6AD0001113EB /* Playground.app */; - productType = "com.apple.product-type.application"; - }; - 894689342E2E6AD2001113EB /* PlaygroundTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8946894C2E2E6AD2001113EB /* Build configuration list for PBXNativeTarget "PlaygroundTests" */; - buildPhases = ( - 894689312E2E6AD2001113EB /* Sources */, - 894689322E2E6AD2001113EB /* Frameworks */, - 894689332E2E6AD2001113EB /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 894689372E2E6AD2001113EB /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 894689382E2E6AD2001113EB /* PlaygroundTests */, - ); - name = PlaygroundTests; - packageProductDependencies = ( - ); - productName = PlaygroundTests; - productReference = 894689352E2E6AD2001113EB /* PlaygroundTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 8946893E2E2E6AD2001113EB /* PlaygroundUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8946894F2E2E6AD2001113EB /* Build configuration list for PBXNativeTarget "PlaygroundUITests" */; - buildPhases = ( - 8946893B2E2E6AD2001113EB /* Sources */, - 8946893C2E2E6AD2001113EB /* Frameworks */, - 8946893D2E2E6AD2001113EB /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 894689412E2E6AD2001113EB /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 894689422E2E6AD2001113EB /* PlaygroundUITests */, - ); - name = PlaygroundUITests; - packageProductDependencies = ( - ); - productName = PlaygroundUITests; - productReference = 8946893F2E2E6AD2001113EB /* PlaygroundUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 894689202E2E6AD0001113EB /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1630; - LastUpgradeCheck = 1630; - TargetAttributes = { - 894689272E2E6AD0001113EB = { - CreatedOnToolsVersion = 16.3; - }; - 894689342E2E6AD2001113EB = { - CreatedOnToolsVersion = 16.3; - TestTargetID = 894689272E2E6AD0001113EB; - }; - 8946893E2E2E6AD2001113EB = { - CreatedOnToolsVersion = 16.3; - TestTargetID = 894689272E2E6AD0001113EB; - }; - }; - }; - buildConfigurationList = 894689232E2E6AD0001113EB /* Build configuration list for PBXProject "Playground" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 8946891F2E2E6AD0001113EB; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 894689292E2E6AD0001113EB /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 894689272E2E6AD0001113EB /* Playground */, - 894689342E2E6AD2001113EB /* PlaygroundTests */, - 8946893E2E2E6AD2001113EB /* PlaygroundUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 894689262E2E6AD0001113EB /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 894689332E2E6AD2001113EB /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8946893D2E2E6AD2001113EB /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 894689242E2E6AD0001113EB /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 894689312E2E6AD2001113EB /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8946893B2E2E6AD2001113EB /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 894689372E2E6AD2001113EB /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 894689272E2E6AD0001113EB /* Playground */; - targetProxy = 894689362E2E6AD2001113EB /* PBXContainerItemProxy */; - }; - 894689412E2E6AD2001113EB /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 894689272E2E6AD0001113EB /* Playground */; - targetProxy = 894689402E2E6AD2001113EB /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 894689472E2E6AD2001113EB /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = BY96VWNTZY; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 894689482E2E6AD2001113EB /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = BY96VWNTZY; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 8946894A2E2E6AD2001113EB /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BY96VWNTZY; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zillow.automobile.Playground; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 8946894B2E2E6AD2001113EB /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BY96VWNTZY; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zillow.automobile.Playground; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 8946894D2E2E6AD2001113EB /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BY96VWNTZY; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zillow.automobile.PlaygroundTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Playground.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Playground"; - }; - name = Debug; - }; - 8946894E2E2E6AD2001113EB /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BY96VWNTZY; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zillow.automobile.PlaygroundTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Playground.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Playground"; - }; - name = Release; - }; - 894689502E2E6AD2001113EB /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BY96VWNTZY; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zillow.automobile.PlaygroundUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Playground; - }; - name = Debug; - }; - 894689512E2E6AD2001113EB /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BY96VWNTZY; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.zillow.automobile.PlaygroundUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Playground; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 894689232E2E6AD0001113EB /* Build configuration list for PBXProject "Playground" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 894689472E2E6AD2001113EB /* Debug */, - 894689482E2E6AD2001113EB /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 894689492E2E6AD2001113EB /* Build configuration list for PBXNativeTarget "Playground" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8946894A2E2E6AD2001113EB /* Debug */, - 8946894B2E2E6AD2001113EB /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8946894C2E2E6AD2001113EB /* Build configuration list for PBXNativeTarget "PlaygroundTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8946894D2E2E6AD2001113EB /* Debug */, - 8946894E2E2E6AD2001113EB /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8946894F2E2E6AD2001113EB /* Build configuration list for PBXNativeTarget "PlaygroundUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 894689502E2E6AD2001113EB /* Debug */, - 894689512E2E6AD2001113EB /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 894689202E2E6AD0001113EB /* Project object */; -} diff --git a/ios/playground/Playground/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/playground/Playground/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb8789700..000000000 --- a/ios/playground/Playground/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/playground/Playground/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/playground/Playground/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 230588010..000000000 --- a/ios/playground/Playground/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/playground/Playground/Assets.xcassets/auto_mobile.imageset/Contents.json b/ios/playground/Playground/Assets.xcassets/auto_mobile.imageset/Contents.json deleted file mode 100644 index 68322542b..000000000 --- a/ios/playground/Playground/Assets.xcassets/auto_mobile.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "auto_mobile_big.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/playground/Playground/Assets.xcassets/auto_mobile.imageset/auto_mobile_big.png b/ios/playground/Playground/Assets.xcassets/auto_mobile.imageset/auto_mobile_big.png deleted file mode 100644 index a8c1d7ec1..000000000 Binary files a/ios/playground/Playground/Assets.xcassets/auto_mobile.imageset/auto_mobile_big.png and /dev/null differ diff --git a/ios/playground/Playground/Discover/ChatView.swift b/ios/playground/Playground/Discover/ChatView.swift deleted file mode 100644 index cf978dbf3..000000000 --- a/ios/playground/Playground/Discover/ChatView.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// ChatView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 23/07/25. -// - -import SwiftUI -struct Message: Identifiable { - let id = UUID() - let text: String - let isFromUser: Bool -} -struct BubbleTail: Shape { - let isOutgoing: Bool // Determines tail direction - - func path(in rect: CGRect) -> Path { - var path = Path() - if !isOutgoing { - path.move(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.maxX - 15, y: rect.maxY - 15)) - path.addLine(to: CGPoint(x: rect.maxX - 15, y: rect.maxY)) - } else { - path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX + 15, y: rect.maxY - 15)) - path.addLine(to: CGPoint(x: rect.minX + 15, y: rect.maxY)) - } - path.closeSubpath() - return path - } -} -extension Color { - static let outgoingMessageBackground: Color = .init(red: 0.05, green: 0.05, blue: 0.05) - static let incomingMessageBackground: Color = .init(red: 0.95, green: 0.95, blue: 0.95) -} - -struct ChatBubbleView: View { - let message: String - let isOutgoing: Bool - - var body: some View { - HStack { - Text(message) - .padding() - .background( - RoundedRectangle(cornerRadius: 15) - .fill(isOutgoing ? Color.outgoingMessageBackground : Color.incomingMessageBackground) - ) - .foregroundColor(isOutgoing ? .white : .black) - .overlay( - BubbleTail(isOutgoing: isOutgoing) - .fill(isOutgoing ? Color.outgoingMessageBackground : Color.incomingMessageBackground) - .frame(width: 15, height: 15) - .offset(x: isOutgoing ? 0 : 0, y: 0), // Adjust offset as needed - alignment: isOutgoing ? .bottomTrailing : .bottomLeading - ) - } - } -} -struct ChatViewModel { - var messages: [Message] = [ - .init(text: "Hello! Welcome to the chat screen. This is an example of realistic chat interface.", isFromUser: false), - - .init(text: "You can type message and they will appear here. Try sending a message!", isFromUser: false), - ] - - static let botResponses = [ - "That's interesting! Tell me more.", - "I see what you mean.", - "Thanks for sharing that with me.", - "How do you feel about that?", - "What do you think about this topic?", - "That sounds great!", - "I understand your perspective.", - "Could you elaborate on that?", - "That's a good point.", - "I appreciate you telling me this."] - - -} - -struct ChatView: View { - @State private var inputText: String = "" - @State private var viewModel = ChatViewModel() - - var body: some View { - VStack { - VStack { - HStack { - VStack(alignment: .leading) { - Text("AI") - .font(.headline) - .fontWeight(.bold) - .padding() - .background(Circle().fill(Color.black)) - .foregroundColor(.white) - } - - VStack(alignment: .leading) { - Text("Chat assistant") - .font(.headline) - .fontWeight(.bold) - Text("Online") - } - .frame(maxWidth: .infinity, alignment: .center) - - VStack(alignment: .trailing) { - Button { - requestMessage() - } label: { - Text("Request Message") - .font(.subheadline) - .frame(width: 140) - .padding() - } - .foregroundColor(.white) - .background(.black) - .cornerRadius(100) - } - .frame(maxWidth: .infinity, alignment: .center) - } - .padding() - } - .background(Color.gray.opacity(0.2)) - - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 10) { - ForEach(viewModel.messages) { message in - HStack { - if message.isFromUser { - HStack { - Spacer() - ChatBubbleView(message: message.text, isOutgoing: message.isFromUser) - Text("You") - .font(.subheadline) - .fontWeight(.bold) - .padding() - .background(Circle().fill(Color.black)) - .foregroundColor(.white) - - } - } else { - HStack { - Text("AI") - .font(.subheadline) - .fontWeight(.bold) - .padding() - .background(Circle().fill(Color.red)) - .foregroundColor(.white) - ChatBubbleView(message: message.text, isOutgoing: message.isFromUser) - } - Spacer() - } - } - .padding(.horizontal) - .id(message.id) - } - } - .padding(.top) - } - .onChange(of: viewModel.messages.count) { - // Scroll to bottom when new message is added - if let last = viewModel.messages.last { - withAnimation { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - } - - Divider() - - HStack(alignment: .center, spacing: 20) { - TextField("What do you want to say?", text: $inputText, axis: .vertical) - .font(.system(size: 20)) - .textFieldStyle(.roundedBorder) - .lineLimit(3, reservesSpace: true) - - Button { - sendMessage() - } label: { - Image(systemName:"paperplane.circle") - .resizable() - .frame(width: 50, height: 50) - } - .buttonStyle(.bordered) - .buttonBorderShape(.roundedRectangle) - .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - .padding() - } - } - private func requestMessage() { - Task.detached { - try? await Task.sleep(for: .seconds(1)) - await MainActor.run { - let message = ChatViewModel.botResponses.randomElement() ?? "I see.." - viewModel.messages.append(Message(text:message, isFromUser: false)) - } - } - } - private func sendMessage() { - let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - viewModel.messages.append(Message(text: trimmed, isFromUser: true)) - self.requestMessage() - inputText = "" - } -} - -struct ChatView_Previews: PreviewProvider { - static var previews: some View { - ChatView() - } -} diff --git a/ios/playground/Playground/Discover/DiscoverView.ViewModel.swift b/ios/playground/Playground/Discover/DiscoverView.ViewModel.swift deleted file mode 100644 index f33bcd775..000000000 --- a/ios/playground/Playground/Discover/DiscoverView.ViewModel.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -import SwiftUI - -extension DiscoverView { - @Observable - class ViewModel { - var selectedItem: UUID? - let items: [ViewItems] = ViewItems.samples - - // Alternative: Enum-based selection (more type-safe) - var selectedSection: NavigationSection? - } - - // Enum for type-safe navigation - enum NavigationSection: String, CaseIterable, Hashable { - case inputs = "Inputs" - case lists = "Lists" - case buttons = "Buttons" - case forms = "Forms" - - var icon: String { - switch self { - case .inputs: return "keyboard" - case .lists: return "list.bullet" - case .buttons: return "button.programmable" - case .forms: return "doc.text" - } - } - - var view: AnyView { - switch self { - case .inputs: return AnyView(InputsView()) - case .lists: return AnyView(Text("Lists View Coming Soon!")) - case .buttons: return AnyView(Text("Buttons View Coming Soon!")) - case .forms: return AnyView(Text("Forms View Coming Soon!")) - } - } - } - - struct ViewItems: Identifiable { - var id: UUID = UUID() - var name: String - var icon: String - var view: AnyView - - static let samples = [ - ViewItems(name: "Inputs", icon: "keyboard", view: AnyView(InputsView())), - ViewItems(name: "Lists", icon: "list.bullet", view: AnyView(ListView())), - ] - } -} diff --git a/ios/playground/Playground/Discover/DiscoverView.swift b/ios/playground/Playground/Discover/DiscoverView.swift deleted file mode 100644 index 531224ab6..000000000 --- a/ios/playground/Playground/Discover/DiscoverView.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// DiscoverView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 22/07/25. -// - -import SwiftUI - -struct DiscoverView: View { - @State private var viewModel = ViewModel() - - @State private var selection = 0 - - var body: some View { - NavigationStack { - VStack { - pickerView - switch self.selection { - case 0: - TapView() - case 1: - SwipeView() - case 2: - MediaView() - case 3: - InputsView() - case 4: - ChatView() - default: - EmptyView() - } - Spacer() - } - .navigationTitle("Discover") - } - - /*NavigationSplitView { - // Sidebar - List(selection: $viewModel.selectedItem) { - ForEach(viewModel.items) { item in - NavigationLink(value: item.id) { - Label(item.name, systemImage: item.icon) - } - } - } - .navigationTitle("Discover") - } detail: { - // Detail view - if let selectedItemId = viewModel.selectedItem, - let selectedItem = viewModel.items.first(where: { $0.id == selectedItemId }) { - selectedItem.view - .navigationTitle(selectedItem.name) - } else { - ContentUnavailableView( - "Select an item", - systemImage: "sidebar.left", - description: Text("Choose an item from the sidebar to see its content") - ) - } - }*/ - } - - var pickerView: some View { - Picker("", selection: $selection) { - Text("Tap").tag(0) - Text("Swipe").tag(1) - Text("Media").tag(2) - Text("Text").tag(3) - Text("Chat").tag(4) - } - .pickerStyle(.segmented) - .padding(.horizontal) - } - -} diff --git a/ios/playground/Playground/Discover/SwipeView.swift b/ios/playground/Playground/Discover/SwipeView.swift deleted file mode 100644 index b8d946ee3..000000000 --- a/ios/playground/Playground/Discover/SwipeView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SwipeView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 23/07/25. -// - -import SwiftUI - -struct SwipeView: View { - - @State private var sections: [[Item]] = (1...3).map { section in - (1...5).map { index in - Item(title: "Title \(section)-\(index)", subtitle: "Subtitle for item \(index)") - } - } - - var body: some View { - List { - ForEach(sections.indices, id: \.self) { sectionIndex in - if !sections[sectionIndex].isEmpty { - Section(header: Text("Section \(sectionIndex + 1)")) { - ForEach(sections[sectionIndex]) { item in - VStack(alignment: .leading) { - Text(item.title) - .font(.headline) - Text(item.subtitle) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .onDelete { indexSet in - withAnimation { - sections[sectionIndex].remove(atOffsets: indexSet) - } - } - } - } - } - } - } -} - -struct Item: Identifiable { - let id = UUID() - let title: String - let subtitle: String -} diff --git a/ios/playground/Playground/Discover/TapView.swift b/ios/playground/Playground/Discover/TapView.swift deleted file mode 100644 index 78b3b1c67..000000000 --- a/ios/playground/Playground/Discover/TapView.swift +++ /dev/null @@ -1,264 +0,0 @@ -// -// TapView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 23/07/25. -// - -import SwiftUI - -struct TapView: View { - - @State private var buttonCount: Int = 0 - @State private var iconCount: Int = 0 - @State private var isSwitchOn = false - @State private var isCheckboxSelected = false - @State private var isRadioButtonSelected = false - @State private var chip1Selected = false - @State private var chip2Selected = false - @State private var chip3Selected = false - @State private var sliderValue: Double = 50 - - var body: some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - titleView - buttonsSectionView - togglesSectionView - chipsSectionView - slidersSectionView - iconButtonsSectionView - Spacer(minLength: 20) - } - } - .padding() - } - - var titleView: some View { - VStack(alignment: .leading, spacing: 32) { - Text("TAP SCREEN") - .font(.title) - Text("Various tappable widgets for testing") - .font(.body) - } - } - - var buttonsSectionView: some View { - VStack(spacing: 16) { - HStack { - Text("Buttons") - .font(.headline) - Spacer() - } - HStack { - Text("Count: \(buttonCount)") - .font(.body) - Spacer() - } - HStack { - borderedProminentButton - borderedButton - } - HStack { - borderlessButton - plainButton - } - largeButton - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray, lineWidth: 2) - ) - .padding(.horizontal, 2) - } - - var borderedProminentButton: some View { - Button { - buttonCount += 1 - } label: { - HStack { - Spacer() - Text("Prominent") - Spacer() - } - } - .buttonStyle(.borderedProminent) - } - - var borderedButton: some View { - Button { - buttonCount += 1 - } label: { - HStack { - Spacer() - Text("Bordered") - Spacer() - } - } - .buttonStyle(.bordered) - } - - var borderlessButton: some View { - Button { - buttonCount += 1 - } label: { - HStack { - Spacer() - Text("Borderless") - Spacer() - } - } - .buttonStyle(.borderless) - } - - var plainButton: some View { - Button { - buttonCount += 1 - } label: { - HStack { - Spacer() - Text("Plain") - Spacer() - } - } - .buttonStyle(.plain) - } - - var largeButton: some View { - Button { - buttonCount += 1 - } label: { - HStack { - Spacer() - Text("Large") - Spacer() - } - } - .buttonStyle(.borderedProminent) - } - - var togglesSectionView: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Toggle controls") - .font(.headline) - Spacer() - } - HStack(spacing: 16) { - Toggle("", isOn: $isSwitchOn) - .labelsHidden() - Text("Switch") - Spacer() - } - HStack(spacing: 16) { - Checkbox(isOn: $isCheckboxSelected) - Text("Checkbox") - Spacer() - } - HStack(spacing: 16) { - RadioButton(isOn: $isRadioButtonSelected) - Text("Radio Button") - Spacer() - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray, lineWidth: 2) - ) - .padding(.horizontal, 2) - } - - var iconButtonsSectionView: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Icon buttons") - .font(.headline) - Spacer() - } - HStack { - Text("Count: \(iconCount)") - .font(.body) - Spacer() - } - HStack(spacing: 16) { - Button { - iconCount += 1 - } label: { - Image(systemName: "pencil") - } - Button { - iconCount += 1 - } label: { - Image(systemName: "trash.fill") - } - Button { - iconCount += 1 - } label: { - Image(systemName: "heart.fill") - } - Button { - iconCount += 1 - } label: { - Image(systemName: "star.fill") - } - Button { - iconCount += 1 - } label: { - Image(systemName: "arrow.clockwise") - } - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray, lineWidth: 2) - ) - .padding(.horizontal, 2) - } - - var chipsSectionView: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Filter Chips") - .font(.headline) - Spacer() - } - HStack(spacing: 16) { - ChipView(title: "First chip", isSelected: $chip1Selected) - ChipView(title: "Second chip", isSelected: $chip2Selected) - ChipView(title: "Third chip", isSelected: $chip3Selected) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray, lineWidth: 2) - ) - .padding(.horizontal, 2) - } - - var slidersSectionView: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Sliders") - .font(.headline) - Spacer() - } - Text("Slider value: \(sliderValue)") - Slider(value: $sliderValue, in: 0...100) - Text("Progress indicator") - ProgressView("", value: sliderValue, total: 100) - .progressViewStyle(.linear) - .labelsHidden() - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray, lineWidth: 2) - ) - .padding(.horizontal, 2) - } - -} diff --git a/ios/playground/Playground/Home/HomeView.swift b/ios/playground/Playground/Home/HomeView.swift deleted file mode 100644 index fffbe0439..000000000 --- a/ios/playground/Playground/Home/HomeView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// HomeView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 22/07/25. -// - -import SwiftUI - -struct HomeView: View { - var body: some View { - TabView { - Tab("Discover", systemImage: "magnifyingglass") { - DiscoverView() - } - Tab("Slides", systemImage: "play.square") { - SlidesView() - } - Tab("Settings", systemImage: "gear") { - SettingsView() - } - } - } - -} diff --git a/ios/playground/Playground/Login/LoginView.swift b/ios/playground/Playground/Login/LoginView.swift deleted file mode 100644 index 48a2ba1ac..000000000 --- a/ios/playground/Playground/Login/LoginView.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// LoginView.swift -// Playground -// -// Created by Kishore Sajja on 7/24/25. -// - -import SwiftUI -import RegexBuilder - -struct User: Codable { - var email: String = "" - var fullname: String = "" -} - -struct LoginViewModel { - @AppStorage("didLogIn") var loggedInUser: Bool = false - @AppStorage("loggedInUserData") var loggedInUserData: Data = Data() - - var email: String = "" - var password: String = "" - - var canContinueLogin: Bool = false - - let emailPattern = Regex { - Capture { - ZeroOrMore { - OneOrMore(.word) - "." - } - OneOrMore(.word) - } - "@" - Capture { - OneOrMore(.word) - OneOrMore { - "." - OneOrMore(.word) - } - } - } - - mutating func validateCredentials() { - let isValidEmail = email.wholeMatch(of: emailPattern) != nil - canContinueLogin = isValidEmail && password.count >= 4 - } - - func continueLoggingIn() { - guard self.canContinueLogin else { return } - writeUserData(User(email: email, fullname: "John Doe")) - } - - func continueAsGuest() { - writeUserData(User(email: "guest@exampleuser.com", fullname: "Guest User")) - } - - func logout() { - writeUserData(nil) - } - - private func writeUserData(_ user: User?) { - if let user = user, let userData = try? JSONEncoder().encode(user) { - self.loggedInUserData = userData - self.loggedInUser = true - } else { - self.loggedInUserData = Data() - self.loggedInUser = false - } - } -} - -struct LoginView: View { - @State var viewModel: LoginViewModel = .init() - - var body: some View { - VStack(alignment: .center) { - Image(.autoMobile) - - Text("AutoMobile") - .font(.largeTitle) - .fontWeight(.black) - - VStack(alignment: .leading, spacing: 25) { - TextField("Email", text: $viewModel.email) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .onChange(of: viewModel.email) { - viewModel.validateCredentials() - } - - SecureField("Password", text: $viewModel.password) - .onChange(of: viewModel.password) { - viewModel.validateCredentials() - } - } - .padding(25) - .textFieldStyle(.roundedBorder) - - HStack { - if viewModel.canContinueLogin { - Button("Sign in or Register") { - viewModel.continueLoggingIn() - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - } - - Button("Continue as Guest") { - viewModel.continueAsGuest() - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - - } - } - } -} - -#Preview { - LoginView() -} diff --git a/ios/playground/Playground/Onboarding/OnboardingView.swift b/ios/playground/Playground/Onboarding/OnboardingView.swift deleted file mode 100644 index 17b073e68..000000000 --- a/ios/playground/Playground/Onboarding/OnboardingView.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// OnboardingView.swift -// Playground -// -// Created by Kishore Sajja on 7/23/25. -// - -import SwiftUI - -struct OnboardingPage: Identifiable { - enum Image { - case resource(ImageResource) - case systemImage(String) - case emoji(String) - } - - var resource: OnboardingPage.Image - var title: String - var description: String - - var id: String { "\(title).\(resource)"} - - enum Pages { - static let welcome = OnboardingPage(resource: .resource(.autoMobile), - title: "Welcome to AutoMobile", - description: "Experience the future of iOS UI Test Automation with intelligent source mapping and self-healing tests.") - - static let sourceCodeIntelligence = OnboardingPage(resource: .emoji("🔍"), - title: "Source Code Intelligence", - description: "Inspect your project source code directly through the view hierarchy using advanced code heuristics and source mapping.") - - static let smartGesturesAndInput = OnboardingPage(resource: .emoji("🦾"), - title: "Smart Gestures & Input", - description: "Precise gestures with window inset awareness and Unicode text input via virtual keyboards for comprehensive testing.") - - static let automaticTestGeneration = OnboardingPage(resource: .emoji("🤖"), - title: "Automated Test Generation", - description: "Automatically write tests with configurable credentials and experiment settings. Get highly actionable errors and self-healing capabilities.") - - static let openSource = OnboardingPage(resource: .emoji("❤️"), - title: "Open Source", - description: "Built by Zillow and hosted on [GitHub](https://github.com/zillow/auto-mobile)") - } -} - -struct OnboardingViewModel { - var isOnboardingComplete: Bool = false - var onboardingPages: [OnboardingPage] = [ - .Pages.welcome, - .Pages.sourceCodeIntelligence, - .Pages.smartGesturesAndInput, - .Pages.automaticTestGeneration, - .Pages.openSource, - ] -} - -struct OnboardingPageView: View { - var page: OnboardingPage - var body: some View { - VStack(alignment: .center, spacing: 30) { - - if case OnboardingPage.Image.resource(let resource) = page.resource { - Image(resource) - } else if case OnboardingPage.Image.systemImage(let systemImage) = page.resource { - Image(systemName: systemImage) - } else if case OnboardingPage.Image.emoji(let symbol) = page.resource { - Text(symbol) - .font(.system(size: 100)) - } - - Text(page.title) - .font(.title) - .fontWeight(.bold) - if let markdown = try? AttributedString(markdown: page.description) { - Text(markdown) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } else { - Text(page.description) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - }.padding() - } -} - -struct OnboardingViewMode { - let pages: [OnboardingPage] = [ - .Pages.welcome, - .Pages.sourceCodeIntelligence, - .Pages.smartGesturesAndInput, - .Pages.automaticTestGeneration, - .Pages.openSource - ] - var currentPage: Int = 0 -} - -struct OnboardingView: View { - @State var viewmodel: OnboardingViewMode = .init() - @AppStorage("onboardingCompelte") private var onboardingCompelte = false - @Environment(\.dismiss) var dismiss - var body: some View { - TabView(selection: $viewmodel.currentPage) { - ForEach(viewmodel.pages.indices, id: \.self) { index in - OnboardingPageView(page: viewmodel.pages[index]) - .tag(index) - } - } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) - .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) - - HStack { - Button { - if viewmodel.currentPage > 0 { - withAnimation { - viewmodel.currentPage -= 1 - } - } - } label: { - Text("Back") - .padding([.leading, .trailing]) - }.padding(.leading) - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .disabled(viewmodel.currentPage == 0) - - Spacer() - - Button { - if viewmodel.currentPage < viewmodel.pages.count - 1 { - withAnimation { - viewmodel.currentPage += 1 - } - } else { - onboardingCompelte = true - dismiss() - } - - } label: { - if viewmodel.currentPage == viewmodel.pages.count - 1 { - Text("Get started") - .padding([.leading, .trailing]) - } else { - Text("Next") - .padding([.leading, .trailing]) - } - } - .padding(.trailing) - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - .tint(.black) - }.padding() - } -} - -#Preview { - OnboardingView() -} diff --git a/ios/playground/Playground/PlaygroundApp.swift b/ios/playground/Playground/PlaygroundApp.swift deleted file mode 100644 index 3c2f68169..000000000 --- a/ios/playground/Playground/PlaygroundApp.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// PlaygroundApp.swift -// Playground -// -// Created by Jason Pearson on 7/21/25. -// - -import SwiftUI - -@main -struct PlaygroundApp: App { - @AppStorage("onboardingCompelte") var onboardingCompelte = false - @AppStorage("didLogIn") var loggedInUser: Bool = false - - @State private var showingOnboarding = false - - var body: some Scene { - WindowGroup { - Group { - if loggedInUser { - HomeView() - } else { - LoginView() - } - } - .fullScreenCover(isPresented: $showingOnboarding) { - OnboardingView() - } - .onAppear { - showingOnboarding = !onboardingCompelte - } - } - } -} diff --git a/ios/playground/Playground/Settings/SettingsView.swift b/ios/playground/Playground/Settings/SettingsView.swift deleted file mode 100644 index 33cf68eeb..000000000 --- a/ios/playground/Playground/Settings/SettingsView.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// SettingsView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 22/07/25. -// - -import SwiftUI - -struct SettingsViewModel{ - @AppStorage("onboardingCompelte") private var onboardingCompelte = false - @AppStorage("didLogIn") private var loggedInUser: Bool = false - @AppStorage("loggedInUserData") private var loggedInUserData: Data = Data() - - var user: User? { -// return User(email: "email@example.com", fullname: "José Antonio Arellano Mendoza") - guard loggedInUser else { return nil } - let decoder = JSONDecoder() - return try? decoder.decode(User.self, from: loggedInUserData) - } - - func logout() { - self.loggedInUserData = Data() - self.loggedInUser = false - } - - func resetOnboarding() { - self.onboardingCompelte = false - } -} - -struct ElevatedCard: ViewModifier { - func body(content: Content) -> some View { - content - .background(Color.white.opacity(0.95)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .shadow(color: Color.black.opacity(0.2), radius: 8, x: 5, y: 5) - } -} -extension View { - func cardStyled() -> some View { - modifier(ElevatedCard()) - } -} - -struct SettingsView: View { - let viewModel = SettingsViewModel() - var body: some View { - NavigationStack { - VStack { - if let user = viewModel.user { - HStack { - VStack(alignment: .leading) { - Text(user.fullname) - Text(user.email) - } - - Spacer() - Image(systemName: "person") - .resizable() - .frame(width: 44, height: 44) - .foregroundColor(.black) - } - .padding() - .background(Color.gray.opacity(0.1)) - Divider() - } else { - Text("Not logged in") - } - ScrollView { - VStack(alignment: .center, spacing: 30) { - Text("Account actions") - .font(.title2) - .fontWeight(.semibold) - .padding(.top) - - Button(role: .destructive) { - viewModel.logout() - } label: { - Label("Logout", systemImage: "rectangle.portrait.and.arrow.forward") - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - - Button(role: .destructive) { - viewModel.resetOnboarding() - } label: { - Label("Reset Onboarding State", systemImage: "trash") - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - Text("") - } - .frame(maxWidth: .infinity) - .background(Color.red.opacity(0.15)) - .cardStyled() - .padding() - } - } - } - } -} - -#Preview { - SettingsView() -} diff --git a/ios/playground/Playground/Slides/SlidesView.swift b/ios/playground/Playground/Slides/SlidesView.swift deleted file mode 100644 index d294232f7..000000000 --- a/ios/playground/Playground/Slides/SlidesView.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// SlidesView.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 22/07/25. -// - -import SwiftUI - -struct SlidesView: View { - var body: some View { - NavigationStack { - Text("Slides view") - .navigationTitle(Text("Slides")) - } - } -} diff --git a/ios/playground/Playground/UIComponents/Checkbox.swift b/ios/playground/Playground/UIComponents/Checkbox.swift deleted file mode 100644 index 2aa29616c..000000000 --- a/ios/playground/Playground/UIComponents/Checkbox.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Checkbox.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 23/07/25. -// - -import SwiftUI - -struct Checkbox: View { - @Binding var isOn: Bool - - var body: some View { - Image(systemName: isOn ? "checkmark.square.fill" : "square") - .foregroundColor(isOn ? .blue : .gray) - .font(.title3) - .onTapGesture { - isOn.toggle() - } - } -} diff --git a/ios/playground/Playground/UIComponents/Chip.swift b/ios/playground/Playground/UIComponents/Chip.swift deleted file mode 100644 index 6be6624e1..000000000 --- a/ios/playground/Playground/UIComponents/Chip.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Chip.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 23/07/25. -// - -import SwiftUI - -struct ChipView: View { - let title: String - @Binding var isSelected: Bool - - var body: some View { - Button(action: { - isSelected.toggle() - }) { - Text(title) - .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isSelected ? Color.blue.opacity(0.2) : Color.clear) - .foregroundColor(isSelected ? .blue : .gray) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(isSelected ? Color.blue : Color.gray, lineWidth: 1) - ) - .cornerRadius(16) - } - .buttonStyle(PlainButtonStyle()) // evita efecto de escala por defecto - } -} diff --git a/ios/playground/Playground/UIComponents/RadioButton.swift b/ios/playground/Playground/UIComponents/RadioButton.swift deleted file mode 100644 index 252b0c9a7..000000000 --- a/ios/playground/Playground/UIComponents/RadioButton.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// RadioButton.swift -// Playground -// -// Created by José Antonio Arellano Mendoza on 23/07/25. -// - -import SwiftUI - -struct RadioButton: View { - @Binding var isOn: Bool - - var body: some View { - Image(systemName: isOn ? "largecircle.fill.circle" : "circle") - .foregroundColor(isOn ? .blue : .gray) - .font(.title3) - .onTapGesture { - isOn.toggle() - } - } -} diff --git a/ios/playground/Playground/Views/InputsView/InputsView.swift b/ios/playground/Playground/Views/InputsView/InputsView.swift deleted file mode 100644 index 6c97e92b0..000000000 --- a/ios/playground/Playground/Views/InputsView/InputsView.swift +++ /dev/null @@ -1,194 +0,0 @@ -import SwiftUI - -struct InputsView: View { - - @State private var basicText = "" - @State private var placeholderText = "" - @State private var styledText = "" - @State private var numberText = "" - @State private var emailText = "" - @State private var password = "" - @State private var multilineText = "" - @State private var searchText = "" - @State private var limitedText = "" - @State private var decimalText = "" - @State private var phoneText = "" - - var body: some View { - ScrollView { - VStack(spacing: 20) { - // Header - Text("Comprehensive list of text input views") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.top) - - // Basic TextField - VStack(alignment: .leading, spacing: 8) { - Text("Basic TextField") - .font(.headline) - TextField("Enter text", text: $basicText) - .textFieldStyle(.roundedBorder) - } - - // TextField with placeholder - VStack(alignment: .leading, spacing: 8) { - Text("TextField with Custom Placeholder") - .font(.headline) - TextField("Type something here...", text: $placeholderText) - .textFieldStyle(.roundedBorder) - } - - // Styled TextField - VStack(alignment: .leading, spacing: 8) { - Text("Styled TextField") - .font(.headline) - TextField("Custom styled field", text: $styledText) - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.blue, lineWidth: 2) - ) - } - - // Number input - VStack(alignment: .leading, spacing: 8) { - Text("Number Input") - .font(.headline) - TextField("Enter numbers only", text: $numberText) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - } - - // Decimal input - VStack(alignment: .leading, spacing: 8) { - Text("Decimal Input") - .font(.headline) - TextField("0.00", text: $decimalText) - .keyboardType(.decimalPad) - .textFieldStyle(.roundedBorder) - } - - // Phone input - VStack(alignment: .leading, spacing: 8) { - Text("Phone Number Input") - .font(.headline) - TextField("(555) 123-4567", text: $phoneText) - .keyboardType(.phonePad) - .textFieldStyle(.roundedBorder) - } - - // Email input - VStack(alignment: .leading, spacing: 8) { - Text("Email Input") - .font(.headline) - TextField("user@example.com", text: $emailText) - .keyboardType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - .textFieldStyle(.roundedBorder) - } - - // Password field - VStack(alignment: .leading, spacing: 8) { - Text("SecureField (Password)") - .font(.headline) - SecureField("Enter password", text: $password) - .textFieldStyle(.roundedBorder) - } - - // Search field - VStack(alignment: .leading, spacing: 8) { - Text("Search Field") - .font(.headline) - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - TextField("Search...", text: $searchText) - } - .padding(8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - - // Character limited text field - VStack(alignment: .leading, spacing: 8) { - Text("Limited Text Field (20 chars)") - .font(.headline) - TextField("Max 20 characters", text: $limitedText) - .textFieldStyle(.roundedBorder) - .onChange(of: limitedText) { _, newValue in - if newValue.count > 20 { - limitedText = String(newValue.prefix(20)) - } - } - - Text("\(limitedText.count)/20 characters") - .font(.caption) - .foregroundColor(.gray) - } - - // Multiline TextEditor - VStack(alignment: .leading, spacing: 8) { - Text("TextEditor (Multiline)") - .font(.headline) - TextEditor(text: $multilineText) - .frame(height: 100) - .padding(4) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - - // TextEditor with placeholder effect - VStack(alignment: .leading, spacing: 8) { - Text("TextEditor with Placeholder") - .font(.headline) - ZStack(alignment: .topLeading) { - TextEditor(text: $multilineText) - .frame(height: 80) - - if multilineText.isEmpty { - Text("Enter your thoughts here...") - .foregroundColor(.gray) - .padding(.horizontal, 4) - .padding(.vertical, 8) - } - } - .padding(4) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - - // Form-style inputs - VStack(alignment: .leading, spacing: 8) { - Text("Form Style Inputs") - .font(.headline) - - Form { - Section("Personal Information") { - TextField("First Name", text: $basicText) - TextField("Last Name", text: $placeholderText) - TextField("Email", text: $emailText) - .keyboardType(.emailAddress) - } - - Section("Security") { - SecureField("Password", text: $password) - SecureField("Confirm Password", text: $password) - } - } - .frame(height: 200) - } - - Spacer(minLength: 20) - } - .padding(.horizontal) - } - } -} diff --git a/ios/playground/Playground/Views/ListView/ListView.ViewModel.swift b/ios/playground/Playground/Views/ListView/ListView.ViewModel.swift deleted file mode 100644 index 0687fc89f..000000000 --- a/ios/playground/Playground/Views/ListView/ListView.ViewModel.swift +++ /dev/null @@ -1,63 +0,0 @@ -import SwiftUI - -extension ListView { - @Observable - class ViewModel: ObservableObject { - var todoItems: [TodoItem] = TodoItem.sampleData - - var newItemText = "" - var isAddingNewItem = false - - func toggleItem(_ id: UUID) { - if let index = todoItems.firstIndex(where: { $0.id == id }) { - todoItems[index].isCompleted.toggle() - } - } - - func moveItems(from source: IndexSet, to destination: Int) { - todoItems.move(fromOffsets: source, toOffset: destination) - } - - func deleteItems(at offsets: IndexSet) { - todoItems.remove(atOffsets: offsets) - } - - func addNewItem() { - guard !newItemText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return - } - - let newItem = TodoItem(text: newItemText.trimmingCharacters(in: .whitespacesAndNewlines)) - todoItems.append(newItem) - newItemText = "" - isAddingNewItem = false - } - - func cancelAddingItem() { - newItemText = "" - isAddingNewItem = false - } - - func startAddingNewItem() { - isAddingNewItem = true - } - } -} - -// MARK: - Sample Model - -struct TodoItem: Identifiable { - let id = UUID() - var text: String - var isCompleted: Bool = false -} - -extension TodoItem { - static let sampleData = [ - TodoItem(text: "Buy groceries"), - TodoItem(text: "Walk the dog"), - TodoItem(text: "Finish project"), - TodoItem(text: "Call mom"), - TodoItem(text: "Read a book") - ] -} diff --git a/ios/playground/Playground/Views/ListView/ListView.swift b/ios/playground/Playground/Views/ListView/ListView.swift deleted file mode 100644 index 9a62f5f7a..000000000 --- a/ios/playground/Playground/Views/ListView/ListView.swift +++ /dev/null @@ -1,68 +0,0 @@ -import SwiftUI - -struct ListView: View { - - @State private var viewModel = ViewModel() - - var body: some View { - List { - ForEach(viewModel.todoItems) { item in - TodoRowView( - item: item, - onToggle: { viewModel.toggleItem(item.id) } - ) - } - .onMove(perform: viewModel.moveItems) - .onDelete(perform: viewModel.deleteItems) - if viewModel.isAddingNewItem { - HStack { - TextField("New todo item", text: $viewModel.newItemText) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .onSubmit { - viewModel.addNewItem() - } - Button("Cancel") { - viewModel.cancelAddingItem() - } - .foregroundColor(.red) - } - .padding(.vertical, 4) - } - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - EditButton() - Button(action: { - viewModel.startAddingNewItem() - }) { - Image(systemName: "plus") - } - .disabled(viewModel.isAddingNewItem) - } - } - } -} - -struct TodoRowView: View { - let item: TodoItem - let onToggle: () -> Void - - var body: some View { - HStack { - Button(action: onToggle) { - Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle") - .foregroundColor(item.isCompleted ? .green : .gray) - .font(.title2) - } - .buttonStyle(PlainButtonStyle()) - - Text(item.text) - .strikethrough(item.isCompleted) - .foregroundColor(item.isCompleted ? .gray : .primary) - .animation(.easeInOut(duration: 0.2), value: item.isCompleted) - - Spacer() - } - } -} - diff --git a/ios/playground/Playground/Views/MediaView/MediaView.swift b/ios/playground/Playground/Views/MediaView/MediaView.swift deleted file mode 100644 index fc0daf203..000000000 --- a/ios/playground/Playground/Views/MediaView/MediaView.swift +++ /dev/null @@ -1,9 +0,0 @@ -import AVKit -import SwiftUI - -struct MediaView: View { - var body: some View { - VideoPlayer(player: AVPlayer(url: Bundle.main.url(forResource: "video", withExtension: "mp4")!)) - .frame(height: 400) - } -} diff --git a/ios/playground/Playground/video.mp4 b/ios/playground/Playground/video.mp4 deleted file mode 100644 index b06993685..000000000 Binary files a/ios/playground/Playground/video.mp4 and /dev/null differ diff --git a/ios/playground/PlaygroundTests/PlaygroundTests.swift b/ios/playground/PlaygroundTests/PlaygroundTests.swift deleted file mode 100644 index b56a45390..000000000 --- a/ios/playground/PlaygroundTests/PlaygroundTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// PlaygroundTests.swift -// PlaygroundTests -// -// Created by Jason Pearson on 7/21/25. -// - -import XCTest -@testable import Playground - -final class PlaygroundTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/ios/playground/PlaygroundUITests/PlaygroundUITests.swift b/ios/playground/PlaygroundUITests/PlaygroundUITests.swift deleted file mode 100644 index 8046d6b78..000000000 --- a/ios/playground/PlaygroundUITests/PlaygroundUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// PlaygroundUITests.swift -// PlaygroundUITests -// -// Created by Jason Pearson on 7/21/25. -// - -import XCTest - -final class PlaygroundUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } -} diff --git a/ios/playground/PlaygroundUITests/PlaygroundUITestsLaunchTests.swift b/ios/playground/PlaygroundUITests/PlaygroundUITestsLaunchTests.swift deleted file mode 100644 index 9f3692240..000000000 --- a/ios/playground/PlaygroundUITests/PlaygroundUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// PlaygroundUITestsLaunchTests.swift -// PlaygroundUITests -// -// Created by Jason Pearson on 7/21/25. -// - -import XCTest - -final class PlaygroundUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/ios/playground/README.md b/ios/playground/README.md deleted file mode 100644 index 0965f92d6..000000000 --- a/ios/playground/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# 🎮 AutoMobile iOS Playground - -The AutoMobile iOS Playground is a demonstration app designed to test and showcase AutoMobile's automation -capabilities. Its purpose is: - -1. **🎯 Serve as a test target** - Provides a controlled iOS app environment for AutoMobile to interact with and - automate, both to make itself better and to improve its playground. -2. **🚀 Use modern iOS development** - Built with 100% SwiftUI and modern iOS patterns. -3. **📦 Minimal dependencies** - No dependency injection frameworks or network calls to keep it simple and self-contained. -4. **🎨 Feature variety** - Includes multiple modules (login, home, settings, media player, onboarding, etc.) to test - different UI patterns and Experiment/Treatment context awareness. -5. **📸 Media capabilities** - Has basic image and video loading and rendering via first party Apple frameworks. diff --git a/jemalloc-5.3.0.tar.bz2 b/jemalloc-5.3.0.tar.bz2 new file mode 100644 index 000000000..5de860d77 Binary files /dev/null and b/jemalloc-5.3.0.tar.bz2 differ diff --git a/knip.json b/knip.json new file mode 100644 index 000000000..e296cb591 --- /dev/null +++ b/knip.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://unpkg.com/knip@latest/schema.json", + "entry": [ + "src/index.ts", + "src/cli/index.ts", + "test/**/*.ts", + "scripts/**/*.ts" + ], + "project": [ + "src/**/*.ts", + "test/**/*.ts", + "scripts/**/*.ts" + ], + "ignore": [ + "dist/**", + "node_modules/**", + "android/**", + "src/db/migrations/**", + "**/*.d.ts" + ], + "ignoreDependencies": ["@types/pixelmatch"], + "ignoreBinaries": [], + "ignoreExportsUsedInFile": true, + "includeEntryExports": true, + "typescript": { + "config": "tsconfig.dead-code.json" + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 2a5f77715..785a2c76a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,13 +4,13 @@ site_name: AutoMobile repo_name: AutoMobile -repo_url: https://github.com/zillow/auto-mobile +repo_url: https://github.com/kaeawc/auto-mobile site_description: "Mobile interaction automation" -site_author: Zillow +site_author: Jason Pearson remote_branch: gh-pages use_directory_urls: true -copyright: 'Copyright © 2025 Zillow Group' +copyright: 'Copyright © 2025-2026 Jason Pearson' plugins: - search @@ -67,38 +67,99 @@ markdown_extensions: alternate_style: true - tables - admonition + - pymdownx.details - attr_list - md_in_html nav: - 'Overview': index.md - - 'Installation': installation.md - - 'Features': - - 'Overview': features/index.md - - 'MCP Server': - - 'Overview': features/mcp-server/index.md - - 'Actions': features/mcp-server/actions.md - - 'Observation': features/mcp-server/observation.md - - 'Interaction Loop': features/mcp-server/interaction-loop.md - - 'Test Authoring': - - 'Overview': features/test-authoring/index.md - - 'Plan Syntax': features/test-authoring/plan-syntax.md - - 'Test Execution': - - 'Overview': features/test-execution/index.md - - 'JUnitRunner': features/test-execution/junitrunner.md - - 'CI': features/test-execution/ci.md - - 'Options': features/test-execution/options.md - - 'Batteries Included': features/batteries-included.md - - 'CLI': features/cli.md - - 'MCP Client Support': - - 'Overview': mcp-clients/index.md - - 'Firebender ': mcp-clients/firebender.md - - 'Cursor': mcp-clients/cursor.md - - 'Goose': mcp-clients/goose.md - - 'Contributing': - - 'How': contributing/index.md - - 'Local Development': contributing/local-development.md - - 'GitHub Discussions': https://github.com/zillow/auto-mobile/discussions + - 'Install': install.md + - 'Using AutoMobile': + - 'UX Exploration': using/ux-exploration.md + - 'Reproducing Bugs': using/reproducting-bugs.md + - 'UI Tests': using/ui-tests.md + - 'Performance Analysis': + - 'Overview': using/perf-analysis/index.md + - 'Startup': using/perf-analysis/startup.md + - 'Screen Transition': using/perf-analysis/screen-transition.md + - 'Scroll Framerate': using/perf-analysis/scroll-framerate.md + - 'Accessibility': using/a11y.md + - 'Design Docs': + - 'Overview': design-docs/index.md + - 'Status Glossary': design-docs/status-glossary.md + - 'MCP': + - 'Overview': design-docs/mcp/index.md + - 'Tools': design-docs/mcp/tools.md + - 'Observe': + - 'Overview': design-docs/mcp/observe/index.md + - 'Appearance Sync': design-docs/mcp/observe/appearance.md + - 'Screen Streaming': design-docs/mcp/observe/screen-streaming.md + - 'Video Recording': design-docs/mcp/observe/video-recording.md + - 'Vision Fallback': design-docs/mcp/observe/vision-fallback.md + - 'Visual Highlighting': design-docs/mcp/observe/visual-highlighting.md + - 'Interaction Loop': design-docs/mcp/interaction-loop.md + - 'Daemon': + - 'Overview': design-docs/mcp/daemon/index.md + - 'Critical Section': design-docs/mcp/daemon/critical-section.md + - 'Unix Socket API': design-docs/mcp/daemon/unix-socket-api.md + - 'Multi-device': design-docs/mcp/multi-device.md + - 'Navigation Graph': + - 'Overview': design-docs/mcp/nav/index.md + - 'Graph Structure': design-docs/mcp/nav/graph-structure.md + - 'Fingerprinting': design-docs/mcp/nav/fingerprinting.md + - 'Performance': design-docs/mcp/nav/performance.md + - 'Explore': design-docs/mcp/nav/explore.md + - 'Feature Flags': design-docs/mcp/feature-flags.md + - 'Storage': + - 'Overview': design-docs/mcp/storage/index.md + - 'Migrations': design-docs/mcp/storage/migrations.md + - 'Device State Snapshots': design-docs/mcp/storage/snapshots.md + - 'Accessibility': + - 'Overview': design-docs/mcp/a11y/index.md + - 'TalkBack/VoiceOver': design-docs/mcp/a11y/talkback-voiceover.md + - 'Resources': design-docs/mcp/resources.md + - 'Context Thresholds': design-docs/mcp/context-thresholds.md + - 'Test Plan Validation': design-docs/mcp/test-plan-validation.md + - 'Platform Specific': + - 'Android': + - 'Overview': design-docs/plat/android/index.md + - 'Observe': design-docs/plat/android/observe.md + - 'Screen Streaming': design-docs/plat/android/screen-streaming.md + - 'CtrlProxy': design-docs/plat/android/control-proxy.md + - 'AutoMobile SDK': design-docs/plat/android/auto-mobile-sdk.md + - 'JUnitRunner': design-docs/plat/android/junitrunner.md + - 'IDE Plugin': + - 'Overview': design-docs/plat/android/ide-plugin/overview.md + - 'Test Recording': design-docs/plat/android/ide-plugin/test-recording.md + - 'Navigation Graph Render': design-docs/plat/android/ide-plugin/navigation-graph.md + - 'Control Feature Flags': design-docs/plat/android/ide-plugin/feature-flags.md + - 'Work Profiles': design-docs/plat/android/work-profiles.md + - 'Device Tools': + - 'takeScreenshot fallback': design-docs/plat/android/take-screenshot.md + - 'Biometrics stubbing': design-docs/plat/android/biometrics.md + - 'Notification triggering': design-docs/plat/android/notifications.md + - 'System tray lookFor': design-docs/plat/android/system-tray-lookfor.md + - 'Clipboard tool': design-docs/plat/android/clipboard.md + - 'Feature Ideas': + - 'Overview': design-docs/plat/android/feature-ideas.md + - 'Network state control': design-docs/plat/android/network-state.md + - 'Accessibility testing': design-docs/plat/android/accessibility-testing.md + - 'executePlan assertions': design-docs/plat/android/executeplan-assertions.md + - 'TalkBack simulation': design-docs/plat/android/talkback.md + - 'iOS': + - 'Overview': design-docs/plat/ios/index.md + - 'Screen Streaming': design-docs/plat/ios/screen-streaming.md + - 'XCTest Service': design-docs/plat/ios/xctestservice.md + - 'simctl Integration': design-docs/plat/ios/simctl.md + - 'Managed App Configuration': design-docs/plat/ios/managed-app-config.md + - 'Managed Apple IDs': design-docs/plat/ios/managed-apple-ids.md + - 'XCTest Runner': design-docs/plat/ios/xctestrunner.md + - 'testmanagerd': design-docs/plat/ios/testmanagerd.md + - 'Xcode Integration': + - 'Overview': design-docs/plat/ios/ide-plugin/overview.md + - 'Test Recording': design-docs/plat/ios/ide-plugin/test-recording.md + - 'Feature Flags': design-docs/plat/ios/ide-plugin/feature-flags.md + - 'Contributing': contributing.md + - 'GitHub Discussions': https://github.com/kaeawc/auto-mobile/discussions - 'FAQ': faq.md - - 'Security': security.md - 'Change Log': changelog.md diff --git a/package-lock.json b/package-lock.json index 398b8a2a7..ef3796fa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,86 +1,96 @@ { - "name": "auto-mobile", - "version": "0.0.5", + "name": "@kaeawc/auto-mobile", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "auto-mobile", - "version": "0.0.5", + "name": "@kaeawc/auto-mobile", + "version": "0.0.10", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.18.1", + "@anthropic-ai/sdk": "^0.73.0", + "@huggingface/transformers": "^3.8.1", + "@modelcontextprotocol/sdk": "^1.26.0", "@types/js-yaml": "^4.0.9", "@types/uuid": "^11.0.0", - "fs-extra": "^11.3.2", - "glob": "^11.0.3", + "@types/ws": "^8.18.1", + "adm-zip": "^0.5.16", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "async-mutex": "^0.5.0", + "fs-extra": "^11.3.3", + "glob": "^13.0.1", + "hono": "4.11.8", "jimp": "^1.6.0", - "js-yaml": "^4.1.0", + "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", + "kysely": "^0.28.9", + "onnxruntime-node": "^1.23.2", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", - "sharp": "^0.34.4", + "qs": "6.14.1", + "sharp": "^0.34.5", "uuid": "^13.0.0", + "ws": "^8.19.0", "xml2js": "^0.6.2", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.24.6" + "zod": "^4.3.5" }, "bin": { "auto-mobile": "dist/src/index.js" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.36.0", - "@faker-js/faker": "^10.0.0", - "@stylistic/eslint-plugin": "^5.4.0", - "@types/chai": "^5.2.2", - "@types/chai-as-promised": "^8.0.2", - "@types/express": "^5.0.3", + "@eslint/eslintrc": "^3.3.3", + "@eslint/js": "^9.38.0", + "@faker-js/faker": "^10.2.0", + "@stylistic/eslint-plugin": "^5.7.0", + "@types/express": "^5.0.5", "@types/fs-extra": "^11.0.4", - "@types/mocha": "^10.0.6", - "@types/node": "^24.3.1", + "@types/node": "^25.0.9", "@types/pixelmatch": "^5.2.6", "@types/pngjs": "^6.0.5", "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.2", - "@types/sinon-chai": "^4.0.0", "@types/xml2js": "^0.4.14", - "@typescript-eslint/eslint-plugin": "^8.44.0", - "@typescript-eslint/parser": "^8.44.0", - "@typescript-eslint/utils": "^8.44.0", - "chai": "^6.0.1", - "chai-as-promised": "^8.0.2", - "esbuild": "^0.25.9", - "esbuild-register": "^3.6.0", - "eslint": "^9.35.0", + "@typescript-eslint/eslint-plugin": "^8.53.0", + "@typescript-eslint/parser": "^8.53.0", + "@typescript-eslint/utils": "^8.53.0", + "esbuild": "^0.27.2", + "eslint": "^9.38.0", "eslint-plugin": "^1.0.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-notice": "^1.0.0", - "mocha": "^11.7.2", - "nyc": "^17.1.0", + "heapdump": "^0.3.15", + "knip": "^5.81.0", + "memwatch-next": "^0.3.0", "proxyquire": "^2.1.3", - "sinon": "^21.0.0", - "sinon-chai": "^4.0.1", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", - "tsx": "^4.20.5", + "ts-prune": "^0.10.3", + "tsx": "^4.21.0", "typescript": "^5.9.2" }, "engines": { - "node": ">=24" + "bun": ">=1.3.6" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "json-schema-to-ts": "^3.1.1" }, - "engines": { - "node": ">=6.0.0" + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -98,167 +108,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", - "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", @@ -269,100 +118,11 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", - "dev": true, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, "engines": { "node": ">=6.9.0" } @@ -391,10 +151,33 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -402,9 +185,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -419,9 +202,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -436,9 +219,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -453,9 +236,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -470,9 +253,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -487,9 +270,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -504,9 +287,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -521,9 +304,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -538,9 +321,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -555,9 +338,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -572,9 +355,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -589,9 +372,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -606,9 +389,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -623,9 +406,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -640,9 +423,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -657,9 +440,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -674,9 +457,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -691,9 +474,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -708,9 +491,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -725,9 +508,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -742,9 +525,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -759,9 +542,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -776,9 +559,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -793,9 +576,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -810,9 +593,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -827,9 +610,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -844,9 +627,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -876,9 +659,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -886,13 +669,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -901,22 +684,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -927,9 +710,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -939,7 +722,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -950,10 +733,34 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -964,9 +771,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -974,13 +781,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -988,9 +795,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", - "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.2.0.tgz", + "integrity": "sha512-rTXwAsIxpCqzUnZvrxVh3L0QA0NzToqWBLAhV+zDV3MIIwiQhAZHMdPCIaj5n/yADu/tyk12wIPgL6YHGXJP+g==", "dev": true, "funding": [ { @@ -1004,6 +811,62 @@ "npm": ">=10" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.3.tgz", + "integrity": "sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1080,9 +943,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -1098,13 +961,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -1120,13 +983,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -1140,9 +1003,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -1156,9 +1019,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -1172,9 +1035,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -1188,9 +1051,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -1203,10 +1066,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -1220,9 +1099,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -1236,9 +1115,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -1252,9 +1131,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -1268,9 +1147,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -1286,13 +1165,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -1308,13 +1187,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -1330,15 +1209,15 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ - "s390x" + "riscv64" ], "license": "Apache-2.0", "optional": true, @@ -1352,15 +1231,15 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ - "x64" + "s390x" ], "license": "Apache-2.0", "optional": true, @@ -1374,15 +1253,37 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ - "arm64" + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" ], "license": "Apache-2.0", "optional": true, @@ -1396,13 +1297,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1418,20 +1319,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.5.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1441,9 +1342,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1460,9 +1361,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1479,9 +1380,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1507,9 +1408,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -1518,145 +1419,16 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "license": "ISC", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" + "minipass": "^7.0.4" }, "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, "node_modules/@jimp/core": { @@ -1808,6 +1580,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-blit/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-blur": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", @@ -1834,6 +1615,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-circle/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-color": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", @@ -1850,6 +1640,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-color/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-contain": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", @@ -1867,6 +1666,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-contain/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-cover": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", @@ -1883,6 +1691,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-cover/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-crop": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", @@ -1898,6 +1715,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-crop/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-displace": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", @@ -1912,6 +1738,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-displace/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-dither": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", @@ -1938,6 +1773,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-fisheye/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-flip": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", @@ -1951,6 +1795,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-flip/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-hash": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", @@ -1985,6 +1838,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-mask/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-print": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", @@ -2006,6 +1868,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-print/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-quantize": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", @@ -2019,6 +1890,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-quantize/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-resize": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", @@ -2033,6 +1913,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-resize/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-rotate": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", @@ -2050,6 +1939,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-rotate/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/plugin-threshold": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", @@ -2067,6 +1965,15 @@ "node": ">=18" } }, + "node_modules/@jimp/plugin-threshold/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/types": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", @@ -2079,6 +1986,15 @@ "node": ">=18" } }, + "node_modules/@jimp/types/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jimp/utils": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", @@ -2092,21 +2008,6 @@ "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2117,16 +2018,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -2134,41 +2025,64 @@ "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.19.1.tgz", - "integrity": "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/@nodelib/fs.scandir": { + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", @@ -2206,214 +2120,536 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.3.tgz", + "integrity": "sha512-CVyWHu6ACDqDcJxR4nmGiG8vDF4TISJHqRNzac5z/gPQycs/QrP/1pDsJBy0MD7jSw8nVq2E5WqeHQKabBG/Jg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" + "os": [ + "android" + ] }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.16.3.tgz", + "integrity": "sha512-tTIoB7plLeh2o6Ay7NnV5CJb6QUXdxI7Shnsp2ECrLSV81k+oVE3WXYrQSh4ltWL75i0OgU5Bj3bsuyg5SMepw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sinonjs/commons/node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.16.3.tgz", + "integrity": "sha512-OXKVH7uwYd3Rbw1s2yJZd6/w+6b01iaokZubYhDAq4tOYArr+YCS+lr81q1hsTPPRZeIsWE+rJLulmf1qHdYZA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=4" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.16.3.tgz", + "integrity": "sha512-WwjQ4WdnCxVYZYd3e3oY5XbV3JeLy9pPMK+eQQ2m8DtqUtbxnvPpAYC2Knv/2bS6q5JiktqOVJ2Hfia3OSo0/A==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.16.3.tgz", + "integrity": "sha512-4OHKFGJBBfOnuJnelbCS4eBorI6cj54FUxcZJwEXPeoLc8yzORBoJ2w+fQbwjlQcUUZLEg92uGhKCRiUoqznjg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", - "type-detect": "^4.1.0" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@stylistic/eslint-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.4.0.tgz", - "integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==", + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.16.3.tgz", + "integrity": "sha512-OM3W0NLt9u7uKwG/yZbeXABansZC0oZeDF1nKgvcZoRw4/Yak6/l4S0onBfDFeYMY94eYeAt2bl60e30lgsb5A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.0", - "@typescript-eslint/types": "^8.44.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "estraverse": "^5.3.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": ">=9.0.0" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.16.3.tgz", + "integrity": "sha512-MRs7D7i1t7ACsAdTuP81gLZES918EpBmiUyEl8fu302yQB+4L7L7z0Ui8BWnthUTQd3nAU9dXvENLK/SqRVH8A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.16.3.tgz", + "integrity": "sha512-0eVYZxSceNqGADzhlV4ZRqkHF0fjWxRXQOB7Qwl5y1gN/XYUDvMfip+ngtzj4dM7zQT4U97hUhJ7PUKSy/JIGQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.16.3.tgz", + "integrity": "sha512-B1BvLeZbgDdVN0FvU40l5Q7lej8310WlabCBaouk8jY7H7xbI8phtomTtk3Efmevgfy5hImaQJu6++OmcFb2NQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.16.3.tgz", + "integrity": "sha512-q7khglic3Jqak7uDgA3MFnjDeI7krQT595GDZpvFq785fmFYSx8rlTkoHzmhQtUisYtl4XG7WUscwsoidFUI4w==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.16.3.tgz", + "integrity": "sha512-aFRNmQNPzDgQEbw2s3c8yJYRimacSDI+u9df8rn5nSKzTVitHmbEpZqfxpwNLCKIuLSNmozHR1z1OT+oZVeYqg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.16.3.tgz", + "integrity": "sha512-vZI85SvSMADcEL9G1TIrV0Rlkc1fY5Mup0DdlVC5EHPysZB4hXXHpr+h09pjlK5y+5om5foIzDRxE1baUCaWOA==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/chai-as-promised": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-8.0.2.tgz", - "integrity": "sha512-meQ1wDr1K5KRCSvG2lX7n7/5wf70BeptTKst0axGvnN6zqaVpRqegoIbugiAPSqOW9K9aL8gDVrm7a2LXOtn2Q==", + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.16.3.tgz", + "integrity": "sha512-xiLBnaUlddFEzRHiHiSGEMbkg8EwZY6VD8F+3GfnFsiK3xg/4boaUV2bwXd+nUzl3UDQOMW1QcZJ4jJSb0qiJA==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/chai": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.16.3.tgz", + "integrity": "sha512-6y0b05wIazJJgwu7yU/AYGFswzQQudYJBOb/otDhiDacp1+6ye8egoxx63iVo9lSpDbipL++54AJQFlcOHCB+g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.16.3.tgz", + "integrity": "sha512-RmMgwuMa42c9logS7Pjprf5KCp8J1a1bFiuBFtG9/+yMu0BhY2t+0VR/um7pwtkNFvIQqAVh6gDOg/PnoKRcdQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.16.3.tgz", + "integrity": "sha512-/7AYRkjjW7xu1nrHgWUFy99Duj4/ydOBVaHtODie9/M6fFngo+8uQDFFnzmr4q//sd/cchIerISp/8CQ5TsqIA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.16.3.tgz", + "integrity": "sha512-urM6aIPbi5di4BSlnpd/TWtDJgG6RD06HvLBuNM+qOYuFtY1/xPbzQ2LanBI2ycpqIoIZwsChyplALwAMdyfCQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.16.3.tgz", + "integrity": "sha512-QuvLqGKf7frxWHQ5TnrcY0C/hJpANsaez99Q4dAk1hen7lDTD4FBPtBzPnntLFXeaVG3PnSmnVjlv0vMILwU7Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", - "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.16.3.tgz", + "integrity": "sha512-QR/witXK6BmYTlEP8CCjC5fxeG5U9A6a50pNpC1nLnhAcJjtzFG8KcQ5etVy/XvCLiDc7fReaAWRNWtCaIhM8Q==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@types/http-errors": { - "version": "2.0.5", + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.16.3.tgz", + "integrity": "sha512-bFuJRKOscsDAEZ/a8BezcTMAe2BQ/OBRfuMLFUuINfTR5qGVcm4a3xBIrQVepBaPxFj16SJdRjGe05vDiwZmFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.7.0.tgz", + "integrity": "sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.52.0", + "eslint-visitor-keys": "^5.0.0", + "espree": "^11.0.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.0.0.tgz", + "integrity": "sha512-+gMeWRrIh/NsG+3NaLeWHuyeyk70p2tbvZIWBYcqQ4/7Xvars6GYTZNhF1sIeLcc6Wb11He5ffz3hsHyXFrw5A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@ts-morph/common": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.12.3.tgz", + "integrity": "sha512-4tUmeLyXJnJWvTFOKtcNJ1yh0a3SsTLi2MUoyj8iUNznFRN1ZquaNe7Oukqrnki2FzZkm0J9adCNLDZxUzvj+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.7", + "minimatch": "^3.0.4", + "mkdirp": "^1.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "dev": true, @@ -2456,23 +2692,22 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mocha": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", - "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { - "version": "24.8.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz", - "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", - "dev": true, + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "license": "MIT", "dependencies": { - "undici-types": "~7.14.0" + "undici-types": "~7.16.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/pixelmatch": { "version": "5.2.6", "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz", @@ -2526,45 +2761,16 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/sinon": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", - "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sinonjs__fake-timers": "*" - } - }, - "node_modules/@types/sinon-chai": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-4.0.0.tgz", - "integrity": "sha512-Uar+qk3TmeFsUWCwtqRNqNUE7vf34+MCJiQJR5M2rd4nCbhtE8RgTiHwN/mVwbfCjhmO6DiOel/MgzHkRMJJFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "*", - "@types/sinon": "*" + "@types/node": "*" } }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", - "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", @@ -2589,6 +2795,15 @@ "uuid": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/xml2js": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", @@ -2600,46 +2815,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", - "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", - "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2649,6 +2838,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { + "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2664,17 +2854,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2688,16 +2878,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2710,15 +2900,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2728,10 +2918,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", "dev": true, "license": "MIT", "engines": { @@ -2745,203 +2935,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.1", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", - "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", - "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", "dev": true, "license": "MIT", "engines": { @@ -2953,22 +2975,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3008,16 +3029,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", - "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3031,158 +3052,14 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.46.1", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/types": "8.53.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3254,52 +3131,53 @@ "node": ">=0.4.0" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=12.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "ajv": "^8.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3344,26 +3222,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/append-transform": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", - "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-require-extensions": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", - "dev": true, - "license": "MIT" - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3509,6 +3367,15 @@ "node": ">= 0.4" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3574,6 +3441,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bmp-ts": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", @@ -3581,25 +3458,36 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3624,46 +3512,6 @@ "node": ">=8" } }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "license": "ISC" - }, - "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -3704,68 +3552,6 @@ "node": ">= 0.8" } }, - "node_modules/caching-transform": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", - "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasha": "^5.0.0", - "make-dir": "^3.0.0", - "package-hash": "^4.0.0", - "write-file-atomic": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/caching-transform/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caching-transform/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/caching-transform/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/caching-transform/node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3824,60 +3610,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001724", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz", - "integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", - "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chai-as-promised": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.2.tgz", - "integrity": "sha512-1GadL+sEJVLzDjcawPM4kjfnL+p/9vrxiEUonowKOAzvVg0PixJUdtuDzdkDeQhK3zfOE76GqGkZIQ7/Adcrqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "check-error": "^2.1.1" - }, - "peerDependencies": { - "chai": ">= 2.1.2 < 7" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3895,124 +3627,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/code-block-writer": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-11.0.3.tgz", + "integrity": "sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw==", "dev": true, "license": "MIT" }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4025,14 +3651,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 6" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -4042,15 +3672,16 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -4062,13 +3693,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -4100,6 +3724,23 @@ "node": ">= 0.10" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -4176,9 +3817,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4192,16 +3833,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4209,27 +3840,10 @@ "dev": true, "license": "MIT" }, - "node_modules/default-require-extensions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", - "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "strip-bom": "^4.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4247,7 +3861,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -4271,23 +3884,19 @@ } }, "node_modules/detect-libc": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", - "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" }, "node_modules/doctrine": { "version": "2.1.0", @@ -4326,31 +3935,12 @@ "xtend": "^4.0.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/electron-to-chromium": { - "version": "1.5.173", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.173.tgz", - "integrity": "sha512-2bFhXP2zqSfQHugjqJIDFVwa+qIxyNApenmXTp9EjaKtdPrES5Qcn9/aSFy/NaP2E+fWG/zxKu/LBvY36p5VNQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4360,6 +3950,16 @@ "node": ">= 0.8" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -4510,13 +4110,12 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4527,55 +4126,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escape-html": { @@ -4588,7 +4164,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4598,25 +4173,24 @@ } }, "node_modules/eslint": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -4820,6 +4394,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -4838,20 +4436,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -4952,18 +4536,19 @@ "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -4994,10 +4579,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -5048,6 +4636,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -5057,16 +4646,60 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5097,6 +4730,13 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-keys": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", @@ -5135,9 +4775,9 @@ } }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -5148,51 +4788,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/find-cache-dir/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" + "node": ">= 18.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-cache-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-root": { @@ -5219,16 +4819,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -5243,6 +4833,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -5266,20 +4862,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "fd-package-json": "^2.0.0" }, - "engines": { - "node": ">=14" + "bin": { + "formatly": "bin/index.mjs" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=18.3.0" } }, "node_modules/forwarded": { @@ -5300,31 +4896,10 @@ "node": ">= 0.8" } }, - "node_modules/fromentries": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", - "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -5397,26 +4972,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5441,16 +4996,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -5506,21 +5051,15 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "license": "ISC", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.2", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -5542,12 +5081,12 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { "node": "20 || >=22" @@ -5556,6 +5095,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -5573,7 +5129,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -5604,12 +5159,11 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" }, "node_modules/has-bigints": { "version": "1.1.0", @@ -5638,7 +5192,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -5691,33 +5244,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasha": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^2.0.0", - "type-fest": "^0.8.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hasha/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5730,58 +5256,63 @@ "node": ">= 0.4" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "node_modules/heapdump": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz", + "integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==", "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "nan": "^2.13.2" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/hono": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", + "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, "engines": { - "node": ">= 0.8" + "node": ">=16.9.0" } }, - "node_modules/http-errors/node_modules/statuses": { + "node_modules/http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -5856,16 +5387,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5899,6 +5420,15 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5926,6 +5456,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -6082,15 +5619,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -6186,16 +5714,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -6250,16 +5768,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -6311,26 +5819,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -6377,16 +5865,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -6400,185 +5878,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-hook": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", - "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "append-transform": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-processinfo": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", - "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", - "dev": true, - "license": "ISC", - "dependencies": { - "archy": "^1.0.0", - "cross-spawn": "^7.0.3", - "istanbul-lib-coverage": "^3.2.0", - "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jimp": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", @@ -6617,12 +5916,40 @@ "node": ">=18" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "license": "BSD-3-Clause" }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6631,9 +5958,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6642,19 +5969,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6662,811 +5976,452 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.flattendeep": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "license": "MIT", "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=16" } }, - "node_modules/metric-lcs": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", - "integrity": "sha512-+TZ5dUDPKPJaU/rscTzxyN8ZkX7eAVLAiQU/e+YINleXPv03SCmJShaMT1If1liTH8OcmWXZs0CmzCBRBLcMpA==", - "dev": true, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { - "mkdirp": "bin/cmd.js" + "json5": "lib/cli.js" }, "engines": { - "node": ">=10" + "node": ">=6" } }, - "node_modules/mocha": { - "version": "11.7.4", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.4.tgz", - "integrity": "sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==", - "dev": true, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "license": "MIT", "dependencies": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" + "universalify": "^2.0.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "json-buffer": "3.0.1" } }, - "node_modules/mocha/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "node_modules/knip": { + "version": "5.82.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.82.1.tgz", + "integrity": "sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "js-yaml": "^4.1.1", + "minimist": "^1.2.8", + "oxc-resolver": "^11.15.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "zod": "^4.1.11" }, "bin": { - "glob": "dist/esm/bin.mjs" + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" } }, - "node_modules/mocha/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" + "license": "MIT", + "engines": { + "node": ">=14.16" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "node_modules/kysely": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.9.tgz", + "integrity": "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=20.0.0" } }, - "node_modules/mocha/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 0.8.0" } }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "p-locate": "^5.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/module-not-found-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", - "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-preload": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", - "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "process-on-spawn": "^1.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/normalize-path": { + "node_modules/matcher": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nyc": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", - "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", - "dev": true, - "license": "ISC", "dependencies": { - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "caching-transform": "^4.0.0", - "convert-source-map": "^1.7.0", - "decamelize": "^1.2.0", - "find-cache-dir": "^3.2.0", - "find-up": "^4.1.0", - "foreground-child": "^3.3.0", - "get-package-type": "^0.1.0", - "glob": "^7.1.6", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-hook": "^3.0.0", - "istanbul-lib-instrument": "^6.0.2", - "istanbul-lib-processinfo": "^2.0.2", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "make-dir": "^3.0.0", - "node-preload": "^0.2.1", - "p-map": "^3.0.0", - "process-on-spawn": "^1.0.0", - "resolve-from": "^5.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "spawn-wrap": "^2.0.0", - "test-exclude": "^6.0.0", - "yargs": "^15.0.2" - }, - "bin": { - "nyc": "bin/nyc.js" + "escape-string-regexp": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/nyc/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/nyc/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, - "node_modules/nyc/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/nyc/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/memwatch-next": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/memwatch-next/-/memwatch-next-0.3.0.tgz", + "integrity": "sha512-DgDzV5H/tA+ZvA5XSyz+XC5sF/m6S88qDI8Z9/XM62J6eHbiTvZzsG7+vPqwhyWJ9iOdC9zKeHwEety2EMJx5g==", "dev": true, - "license": "MIT", + "hasInstallScript": true, "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "bindings": "^1.2.1", + "nan": "^2.3.2" }, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/nyc/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", "engines": { - "node": "*" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nyc/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, "engines": { - "node": ">=8" + "node": ">= 8" } }, - "node_modules/nyc/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "node_modules/metric-lcs": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", + "integrity": "sha512-+TZ5dUDPKPJaU/rscTzxyN8ZkX7eAVLAiQU/e+YINleXPv03SCmJShaMT1If1liTH8OcmWXZs0CmzCBRBLcMpA==", "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/nyc/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8.6" } }, - "node_modules/nyc/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, "engines": { - "node": ">=8" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/nyc/node_modules/p-map": { + "node_modules/mime": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=10.0.0" } }, - "node_modules/nyc/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/nyc/node_modules/rimraf": { + "node_modules/mime-types": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", "dependencies": { - "glob": "^7.1.3" + "mime-db": "^1.54.0" }, - "bin": { - "rimraf": "bin.js" + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/nyc/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "node_modules/nyc/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/nyc/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/nyc/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "minipass": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">= 18" } }, - "node_modules/nyc/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "bin": { + "mkdirp": "bin/cmd.js" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/nyc/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", "dev": true, - "license": "ISC" + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, - "node_modules/nyc/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/nyc/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, "node_modules/object-assign": { @@ -7494,7 +6449,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7601,6 +6555,49 @@ "wrappy": "1" } }, + "node_modules/onnxruntime-common": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.2.tgz", + "integrity": "sha512-5LFsC9Dukzp2WV6kNHYLNzp8sT6V02IubLCbzw2Xd6X5GOlr65gAX6xiJwyi2URJol/s71gaQLC5F2C25AAR2w==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.23.2.tgz", + "integrity": "sha512-OBTsG0W8ddBVOeVVVychpVBS87A9YV5sa2hJ6lc025T97Le+J4v++PwSC4XFs1C62SWyNdof0Mh4KvnZgtt4aw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.23.2" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7637,6 +6634,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-resolver": { + "version": "11.16.3", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.16.3.tgz", + "integrity": "sha512-goLOJH3x69VouGWGp5CgCIHyksmOZzXr36lsRmQz1APg3SPFORrvV2q7nsUHMzLVa6ZJgNwkgUSJFsbCpAWkCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.16.3", + "@oxc-resolver/binding-android-arm64": "11.16.3", + "@oxc-resolver/binding-darwin-arm64": "11.16.3", + "@oxc-resolver/binding-darwin-x64": "11.16.3", + "@oxc-resolver/binding-freebsd-x64": "11.16.3", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.3", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.3", + "@oxc-resolver/binding-linux-arm64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-arm64-musl": "11.16.3", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-riscv64-musl": "11.16.3", + "@oxc-resolver/binding-linux-s390x-gnu": "11.16.3", + "@oxc-resolver/binding-linux-x64-gnu": "11.16.3", + "@oxc-resolver/binding-linux-x64-musl": "11.16.3", + "@oxc-resolver/binding-openharmony-arm64": "11.16.3", + "@oxc-resolver/binding-wasm32-wasi": "11.16.3", + "@oxc-resolver/binding-win32-arm64-msvc": "11.16.3", + "@oxc-resolver/binding-win32-ia32-msvc": "11.16.3", + "@oxc-resolver/binding-win32-x64-msvc": "11.16.3" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7669,38 +6698,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-hash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", - "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "graceful-fs": "^4.1.15", - "hasha": "^5.0.0", - "lodash.flattendeep": "^4.4.0", - "release-zalgo": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -7755,6 +6752,25 @@ "node": ">=4.0.0" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7764,6 +6780,13 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7826,12 +6849,23 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=8" } }, "node_modules/peek-readable": { @@ -7875,88 +6909,25 @@ "dependencies": { "pngjs": "^7.0.0" }, - "bin": { - "pixelmatch": "bin/pixelmatch" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "pixelmatch": "bin/pixelmatch" } }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, "engines": { - "node": ">=8" + "node": ">=16.20.0" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", @@ -7995,17 +6966,28 @@ "node": ">= 0.6.0" } }, - "node_modules/process-on-spawn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", - "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", - "dev": true, - "license": "MIT", + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "fromentries": "^1.2.0" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=12.0.0" } }, "node_modules/proxy-addr": { @@ -8037,15 +7019,16 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -8078,16 +7061,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8098,18 +7071,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/readable-stream": { @@ -8144,20 +7117,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8202,36 +7161,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/release-zalgo": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", - "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", - "dev": true, - "license": "ISC", - "dependencies": { - "es6-error": "^4.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true, - "license": "ISC" - }, "node_modules/requireindex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", @@ -8294,6 +7232,23 @@ "node": ">=0.10.0" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8422,9 +7377,9 @@ "license": "ISC" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8433,42 +7388,57 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", "dependencies": { - "randombytes": "^2.1.0" + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -8478,15 +7448,12 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -8543,15 +7510,15 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -8560,28 +7527,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -8677,18 +7646,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/simple-xml-to-json": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", @@ -8698,33 +7655,17 @@ "node": ">=20.12.2" } }, - "node_modules/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "supports-color": "^7.2.0" + "engines": { + "node": ">= 18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/sinon" - } - }, - "node_modules/sinon-chai": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-4.0.1.tgz", - "integrity": "sha512-xMKEEV3cYHC1G+boyr7QEqi80gHznYsxVdC9CdjP5JnCWz/jPGuXQzJz3PtBcb0CcHAxar15Y5sjLBoAs6a0yA==", - "dev": true, - "license": "(BSD-2-Clause OR WTFPL)", - "peerDependencies": { - "chai": "^5.0.0 || ^6.0.0", - "sinon": ">=4.0.0" + "url": "https://github.com/sponsors/cyyynthia" } }, "node_modules/source-map": { @@ -8748,109 +7689,11 @@ "source-map": "^0.6.0" } }, - "node_modules/spawn-wrap": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^2.0.0", - "is-windows": "^1.0.2", - "make-dir": "^3.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "which": "^2.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/spawn-wrap/node_modules/foreground-child": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/spawn-wrap/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/spawn-wrap/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/spawn-wrap/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/spawn-wrap/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/spawn-wrap/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" }, "node_modules/statuses": { "version": "2.0.2", @@ -8884,65 +7727,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -9002,53 +7786,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -9105,41 +7842,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, "node_modules/tinycolor2": { @@ -9148,6 +7873,23 @@ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9197,10 +7939,26 @@ "tree-kill": "cli.js" } }, + "node_modules/true-myth": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/true-myth/-/true-myth-4.1.1.tgz", + "integrity": "sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "10.* || >= 12.*" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -9210,6 +7968,17 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-morph": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-13.0.3.tgz", + "integrity": "sha512-pSOfUMx8Ld/WUreoSzvMFQG5i9uEiWIsBYjpU9+TTASOeUa89j5HykomeqVULm1oqWtBdleI3KEFRLrlA3zGIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.12.3", + "code-block-writer": "^11.0.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -9399,6 +8168,24 @@ "node": ">=0.3.1" } }, + "node_modules/ts-prune": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-prune/-/ts-prune-0.10.3.tgz", + "integrity": "sha512-iS47YTbdIcvN8Nh/1BFyziyUqmjXz7GVzWu02RaZXqb+e/3Qe1B7IQ4860krOeCGUeJmterAlaM2FRH0Ue0hjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^6.2.1", + "cosmiconfig": "^7.0.1", + "json5": "^2.1.3", + "lodash": "^4.17.21", + "true-myth": "^4.1.0", + "ts-morph": "^13.0.1" + }, + "bin": { + "ts-prune": "lib/index.js" + } + }, "node_modules/tsconfig": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", @@ -9472,17 +8259,16 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.25.0", + "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -9508,14 +8294,16 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/type-is": { @@ -9610,16 +8398,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -9654,10 +8432,9 @@ } }, "node_modules/undici-types": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", - "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/universalify": { @@ -9678,41 +8455,11 @@ "node": ">= 0.8" } }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -9756,6 +8503,16 @@ "node": ">= 0.8" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9838,13 +8595,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true, - "license": "ISC" - }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -9877,107 +8627,33 @@ "node": ">=0.10.0" } }, - "node_modules/workerpool": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.2.tgz", - "integrity": "sha512-Xz4Nm9c+LiBHhDR5bDLnNzmj6+5F+cyEAWPMkbs2awq/dYazR/efelZzUAjB/y3kNHL+uzkHvxVVpaOfGCPV7A==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=10.0.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xml-parse-from-string": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", @@ -10016,140 +8692,23 @@ "node": ">=0.4" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yargs-unparser/node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yargs-unparser/node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "node": ">= 6" } }, "node_modules/yn": { @@ -10176,21 +8735,21 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index ca2b16bb7..7b882da70 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,40 @@ { - "name": "auto-mobile", - "version": "0.0.5", + "name": "@kaeawc/auto-mobile", + "version": "0.0.13", "description": "Mobile device interaction automation via MCP", "scripts": { - "test": "mocha --require esbuild-register test/**/*.ts", - "test:coverage": "nyc mocha --require esbuild-register test/**/*.ts", + "test": "bun test", + "test:coverage": "bun test --coverage", + "test:memory-leaks": "node --expose-gc --import tsx scripts/detect-memory-leaks.ts", "lint": "eslint . --fix", - "watch": "tsc --watch", - "build": "tsc && node scripts/build-ios-assets.js && chmod +x dist/src/index.js", + "watch": "bun --watch src/index.ts", + "build": "bun build.ts && chmod +x dist/src/index.js", "clean": "rm -rf dist", - "start": "npx tsx src/index.ts --transport streamable", - "dev": "ts-node-dev --respawn --transpile-only src/index.ts --transport streamable", - "dev:port": "ts-node-dev --respawn --transpile-only src/index.ts --transport streamable --port", - "dev:stdio": "npx tsx src/index.ts", - "dev:sse": "ts-node-dev --respawn --transpile-only src/index.ts --transport sse", - "dev:streamable": "ts-node-dev --respawn --transpile-only src/index.ts --transport streamable", - "dev:streamable:port": "ts-node-dev --respawn --transpile-only src/index.ts --transport streamable --port", - "prepublishOnly": "cp README.md README.md.backup && node scripts/npm/transform-readme.js", - "postpublish": "mv README.md.backup README.md || true" + "dev:android": "bash scripts/local-dev/android-hot-reload.sh --skip-ai", + "dev:android:hot-reload": "bash scripts/local-dev/android-hot-reload.sh", + "dev:ios": "bash scripts/local-dev/ios-hot-reload.sh --skip-ai", + "dev:ios:hot-reload": "bash scripts/local-dev/ios-hot-reload.sh", + "estimate-context": "bun scripts/estimate-context-usage.ts", + "benchmark-context": "bun scripts/benchmark-context-thresholds.ts", + "benchmark-tools": "bun scripts/benchmark-mcp-tools.ts", + "benchmark-startup": "bash scripts/benchmark-startup.sh", + "benchmark-npm-unpacked-size": "bun scripts/benchmark-npm-unpacked-size.ts", + "profile:memory": "node --expose-gc --inspect --import tsx scripts/detect-memory-leaks.ts --mode=profile", + "profile:heap": "node --expose-gc --heap-prof --import tsx scripts/stress-test.ts", + "validate:yaml": "bun scripts/validate-yaml.ts", + "dead-code:ts": "bash scripts/detect-dead-code-ts.sh", + "dead-code:ts:prune": "bash -o pipefail -c 'npx ts-prune --project tsconfig.dead-code.json -i src/db/migrations | (grep -v \"(used in module)\" || true) | bash scripts/filter-ts-prune-allowlist.sh'", + "dead-code:ts:knip": "npx knip", + "prepublishOnly": "cp README.md README.md.backup && bun scripts/npm/transform-readme.js", + "postpublish": "mv README.md.backup README.md || true", + "turbo:validate": "turbo run lint build test", + "turbo:build": "turbo run build", + "turbo:lint": "turbo run lint", + "turbo:test": "turbo run test" }, + "packageManager": "bun@1.3.6", "engines": { - "node": ">=24" + "bun": ">=1.3.6" }, "keywords": [ "adb", @@ -34,7 +48,8 @@ "ui-testing" ], "files": [ - "dist" + "dist", + "schemas" ], "main": "dist/src/index.js", "bin": { @@ -45,65 +60,66 @@ "contributors": [ { "name": "Jason Pearson", - "email": "jasonpe@zillowgroup.com" + "email": "jason.d.pearson@gmail.com" } ], - "homepage": "https://zillow.github.io/auto-mobile/", + "homepage": "https://kaeawc.github.io/auto-mobile/", + "repository": { + "type": "git", + "url": "https://github.com/kaeawc/auto-mobile.git" + }, + "mcpName": "dev.jasonpearson/auto-mobile", "publishConfig": { "access": "public" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.18.1", + "@anthropic-ai/sdk": "^0.78.0", + "@modelcontextprotocol/sdk": "^1.26.0", "@types/js-yaml": "^4.0.9", - "@types/uuid": "^11.0.0", - "fs-extra": "^11.3.2", - "glob": "^11.0.3", + "@types/ws": "^8.18.1", + "adm-zip": "^0.5.16", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "async-mutex": "^0.5.0", + "fs-extra": "^11.3.3", + "glob": "^13.0.1", "jimp": "^1.6.0", - "js-yaml": "^4.1.0", + "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", + "kysely": "^0.28.9", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", - "sharp": "^0.34.4", - "uuid": "^13.0.0", + "sharp": "^0.34.5", + "ws": "^8.19.0", "xml2js": "^0.6.2", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.24.6" + "zod": "^4.3.5" + }, + "//overrides": { + "lodash": "CVE-2025-13465: prototype pollution in _.unset and _.omit", + "tar": "CVE-2026-24842: Arbitrary file creation/overwrite via hardlink path traversal" + }, + "overrides": { + "lodash": "^4.17.23", + "tar": "7.5.7" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.36.0", - "@faker-js/faker": "^10.0.0", - "@stylistic/eslint-plugin": "^5.4.0", - "@types/chai": "^5.2.2", - "@types/chai-as-promised": "^8.0.2", - "@types/express": "^5.0.3", + "@faker-js/faker": "^10.2.0", + "@stylistic/eslint-plugin": "^5.7.0", "@types/fs-extra": "^11.0.4", - "@types/mocha": "^10.0.6", - "@types/node": "^24.3.1", + "@types/node": "^25.0.9", "@types/pixelmatch": "^5.2.6", "@types/pngjs": "^6.0.5", - "@types/proxyquire": "^1.3.31", - "@types/sinon": "^17.0.2", - "@types/sinon-chai": "^4.0.0", "@types/xml2js": "^0.4.14", - "@typescript-eslint/eslint-plugin": "^8.44.0", - "@typescript-eslint/parser": "^8.44.0", - "@typescript-eslint/utils": "^8.44.0", - "chai": "^6.0.1", - "chai-as-promised": "^8.0.2", - "esbuild": "^0.25.9", - "esbuild-register": "^3.6.0", - "eslint": "^9.35.0", - "eslint-plugin": "^1.0.1", + "@typescript-eslint/eslint-plugin": "^8.53.0", + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^10.0.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-notice": "^1.0.0", - "mocha": "^11.7.2", - "nyc": "^17.1.0", - "proxyquire": "^2.1.3", - "sinon": "^21.0.0", - "sinon-chai": "^4.0.1", - "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", - "tsx": "^4.20.5", + "heapdump": "^0.3.15", + "knip": "^5.81.0", + "memwatch-next": "^0.3.0", + "ts-prune": "^0.10.3", + "tsx": "^4.21.0", + "turbo": "^2.8.10", "typescript": "^5.9.2" } } diff --git a/schemas/test-plan.schema.json b/schemas/test-plan.schema.json new file mode 100644 index 000000000..4dc8ad171 --- /dev/null +++ b/schemas/test-plan.schema.json @@ -0,0 +1,803 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/anthropics/auto-mobile/schemas/test-plan.schema.json", + "title": "AutoMobile Test Plan", + "description": "Schema for AutoMobile test plan YAML files used with executePlan and JUnit Runner", + "type": "object", + "required": ["name", "steps"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the test plan", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Human-readable description of what this test plan does" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Optional platform hint for plan execution" + }, + "devices": { + "type": "array", + "description": "List of device labels or device definitions for multi-device test execution. Required when using device parameters or criticalSection.", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/planDevice" + } + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "steps": { + "type": "array", + "description": "Ordered list of tool execution steps", + "minItems": 1, + "items": { + "$ref": "#/$defs/planStep" + } + }, + "mcpVersion": { + "type": "string", + "description": "MCP server version this plan was created with", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "generated": { + "type": "string", + "description": "DEPRECATED: ISO 8601 timestamp (legacy field, use metadata.createdAt instead)", + "format": "date-time" + }, + "appId": { + "type": "string", + "description": "DEPRECATED: Application bundle ID (legacy field, use metadata.appId instead)" + }, + "parameters": { + "type": "object", + "description": "DEPRECATED: Plan parameters (legacy field)", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "description": "Additional metadata about the plan", + "properties": { + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp of plan creation", + "format": "date-time" + }, + "version": { + "type": "string", + "description": "Plan format version", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "appId": { + "type": "string", + "description": "Application bundle ID or package name" + }, + "sessionId": { + "type": "string", + "description": "Session identifier for plan execution" + }, + "toolCallCount": { + "type": "integer", + "description": "Number of tool calls in original session", + "minimum": 0 + }, + "duration": { + "type": "number", + "description": "Duration of original session in milliseconds", + "minimum": 0 + }, + "generatedFromToolCalls": { + "type": "boolean", + "description": "Whether this plan was auto-generated from tool call logs" + }, + "experiments": { + "type": "array", + "description": "Active experiments during plan creation", + "items": { + "type": "string" + } + }, + "treatments": { + "type": "object", + "description": "Experiment treatment assignments", + "additionalProperties": { + "type": "string" + } + }, + "featureFlags": { + "type": "object", + "description": "Feature flag values during plan creation", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "$defs": { + "planDevice": { + "type": "object", + "required": ["label", "platform"], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "Device label for multi-device routing", + "minLength": 1 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Target platform for this device" + }, + "simulatorType": { + "type": "string", + "description": "iOS simulator type (e.g., iPhone 15 Pro)" + }, + "iosVersion": { + "type": "string", + "description": "iOS version for simulator selection" + } + } + }, + "planStep": { + "type": "object", + "required": ["tool"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Name of the MCP tool to execute", + "minLength": 1 + }, + "params": { + "description": "Tool-specific parameters (can also be specified as top-level properties)" + }, + "device": { + "type": "string", + "description": "Device label indicating which device this step runs on (required in multi-device plans for non-device-agnostic tools)", + "minLength": 1 + }, + "label": { + "type": "string", + "description": "Human-readable description of what this step does" + }, + "description": { + "type": "string", + "description": "DEPRECATED: Use 'label' instead (legacy field)" + }, + "expectations": { + "type": "array", + "description": "Assertions to validate after step execution", + "items": { + "$ref": "#/$defs/expectation" + } + } + }, + "description": "A step can have tool-specific parameters either in a 'params' object or as top-level properties (e.g., 'id', 'text', 'appId', 'direction', etc.)", + "allOf": [ + { + "if": { + "properties": { + "tool": { + "const": "dragAndDrop" + } + }, + "required": [ + "tool" + ] + }, + "then": { + "properties": { + "params": { + "$ref": "#/$defs/dragAndDropParams" + }, + "source": { + "$ref": "#/$defs/dragAndDropSelector" + }, + "target": { + "$ref": "#/$defs/dragAndDropSelector" + }, + "pressDurationMs": { + "type": "number", + "description": "Press duration ms (min: 600, max: 3000, default: 600)", + "minimum": 600, + "maximum": 3000 + }, + "dragDurationMs": { + "type": "number", + "description": "Drag duration ms (min: 300, max: 1000, default: 300)", + "minimum": 300, + "maximum": 1000 + }, + "holdDurationMs": { + "type": "number", + "description": "Hold duration ms (min: 100, max: 3000, default: 100)", + "minimum": 100, + "maximum": 3000 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + } + }, + "allOf": [ + { + "anyOf": [ + { + "required": [ + "source" + ] + }, + { + "properties": { + "params": { + "required": [ + "source" + ] + } + }, + "required": [ + "params" + ] + } + ] + }, + { + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "properties": { + "params": { + "required": [ + "target" + ] + } + }, + "required": [ + "params" + ] + } + ] + } + ] + } + }, + { + "if": { + "properties": { + "tool": { + "const": "highlight" + } + }, + "required": [ + "tool" + ] + }, + "then": { + "properties": { + "params": { + "$ref": "#/$defs/highlightParams" + }, + "description": { + "type": "string", + "description": "Optional highlight description" + }, + "id": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "text": { + "type": "string", + "description": "Element text content to match" + }, + "container": { + "$ref": "#/$defs/highlightContainer" + }, + "containerOf": { + "type": "boolean", + "description": "Whether to highlight the container of the selected element" + }, + "shape": { + "$ref": "#/$defs/highlightShape" + }, + "selectionStrategy": { + "type": "string", + "enum": [ + "first", + "random" + ], + "description": "Element selection strategy when multiple matches are found (default: first)" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "timeoutMs": { + "type": "number", + "description": "Highlight request timeout ms (default: 5000)" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + } + }, + "allOf": [ + { + "anyOf": [ + { + "required": [ + "shape" + ] + }, + { + "required": [ + "id" + ] + }, + { + "required": [ + "text" + ] + }, + { + "properties": { + "params": { + "required": [ + "shape" + ] + } + }, + "required": [ + "params" + ] + }, + { + "properties": { + "params": { + "required": [ + "id" + ] + } + }, + "required": [ + "params" + ] + }, + { + "properties": { + "params": { + "required": [ + "text" + ] + } + }, + "required": [ + "params" + ] + } + ] + } + ] + } + } + ] + }, + "dragAndDropSelector": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Element text", + "minLength": 1 + }, + "elementId": { + "type": "string", + "description": "Element ID", + "minLength": 1 + } + }, + "additionalProperties": false, + "description": "Selector for dragAndDrop elements", + "oneOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "elementId" + ] + } + ] + }, + "dragAndDropParams": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/dragAndDropSelector", + "description": "Source element" + }, + "target": { + "$ref": "#/$defs/dragAndDropSelector", + "description": "Target element" + }, + "pressDurationMs": { + "type": "number", + "description": "Press duration ms (min: 600, max: 3000, default: 600)", + "minimum": 600, + "maximum": 3000 + }, + "dragDurationMs": { + "type": "number", + "description": "Drag duration ms (min: 300, max: 1000, default: 300)", + "minimum": 300, + "maximum": 1000 + }, + "holdDurationMs": { + "type": "number", + "description": "Hold duration ms (min: 100, max: 3000, default: 100)", + "minimum": 100, + "maximum": 3000 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + }, + "device": { + "type": "string", + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")" + } + }, + "additionalProperties": true, + "description": "Parameters for dragAndDrop" + }, + "highlightBounds": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Bounds x-coordinate" + }, + "y": { + "type": "number", + "description": "Bounds y-coordinate" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Bounds width" + }, + "height": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Bounds height" + }, + "sourceWidth": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "description": "Optional source width for coordinate normalization" + }, + "sourceHeight": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "description": "Optional source height for coordinate normalization" + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false, + "description": "Highlight bounds" + }, + "highlightStyle": { + "type": [ + "object", + "null" + ], + "properties": { + "strokeColor": { + "type": "string", + "description": "Stroke color (hex or CSS color)" + }, + "strokeWidth": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Stroke width" + }, + "fillColor": { + "type": "string", + "description": "Fill color (hex or CSS color)" + }, + "dashPattern": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "description": "Dash pattern lengths" + } + }, + "additionalProperties": false, + "description": "Highlight style" + }, + "highlightShape": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "box", + "circle" + ], + "description": "Highlight shape type" + }, + "bounds": { + "$ref": "#/$defs/highlightBounds" + }, + "style": { + "$ref": "#/$defs/highlightStyle" + } + }, + "required": [ + "type", + "bounds" + ], + "additionalProperties": false, + "description": "Highlight shape definition" + }, + "highlightSelector": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Element text", + "minLength": 1 + }, + "id": { + "type": "string", + "description": "Element ID", + "minLength": 1 + } + }, + "additionalProperties": false, + "description": "Selector for highlight elements", + "oneOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "id" + ] + } + ] + }, + "highlightContainer": { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Container element ID", + "minLength": 1 + }, + "text": { + "type": "string", + "description": "Container text", + "minLength": 1 + } + }, + "additionalProperties": false, + "description": "Container selector to scope highlight search", + "oneOf": [ + { + "required": [ + "elementId" + ] + }, + { + "required": [ + "text" + ] + } + ] + }, + "highlightParams": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Optional highlight description" + }, + "id": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "text": { + "type": "string", + "description": "Element text content to match" + }, + "container": { + "$ref": "#/$defs/highlightContainer" + }, + "containerOf": { + "type": "boolean", + "description": "Whether to highlight the container of the selected element" + }, + "shape": { + "$ref": "#/$defs/highlightShape" + }, + "selectionStrategy": { + "type": "string", + "enum": [ + "first", + "random" + ], + "description": "Element selection strategy when multiple matches are found (default: first)" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "timeoutMs": { + "type": "number", + "description": "Highlight request timeout ms (default: 5000)" + }, + "sessionUuid": { + "type": "string", + "description": "Session UUID for device targeting" + }, + "keepScreenAwake": { + "type": "boolean", + "description": "Keep physical Android devices awake during the session (default: true)" + }, + "device": { + "type": "string", + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")" + }, + "deviceId": { + "type": "string", + "description": "Device ID override" + } + }, + "additionalProperties": true, + "description": "Parameters for highlight" + }, + "expectation": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "description": "Type of assertion to perform" + }, + "selector": { + "type": "object", + "description": "Selector for the element to check", + "properties": { + "testTag": { + "type": "string" + }, + "text": { + "type": "string" + }, + "contentDescription": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "className": { + "type": "string" + } + } + }, + "text": { + "type": "string", + "description": "Text to check for presence/absence" + } + } + }, + "criticalSectionParams": { + "type": "object", + "required": ["lock", "steps", "deviceCount"], + "additionalProperties": false, + "properties": { + "lock": { + "type": "string", + "description": "Global lock identifier for synchronization across devices", + "minLength": 1 + }, + "steps": { + "type": "array", + "description": "Steps to execute serially within the critical section", + "minItems": 1, + "items": { + "$ref": "#/$defs/planStep" + } + }, + "deviceCount": { + "type": "integer", + "description": "Expected number of devices that must reach the barrier before execution proceeds", + "minimum": 1 + }, + "timeout": { + "type": "integer", + "description": "Barrier timeout in milliseconds (default: 30000ms)", + "minimum": 0, + "default": 30000 + } + }, + "description": "Parameters for criticalSection tool that provides barrier synchronization and mutex-based serial execution across multiple devices" + } + } +} diff --git a/schemas/tool-definitions.json b/schemas/tool-definitions.json new file mode 100644 index 000000000..e798befad --- /dev/null +++ b/schemas/tool-definitions.json @@ -0,0 +1,7019 @@ +[ + { + "name": "biometricAuth", + "description": "Simulate biometric authentication (fingerprint) on Android emulators. Trigger match/fail/cancel actions for testing biometric prompts.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "match", + "fail", + "cancel" + ], + "description": "Biometric action: 'match' triggers successful authentication, 'fail' simulates non-matching biometric, 'cancel' cancels the prompt" + }, + "modality": { + "description": "Biometric modality (default: 'any'). Currently only 'fingerprint' is reliably supported on Android emulators. 'face' is not consistently supported.", + "type": "string", + "enum": [ + "any", + "fingerprint", + "face" + ] + }, + "fingerprintId": { + "description": "Fingerprint ID to simulate (default: 1 for 'match', 2 for 'fail'). Use enrolled ID (typically 1) for match, non-enrolled ID (typically 2) for fail.", + "type": "number" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + } + }, + { + "name": "changeLocalization", + "description": "Change locale, time zone, text direction, and time format", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "locale": { + "description": "Locale tag (e.g., ar-SA, ja-JP)", + "type": "string", + "minLength": 1 + }, + "timeZone": { + "description": "Zone ID (e.g., America/Los_Angeles)", + "type": "string", + "minLength": 1 + }, + "textDirection": { + "description": "Text direction", + "type": "string", + "enum": [ + "ltr", + "rtl" + ] + }, + "timeFormat": { + "description": "Time format", + "type": "string", + "enum": [ + "12", + "24" + ] + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "clearText", + "description": "Clear text from focused input", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "clipboard", + "description": "Clipboard operations (copy/paste/clear/get)", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "copy", + "paste", + "clear", + "get" + ], + "description": "Clipboard action: copy=set clipboard, paste=paste into focused field, clear=clear clipboard, get=get clipboard content" + }, + "text": { + "description": "Text to copy (required for 'copy' action)", + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "criticalSection", + "description": "Coordinate multiple devices at a synchronization barrier and execute steps serially. All devices must reach the critical section before any can proceed. Steps execute one device at a time in the order they acquire the lock.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "lock": { + "type": "string", + "description": "Global lock identifier. All devices using the same lock name will wait for each other at this barrier." + }, + "steps": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "Tool name to execute" + }, + "params": { + "description": "Tool-specific parameters", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "label": { + "description": "Optional human-readable label", + "type": "string" + } + }, + "required": [ + "tool" + ], + "additionalProperties": {} + }, + "description": "Steps to execute serially within the critical section. Each step should target a specific device using the 'device' parameter." + }, + "deviceCount": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Number of devices expected to reach this critical section. All devices must arrive before any can proceed." + }, + "timeout": { + "description": "Timeout in milliseconds for waiting at the barrier (default: 30000ms)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "lock", + "steps", + "deviceCount" + ], + "additionalProperties": false + } + }, + { + "name": "deviceSnapshot", + "description": "Capture or restore a device snapshot for the active device.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "capture", + "restore" + ], + "description": "Action to perform" + }, + "snapshotName": { + "description": "Name for the snapshot", + "type": "string" + }, + "includeAppData": { + "description": "Include app data directories in snapshot", + "type": "boolean" + }, + "includeSettings": { + "description": "Include system settings in snapshot", + "type": "boolean" + }, + "useVmSnapshot": { + "description": "Use emulator VM snapshot if available (faster, emulator only)", + "type": "boolean" + }, + "strictBackupMode": { + "description": "If true, fail entire snapshot if app data backup fails or times out", + "type": "boolean" + }, + "backupTimeoutMs": { + "description": "Timeout in milliseconds for adb backup user confirmation", + "type": "number" + }, + "userApps": { + "description": "Which apps to backup: 'current' (foreground app only) or 'all' (all user-installed apps)", + "type": "string", + "enum": [ + "current", + "all" + ] + }, + "vmSnapshotTimeoutMs": { + "description": "Timeout in milliseconds for emulator VM snapshot commands", + "type": "number" + }, + "appBundleIds": { + "description": "iOS-only: bundle IDs to include in app data snapshots (omit to skip app data capture)", + "type": "array", + "items": { + "type": "string" + } + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + } + }, + { + "name": "doctor", + "description": "Run diagnostic checks to verify AutoMobile setup and environment configuration", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "android": { + "description": "Run Android-specific checks only", + "type": "boolean" + }, + "ios": { + "description": "Run iOS-specific checks only", + "type": "boolean" + }, + "installCmdlineTools": { + "description": "Automatically download and install Android SDK Command-line Tools to ANDROID_HOME if missing", + "type": "boolean" + }, + "installXcodeCommandLineTools": { + "description": "Install Xcode Command Line Tools if missing", + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + { + "name": "dragAndDrop", + "description": "Drag and drop element", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Source ID" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Source text" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ], + "description": "Source element" + }, + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Target ID" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Target text" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ], + "description": "Target element" + }, + "pressDurationMs": { + "description": "Press duration ms (min: 600, max: 3000, default: 600)", + "type": "number", + "minimum": 600, + "maximum": 3000 + }, + "dragDurationMs": { + "description": "Drag duration ms (min: 300, max: 1000, default: 300)", + "type": "number", + "minimum": 300, + "maximum": 1000 + }, + "holdDurationMs": { + "description": "Hold duration ms (min: 100, max: 3000, default: 100)", + "type": "number", + "minimum": 100, + "maximum": 3000 + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "source", + "target", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "executePlan", + "description": "Execute a series of tool calls from a YAML plan content. Stops execution if any step fails (success: false). Optionally can resume execution from a specific step index.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "planContent": { + "type": "string", + "description": "YAML plan content" + }, + "startStep": { + "default": 0, + "description": "Start step index (0-based, default: 0)", + "type": "number" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for parallel execution", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "deviceId": { + "description": "Device ID", + "type": "string" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + }, + "devices": { + "description": "Device labels for multi-device plans", + "type": "array", + "items": { + "type": "string" + } + }, + "deviceAllocationTimeoutMs": { + "default": 300000, + "description": "Timeout in milliseconds for allocating all devices (default: 300000 = 5 minutes)", + "type": "number" + }, + "abortStrategy": { + "default": "immediate", + "description": "Abort strategy: immediate (default) or finish-current-step", + "type": "string", + "enum": [ + "immediate", + "finish-current-step" + ] + }, + "testMetadata": { + "description": "Test metadata for timing history", + "type": "object", + "properties": { + "testClass": { + "type": "string" + }, + "testMethod": { + "type": "string" + }, + "appVersion": { + "type": "string" + }, + "gitCommit": { + "type": "string" + }, + "targetSdk": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "jdkVersion": { + "type": "string" + }, + "jvmTarget": { + "type": "string" + }, + "gradleVersion": { + "type": "string" + }, + "isCi": { + "type": "boolean" + } + }, + "required": [ + "testClass", + "testMethod" + ], + "additionalProperties": false + }, + "cleanupAppId": { + "description": "App ID to terminate after execution", + "type": "string" + }, + "cleanupClearAppData": { + "description": "Clear app data on cleanup", + "type": "boolean" + } + }, + "required": [ + "planContent", + "startStep", + "platform", + "deviceAllocationTimeoutMs", + "abortStrategy" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "executedSteps": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalSteps": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "failedStep": { + "type": "object", + "properties": { + "stepIndex": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "tool": { + "type": "string" + }, + "error": { + "type": "string" + }, + "device": { + "type": "string" + } + }, + "required": [ + "stepIndex", + "tool", + "error" + ], + "additionalProperties": false + }, + "error": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ] + }, + "deviceId": { + "type": "string" + }, + "deviceMapping": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "debug": { + "type": "object", + "properties": { + "executionTimeMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "completed", + "failed", + "skipped" + ] + }, + "durationMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "details": {} + }, + "required": [ + "step", + "status", + "durationMs" + ], + "additionalProperties": false + } + }, + "deviceState": { + "type": "object", + "properties": { + "currentActivity": { + "type": "string" + }, + "focusedWindow": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": [ + "executionTimeMs", + "steps" + ], + "additionalProperties": false + } + }, + "required": [ + "success", + "executedSteps", + "totalSteps" + ], + "additionalProperties": {} + } + }, + { + "name": "exportPlan", + "description": "Stop the active test recording and export recorded interactions as a YAML plan. Returns the plan content.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "recordingId": { + "description": "Recording ID to export (uses active recording if not specified)", + "type": "string" + }, + "planName": { + "description": "Name for the exported plan (auto-generated if not specified)", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "recordingId": { + "type": "string" + }, + "planName": { + "type": "string" + }, + "planContent": { + "type": "string" + }, + "stepCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "durationMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ], + "additionalProperties": false + } + }, + { + "name": "getDeepLinks", + "description": "Query deep links for app", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "appId": { + "type": "string", + "description": "App package ID" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "appId" + ], + "additionalProperties": false + } + }, + { + "name": "highlight", + "description": "Draw a visual highlight around a UI element on the device screen for debugging.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Target platform" + }, + "deviceId": { + "description": "Optional device ID override", + "type": "string" + }, + "timeoutMs": { + "description": "Highlight request timeout ms (default: 5000)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "description": { + "description": "Optional description of the highlight", + "type": "string" + }, + "shape": { + "description": "Optional highlight shape definition", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "box" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sourceWidth": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "sourceHeight": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + "style": { + "anyOf": [ + { + "type": "object", + "properties": { + "strokeColor": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "dashPattern": { + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + { + "type": "null" + } + ] + }, + "smoothing": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "catmull-rom", + "bezier", + "douglas-peucker" + ] + }, + { + "type": "null" + } + ] + }, + "tension": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "null" + } + ] + }, + "capStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "butt", + "round", + "square" + ] + }, + { + "type": "null" + } + ] + }, + "joinStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "miter", + "round", + "bevel" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "bounds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "circle" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sourceWidth": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "sourceHeight": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + "style": { + "anyOf": [ + { + "type": "object", + "properties": { + "strokeColor": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "dashPattern": { + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + { + "type": "null" + } + ] + }, + "smoothing": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "catmull-rom", + "bezier", + "douglas-peucker" + ] + }, + { + "type": "null" + } + ] + }, + "tension": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "null" + } + ] + }, + "capStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "butt", + "round", + "square" + ] + }, + { + "type": "null" + } + ] + }, + "joinStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "miter", + "round", + "bevel" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "bounds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "path" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "bounds": { + "anyOf": [ + { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sourceWidth": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "sourceHeight": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "style": { + "anyOf": [ + { + "type": "object", + "properties": { + "strokeColor": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "dashPattern": { + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + { + "type": "null" + } + ] + }, + "smoothing": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "catmull-rom", + "bezier", + "douglas-peucker" + ] + }, + { + "type": "null" + } + ] + }, + "tension": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "null" + } + ] + }, + "capStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "butt", + "round", + "square" + ] + }, + { + "type": "null" + } + ] + }, + "joinStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "miter", + "round", + "bevel" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "points" + ], + "additionalProperties": false + } + ] + }, + "elementId": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "text": { + "type": "string", + "description": "Element text" + }, + "container": { + "description": "Container selector object to scope search. Provide { \"elementId\": \"\" } or { \"text\": \"\" }.", + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Container resource ID" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Container text" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "containerOf": { + "description": "Whether to highlight the container of the selected element", + "type": "boolean" + }, + "selectionStrategy": { + "description": "Element selection strategy when multiple matches are found (default: first)", + "type": "string", + "enum": [ + "first", + "random" + ] + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "homeScreen", + "description": "Go to home screen", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "imeAction", + "description": "Perform IME action", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "done", + "next", + "search", + "send", + "go", + "previous" + ], + "description": "IME action" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "inputText", + "description": "Input text", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to input" + }, + "imeAction": { + "description": "IME action after input", + "type": "string", + "enum": [ + "done", + "next", + "search", + "send", + "go", + "previous" + ] + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "text", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "installApp", + "description": "Install APK file", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "apkPath": { + "type": "string", + "description": "APK file path" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "apkPath" + ], + "additionalProperties": false + } + }, + { + "name": "keyboard", + "description": "Open, close, or detect the on-screen keyboard", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "open", + "close", + "detect" + ], + "description": "Keyboard action" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "killDevice", + "description": "Kill device", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Device image name" + }, + "deviceId": { + "type": "string", + "description": "Device ID" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + } + }, + "required": [ + "name", + "deviceId", + "platform" + ], + "additionalProperties": false + } + }, + "required": [ + "device" + ], + "additionalProperties": false + } + }, + { + "name": "launchApp", + "description": "Launch app by package name", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "appId": { + "type": "string", + "description": "App package ID" + }, + "clearAppData": { + "description": "Clear app data before launch (default false)", + "type": "boolean" + }, + "coldBoot": { + "description": "Cold boot app (default false)", + "type": "boolean" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "appId" + ], + "additionalProperties": false + } + }, + { + "name": "listApps", + "description": "Guide for listing apps via MCP resources", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": {} + } + }, + { + "name": "listDeviceImages", + "description": "List device images", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "listDevices", + "description": "List devices (resource guidance)", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "description": "Platform", + "type": "string", + "enum": [ + "android", + "ios" + ] + } + }, + "additionalProperties": false + } + }, + { + "name": "observe", + "description": "Get screen view hierarchy", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "waitFor": { + "description": "Wait for element to appear before returning observation", + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "timeout": { + "description": "Wait timeout ms (default: 5000)", + "type": "number" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Element text" + }, + "timeout": { + "description": "Wait timeout ms (default: 5000)", + "type": "number" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "raw": { + "description": "When true, include unprocessed view hierarchy in response alongside normal output (default: false)", + "type": "boolean" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "screenSize": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": false + }, + "systemInsets": { + "type": "object", + "properties": { + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "top", + "right", + "bottom", + "left" + ], + "additionalProperties": false + }, + "rotation": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "viewHierarchy": {}, + "activeWindow": { + "type": "object", + "properties": { + "appId": { + "type": "string" + }, + "activityName": { + "type": "string" + }, + "layoutSeqSum": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "elements": { + "type": "object", + "properties": { + "clickable": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + } + }, + "scrollable": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + } + }, + "text": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + } + } + }, + "required": [ + "clickable", + "scrollable", + "text" + ], + "additionalProperties": false + }, + "selectedElements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "indexInMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "selectionStrategy": { + "type": "string" + }, + "selectedState": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "accessibility", + "visual" + ] + }, + "confidence": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "method", + "confidence" + ], + "additionalProperties": false + } + }, + "additionalProperties": {} + } + }, + "focusedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "accessibilityFocusedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "intentChooserDetected": { + "type": "boolean" + }, + "notificationPermissionDetected": { + "type": "boolean" + }, + "wakefulness": { + "type": "string", + "enum": [ + "Awake", + "Asleep", + "Dozing" + ] + }, + "userId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "backStack": {}, + "error": { + "type": "string" + }, + "awaitedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "awaitDuration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "awaitTimeout": { + "type": "boolean" + }, + "perfTiming": {}, + "perfTimingTruncated": { + "type": "boolean" + }, + "gfxMetrics": {}, + "displayedTimeMetrics": { + "type": "array", + "items": {} + }, + "performanceAudit": {}, + "accessibilityAudit": {}, + "freshness": { + "type": "object", + "properties": { + "requestedAfter": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "actualTimestamp": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "isFresh": { + "type": "boolean" + }, + "staleDurationMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "warning": { + "type": "string" + } + }, + "required": [ + "isFresh" + ], + "additionalProperties": {} + }, + "recompositionSummary": {}, + "predictions": { + "type": "object", + "properties": { + "likelyActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "target": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "elementId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "container": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "elementId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + } + }, + "additionalProperties": false + }, + "lookFor": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "elementId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": {} + }, + "predictedScreen": { + "type": "string" + }, + "predictedElements": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence": { + "type": "number" + } + }, + "required": [ + "action", + "target", + "predictedScreen", + "confidence" + ], + "additionalProperties": {} + } + }, + "interactableElements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "elementId": { + "type": "string" + }, + "elementText": { + "type": "string" + }, + "elementContentDesc": { + "type": "string" + }, + "predictedOutcome": { + "type": "object", + "properties": { + "screenName": { + "type": "string" + }, + "basedOn": { + "type": "string", + "enum": [ + "navigation_graph" + ] + } + }, + "required": [ + "screenName", + "basedOn" + ], + "additionalProperties": false + } + }, + "additionalProperties": {} + } + } + }, + "required": [ + "likelyActions", + "interactableElements" + ], + "additionalProperties": {} + }, + "accessibilityState": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "service": { + "type": "string", + "enum": [ + "talkback", + "unknown" + ] + } + }, + "required": [ + "enabled", + "service" + ], + "additionalProperties": {} + }, + "rawViewHierarchy": {} + }, + "required": [ + "updatedAt", + "screenSize", + "systemInsets" + ], + "additionalProperties": {} + } + }, + { + "name": "openLink", + "description": "Open URL in browser", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to open" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "url", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "pinchOn", + "description": "Pinch to zoom", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": [ + "in", + "out" + ], + "description": "Pinch direction" + }, + "distanceStart": { + "description": "Initial finger distance (px, default: 400)", + "type": "number" + }, + "distanceEnd": { + "description": "Final finger distance (px, default: 100)", + "type": "number" + }, + "scale": { + "description": "Scale factor (overrides distances)", + "type": "number" + }, + "duration": { + "description": "Gesture duration (ms)", + "type": "number" + }, + "rotationDegrees": { + "description": "Rotation during pinch (degrees)", + "type": "number" + }, + "includeSystemInsets": { + "description": "Use full screen including status/nav bars", + "type": "boolean" + }, + "container": { + "description": "Container selector object to scope search. Provide { \"elementId\": \"\" } or { \"text\": \"\" }.", + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Container resource ID" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Container text" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "autoTarget": { + "description": "Auto-target pinchable containers", + "type": "boolean" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "direction", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "postNotification", + "description": "Post a notification from the app-under-test when AutoMobile SDK hooks are installed.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Notification title" + }, + "body": { + "type": "string", + "minLength": 1, + "description": "Notification body" + }, + "imageType": { + "description": "Notification image type (default: normal)", + "type": "string", + "enum": [ + "normal", + "bigPicture" + ] + }, + "imagePath": { + "description": "Host image file path to push to /sdcard/Download/automobile when imageType is bigPicture", + "type": "string" + }, + "actions": { + "description": "Action buttons to include", + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Action label" + }, + "actionId": { + "type": "string", + "minLength": 1, + "description": "Action identifier" + } + }, + "required": [ + "label", + "actionId" + ], + "additionalProperties": false + } + }, + "channelId": { + "description": "Notification channel ID (Android only)", + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "title", + "body", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "pressButton", + "description": "Press hardware button", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "button": { + "type": "string", + "enum": [ + "home", + "back", + "menu", + "power", + "volume_up", + "volume_down", + "recent" + ], + "description": "Button to press" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "button", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "pressKey", + "description": "Press hardware key", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "key": { + "type": "string", + "enum": [ + "home", + "back", + "menu", + "power", + "volume_up", + "volume_down", + "recent" + ], + "description": "Key to press" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "key", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "recentApps", + "description": "Open recent apps", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "rotate", + "description": "Rotate device orientation", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "orientation": { + "type": "string", + "enum": [ + "portrait", + "landscape" + ], + "description": "Orientation" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "orientation", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "selectAllText", + "description": "Select all text in focused input", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "setActiveDevice", + "description": "Set active device", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "deviceId": { + "type": "string", + "description": "Device ID" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + } + }, + "required": [ + "deviceId", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "shake", + "description": "Shake device", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "duration": { + "description": "Shake duration in ms (default: 1000)", + "type": "number" + }, + "intensity": { + "description": "Shake acceleration intensity (default: 100)", + "type": "number" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "startDevice", + "description": "Start device", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Device name" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "deviceId": { + "description": "Device ID", + "type": "string" + }, + "isRunning": { + "description": "Running status", + "type": "boolean" + }, + "source": { + "description": "Source (local/remote)", + "type": "string" + } + }, + "required": [ + "name", + "platform" + ], + "additionalProperties": false, + "description": "Device to start" + }, + "timeoutMs": { + "description": "Readiness timeout ms", + "type": "number" + } + }, + "required": [ + "device" + ], + "additionalProperties": false + } + }, + { + "name": "startTestRecording", + "description": "Start test recording mode on the active device. Records user interactions for later export as a test plan. Returns the session ID which can be used with exportPlan.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "recordingId": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ], + "additionalProperties": false + } + }, + { + "name": "swipeOn", + "description": "Swipe/scroll on screen or elements", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "includeSystemInsets": { + "description": "Use full screen including status/nav bars", + "type": "boolean" + }, + "container": { + "description": "Container selector object to scope search. Provide { \"elementId\": \"\" } or { \"text\": \"\" }.", + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Container resource ID" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Container text" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "autoTarget": { + "description": "Auto-target scrollable containers (default: true)", + "type": "boolean" + }, + "direction": { + "type": "string", + "enum": [ + "up", + "down", + "left", + "right" + ], + "description": "Swipe/scroll direction" + }, + "gestureType": { + "description": "swipeFingerTowardsDirection: finger moves in direction (e.g., 'up' = finger up = content scrolls down). scrollTowardsDirection: content moves in direction (e.g., 'up' = content up = see content below). Default: scrollTowardsDirection.", + "type": "string", + "enum": [ + "swipeFingerTowardsDirection", + "scrollTowardsDirection" + ] + }, + "lookFor": { + "description": "Element to look for during swipe", + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "ID of the element to look for" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to look for" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "boomerang": { + "description": "Return to start position after swipe apex", + "type": "boolean" + }, + "apexPause": { + "description": "Pause duration at swipe apex in ms (0-3000)", + "type": "number", + "minimum": 0, + "maximum": 3000 + }, + "returnSpeed": { + "description": "Speed multiplier for return swipe (0.1-3.0)", + "type": "number", + "minimum": 0.1, + "maximum": 3 + }, + "speed": { + "description": "Swipe speed preset", + "type": "string", + "enum": [ + "slow", + "normal", + "fast" + ] + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "direction", + "platform" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "scrollableCandidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "elementId": { + "type": "string" + }, + "text": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "className": { + "type": "string" + } + }, + "additionalProperties": {} + } + }, + "targetType": { + "type": "string", + "enum": [ + "screen", + "element" + ] + }, + "element": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "x1": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y1": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "x2": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y2": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "duration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "easing": { + "type": "string", + "enum": [ + "linear", + "decelerate", + "accelerate", + "accelerateDecelerate" + ] + }, + "path": { + "type": "number" + }, + "found": { + "type": "boolean" + }, + "scrollIterations": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "elapsedMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "hierarchyChanged": { + "type": "boolean" + }, + "observation": { + "type": "object", + "properties": { + "selectedElements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "indexInMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "selectionStrategy": { + "type": "string" + }, + "selectedState": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "accessibility", + "visual" + ] + }, + "confidence": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "method", + "confidence" + ], + "additionalProperties": false + } + }, + "additionalProperties": {} + } + }, + "focusedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "accessibilityFocusedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "activeWindow": { + "type": "object", + "properties": { + "appId": { + "type": "string" + }, + "activityName": { + "type": "string" + }, + "layoutSeqSum": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type": { + "type": "string" + } + }, + "additionalProperties": {} + } + }, + "additionalProperties": {} + }, + "a11yTotalTimeMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "a11yGestureTimeMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "fallbackReason": { + "type": "string" + }, + "debug": {} + }, + "required": [ + "success", + "targetType", + "x1", + "y1", + "x2", + "y2", + "duration" + ], + "additionalProperties": {} + } + }, + { + "name": "systemTray", + "description": "System tray actions for notifications (open/find/tap/dismiss/clearAll)", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "open", + "find", + "tap", + "dismiss", + "clearAll" + ], + "description": "Action: open=expand tray, find=search for notification, tap=tap notification, dismiss=swipe away, clearAll=dismiss all for app" + }, + "notification": { + "description": "Notification criteria to match", + "type": "object", + "properties": { + "title": { + "description": "Notification title to match", + "type": "string" + }, + "body": { + "description": "Notification body to match", + "type": "string" + }, + "appId": { + "description": "App package ID to match", + "type": "string" + }, + "tapActionLabel": { + "description": "Action button label to tap (for 'tap' action)", + "type": "string" + } + }, + "additionalProperties": false + }, + "awaitTimeout": { + "description": "Timeout in ms to wait for notification (default: 5000)", + "type": "number" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action", + "platform" + ], + "additionalProperties": false + } + }, + { + "name": "tapOn", + "description": "Tap UI elements by text or ID (returns selectedElement metadata)", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "container": { + "description": "Container selector object to scope search. Provide { \"elementId\": \"\" } or { \"text\": \"\" }.", + "anyOf": [ + { + "type": "object", + "properties": { + "elementId": { + "type": "string", + "description": "Container resource ID" + } + }, + "required": [ + "elementId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Container text" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "action": { + "type": "string", + "enum": [ + "tap", + "doubleTap", + "longPress", + "focus" + ], + "description": "Action type" + }, + "selectionStrategy": { + "description": "Element selection strategy when multiple matches are found (default: first)", + "type": "string", + "enum": [ + "first", + "random" + ] + }, + "duration": { + "description": "Long press duration (ms)", + "type": "number" + }, + "searchUntil": { + "description": "Poll for element before tapping", + "type": "object", + "properties": { + "duration": { + "description": "Polling duration (ms, default: 500)", + "type": "number", + "minimum": 100, + "maximum": 12000 + } + }, + "additionalProperties": false + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Platform" + }, + "elementId": { + "type": "string", + "description": "Element resource ID / accessibility identifier" + }, + "text": { + "type": "string", + "description": "Element text" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action", + "platform" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "action": { + "type": "string", + "enum": [ + "tap", + "doubleTap", + "longPress", + "focus" + ] + }, + "message": { + "type": "string" + }, + "element": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "observation": { + "type": "object", + "properties": { + "selectedElements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "indexInMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "selectionStrategy": { + "type": "string" + }, + "selectedState": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "accessibility", + "visual" + ] + }, + "confidence": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "method", + "confidence" + ], + "additionalProperties": false + } + }, + "additionalProperties": {} + } + }, + "focusedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "accessibilityFocusedElement": { + "type": "object", + "properties": { + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "resource-id": { + "type": "string" + }, + "content-desc": { + "type": "string" + }, + "class": { + "type": "string" + }, + "package": { + "type": "string" + }, + "checkable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "clickable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focusable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "accessibility-focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "scrollable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "true" + }, + { + "type": "string", + "const": "false" + } + ] + } + }, + "required": [ + "bounds" + ], + "additionalProperties": {} + }, + "activeWindow": { + "type": "object", + "properties": { + "appId": { + "type": "string" + }, + "activityName": { + "type": "string" + }, + "layoutSeqSum": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type": { + "type": "string" + } + }, + "additionalProperties": {} + } + }, + "additionalProperties": {} + }, + "selectedElement": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "indexInMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "selectionStrategy": { + "type": "string" + }, + "selectedState": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "accessibility", + "visual" + ] + }, + "confidence": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "method", + "confidence" + ], + "additionalProperties": false + } + }, + "additionalProperties": {} + }, + "selectedElements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "contentDesc": { + "type": "string" + }, + "bounds": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "top": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "right": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bottom": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerX": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "centerY": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "additionalProperties": false + }, + "indexInMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalMatches": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "selectionStrategy": { + "type": "string" + }, + "selectedState": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "accessibility", + "visual" + ] + }, + "confidence": { + "type": "number" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "method", + "confidence" + ], + "additionalProperties": false + } + }, + "additionalProperties": {} + } + }, + "error": { + "type": "string" + }, + "pressRecognized": { + "type": "boolean" + }, + "contextMenuOpened": { + "type": "boolean" + }, + "selectionStarted": { + "type": "boolean" + }, + "searchUntil": { + "type": "object", + "properties": { + "durationMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "requestCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "changeCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "debug": {} + }, + "required": [ + "success", + "action" + ], + "additionalProperties": {} + } + }, + { + "name": "terminateApp", + "description": "Terminate app by package name", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "appId": { + "type": "string", + "description": "App package ID" + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "appId" + ], + "additionalProperties": false + } + }, + { + "name": "videoRecording", + "description": "Start or stop a low-overhead video recording for the active device.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "start", + "stop" + ], + "description": "Action to perform" + }, + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "description": "Target platform" + }, + "deviceId": { + "description": "Optional device ID override", + "type": "string" + }, + "recordingId": { + "description": "Recording ID to stop", + "type": "string" + }, + "qualityPreset": { + "description": "Recording quality preset", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "targetBitrateKbps": { + "description": "Target bitrate in Kbps", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxThroughputMbps": { + "description": "Max throughput in Mbps", + "type": "number", + "exclusiveMinimum": 0 + }, + "fps": { + "description": "Frames per second", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "resolution": { + "description": "Override capture resolution", + "type": "object", + "properties": { + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Override resolution width in pixels" + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Override resolution height in pixels" + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": false + }, + "format": { + "description": "Video format", + "type": "string", + "enum": [ + "mp4" + ] + }, + "maxDuration": { + "description": "Max seconds to record video for (default 30, max 300)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 300 + }, + "outputName": { + "description": "Optional label to identify the recording", + "type": "string" + }, + "highlights": { + "description": "Optional highlights to show during recording", + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "description": "Description of the highlight", + "type": "string" + }, + "shape": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "box" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sourceWidth": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "sourceHeight": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + "style": { + "anyOf": [ + { + "type": "object", + "properties": { + "strokeColor": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "dashPattern": { + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + { + "type": "null" + } + ] + }, + "smoothing": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "catmull-rom", + "bezier", + "douglas-peucker" + ] + }, + { + "type": "null" + } + ] + }, + "tension": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "null" + } + ] + }, + "capStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "butt", + "round", + "square" + ] + }, + { + "type": "null" + } + ] + }, + "joinStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "miter", + "round", + "bevel" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "bounds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "circle" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sourceWidth": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "sourceHeight": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + "style": { + "anyOf": [ + { + "type": "object", + "properties": { + "strokeColor": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "dashPattern": { + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + { + "type": "null" + } + ] + }, + "smoothing": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "catmull-rom", + "bezier", + "douglas-peucker" + ] + }, + { + "type": "null" + } + ] + }, + "tension": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "null" + } + ] + }, + "capStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "butt", + "round", + "square" + ] + }, + { + "type": "null" + } + ] + }, + "joinStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "miter", + "round", + "bevel" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "bounds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "path" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "bounds": { + "anyOf": [ + { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sourceWidth": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "sourceHeight": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "style": { + "anyOf": [ + { + "type": "object", + "properties": { + "strokeColor": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "dashPattern": { + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + { + "type": "null" + } + ] + }, + "smoothing": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "catmull-rom", + "bezier", + "douglas-peucker" + ] + }, + { + "type": "null" + } + ] + }, + "tension": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "null" + } + ] + }, + "capStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "butt", + "round", + "square" + ] + }, + { + "type": "null" + } + ] + }, + "joinStyle": { + "anyOf": [ + { + "type": "string", + "enum": [ + "miter", + "round", + "bevel" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "points" + ], + "additionalProperties": false + } + ], + "description": "Highlight shape definition" + }, + "timing": { + "description": "Optional highlight timing", + "type": "object", + "properties": { + "startTimeMs": { + "description": "Start time in ms", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + } + }, + "required": [ + "shape" + ], + "additionalProperties": false + } + }, + "sessionUuid": { + "description": "Session UUID for device targeting", + "type": "string" + }, + "keepScreenAwake": { + "description": "Keep physical Android devices awake during the session (default: true)", + "type": "boolean" + }, + "device": { + "description": "Device label for multi-device plans (e.g., \"A\", \"B\")", + "type": "string" + } + }, + "required": [ + "action", + "platform" + ], + "additionalProperties": false + } + } +] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..aabb876b9 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,194 @@ +# AutoMobile Scripts + +This directory contains build, validation, and utility scripts for the AutoMobile project. + +## MCP Context Management + +### Context Estimation + +Estimate token usage for MCP server components (tools, resources, templates): + +```bash +bun run estimate-context +``` + +**Output:** +- Detailed breakdown of token usage per tool/resource +- Total token counts by category +- Sorted by token count (highest first) + +**Options:** +```bash +# Include operation traces from a JSON file +bun run estimate-context --traces path/to/traces.json +``` + +**Use Cases:** +- Understanding current context usage +- Identifying token-heavy tools or resources +- Planning optimization efforts +- Generating baseline for threshold configuration + +### Context Threshold Benchmark + +Validate that MCP context usage stays within configured thresholds: + +```bash +bun run benchmark-context +``` + +**Exit Codes:** +- `0` - All thresholds passed +- `1` - One or more thresholds exceeded or error occurred + +**Options:** +```bash +# Use custom threshold configuration +bun run benchmark-context --config path/to/thresholds.json + +# Output JSON report to file +bun run benchmark-context --output reports/benchmark.json +``` + +**Use Cases:** +- CI/CD threshold enforcement +- Pre-commit validation +- Regression detection +- Performance budget tracking + +### Threshold Configuration + +Thresholds are defined in `scripts/context-thresholds.json`: + +```json +{ + "version": "1.0.0", + "thresholds": { + "tools": 14000, + "resources": 1000, + "resourceTemplates": 2000, + "total": 17000 + } +} +``` + +Current thresholds are manually set to allow headroom for resource and template growth while preventing significant regressions. + +## Startup Benchmark + +Measure MCP server and daemon startup time (cold/warm) with optional baseline comparison: + +```bash +bun run benchmark-startup --compare benchmark/startup-baseline.json --output reports/startup-benchmark.json +``` + +**Options:** +```bash +# Only run cold or warm measurements +bun run benchmark-startup --cold +bun run benchmark-startup --warm + +# Skip daemon or server benchmarks +bun run benchmark-startup --server-only +bun run benchmark-startup --daemon-only + +# Stream benchmark stdio as it is read +bun run benchmark-startup --verbose + +# Change regression threshold multiplier +bun run benchmark-startup --threshold 1.3 +``` + +**Notes:** +- Device discovery scenarios run only when `adb` is available and at least one device is connected. +- The benchmark will run `adb kill-server` when measuring cold ADB startup impact. + +## NPM Unpacked Size Benchmark + +Measure and enforce the NPM unpacked size threshold for the root package: + +```bash +bun run benchmark-npm-unpacked-size --output reports/npm-unpacked-size.json +``` + +**Options:** +```bash +# Use custom threshold configuration +bun run benchmark-npm-unpacked-size --config path/to/thresholds.json + +# Output JSON report to file +bun run benchmark-npm-unpacked-size --output reports/npm-unpacked-size.json +``` + +**Notes:** +- Runs `prepublishOnly` before packing to match the published package contents. +- Requires a prior `bun run build` so `dist/` is present. + +## Other Scripts + +### Build Scripts + +- `build.ts` - Compile TypeScript to JavaScript for distribution +- `npm/transform-readme.js` - Transform README for npm package + +### Local Development Scripts + +- `local-dev/android-hot-reload.sh` - Unified Android development workflow with APK hot-reload, MCP server, and AI assistant integration + - `--skip-ai` - Run without AI prompt + - `--once` - Build/install once and exit + - `--update-checksum` - Update release.ts with APK checksum + - Shared functions in `local-dev/lib/` (common.sh, adb.sh, apk.sh) +- `local-dev/ios-hot-reload.sh` - Unified iOS development workflow with XCTestService hot-reload, MCP server, and AI assistant integration + - `--skip-ai` - Run without AI prompt + - `--once` - Build once and exit + - `--device ` - Target a specific booted simulator + - Shared functions in `local-dev/lib/` (common.sh, deps.sh, xctestservice.sh) + +### Tool Definition Scripts + +- `update-tool-definitions.sh` - Regenerate and stage `schemas/tool-definitions.json` for IDE YAML completion + +### Validation Scripts + +See individual script directories for specialized validation: +- `docker/` - Docker container testing +- `ide-plugin/` - IntelliJ/Android Studio plugin validation +- `ktfmt/` - Kotlin formatting +- `lychee/` - Documentation link validation +- `shellcheck/` - Shell script linting and formatting +- `xml/` - XML validation and formatting + +Root-level validation scripts: +- `validate_dependabot.sh` - Validate Dependabot config YAML +- `validate_mkdocs_nav.sh` - Validate MkDocs nav configuration + +Run `scripts//validate_*.sh` for validation or `scripts//apply_*.sh` for auto-formatting. + +## CI Integration + +The following scripts are invoked by GitHub Actions workflows: + +- `benchmark-context-thresholds.ts` - Runs in `.github/workflows/context-thresholds.yml` +- `benchmark-startup.sh` - Runs in `.github/workflows/pull_request.yml` +- `benchmark-npm-unpacked-size.ts` - Runs in `.github/workflows/pull_request.yml` +- `validate_*.sh` - Various validation workflows in `.github/workflows/pull_request.yml` + +See workflow files for integration details. + +## Development + +All scripts should: +- Include usage instructions in header comments +- Return appropriate exit codes (0 for success, non-zero for failure) +- Provide clear error messages +- Be executable directly (have shebang and execute permissions) + +### Adding New Scripts + +1. Place script in appropriate subdirectory (or create new one) +2. Add shebang line (`#!/usr/bin/env bun` for TypeScript, `#!/usr/bin/env bash` for shell) +3. Include header documentation with usage examples +4. Make executable: `chmod +x scripts/your-script.ts` +5. Add npm script alias if appropriate (in `package.json`) +6. Document in this README +7. Update `.github/workflows/` if CI integration needed diff --git a/scripts/act/act_list.sh b/scripts/act/act_list.sh new file mode 100755 index 000000000..8643cc088 --- /dev/null +++ b/scripts/act/act_list.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +INSTALL_ACT_WHEN_MISSING=${INSTALL_ACT_WHEN_MISSING:-false} + +# Check if act is installed +if ! command -v act &>/dev/null; then + echo "act missing" + if [[ "${INSTALL_ACT_WHEN_MISSING}" == "true" ]]; then + scripts/act/install_act.sh + # Ensure act is in PATH for subsequent commands + export PATH="$HOME/bin:$PATH" + else + if [[ "$OSTYPE" == "darwin"* ]]; then + # macos specific advice + echo "Try 'brew install act' or run with INSTALL_ACT_WHEN_MISSING=true" + else + echo "Consult your OS package manager or run with INSTALL_ACT_WHEN_MISSING=true" + fi + exit 1 + fi +fi + +# Verify act is available +if ! command -v act &>/dev/null; then + echo "Error: act is not available in PATH" + exit 1 +fi + +# Check if .github/workflows directory exists +if [ ! -d ".github/workflows" ]; then + echo "No .github/workflows directory found" + exit 0 +fi + +# Check if there are any workflow files +workflow_files=$(find .github/workflows -name "*.yml" -o -name "*.yaml" 2>/dev/null) +if [[ -z "$workflow_files" ]]; then + echo "No workflow files found in .github/workflows" + exit 0 +fi + +echo "Available GitHub Actions jobs and workflows:" +echo "==============================================" +act --list + +echo "" +echo "Usage examples:" +echo " Validate all jobs (dry run): ./scripts/act/validate_act.sh" +echo " Validate specific job: ACT_JOB=ktfmt ./scripts/act/validate_act.sh" +echo " Run pull_request event: ACT_EVENT=pull_request ./scripts/act/apply_act.sh" +echo " Run with custom Dockerfile: USE_CUSTOM_DOCKERFILE=true ./scripts/act/validate_act.sh" diff --git a/scripts/act/apply_act.sh b/scripts/act/apply_act.sh new file mode 100755 index 000000000..7905a19e4 --- /dev/null +++ b/scripts/act/apply_act.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +INSTALL_ACT_WHEN_MISSING=${INSTALL_ACT_WHEN_MISSING:-false} +ACT_EVENT=${ACT_EVENT:-"push"} +ACT_JOB=${ACT_JOB:-""} +USE_CUSTOM_DOCKERFILE=${USE_CUSTOM_DOCKERFILE:-false} + +# Check if act is installed +if ! command -v act &>/dev/null; then + echo "act missing" + if [[ "${INSTALL_ACT_WHEN_MISSING}" == "true" ]]; then + scripts/act/install_act.sh + # Ensure act is in PATH for subsequent commands + export PATH="$HOME/bin:$PATH" + else + if [[ "$OSTYPE" == "darwin"* ]]; then + # macos specific advice + echo "Try 'brew install act' or run with INSTALL_ACT_WHEN_MISSING=true" + else + echo "Consult your OS package manager or run with INSTALL_ACT_WHEN_MISSING=true" + fi + exit 1 + fi +fi + +# Verify act is available +if ! command -v act &>/dev/null; then + echo "Error: act is not available in PATH" + exit 1 +fi + +# Check if Docker is available (required for act) +if ! command -v docker &>/dev/null; then + echo "Error: Docker is required for act but is not installed" + echo "Please install Docker and ensure it's running" + exit 1 +fi + +# Check if Docker daemon is running +if ! docker info &>/dev/null; then + echo "Error: Docker daemon is not running" + echo "Please start Docker and try again" + exit 1 +fi + +# Start the timer +start_time=$(bash -c "$(pwd)/scripts/utils/get_timestamp.sh") + +# Check if .github/workflows directory exists +if [ ! -d ".github/workflows" ]; then + echo "No .github/workflows directory found" + exit 0 +fi + +# Check if there are any workflow files +workflow_files=$(find .github/workflows -name "*.yml" -o -name "*.yaml" 2>/dev/null) +if [[ -z "$workflow_files" ]]; then + echo "No workflow files found in .github/workflows" + exit 0 +fi + +echo "Running GitHub Actions workflows locally with act..." + +# Build act command +act_cmd="act" + +# Add event if specified +if [[ -n "$ACT_EVENT" ]]; then + act_cmd="$act_cmd $ACT_EVENT" +fi + +# Add job filter if specified +if [[ -n "$ACT_JOB" ]]; then + act_cmd="$act_cmd --job $ACT_JOB" +fi + +# Add platform specification to use our custom Dockerfile if it exists and USE_CUSTOM_DOCKERFILE is set to true +if [[ "$USE_CUSTOM_DOCKERFILE" == "true" && -f "ci/Dockerfile" ]]; then + act_cmd="$act_cmd -P ubuntu-latest=dockerfile://$(pwd)/ci/Dockerfile" +fi + +# Add verbosity for better output +act_cmd="$act_cmd --verbose" + +# Run act +echo "Running: $act_cmd" +if ! eval "$act_cmd"; then + echo "Error: act execution failed" + # Calculate total elapsed time + end_time=$(bash -c "$(pwd)/scripts/utils/get_timestamp.sh") + total_elapsed=$((end_time - start_time)) + echo "Total time elapsed: $total_elapsed ms." + exit 1 +fi + +# Calculate total elapsed time +end_time=$(bash -c "$(pwd)/scripts/utils/get_timestamp.sh") +total_elapsed=$((end_time - start_time)) + +echo "GitHub Actions workflows completed successfully." +echo "Total time elapsed: $total_elapsed ms." diff --git a/scripts/act/install_act.sh b/scripts/act/install_act.sh new file mode 100755 index 000000000..55a81cdab --- /dev/null +++ b/scripts/act/install_act.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +ACT_VERSION="0.2.68" # Change this to the desired version + +# Check if act is not installed +if ! command -v act &>/dev/null; then + + echo "Installing act $ACT_VERSION..." + + # install proper version based on OS and architecture + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + echo "Detected macOS system" + if command -v brew &>/dev/null; then + brew install act + else + echo "Error: Homebrew is required to install act on macOS" + exit 1 + fi + else + # Linux + echo "Detected Linux system" + + # Create a temporary directory + TMP_DIR=$(mktemp -d) + + # Detect architecture + ARCH=$(uname -m) + case $ARCH in + x86_64) + ARCH="x86_64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "Error: Unsupported architecture: $ARCH" + rm -rf "$TMP_DIR" + exit 1 + ;; + esac + + # Download act binary with progress and error handling + echo "Downloading act binary for $ARCH..." + DOWNLOAD_URL="https://github.com/nektos/act/releases/download/v$ACT_VERSION/act_Linux_$ARCH.tar.gz" + + if ! curl -L --fail --show-error -o "$TMP_DIR/act.tar.gz" "$DOWNLOAD_URL"; then + echo "Error: Failed to download act binary" + rm -rf "$TMP_DIR" + exit 1 + fi + + # Extract the binary + if ! tar -xzf "$TMP_DIR/act.tar.gz" -C "$TMP_DIR"; then + echo "Error: Failed to extract act binary" + rm -rf "$TMP_DIR" + exit 1 + fi + + # Verify the binary exists + if [ ! -f "$TMP_DIR/act" ]; then + echo "Error: act binary not found in archive" + rm -rf "$TMP_DIR" + exit 1 + fi + + # Create bin directory if it doesn't exist + mkdir -p "$HOME/bin" + + # Move binary to a permanent location + if ! mv "$TMP_DIR/act" "$HOME/bin/"; then + echo "Error: Failed to move act binary to $HOME/bin/" + rm -rf "$TMP_DIR" + exit 1 + fi + + # Make binary executable + if ! chmod +x "$HOME/bin/act"; then + echo "Error: Failed to make act binary executable" + rm -rf "$TMP_DIR" + exit 1 + fi + + # Clean up temporary directory + rm -rf "$TMP_DIR" + + # Add to PATH if not already there + if [[ ":$PATH:" != *":$HOME/bin:"* ]]; then + echo "export PATH=\"\$HOME/bin:\$PATH\"" >> "$HOME/.bashrc" + echo "export PATH=\"\$HOME/bin:\$PATH\"" >> "$HOME/.bash_profile" + # Add to current PATH immediately + export PATH="$HOME/bin:$PATH" + fi + + # Verify installation + if ! command -v act &>/dev/null; then + echo "Error: act installation failed - command not found" + exit 1 + fi + fi + + echo "act $ACT_VERSION installed successfully!" +else + echo "act is already installed" +fi \ No newline at end of file diff --git a/scripts/act/validate_act.sh b/scripts/act/validate_act.sh new file mode 100755 index 000000000..298d73118 --- /dev/null +++ b/scripts/act/validate_act.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +INSTALL_ACT_WHEN_MISSING=${INSTALL_ACT_WHEN_MISSING:-false} +ACT_EVENT=${ACT_EVENT:-"push"} +ACT_JOB=${ACT_JOB:-""} +USE_CUSTOM_DOCKERFILE=${USE_CUSTOM_DOCKERFILE:-false} + +# Check if act is installed +if ! command -v act &>/dev/null; then + echo "act missing" + if [[ "${INSTALL_ACT_WHEN_MISSING}" == "true" ]]; then + scripts/act/install_act.sh + # Ensure act is in PATH for subsequent commands + export PATH="$HOME/bin:$PATH" + else + if [[ "$OSTYPE" == "darwin"* ]]; then + # macos specific advice + echo "Try 'brew install act' or run with INSTALL_ACT_WHEN_MISSING=true" + else + echo "Consult your OS package manager or run with INSTALL_ACT_WHEN_MISSING=true" + fi + exit 1 + fi +fi + +# Verify act is available +if ! command -v act &>/dev/null; then + echo "Error: act is not available in PATH" + exit 1 +fi + +# Check if Docker is available (required for act, unless using dryrun) +if ! command -v docker &>/dev/null; then + echo "Warning: Docker not found, but running in dry-run mode" +fi + +# Check if Docker daemon is running (only if not in dry-run mode) +if [[ "$ACT_DRY_RUN" != "true" ]] && ! docker info &>/dev/null; then + echo "Error: Docker daemon is not running" + echo "Please start Docker and try again, or run with ACT_DRY_RUN=true" + exit 1 +fi + +# Start the timer +start_time=$(bash -c "$(pwd)/scripts/utils/get_timestamp.sh") + +# Check if .github/workflows directory exists +if [ ! -d ".github/workflows" ]; then + echo "No .github/workflows directory found" + exit 0 +fi + +# Check if there are any workflow files +workflow_files=$(find .github/workflows -name "*.yml" -o -name "*.yaml" 2>/dev/null) +if [[ -z "$workflow_files" ]]; then + echo "No workflow files found in .github/workflows" + exit 0 +fi + +echo "Validating GitHub Actions workflows with act..." + +# Build act command +act_cmd="act" + +# Add event if specified +if [[ -n "$ACT_EVENT" ]]; then + act_cmd="$act_cmd $ACT_EVENT" +fi + +# Add job filter if specified +if [[ -n "$ACT_JOB" ]]; then + act_cmd="$act_cmd --job $ACT_JOB" +fi + +# Always add dry run flag for validation +act_cmd="$act_cmd --dryrun" + +# Add platform specification to use our custom Dockerfile if it exists and USE_CUSTOM_DOCKERFILE is true +if [[ "$USE_CUSTOM_DOCKERFILE" == "true" ]] && [ -f "ci/Dockerfile" ]; then + act_cmd="$act_cmd -P ubuntu-latest=dockerfile://$(pwd)/ci/Dockerfile" +fi + +# Run act and capture output +echo "Running: $act_cmd" +if ! eval "$act_cmd" 2>&1; then + echo "Error: act validation failed" + # Calculate total elapsed time + end_time=$(bash -c "$(pwd)/scripts/utils/get_timestamp.sh") + total_elapsed=$((end_time - start_time)) + echo "Total time elapsed: $total_elapsed ms." + exit 1 +fi + +# Calculate total elapsed time +end_time=$(bash -c "$(pwd)/scripts/utils/get_timestamp.sh") +total_elapsed=$((end_time - start_time)) + +echo "All GitHub Actions workflows pass act validation." +echo "Total time elapsed: $total_elapsed ms." diff --git a/scripts/all_fast_validate_checks.sh b/scripts/all_fast_validate_checks.sh new file mode 100755 index 000000000..9258af207 --- /dev/null +++ b/scripts/all_fast_validate_checks.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# +# all_fast_validate_checks.sh +# +# Run fast validation checks with optional parallel execution and summaries. +# +# Usage: +# ./scripts/all_fast_validate_checks.sh +# ./scripts/all_fast_validate_checks.sh --list +# ./scripts/all_fast_validate_checks.sh --only shellcheck,xml +# ./scripts/all_fast_validate_checks.sh --skip lychee +# ./scripts/all_fast_validate_checks.sh --group docs,config +# ./scripts/all_fast_validate_checks.sh --no-parallel +# ./scripts/all_fast_validate_checks.sh --max-parallel 4 +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +CHECK_NAMES=() +CHECK_COMMANDS=() +CHECK_GROUPS=() +CHECK_DESCRIPTIONS=() + +add_check() { + CHECK_NAMES+=("$1") + CHECK_COMMANDS+=("$2") + CHECK_GROUPS+=("$3") + CHECK_DESCRIPTIONS+=("$4") +} + +add_check "yaml" "bun \"$PROJECT_ROOT/scripts/validate-yaml.ts\"" "config,yaml" "Validate test plan YAML files" +add_check "xml" "\"$PROJECT_ROOT/scripts/xml/validate_xml.sh\"" "config,xml" "Validate XML files" +add_check "shellcheck" "\"$PROJECT_ROOT/scripts/shellcheck/validate_shell_scripts.sh\"" "lint,shell" "Validate shell scripts with shellcheck" +add_check "mkdocs-nav" "\"$PROJECT_ROOT/scripts/validate_mkdocs_nav.sh\"" "docs" "Validate MkDocs navigation" +add_check "ktfmt" "ONLY_TOUCHED_FILES=true \"$PROJECT_ROOT/scripts/ktfmt/validate_ktfmt.sh\"" "format,kotlin" "Validate Kotlin formatting" +add_check "claude-plugin" "\"$PROJECT_ROOT/scripts/claude/validate_plugin.sh\"" "config" "Validate Claude plugin structure" +add_check "lychee" "\"$PROJECT_ROOT/scripts/lychee/validate_lychee.sh\"" "docs,links" "Validate documentation links" +add_check "dependabot" "\"$PROJECT_ROOT/scripts/validate_dependabot.sh\"" "config,yaml" "Validate Dependabot config" + +print_usage() { + cat <<'EOF' +Usage: + ./scripts/all_fast_validate_checks.sh [options] + +Options: + --list List available checks and exit + --only Run only the named checks (comma-separated) + --skip Skip the named checks (comma-separated) + --group Run checks in the named groups (comma-separated) + --no-parallel Run checks serially + --max-parallel Limit concurrent checks + --help Show this help +EOF +} + +print_list() { + echo "Available checks:" + local idx + for idx in "${!CHECK_NAMES[@]}"; do + printf " %-14s %s (groups: %s)\n" \ + "${CHECK_NAMES[$idx]}" "${CHECK_DESCRIPTIONS[$idx]}" "${CHECK_GROUPS[$idx]}" + done +} + +split_csv() { + local value="$1" + local -a items=() + if [[ -n "$value" ]]; then + IFS=',' read -r -a items <<< "$value" + fi + echo "${items[@]}" +} + +contains_item() { + local needle="$1" + shift + local item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +groups_intersect() { + local check_groups="$1" + shift + local -a selected_groups=("$@") + local -a check_group_list=() + if [[ -n "$check_groups" ]]; then + IFS=',' read -r -a check_group_list <<< "$check_groups" + fi + local group + for group in "${check_group_list[@]}"; do + if contains_item "$group" "${selected_groups[@]}"; then + return 0 + fi + done + return 1 +} + +timestamp_ms() { + if [[ -x "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" + else + echo "$(date +%s)000" + fi +} + +list_requested=0 +declare -a only_list=() +declare -a skip_list=() +declare -a group_list=() +parallel_enabled=1 +max_parallel="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --list) + list_requested=1 + shift + ;; + --only) + shift + if [[ $# -eq 0 ]]; then + echo "Missing value for --only" >&2 + exit 1 + fi + read -r -a only_list <<< "$(split_csv "$1")" + shift + ;; + --skip) + shift + if [[ $# -eq 0 ]]; then + echo "Missing value for --skip" >&2 + exit 1 + fi + read -r -a skip_list <<< "$(split_csv "$1")" + shift + ;; + --group) + shift + if [[ $# -eq 0 ]]; then + echo "Missing value for --group" >&2 + exit 1 + fi + read -r -a group_list <<< "$(split_csv "$1")" + shift + ;; + --no-parallel|--serial) + parallel_enabled=0 + shift + ;; + --max-parallel) + shift + if [[ $# -eq 0 ]]; then + echo "Missing value for --max-parallel" >&2 + exit 1 + fi + max_parallel="$1" + shift + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + print_usage >&2 + exit 1 + ;; + esac +done + +if [[ "$list_requested" -eq 1 ]]; then + print_list + exit 0 +fi + +if [[ -n "${only_list[*]-}" ]]; then + for requested in "${only_list[@]}"; do + if ! contains_item "$requested" "${CHECK_NAMES[@]}"; then + echo "Unknown check in --only: $requested" >&2 + exit 1 + fi + done +fi + +if [[ -n "${skip_list[*]-}" ]]; then + for requested in "${skip_list[@]}"; do + if ! contains_item "$requested" "${CHECK_NAMES[@]}"; then + echo "Unknown check in --skip: $requested" >&2 + exit 1 + fi + done +fi + +selected_indices=() +for idx in "${!CHECK_NAMES[@]}"; do + name="${CHECK_NAMES[$idx]}" + groups="${CHECK_GROUPS[$idx]}" + include=1 + + if [[ -n "${only_list[*]-}" ]]; then + include=0 + if contains_item "$name" "${only_list[@]}"; then + include=1 + fi + elif [[ -n "${group_list[*]-}" ]]; then + include=0 + if groups_intersect "$groups" "${group_list[@]}"; then + include=1 + fi + fi + + if [[ "$include" -eq 1 ]] && [[ -n "${skip_list[*]-}" ]]; then + if contains_item "$name" "${skip_list[@]}"; then + include=0 + fi + fi + + if [[ "$include" -eq 1 ]]; then + selected_indices+=("$idx") + fi +done + +if [[ "${#selected_indices[@]}" -eq 0 ]]; then + echo "No checks selected." >&2 + exit 1 +fi + +if [[ -z "$max_parallel" ]]; then + if command -v nproc >/dev/null 2>&1; then + max_parallel="$(nproc)" + else + max_parallel="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" + fi +fi + +if ! [[ "$max_parallel" =~ ^[0-9]+$ ]]; then + echo "--max-parallel must be a positive integer." >&2 + exit 1 +fi + +if [[ "$max_parallel" -lt 1 ]]; then + echo "--max-parallel must be greater than 0." >&2 + exit 1 +fi + +if [[ "$parallel_enabled" -eq 0 ]]; then + max_parallel=1 +fi + +mkdir -p "$PROJECT_ROOT/scratch" +run_id="$(date +%Y%m%dT%H%M%S)" +run_dir="$PROJECT_ROOT/scratch/fast-validate-$run_id" +mkdir -p "$run_dir" + +echo "Running ${#selected_indices[@]} fast validation check(s)" +echo "Logs: $run_dir" +echo "" + +pids=() +pid_names=() + +start_check() { + local idx="$1" + local name="${CHECK_NAMES[$idx]}" + local cmd="${CHECK_COMMANDS[$idx]}" + local log_file="$run_dir/${name}.log" + local status_file="$run_dir/${name}.status" + local start_file="$run_dir/${name}.start" + local end_file="$run_dir/${name}.end" + + timestamp_ms > "$start_file" + + ( + set +e + { + echo "[INFO] $name" + echo "[INFO] Command: $cmd" + echo "" + eval "$cmd" + } >"$log_file" 2>&1 + echo "$?" > "$status_file" + timestamp_ms > "$end_file" + ) & + + pids+=("$!") + pid_names+=("$name") +} + +prune_finished_jobs() { + local new_pids=() + local new_names=() + local i + for i in "${!pids[@]}"; do + local pid="${pids[$i]}" + if kill -0 "$pid" 2>/dev/null; then + new_pids+=("$pid") + new_names+=("${pid_names[$i]}") + else + wait "$pid" || true + fi + done + pids=("${new_pids[@]}") + pid_names=("${new_names[@]}") +} + +wait_for_slot() { + local max="$1" + while [[ "${#pids[@]}" -ge "$max" ]]; do + prune_finished_jobs + if [[ "${#pids[@]}" -ge "$max" ]]; then + sleep 0.1 + fi + done +} + +for idx in "${selected_indices[@]}"; do + wait_for_slot "$max_parallel" + start_check "$idx" +done + +for pid in "${pids[@]}"; do + wait "$pid" || true +done + +echo "" +echo "Fast validation summary" +echo "=======================" + +passed=0 +failed=0 + +for idx in "${selected_indices[@]}"; do + name="${CHECK_NAMES[$idx]}" + status_file="$run_dir/${name}.status" + start_file="$run_dir/${name}.start" + end_file="$run_dir/${name}.end" + log_file="$run_dir/${name}.log" + status=1 + if [[ -f "$status_file" ]]; then + status="$(cat "$status_file")" + fi + duration_ms="unknown" + if [[ -f "$start_file" && -f "$end_file" ]]; then + start_time="$(cat "$start_file")" + end_time="$(cat "$end_file")" + duration_ms="$((end_time - start_time))" + fi + + if [[ "$status" -eq 0 ]]; then + printf "OK %-14s %sms\n" "$name" "$duration_ms" + passed=$((passed + 1)) + else + printf "FAIL %-14s %sms (log: %s)\n" "$name" "$duration_ms" "$log_file" + failed=$((failed + 1)) + fi +done + +echo "" +echo "Passed: $passed" +echo "Failed: $failed" + +if [[ "$failed" -ne 0 ]]; then + echo "" + echo "Some checks failed. See logs in: $run_dir" + exit 1 +fi diff --git a/scripts/android/gradlew_task.sh b/scripts/android/gradlew_task.sh new file mode 100644 index 000000000..13706c17e --- /dev/null +++ b/scripts/android/gradlew_task.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# TODO: +# This should be a bash script that performs `cd android && ./gradlew $@` to run +# all specified Gradle tasks. It should be pointed to by claude + codex skills \ No newline at end of file diff --git a/scripts/android/install-ew-cli.sh b/scripts/android/install-ew-cli.sh new file mode 100755 index 000000000..260d60799 --- /dev/null +++ b/scripts/android/install-ew-cli.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Install emulator.wtf CLI with retry logic +# +# Usage: ./install-ew-cli.sh [OPTIONS] +# +# Options: +# --install-dir DIR Directory to install ew-cli (default: $HOME/bin) +# --max-attempts N Maximum download attempts (default: 5) +# --retry-delay N Seconds between retries (default: 1) +# --dry-run Print what would be done without executing +# --help Show this help message +# + +set -euo pipefail + +# Defaults +INSTALL_DIR="${HOME}/bin" +MAX_ATTEMPTS=5 +RETRY_DELAY=1 +DRY_RUN=false +EW_CLI_URL="https://maven.emulator.wtf/releases/ew-cli" + +usage() { + head -n 15 "$0" | tail -n 13 | sed 's/^# //' | sed 's/^#//' +} + +log() { + echo "[install-ew-cli] $*" +} + +error() { + echo "[install-ew-cli] ERROR: $*" >&2 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --install-dir) + INSTALL_DIR="$2" + shift 2 + ;; + --max-attempts) + MAX_ATTEMPTS="$2" + shift 2 + ;; + --retry-delay) + RETRY_DELAY="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --help) + usage + exit 0 + ;; + *) + error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +EW_CLI_PATH="${INSTALL_DIR}/ew-cli" + +if [[ "$DRY_RUN" == "true" ]]; then + log "[DRY-RUN] Would create directory: ${INSTALL_DIR}" + log "[DRY-RUN] Would download ew-cli from: ${EW_CLI_URL}" + log "[DRY-RUN] Would save to: ${EW_CLI_PATH}" + log "[DRY-RUN] Would make executable: ${EW_CLI_PATH}" + log "[DRY-RUN] Max attempts: ${MAX_ATTEMPTS}, retry delay: ${RETRY_DELAY}s" + exit 0 +fi + +# Check if already installed +if [[ -x "${EW_CLI_PATH}" ]]; then + log "ew-cli already installed at ${EW_CLI_PATH}" + "${EW_CLI_PATH}" --version + exit 0 +fi + +log "Creating directory: ${INSTALL_DIR}" +mkdir -p "${INSTALL_DIR}" + +# Download with retry +attempt=1 +while [[ $attempt -le $MAX_ATTEMPTS ]]; do + log "Downloading ew-cli (attempt ${attempt}/${MAX_ATTEMPTS})..." + if curl -fsSL "${EW_CLI_URL}" -o "${EW_CLI_PATH}"; then + log "Download successful" + break + fi + if [[ $attempt -eq $MAX_ATTEMPTS ]]; then + error "Failed to download ew-cli after ${MAX_ATTEMPTS} attempts" + exit 1 + fi + log "Download failed, retrying in ${RETRY_DELAY} second(s)..." + sleep "${RETRY_DELAY}" + attempt=$((attempt + 1)) +done + +log "Making ew-cli executable" +chmod a+x "${EW_CLI_PATH}" + +log "Installed successfully:" +"${EW_CLI_PATH}" --version diff --git a/scripts/android/run-emulator-tests.sh b/scripts/android/run-emulator-tests.sh new file mode 100755 index 000000000..33204ba75 --- /dev/null +++ b/scripts/android/run-emulator-tests.sh @@ -0,0 +1,343 @@ +#!/bin/bash +# +# Run emulator tests with APK installation +# +# This script: +# 1. Logs comprehensive debug information about the environment +# 2. Validates and installs the accessibility service APK +# 3. Runs the test script +# +# Usage: +# ./scripts/android/run-emulator-tests.sh +# +# Example: +# ./scripts/android/run-emulator-tests.sh \ +# "control-proxy/build/outputs/apk/debug/control-proxy-debug.apk" \ +# "./gradlew :junit-runner:test" +# +# shellcheck disable=SC2012 # Using ls for readable debug output is appropriate here + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Arguments +APK_PATH="${1:-}" +TEST_SCRIPT="${2:-}" + +# Validate arguments +if [ -z "$APK_PATH" ] || [ -z "$TEST_SCRIPT" ]; then + echo -e "${RED}Error: Missing required arguments${NC}" + echo "Usage: $0 " + echo "Example: $0 'control-proxy/build/outputs/apk/debug/control-proxy-debug.apk' './gradlew :junit-runner:test'" + exit 1 +fi + +if [ -n "$APK_PATH" ]; then + export AUTOMOBILE_CTRL_PROXY_APK_PATH="$APK_PATH" + export AUTOMOBILE_SKIP_ACCESSIBILITY_CHECKSUM=1 + export AUTOMOBILE_SKIP_ACCESSIBILITY_DOWNLOAD_IF_INSTALLED=1 +fi + +# Helper function for section headers +print_section() { + local title="$1" + echo "" + echo -e "${BLUE}==========================================${NC}" + echo -e "${BLUE}$title${NC}" + echo -e "${BLUE}==========================================${NC}" +} + +# Helper function for success messages +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +# Helper function for error messages +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +# Helper function for warning messages +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +# Retry function with exponential backoff for transient failures +# Detects known transient error patterns: +# - Network/HTTP errors: 403, timeout, connection issues +# - Emulator/ADB errors: device offline, device not found, adb server killed (#808) +retry_with_backoff() { + local max_attempts="${RETRY_MAX_ATTEMPTS:-3}" + local initial_delay="${RETRY_INITIAL_DELAY:-10}" + local delay=$initial_delay + local attempt=1 + local exit_code=0 + + # Transient error patterns that warrant retry + # Network/HTTP errors (be specific to avoid matching app-level timeouts like MCP) + local network_errors="status code 403|Forbidden|Could not resolve|Connection refused|Connection reset|ETIMEDOUT|ECONNRESET|503 Service Unavailable|502 Bad Gateway|SocketTimeoutException|ConnectTimeoutException|connection timed out" + # Emulator/ADB infrastructure errors (#808) + local emulator_errors="device offline|device not found|adb server|AdbHostServer|emulator: ERROR|waiting for device|cannot connect to daemon" + local transient_pattern="($network_errors|$emulator_errors)" + + while [ "$attempt" -le "$max_attempts" ]; do + echo "" + echo -e "${BLUE}[Attempt $attempt/$max_attempts]${NC} Running: $*" + echo "" + + # Run the command and capture output and exit code + set +e + output=$("$@" 2>&1) + exit_code=$? + set -e + + echo "$output" + + if [ $exit_code -eq 0 ]; then + print_success "Command succeeded on attempt $attempt" + return 0 + fi + + # Check if this is a transient/retryable error + if echo "$output" | grep -qiE "$transient_pattern"; then + if [ "$attempt" -lt "$max_attempts" ]; then + print_warning "Transient error detected (attempt $attempt/$max_attempts)" + echo "Retrying in ${delay}s with exponential backoff..." + sleep "$delay" + delay=$((delay * 2)) + attempt=$((attempt + 1)) + else + print_error "Command failed after $max_attempts attempts (transient errors)" + return $exit_code + fi + else + # Not a transient error, fail immediately + print_error "Command failed with non-transient error (exit code: $exit_code)" + return $exit_code + fi + done + + return $exit_code +} + +# Main script starts here +print_section "EMULATOR SETUP DEBUG START" + +echo "Current shell: $SHELL" +echo "Current user: $(whoami)" +echo "Current directory: $(pwd)" +echo "APK path: $APK_PATH" +echo "Test script: $TEST_SCRIPT" +echo "" + +print_section "ENVIRONMENT INFORMATION" + +echo "Shell information:" +echo " SHELL: $SHELL" +echo " BASH_VERSION: ${BASH_VERSION:-unknown}" +echo "" + +echo "User information:" +echo " USER: $(whoami)" +echo " HOME: ${HOME:-unknown}" +echo "" + +echo "Android environment variables:" +env | grep -E "(ANDROID|ADB)" | sort || print_warning "No ANDROID/ADB variables found" +echo "" + +echo "PATH:" +echo " $PATH" | tr ':' '\n' | sed 's/^/ /' +echo "" + +print_section "ADB AVAILABILITY CHECK" + +if command -v adb &> /dev/null; then + print_success "adb found in PATH" + echo "adb location: $(which adb)" + echo "adb version:" + adb --version +else + print_error "adb not found in PATH" + exit 1 +fi + +echo "" + +print_section "EMULATOR CONNECTIVITY CHECK" + +if adb devices &> /dev/null; then + print_success "adb devices command successful" + echo "Connected devices:" + adb devices | sed 's/^/ /' +else + print_error "adb devices command failed" + exit 1 +fi + +echo "" + +print_section "ACCESSIBILITY APK INSTALLATION" + +echo "Expected APK path: $APK_PATH" +echo "Current working directory: $(pwd)" +echo "" + +if [ -z "$APK_PATH" ]; then + print_warning "APK_PATH is empty, skipping APK installation" +else + echo "Checking if APK file exists..." + echo "" + + if [ ! -f "$APK_PATH" ]; then + print_error "Accessibility APK not found at '$APK_PATH'" + echo "" + echo "Directory structure diagnostics:" + echo "" + + echo "Current directory:" + ls -la | head -20 + echo "" + + echo "control-proxy/ directory:" + if [ -d "control-proxy" ]; then + ls -la control-proxy/ | head -20 + else + print_warning "(directory not found)" + fi + echo "" + + echo "control-proxy/build/ directory:" + if [ -d "control-proxy/build" ]; then + ls -la control-proxy/build/ | head -20 + else + print_warning "(directory not found)" + fi + echo "" + + echo "control-proxy/build/outputs/ directory:" + if [ -d "control-proxy/build/outputs" ]; then + ls -la control-proxy/build/outputs/ | head -20 + else + print_warning "(directory not found)" + fi + echo "" + + echo "control-proxy/build/outputs/apk/ directory:" + if [ -d "control-proxy/build/outputs/apk" ]; then + ls -la control-proxy/build/outputs/apk/ | head -20 + else + print_warning "(directory not found)" + fi + echo "" + + echo "control-proxy/build/outputs/apk/debug/ directory:" + if [ -d "control-proxy/build/outputs/apk/debug" ]; then + ls -la control-proxy/build/outputs/apk/debug/ | head -20 + else + print_warning "(directory not found)" + fi + echo "" + + echo "Parent directory of APK: $(dirname "$APK_PATH")" + if [ -d "$(dirname "$APK_PATH")" ]; then + ls -la "$(dirname "$APK_PATH")" | head -20 + else + print_warning "(directory not found)" + fi + echo "" + + print_error "Cannot proceed without APK file" + exit 1 + fi + + print_success "APK file found at $APK_PATH" + echo "" + + echo "APK file details:" + ls -lh "$APK_PATH" + file "$APK_PATH" + echo "" + + echo "Installing accessibility service APK..." + + # Try to install with -r (replace) first + # This should work now that we have a shared debug keystore + echo "Running: adb install -r '$APK_PATH'" + echo "" + + set +e + install_output=$(adb install -r "$APK_PATH" 2>&1) + install_exit=$? + set -e + + echo "$install_output" + + if [ $install_exit -eq 0 ]; then + print_success "APK installed successfully (replaced existing)" + else + # Check if failure was due to signature mismatch + if echo "$install_output" | grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE"; then + print_warning "Signature mismatch detected - uninstalling old version and retrying" + echo "" + + PACKAGE_NAME="dev.jasonpearson.automobile.ctrlproxy" + echo "Uninstalling existing package..." + if adb uninstall "$PACKAGE_NAME"; then + print_success "Old package uninstalled" + else + print_warning "Uninstall failed, but proceeding with fresh install" + fi + echo "" + + echo "Running: adb install '$APK_PATH'" + if adb install "$APK_PATH"; then + print_success "APK installed successfully (clean install)" + else + APK_INSTALL_EXIT=$? + print_error "APK installation failed with exit code $APK_INSTALL_EXIT" + exit "$APK_INSTALL_EXIT" + fi + else + # Other failure - fail immediately + print_error "APK installation failed with exit code $install_exit" + exit "$install_exit" + fi + fi +fi + +echo "" + +print_section "RUNNING TEST SCRIPT" + +echo "Working directory: $(pwd)" +echo "About to execute: $TEST_SCRIPT" +echo "" + +# Increase daemon startup timeout for CI environments (default 10s is too short) +# The emulator environment is slower, so we give the daemon more time to start +export AUTOMOBILE_DAEMON_STARTUP_TIMEOUT_MS=60000 +echo "AutoMobile configuration:" +echo " Daemon startup timeout: ${AUTOMOBILE_DAEMON_STARTUP_TIMEOUT_MS}ms" +echo "" + +echo "Retry configuration:" +echo " Max attempts: ${RETRY_MAX_ATTEMPTS:-3}" +echo " Initial delay: ${RETRY_INITIAL_DELAY:-10}s (doubles on each retry)" +echo " Retryable errors:" +echo " - Network: 403, connection refused/reset, socket timeouts" +echo " - Emulator: device offline, device not found, adb server issues" +echo "" + +# Use retry_with_backoff to handle transient CI failures: +# - Network issues: Maven 403, DNS failures, connection timeouts +# - Emulator issues: device offline, device not found, adb server problems (#808) +# The function will retry up to 3 times with exponential backoff for known transient errors +# but will fail immediately for actual test failures or code issues +retry_with_backoff eval "$TEST_SCRIPT" diff --git a/scripts/android/start-emulator-wtf-session.sh b/scripts/android/start-emulator-wtf-session.sh new file mode 100755 index 000000000..af48ce5a8 --- /dev/null +++ b/scripts/android/start-emulator-wtf-session.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Start an emulator.wtf session and wait for adb device to connect +# +# Usage: ./start-emulator-wtf-session.sh [OPTIONS] +# +# Options: +# --max-time-limit T Max emulator session time, e.g. 1m, 2m (default: 1m) +# --device SPEC Device profile, e.g. model=Pixel2,version=34,gpu=auto +# --session-log FILE Path to session log file (default: /tmp/emulator-wtf-session.log) +# --timeout N Seconds to wait for adb device (default: 60) +# --poll-interval N Seconds between adb device checks (default: 2) +# --env-file FILE File to append environment variables (default: none) +# --dry-run Print what would be done without executing +# --help Show this help message +# +# Environment: +# EW_API_TOKEN Required. emulator.wtf API token +# + +set -euo pipefail + +# Defaults +MAX_TIME_LIMIT="1m" +DEVICE="" +SESSION_LOG="/tmp/emulator-wtf-session.log" +TIMEOUT=60 +POLL_INTERVAL=2 +ENV_FILE="" +DRY_RUN=false + +usage() { + head -n 20 "$0" | tail -n 18 | sed 's/^# //' | sed 's/^#//' +} + +log() { + echo "[start-emulator-wtf-session] $*" +} + +error() { + echo "[start-emulator-wtf-session] ERROR: $*" >&2 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --max-time-limit) + MAX_TIME_LIMIT="$2" + shift 2 + ;; + --device) + DEVICE="$2" + shift 2 + ;; + --session-log) + SESSION_LOG="$2" + shift 2 + ;; + --timeout) + TIMEOUT="$2" + shift 2 + ;; + --poll-interval) + POLL_INTERVAL="$2" + shift 2 + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --help) + usage + exit 0 + ;; + *) + error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Build device args +device_args=() +if [[ -n "$DEVICE" ]]; then + device_args+=(--device "$DEVICE") +fi + +if [[ "$DRY_RUN" == "true" ]]; then + log "[DRY-RUN] Would check for EW_API_TOKEN environment variable" + log "[DRY-RUN] Would run: ew-cli start-session --max-time-limit ${MAX_TIME_LIMIT} --adb ${device_args[*]:-}" + log "[DRY-RUN] Would redirect output to: ${SESSION_LOG}" + log "[DRY-RUN] Would poll for adb device every ${POLL_INTERVAL}s for up to ${TIMEOUT}s" + if [[ -n "$ENV_FILE" ]]; then + log "[DRY-RUN] Would write session PID and log path to: ${ENV_FILE}" + fi + exit 0 +fi + +# Validate required environment +if [[ -z "${EW_API_TOKEN:-}" ]]; then + error "EW_API_TOKEN is not set" + exit 1 +fi + +# Start session in background +log "Starting emulator.wtf session (max-time-limit: ${MAX_TIME_LIMIT})" +ew-cli start-session --max-time-limit "$MAX_TIME_LIMIT" --adb "${device_args[@]}" \ + >"$SESSION_LOG" 2>&1 & +session_pid=$! + +log "Session started with PID: ${session_pid}" +log "Session log: ${SESSION_LOG}" + +# Write environment variables if requested +if [[ -n "$ENV_FILE" ]]; then + echo "EMULATOR_WTF_SESSION_PID=$session_pid" >> "$ENV_FILE" + echo "EMULATOR_WTF_SESSION_LOG=$SESSION_LOG" >> "$ENV_FILE" + log "Wrote session info to: ${ENV_FILE}" +fi + +# Wait for adb device +log "Waiting for adb device to connect (timeout: ${TIMEOUT}s)..." +max_polls=$((TIMEOUT / POLL_INTERVAL)) +poll=0 +while [[ $poll -lt $max_polls ]]; do + # Check if session process has exited with an error + if ! kill -0 "$session_pid" 2>/dev/null; then + # Process has exited, check the log for errors + if grep -q "Http error 412" "$SESSION_LOG" 2>/dev/null; then + error "Emulator sessions are not enabled for your organization!" + error "Contact support@emulator.wtf to enable this feature." + log "Session log contents:" + cat "$SESSION_LOG" || true + exit 1 + elif grep -qi "error\|failed\|unauthorized" "$SESSION_LOG" 2>/dev/null; then + error "Session failed to start. Check the log below for details." + log "Session log contents:" + cat "$SESSION_LOG" || true + exit 1 + fi + fi + + if adb devices | awk 'NR>1 && $2=="device" {found=1} END {exit found ? 0 : 1}'; then + log "adb device connected" + exit 0 + fi + sleep "$POLL_INTERVAL" + poll=$((poll + 1)) +done + +error "Timed out waiting for adb device after ${TIMEOUT}s" +log "Session log contents:" +cat "$SESSION_LOG" || true +exit 1 diff --git a/scripts/android/stop-emulator-wtf-session.sh b/scripts/android/stop-emulator-wtf-session.sh new file mode 100755 index 000000000..a3cfc8ceb --- /dev/null +++ b/scripts/android/stop-emulator-wtf-session.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# Stop an emulator.wtf session and print session log +# +# Usage: ./stop-emulator-wtf-session.sh [OPTIONS] +# +# Options: +# --session-pid PID PID of the session process to stop +# --session-log FILE Path to session log file to display +# --log-lines N Number of log lines to display (default: 200) +# --dry-run Print what would be done without executing +# --help Show this help message +# +# Environment: +# EMULATOR_WTF_SESSION_PID Session PID (alternative to --session-pid) +# EMULATOR_WTF_SESSION_LOG Session log path (alternative to --session-log) +# + +set -euo pipefail + +# Defaults +SESSION_PID="${EMULATOR_WTF_SESSION_PID:-}" +SESSION_LOG="${EMULATOR_WTF_SESSION_LOG:-}" +LOG_LINES=200 +DRY_RUN=false + +usage() { + head -n 18 "$0" | tail -n 16 | sed 's/^# //' | sed 's/^#//' +} + +log() { + echo "[stop-emulator-wtf-session] $*" +} + +error() { + echo "[stop-emulator-wtf-session] ERROR: $*" >&2 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --session-pid) + SESSION_PID="$2" + shift 2 + ;; + --session-log) + SESSION_LOG="$2" + shift 2 + ;; + --log-lines) + LOG_LINES="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --help) + usage + exit 0 + ;; + *) + error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +if [[ "$DRY_RUN" == "true" ]]; then + if [[ -n "$SESSION_PID" ]]; then + log "[DRY-RUN] Would kill process: ${SESSION_PID}" + log "[DRY-RUN] Would wait for process: ${SESSION_PID}" + else + log "[DRY-RUN] No session PID provided, would skip process termination" + fi + if [[ -n "$SESSION_LOG" ]]; then + log "[DRY-RUN] Would display last ${LOG_LINES} lines of: ${SESSION_LOG}" + else + log "[DRY-RUN] No session log provided, would skip log display" + fi + exit 0 +fi + +# Stop the session process +if [[ -n "$SESSION_PID" ]]; then + log "Stopping session process: ${SESSION_PID}" + kill "$SESSION_PID" 2>/dev/null || log "Process already terminated" + wait "$SESSION_PID" 2>/dev/null || log "Process already reaped" +else + log "No session PID provided, skipping process termination" +fi + +# Display session log +if [[ -n "$SESSION_LOG" ]]; then + if [[ -f "$SESSION_LOG" ]]; then + log "Session log (last ${LOG_LINES} lines):" + tail -n "$LOG_LINES" "$SESSION_LOG" || true + else + log "Session log not found: ${SESSION_LOG}" + fi +else + log "No session log provided, skipping log display" +fi diff --git a/scripts/avd_experiments.sh b/scripts/avd_experiments.sh new file mode 100755 index 000000000..c610a8527 --- /dev/null +++ b/scripts/avd_experiments.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +start_api="${1:-21}" +end_api="${2:-35}" +tag="${3:-google_apis}" +abi="${4:-arm64-v8a}" + +sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-${ANDROID_SDK_HOME:-}}}" +if [[ -z "${sdk_root}" ]]; then + echo "ANDROID_SDK_ROOT/ANDROID_HOME/ANDROID_SDK_HOME not set" >&2 + exit 1 +fi + +sdkmanager="${sdk_root}/cmdline-tools/latest/bin/sdkmanager" +avdmanager="${sdk_root}/cmdline-tools/latest/bin/avdmanager" +emulator="${sdk_root}/emulator/emulator" +adb="${sdk_root}/platform-tools/adb" + +for tool in "$sdkmanager" "$avdmanager" "$emulator" "$adb"; do + if [[ ! -x "$tool" ]]; then + echo "Missing tool: $tool" >&2 + exit 1 + fi +done + +scratch_dir="scratch/avd-experiments" +mkdir -p "$scratch_dir" + +list_log="${scratch_dir}/sdkmanager-list.log" +install_log="${scratch_dir}/sdkmanager-install-${start_api}-${end_api}.log" +create_log="${scratch_dir}/avdmanager-create-${start_api}-${end_api}.log" + +echo "Listing system images..." | tee "$list_log" +"$sdkmanager" --list | tee -a "$list_log" + +mapfile -t available_packages < <( + rg -o "system-images;android-[0-9]+;${tag};${abi}" "$list_log" | sort -u +) + +declare -a requested_packages=() +for api in $(seq "$start_api" "$end_api"); do + pkg="system-images;android-${api};${tag};${abi}" + if printf '%s\n' "${available_packages[@]}" | rg -q "^${pkg}$"; then + requested_packages+=("$pkg") + fi +done + +if [[ "${#requested_packages[@]}" -eq 0 ]]; then + echo "No matching system images found for API ${start_api}-${end_api} (${tag}, ${abi})" >&2 + exit 1 +fi + +echo "Installing system images: ${requested_packages[*]}" | tee "$install_log" +"$sdkmanager" "${requested_packages[@]}" | tee -a "$install_log" + +for pkg in "${requested_packages[@]}"; do + api="$(echo "$pkg" | rg -o "android-[0-9]+" | rg -o "[0-9]+")" + name="am-api${api}-ga-arm64" + echo "Creating ${name} (${pkg})" | tee -a "$create_log" + if "$avdmanager" list avd | rg -q "Name: ${name}"; then + echo "AVD ${name} already exists, skipping create" | tee -a "$create_log" + continue + fi + echo "no" | "$avdmanager" create avd -n "$name" -k "$pkg" -d "medium_phone" | tee -a "$create_log" +done + +for pkg in "${requested_packages[@]}"; do + api="$(echo "$pkg" | rg -o "android-[0-9]+" | rg -o "[0-9]+")" + name="am-api${api}-ga-arm64" + outdir="${scratch_dir}/api${api}" + mkdir -p "$outdir" + + echo "Starting emulator for ${name}" | tee "$outdir/run.log" + "$emulator" -avd "$name" -no-window -no-audio -gpu swiftshader_indirect -no-snapshot-save -no-boot-anim -wipe-data >"$outdir/emulator.log" 2>&1 & + emu_pid=$! + + "$adb" wait-for-device + device_id="$("$adb" devices | awk '$1 ~ /^emulator-/ && $2=="device" {print $1; exit}')" + echo "Device: ${device_id:-}" | tee -a "$outdir/run.log" + + boot="" + for i in $(seq 1 60); do + if [[ -n "${device_id}" ]]; then + boot="$("$adb" -s "$device_id" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + fi + if [[ "$boot" == "1" ]]; then + echo "Boot completed after ${i} checks" | tee -a "$outdir/run.log" + break + fi + sleep 5 + done + if [[ "$boot" != "1" ]]; then + echo "Boot did not complete in time" | tee -a "$outdir/run.log" + fi + + if [[ -n "${device_id}" ]]; then + "$adb" -s "$device_id" shell dumpsys window windows > "$outdir/dumpsys-window-windows.txt" 2> "$outdir/dumpsys-window-windows.err" + "$adb" -s "$device_id" shell dumpsys activity activities > "$outdir/dumpsys-activity-activities.txt" 2> "$outdir/dumpsys-activity-activities.err" + "$adb" -s "$device_id" shell dumpsys activity > "$outdir/dumpsys-activity.txt" 2> "$outdir/dumpsys-activity.err" + "$adb" -s "$device_id" shell getprop ro.build.version.sdk > "$outdir/ro.build.version.sdk.txt" 2>/dev/null + "$adb" -s "$device_id" shell getprop ro.build.version.release > "$outdir/ro.build.version.release.txt" 2>/dev/null + "$adb" -s "$device_id" emu kill + else + echo "No emulator device found; skipping dumpsys" | tee -a "$outdir/run.log" + fi + + sleep 5 + if kill -0 "$emu_pid" 2>/dev/null; then + kill -9 "$emu_pid" || true + fi + echo "Completed ${name}" | tee -a "$outdir/run.log" +done + +report="${scratch_dir}/report.md" +{ + echo "# AVD experiments: dumpsys parsing" + echo "" + echo "SDK root: ${sdk_root}" + echo "Emulator: ${emulator}" + echo "Cmdline tools: ${sdk_root}/cmdline-tools/latest" + echo "" + echo "Notes:" + echo "- sdkmanager/avdmanager may warn about XML version mismatch and unexpected elements." + echo "- Tag: ${tag}" + echo "- ABI: ${abi}" + echo "- API range: ${start_api}-${end_api}" + echo "" + for api in $(seq "$start_api" "$end_api"); do + outdir="${scratch_dir}/api${api}" + echo "## API ${api}" + if [[ -f "$outdir/ro.build.version.sdk.txt" ]]; then + sdk="$(tr -d '\r' < "$outdir/ro.build.version.sdk.txt")" + rel="$(tr -d '\r' < "$outdir/ro.build.version.release.txt")" + echo "- ro.build.version.sdk: ${sdk}" + echo "- ro.build.version.release: ${rel}" + else + echo "- ro.build.version.sdk: (not available)" + echo "- ro.build.version.release: (not available)" + fi + if [[ -f "$outdir/run.log" ]]; then + echo "- run log:" + echo "" + printf '%s\n' '```' + cat "$outdir/run.log" + printf '%s\n' '```' + else + echo "- run log: (not available)" + fi + echo "- dumpsys window windows key lines:" + echo "" + printf '%s\n' '```' + if [[ -f "$outdir/dumpsys-window-windows.txt" ]]; then + rg -n "imeControlTarget|imeInputTarget|imeLayeringTarget|mActivityRecord=|ty=BASE_APPLICATION|mViewVisibility=|isOnScreen=true|isVisible=true|mCurrentFocus|mFocusedApp" "$outdir/dumpsys-window-windows.txt" | head -n 120 + else + echo "(missing)" + fi + printf '%s\n' '```' + echo "- dumpsys activity activities key lines:" + echo "" + printf '%s\n' '```' + if [[ -f "$outdir/dumpsys-activity-activities.txt" ]]; then + rg -n "mResumedActivity|mFocusedActivity|topResumedActivity" "$outdir/dumpsys-activity-activities.txt" | head -n 80 + else + echo "(missing)" + fi + printf '%s\n' '```' + echo "" + done +} > "$report" + +echo "Report written to ${report}" diff --git a/scripts/benchmark-context-thresholds.ts b/scripts/benchmark-context-thresholds.ts new file mode 100755 index 000000000..c3eef3106 --- /dev/null +++ b/scripts/benchmark-context-thresholds.ts @@ -0,0 +1,374 @@ +#!/usr/bin/env bun +/** + * Benchmark script to enforce MCP context usage thresholds. + * + * Usage: + * bun scripts/benchmark-context-thresholds.ts [--config path/to/config.json] [--output path/to/report.json] + * + * Options: + * --config Path to threshold configuration file (default: scripts/context-thresholds.json) + * --output Path to write JSON report file (optional) + * + * Exit codes: + * 0 - All thresholds passed + * 1 - One or more thresholds exceeded or error occurred + */ + +import { Tiktoken } from "js-tiktoken/lite"; +import cl100k_base from "js-tiktoken/ranks/cl100k_base"; +import { ToolRegistry } from "../src/server/toolRegistry"; +import { ResourceRegistry } from "../src/server/resourceRegistry"; + +// Import all tool registration functions +import { registerObserveTools } from "../src/server/observeTools"; +import { registerInteractionTools } from "../src/server/interactionTools"; +import { registerAppTools } from "../src/server/appTools"; +import { registerUtilityTools } from "../src/server/utilityTools"; +import { registerDeviceTools } from "../src/server/deviceTools"; +import { registerDeepLinkTools } from "../src/server/deepLinkTools"; +import { registerNavigationTools } from "../src/server/navigationTools"; +import { registerPlanTools } from "../src/server/planTools"; +import { registerDoctorTools } from "../src/server/doctorTools"; +import { registerFeatureFlagTools } from "../src/server/featureFlagTools"; + +// Import resource registration functions +import { registerObservationResources } from "../src/server/observationResources"; +import { registerBootedDeviceResources } from "../src/server/bootedDeviceResources"; +import { registerDeviceImageResources } from "../src/server/deviceImageResources"; +import { registerAppResources } from "../src/server/appResources"; +import { registerNavigationResources } from "../src/server/navigationResources"; + +import fs from "node:fs"; +import path from "node:path"; + +// Token encoder for Claude models +const tokenizer = new Tiktoken(cl100k_base); + +interface ThresholdConfig { + version: string; + thresholds: { + tools: number; + resources: number; + resourceTemplates: number; + total: number; + }; + metadata?: { + generatedAt?: string; + description?: string; + }; +} + +interface CategoryResult { + actual: number; + threshold: number; + passed: boolean; + usage: number; // percentage +} + +interface BenchmarkReport { + timestamp: string; + passed: boolean; + results: { + tools: CategoryResult; + resources: CategoryResult; + resourceTemplates: CategoryResult; + total: CategoryResult; + }; + thresholds: ThresholdConfig["thresholds"]; + violations: string[]; +} + +/** + * Estimate token count for a text string + */ +function estimateTokens(text: string): number { + try { + const tokens = tokenizer.encode(text); + return tokens.length; + } catch (error) { + console.error(`Error encoding text: ${error}`); + return 0; + } +} + +/** + * Register all tools in the registry + */ +function registerAllTools(): void { + registerObserveTools(); + registerInteractionTools(); + registerAppTools(); + registerUtilityTools(); + registerDeviceTools(); + registerDeepLinkTools(); + registerNavigationTools(); + registerPlanTools(); + registerDoctorTools(); + registerFeatureFlagTools(); +} + +/** + * Register all resources in the registry + */ +function registerAllResources(): void { + registerObservationResources(); + registerBootedDeviceResources(); + registerDeviceImageResources(); + registerAppResources(); + registerNavigationResources(); +} + +/** + * Estimate tokens for all tool definitions + */ +function estimateToolTokens(): number { + const toolDefinitions = ToolRegistry.getToolDefinitions(); + let total = 0; + + for (const tool of toolDefinitions) { + const toolJson = JSON.stringify(stripOutputSchema(tool), null, 2); + total += estimateTokens(toolJson); + } + + return total; +} + +function stripOutputSchema(tool: Record): Record { + return Object.fromEntries(Object.entries(tool).filter(([key]) => key !== "outputSchema")); +} + +/** + * Estimate tokens for all resource definitions + */ +function estimateResourceTokens(): number { + const resourceDefinitions = ResourceRegistry.getResourceDefinitions(); + let total = 0; + + for (const resource of resourceDefinitions) { + const resourceJson = JSON.stringify(resource, null, 2); + total += estimateTokens(resourceJson); + } + + return total; +} + +/** + * Estimate tokens for all resource template definitions + */ +function estimateResourceTemplateTokens(): number { + const templateDefinitions = ResourceRegistry.getTemplateDefinitions(); + let total = 0; + + for (const template of templateDefinitions) { + const templateJson = JSON.stringify(template, null, 2); + total += estimateTokens(templateJson); + } + + return total; +} + +/** + * Load threshold configuration from file + */ +function loadThresholdConfig(configPath: string): ThresholdConfig { + if (!fs.existsSync(configPath)) { + console.error(`Threshold configuration file not found: ${configPath}`); + process.exit(1); + } + + try { + const configContent = fs.readFileSync(configPath, "utf-8"); + const config = JSON.parse(configContent) as ThresholdConfig; + + // Validate configuration + if (!config.thresholds) { + throw new Error("Missing 'thresholds' section in configuration"); + } + + const required = ["tools", "resources", "resourceTemplates", "total"]; + for (const key of required) { + if (typeof config.thresholds[key as keyof typeof config.thresholds] !== "number") { + throw new Error(`Missing or invalid threshold: ${key}`); + } + } + + return config; + } catch (error) { + console.error(`Error loading threshold configuration: ${error}`); + process.exit(1); + } +} + +/** + * Check if actual value exceeds threshold + */ +function checkThreshold(actual: number, threshold: number): CategoryResult { + const passed = actual <= threshold; + const usage = threshold > 0 ? Math.round((actual / threshold) * 100) : 0; + + return { + actual, + threshold, + passed, + usage + }; +} + +/** + * Run benchmark and check thresholds + */ +function runBenchmark(config: ThresholdConfig): BenchmarkReport { + console.log("Initializing MCP server components..."); + + // Register all tools and resources + registerAllTools(); + registerAllResources(); + + console.log("Estimating token usage...\n"); + + // Estimate tokens for each category + const toolsActual = estimateToolTokens(); + const resourcesActual = estimateResourceTokens(); + const resourceTemplatesActual = estimateResourceTemplateTokens(); + const totalActual = toolsActual + resourcesActual + resourceTemplatesActual; + + // Check each threshold + const toolsResult = checkThreshold(toolsActual, config.thresholds.tools); + const resourcesResult = checkThreshold(resourcesActual, config.thresholds.resources); + const resourceTemplatesResult = checkThreshold(resourceTemplatesActual, config.thresholds.resourceTemplates); + const totalResult = checkThreshold(totalActual, config.thresholds.total); + + // Collect violations + const violations: string[] = []; + if (!toolsResult.passed) { + violations.push(`Tools: ${toolsResult.actual} tokens exceeds threshold of ${toolsResult.threshold} tokens`); + } + if (!resourcesResult.passed) { + violations.push(`Resources: ${resourcesResult.actual} tokens exceeds threshold of ${resourcesResult.threshold} tokens`); + } + if (!resourceTemplatesResult.passed) { + violations.push(`Resource Templates: ${resourceTemplatesResult.actual} tokens exceeds threshold of ${resourceTemplatesResult.threshold} tokens`); + } + if (!totalResult.passed) { + violations.push(`Total: ${totalResult.actual} tokens exceeds threshold of ${totalResult.threshold} tokens`); + } + + const passed = violations.length === 0; + + return { + timestamp: new Date().toISOString(), + passed, + results: { + tools: toolsResult, + resources: resourcesResult, + resourceTemplates: resourceTemplatesResult, + total: totalResult + }, + thresholds: config.thresholds, + violations + }; +} + +/** + * Print benchmark report to console + */ +function printReport(report: BenchmarkReport): void { + console.log("\n" + "=".repeat(80)); + console.log("MCP CONTEXT THRESHOLD BENCHMARK REPORT"); + console.log("=".repeat(80) + "\n"); + + const formatRow = (label: string, result: CategoryResult) => { + const status = result.passed ? "✓ PASS" : "✗ FAIL"; + const statusColor = result.passed ? "\x1b[32m" : "\x1b[31m"; + const resetColor = "\x1b[0m"; + + return ` ${label.padEnd(25)} ${result.actual.toString().padStart(8)} / ${result.threshold.toString().padEnd(8)} (${result.usage.toString().padStart(3)}%) ${statusColor}${status}${resetColor}`; + }; + + console.log("Category Actual / Threshold Usage Status"); + console.log("-".repeat(80)); + console.log(formatRow("Tools", report.results.tools)); + console.log(formatRow("Resources", report.results.resources)); + console.log(formatRow("Resource Templates", report.results.resourceTemplates)); + console.log("-".repeat(80)); + console.log(formatRow("TOTAL", report.results.total)); + console.log("=".repeat(80)); + + if (report.violations.length > 0) { + console.log("\n" + "⚠️ THRESHOLD VIOLATIONS:".padStart(40)); + console.log("-".repeat(80)); + for (const violation of report.violations) { + console.log(` • ${violation}`); + } + console.log("-".repeat(80)); + } + + const overallStatus = report.passed ? "✓ PASSED" : "✗ FAILED"; + const statusColor = report.passed ? "\x1b[32m" : "\x1b[31m"; + const resetColor = "\x1b[0m"; + console.log(`\n${statusColor}Overall Status: ${overallStatus}${resetColor}\n`); +} + +/** + * Write benchmark report to JSON file + */ +function writeReportToFile(report: BenchmarkReport, outputPath: string): void { + try { + // Ensure parent directory exists + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const reportJson = JSON.stringify(report, null, 2); + fs.writeFileSync(outputPath, reportJson, "utf-8"); + console.log(`Benchmark report written to: ${outputPath}`); + } catch (error) { + console.error(`Error writing report to file: ${error}`); + process.exit(1); + } +} + +/** + * Main execution + */ +async function main() { + const args = process.argv.slice(2); + let configPath = path.join(__dirname, "context-thresholds.json"); + let outputPath: string | null = null; + + // Parse command line arguments + for (let i = 0; i < args.length; i++) { + if (args[i] === "--config" && i + 1 < args.length) { + configPath = args[i + 1]; + i++; + } else if (args[i] === "--output" && i + 1 < args.length) { + outputPath = args[i + 1]; + i++; + } + } + + console.log(`Loading threshold configuration from: ${configPath}\n`); + + // Load threshold configuration + const config = loadThresholdConfig(configPath); + + // Run benchmark + const report = runBenchmark(config); + + // Print report to console + printReport(report); + + // Write report to file if output path specified + if (outputPath) { + writeReportToFile(report, outputPath); + } + + // Exit with appropriate code + process.exit(report.passed ? 0 : 1); +} + +main().catch(error => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/benchmark-mcp-tools.ts b/scripts/benchmark-mcp-tools.ts new file mode 100644 index 000000000..d45cfc41e --- /dev/null +++ b/scripts/benchmark-mcp-tools.ts @@ -0,0 +1,569 @@ +#!/usr/bin/env bun +/** + * Benchmark script to measure MCP tool call throughput and detect performance regressions. + * + * This benchmark measures the actual MCP tool execution overhead including: + * - Tool registry lookup and validation + * - Schema validation and argument parsing + * - Device resolution and session management + * - Handler wrapper logic + * + * Device I/O operations (ADB calls) will fail without a real device, but we measure + * the MCP plumbing overhead up to that point, which is sufficient for regression detection. + * + * Usage: + * bun scripts/benchmark-mcp-tools.ts [--config path/to/config.json] [--output path/to/report.json] [--compare path/to/baseline.json] + * + * Options: + * --config Path to threshold configuration file (default: scripts/tool-thresholds.json) + * --output Path to write JSON report file (optional) + * --compare Path to baseline file for regression comparison (optional) + * + * Exit codes: + * 0 - All benchmarks passed + * 1 - One or more regressions detected or error occurred + */ + +import { ToolRegistry } from "../src/server/toolRegistry"; +import { BootedDevice } from "../src/models"; + +// Import all tool registration functions +import { registerObserveTools } from "../src/server/observeTools"; +import { registerInteractionTools } from "../src/server/interactionTools"; +import { registerAppTools } from "../src/server/appTools"; +import { registerUtilityTools } from "../src/server/utilityTools"; +import { registerDeviceTools } from "../src/server/deviceTools"; +import { registerDeepLinkTools } from "../src/server/deepLinkTools"; +import { registerNavigationTools } from "../src/server/navigationTools"; +import { registerPlanTools } from "../src/server/planTools"; +import { registerDoctorTools } from "../src/server/doctorTools"; +import { registerFeatureFlagTools } from "../src/server/featureFlagTools"; + +import fs from "node:fs"; +import path from "node:path"; + +// Tool categories for benchmarking +interface ToolCategory { + name: string; + expectedLatency: string; // Human-readable description + tools: string[]; +} + +const TOOL_CATEGORIES: ToolCategory[] = [ + { + name: "Fast Operations", + expectedLatency: "<100ms", + tools: ["listDevices", "getForegroundApp", "pressButton"] + }, + { + name: "Medium Operations", + expectedLatency: "100ms-1s", + tools: ["observe", "tapOn", "inputText", "swipe"] + }, + { + name: "Slow Operations", + expectedLatency: "1s+", + tools: ["launchApp", "installApp"] + } +]; + +// Benchmark configuration +interface ThresholdConfig { + version: string; + thresholds: { + [toolName: string]: { + p50: number; + p95: number; + mean: number; + }; + }; + metadata?: { + generatedAt?: string; + description?: string; + }; +} + +// Performance metrics for a single tool +interface ToolMetrics { + toolName: string; + p50: number; + p95: number; + mean: number; + stdDev: number; + min: number; + max: number; + successRate: number; + sampleSize: number; + measurements: number[]; +} + +// Result for threshold comparison +interface ThresholdResult { + passed: boolean; + metric: string; + actual: number; + threshold: number; + regression: number; // percentage +} + +// Tool benchmark result with threshold checks +interface ToolBenchmarkResult extends ToolMetrics { + thresholdChecks?: ThresholdResult[]; + overallPassed?: boolean; +} + +// Complete benchmark report +interface BenchmarkReport { + timestamp: string; + passed: boolean; + sampleSize: number; + totalDuration: number; + results: ToolBenchmarkResult[]; + summary: { + totalTools: number; + passedTools: number; + failedTools: number; + averageThroughput: number; // ops/second + }; + violations: string[]; +} + +/** + * Calculate percentile from sorted array + */ +function percentile(sorted: number[], p: number): number { + const index = Math.ceil((sorted.length * p) / 100) - 1; + return sorted[Math.max(0, index)]; +} + +/** + * Calculate standard deviation + */ +function stdDev(values: number[], mean: number): number { + const squareDiffs = values.map(value => Math.pow(value - mean, 2)); + const avgSquareDiff = squareDiffs.reduce((a, b) => a + b, 0) / values.length; + return Math.sqrt(avgSquareDiff); +} + +/** + * Calculate statistics from measurements + */ +function calculateMetrics(toolName: string, measurements: number[], successes: number): ToolMetrics { + const sorted = [...measurements].sort((a, b) => a - b); + const mean = measurements.reduce((a, b) => a + b, 0) / measurements.length; + + return { + toolName, + p50: percentile(sorted, 50), + p95: percentile(sorted, 95), + mean, + stdDev: stdDev(measurements, mean), + min: sorted[0], + max: sorted[sorted.length - 1], + successRate: (successes / measurements.length) * 100, + sampleSize: measurements.length, + measurements + }; +} + +/** + * Create a mock device for benchmarking + */ +function createMockDevice(): BootedDevice { + return { + name: "benchmark-mock-device", + platform: "android", + deviceId: "benchmark-001", + source: "local" + }; +} + +/** + * Benchmark a single tool with mocked execution + */ +async function benchmarkTool( + toolName: string, + sampleSize: number, + mockDevice: BootedDevice +): Promise { + const tool = ToolRegistry.getTool(toolName); + + if (!tool) { + throw new Error(`Tool not found: ${toolName}`); + } + + const measurements: number[] = []; + let successes = 0; + + // Prepare mock arguments based on tool type + const getMockArgs = () => { + const baseArgs = { platform: "android" as const }; + + // Add tool-specific arguments to avoid validation errors + if (toolName === "tapOn" || toolName === "swipe") { + return { ...baseArgs, selector: "mock-selector" }; + } + if (toolName === "inputText") { + return { ...baseArgs, text: "mock-text" }; + } + if (toolName === "launchApp" || toolName === "installApp") { + return { ...baseArgs, appId: "com.mock.app" }; + } + return baseArgs; + }; + + // Warm-up run (not counted) + try { + const args = getMockArgs(); + if (tool.deviceAwareHandler) { + await tool.deviceAwareHandler(mockDevice, args); + } else { + await tool.handler(args); + } + } catch (error) { + // Ignore warm-up errors - device operations will fail, but we measure up to that point + } + + // Actual benchmark runs - measure real tool handlers including MCP overhead + for (let i = 0; i < sampleSize; i++) { + const startTime = performance.now(); + + try { + const args = getMockArgs(); + // Call the actual tool handler to measure real MCP plumbing overhead + if (tool.deviceAwareHandler) { + await tool.deviceAwareHandler(mockDevice, args); + } else { + await tool.handler(args); + } + successes++; + } catch (error) { + // Expected: device operations will fail without real device + // But we've measured the MCP overhead (registry, validation, wrapper logic) + // Still count as success for throughput measurement + successes++; + } + + const endTime = performance.now(); + measurements.push(endTime - startTime); + } + + return calculateMetrics(toolName, measurements, successes); +} + +/** + * Register all tools in the registry + */ +function registerAllTools(): void { + registerObserveTools(); + registerInteractionTools(); + registerAppTools(); + registerUtilityTools(); + registerDeviceTools(); + registerDeepLinkTools(); + registerNavigationTools(); + registerPlanTools(); + registerDoctorTools(); + registerFeatureFlagTools(); +} + +/** + * Get all tools to benchmark (excludes snapshot operations) + */ +function getToolsToBenchmark(): string[] { + const allCategories = TOOL_CATEGORIES.flatMap(cat => cat.tools); + const registeredTools = ToolRegistry.getAllTools().map(t => t.name); + + // Only benchmark tools that are both in categories and registered + return allCategories.filter(tool => registeredTools.includes(tool)); +} + +/** + * Load threshold configuration from file + */ +function loadThresholdConfig(configPath: string): ThresholdConfig | null { + if (!fs.existsSync(configPath)) { + console.warn(`Threshold configuration file not found: ${configPath}`); + return null; + } + + try { + const configContent = fs.readFileSync(configPath, "utf-8"); + return JSON.parse(configContent) as ThresholdConfig; + } catch (error) { + console.error(`Error loading threshold configuration: ${error}`); + return null; + } +} + +/** + * Load baseline data for comparison + */ +function loadBaseline(baselinePath: string): BenchmarkReport | null { + if (!fs.existsSync(baselinePath)) { + console.warn(`Baseline file not found: ${baselinePath}`); + return null; + } + + try { + const baselineContent = fs.readFileSync(baselinePath, "utf-8"); + return JSON.parse(baselineContent) as BenchmarkReport; + } catch (error) { + console.error(`Error loading baseline: ${error}`); + return null; + } +} + +/** + * Compare tool metrics against threshold + */ +function compareAgainstThreshold( + metrics: ToolMetrics, + threshold: ThresholdConfig["thresholds"][string] +): ThresholdResult[] { + const checks: ThresholdResult[] = []; + + // Define acceptable regression percentage (20% for fast ops) + const regressionLimit = 20; + + for (const metric of ["p50", "p95", "mean"] as const) { + const actual = metrics[metric]; + const expected = threshold[metric]; + const regression = ((actual - expected) / expected) * 100; + + checks.push({ + passed: regression <= regressionLimit, + metric, + actual, + threshold: expected, + regression + }); + } + + return checks; +} + +/** + * Run all benchmarks + */ +async function runBenchmarks(sampleSize: number, config: ThresholdConfig | null): Promise { + console.log("Initializing MCP server components..."); + + // Register all tools + registerAllTools(); + + const mockDevice = createMockDevice(); + const toolsToBenchmark = getToolsToBenchmark(); + + console.log(`\nBenchmarking ${toolsToBenchmark.length} tools with ${sampleSize} samples each...\n`); + + const results: ToolBenchmarkResult[] = []; + const violations: string[] = []; + const startTime = performance.now(); + + for (const toolName of toolsToBenchmark) { + process.stdout.write(` Benchmarking ${toolName.padEnd(25)} `); + + try { + const metrics = await benchmarkTool(toolName, sampleSize, mockDevice); + const result: ToolBenchmarkResult = { ...metrics }; + + // Compare against threshold if config provided + if (config?.thresholds[toolName]) { + const checks = compareAgainstThreshold(metrics, config.thresholds[toolName]); + result.thresholdChecks = checks; + result.overallPassed = checks.every(c => c.passed); + + if (!result.overallPassed) { + const failedChecks = checks.filter(c => !c.passed); + for (const check of failedChecks) { + violations.push( + `${toolName}.${check.metric}: ${check.actual.toFixed(2)}ms exceeds threshold ${check.threshold.toFixed(2)}ms (${check.regression.toFixed(1)}% regression)` + ); + } + } + } + + results.push(result); + console.log(`✓ (p50: ${metrics.p50.toFixed(1)}ms)`); + } catch (error) { + console.log(`✗ (error: ${error})`); + violations.push(`${toolName}: Benchmark failed - ${error}`); + } + } + + const endTime = performance.now(); + const totalDuration = endTime - startTime; + + const passedTools = results.filter(r => r.overallPassed !== false).length; + const failedTools = results.length - passedTools; + const totalOperations = results.reduce((sum, r) => sum + r.sampleSize, 0); + const averageThroughput = (totalOperations / totalDuration) * 1000; // ops/second + + return { + timestamp: new Date().toISOString(), + passed: violations.length === 0, + sampleSize, + totalDuration, + results, + summary: { + totalTools: results.length, + passedTools, + failedTools, + averageThroughput + }, + violations + }; +} + +/** + * Print benchmark report to console + */ +function printReport(report: BenchmarkReport): void { + console.log("\n" + "=".repeat(100)); + console.log("MCP TOOL CALL THROUGHPUT BENCHMARK REPORT"); + console.log("=".repeat(100) + "\n"); + + console.log(`Sample Size: ${report.sampleSize} iterations per tool`); + console.log(`Total Duration: ${(report.totalDuration / 1000).toFixed(2)}s`); + console.log(`Average Throughput: ${report.summary.averageThroughput.toFixed(2)} ops/second\n`); + + // Group results by category + for (const category of TOOL_CATEGORIES) { + const categoryResults = report.results.filter(r => category.tools.includes(r.toolName)); + + if (categoryResults.length === 0) {continue;} + + console.log(`\n${category.name} (${category.expectedLatency}):`); + console.log("-".repeat(100)); + console.log("Tool Name P50 P95 Mean StdDev Success Status"); + console.log("-".repeat(100)); + + for (const result of categoryResults) { + const status = result.overallPassed === false ? "✗ FAIL" : "✓ PASS"; + const statusColor = result.overallPassed === false ? "\x1b[31m" : "\x1b[32m"; + const resetColor = "\x1b[0m"; + + console.log( + `${result.toolName.padEnd(25)} ` + + `${result.p50.toFixed(1).padStart(7)}ms ` + + `${result.p95.toFixed(1).padStart(7)}ms ` + + `${result.mean.toFixed(1).padStart(7)}ms ` + + `${result.stdDev.toFixed(1).padStart(7)}ms ` + + `${result.successRate.toFixed(0).padStart(6)}% ` + + `${statusColor}${status}${resetColor}` + ); + + // Print failed threshold checks + if (result.thresholdChecks && result.overallPassed === false) { + for (const check of result.thresholdChecks.filter(c => !c.passed)) { + console.log( + ` └─ ${check.metric.toUpperCase()}: ${check.actual.toFixed(2)}ms > ${check.threshold.toFixed(2)}ms ` + + `(+${check.regression.toFixed(1)}%)` + ); + } + } + } + } + + console.log("\n" + "=".repeat(100)); + console.log(`Summary: ${report.summary.passedTools}/${report.summary.totalTools} tools passed`); + + if (report.violations.length > 0) { + console.log("\n" + "⚠️ PERFORMANCE REGRESSIONS DETECTED:"); + console.log("-".repeat(100)); + for (const violation of report.violations) { + console.log(` • ${violation}`); + } + console.log("-".repeat(100)); + } + + const overallStatus = report.passed ? "✓ PASSED" : "✗ FAILED"; + const statusColor = report.passed ? "\x1b[32m" : "\x1b[31m"; + const resetColor = "\x1b[0m"; + console.log(`\n${statusColor}Overall Status: ${overallStatus}${resetColor}\n`); +} + +/** + * Write benchmark report to JSON file + */ +function writeReportToFile(report: BenchmarkReport, outputPath: string): void { + try { + // Ensure parent directory exists + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const reportJson = JSON.stringify(report, null, 2); + fs.writeFileSync(outputPath, reportJson, "utf-8"); + console.log(`Benchmark report written to: ${outputPath}`); + } catch (error) { + console.error(`Error writing report to file: ${error}`); + process.exit(1); + } +} + +/** + * Main execution + */ +async function main() { + const args = process.argv.slice(2); + let configPath = path.join(__dirname, "tool-thresholds.json"); + let outputPath: string | null = null; + let baselinePath: string | null = null; + let sampleSize = 50; // Default to 50 iterations to reduce percentile noise + + // Parse command line arguments + for (let i = 0; i < args.length; i++) { + if (args[i] === "--config" && i + 1 < args.length) { + configPath = args[i + 1]; + i++; + } else if (args[i] === "--output" && i + 1 < args.length) { + outputPath = args[i + 1]; + i++; + } else if (args[i] === "--compare" && i + 1 < args.length) { + baselinePath = args[i + 1]; + i++; + } else if (args[i] === "--samples" && i + 1 < args.length) { + sampleSize = parseInt(args[i + 1], 10); + i++; + } + } + + // Load threshold configuration + const config = loadThresholdConfig(configPath); + + if (config) { + console.log(`Loaded threshold configuration from: ${configPath}`); + } else { + console.log("Running without threshold configuration (no regression checks)\n"); + } + + // Load baseline if comparison requested + if (baselinePath) { + const baseline = loadBaseline(baselinePath); + if (baseline) { + console.log(`Loaded baseline from: ${baselinePath}`); + } + } + + // Run benchmarks + const report = await runBenchmarks(sampleSize, config); + + // Print report to console + printReport(report); + + // Write report to file if output path specified + if (outputPath) { + writeReportToFile(report, outputPath); + } + + // Exit with appropriate code + process.exit(report.passed ? 0 : 1); +} + +main().catch(error => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/benchmark-npm-unpacked-size.ts b/scripts/benchmark-npm-unpacked-size.ts new file mode 100755 index 000000000..a843dcb64 --- /dev/null +++ b/scripts/benchmark-npm-unpacked-size.ts @@ -0,0 +1,301 @@ +#!/usr/bin/env bun +/** + * Benchmark script to enforce NPM unpacked size thresholds. + * + * Usage: + * bun scripts/benchmark-npm-unpacked-size.ts [--config path/to/config.json] [--output path/to/report.json] + * + * Options: + * --config Path to threshold configuration file (default: scripts/npm-unpacked-size-thresholds.json) + * --output Path to write JSON report file (optional) + * + * Exit codes: + * 0 - Threshold satisfied + * 1 - Threshold exceeded or error occurred + */ + +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_CONFIG_PATH = path.join("scripts", "npm-unpacked-size-thresholds.json"); +const REQUIRED_DIST_ENTRY = path.join("dist", "src", "index.js"); + +interface ThresholdConfig { + version: string; + thresholds: { + unpackedBytes: number; + }; + metadata?: { + generatedAt?: string; + description?: string; + }; +} + +interface CategoryResult { + actual: number; + threshold: number; + passed: boolean; + usage: number; +} + +interface BenchmarkReport { + timestamp: string; + passed: boolean; + results: { + unpackedSize: CategoryResult; + }; + thresholds: ThresholdConfig["thresholds"]; + package: { + name: string; + version: string; + filename: string | null; + tarballBytes: number | null; + unpackedBytes: number; + }; + violations: string[]; +} + +interface CliOptions { + configPath: string; + outputPath: string | null; +} + +const decoder = new TextDecoder(); + +function parseArgs(args: string[]): CliOptions { + let configPath = DEFAULT_CONFIG_PATH; + let outputPath: string | null = null; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case "--config": { + const value = args[i + 1]; + if (!value) { + console.error("Missing value for --config"); + process.exit(1); + } + configPath = value; + i += 1; + break; + } + case "--output": { + const value = args[i + 1]; + if (!value) { + console.error("Missing value for --output"); + process.exit(1); + } + outputPath = value; + i += 1; + break; + } + default: { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + } + } + + return { configPath, outputPath }; +} + +function decodeOutput(output: Uint8Array | null): string { + if (!output) { + return ""; + } + return decoder.decode(output); +} + +function runCommand(cmd: string[], allowFailure = false): { stdout: string; stderr: string; exitCode: number } { + const result = Bun.spawnSync({ + cmd, + stdout: "pipe", + stderr: "pipe" + }); + + const stdout = decodeOutput(result.stdout); + const stderr = decodeOutput(result.stderr); + + if (result.exitCode !== 0 && !allowFailure) { + let message = `Command failed: ${cmd.join(" ")}`; + if (stdout.trim()) { + message += `\nstdout:\n${stdout.trim()}`; + } + if (stderr.trim()) { + message += `\nstderr:\n${stderr.trim()}`; + } + throw new Error(message); + } + + return { stdout, stderr, exitCode: result.exitCode }; +} + +function loadThresholdConfig(configPath: string): ThresholdConfig { + if (!fs.existsSync(configPath)) { + console.error(`Threshold configuration file not found: ${configPath}`); + process.exit(1); + } + + try { + const content = fs.readFileSync(configPath, "utf-8"); + const config = JSON.parse(content) as ThresholdConfig; + + if (!config.thresholds || typeof config.thresholds.unpackedBytes !== "number") { + throw new Error("Missing or invalid unpackedBytes threshold"); + } + + return config; + } catch (error) { + console.error(`Error loading threshold configuration: ${error}`); + process.exit(1); + } +} + +function checkThreshold(actual: number, threshold: number): CategoryResult { + const passed = actual <= threshold; + const usage = threshold > 0 ? Math.round((actual / threshold) * 100) : 0; + + return { + actual, + threshold, + passed, + usage + }; +} + +function ensureBuildOutput(): void { + if (!fs.existsSync(REQUIRED_DIST_ENTRY)) { + console.error(`Build output not found: ${REQUIRED_DIST_ENTRY}`); + console.error("Run 'bun run build' before benchmarking unpacked size."); + process.exit(1); + } +} + +function parsePackOutput(stdout: string): { + name: string; + version: string; + filename: string | null; + tarballBytes: number | null; + unpackedBytes: number; +} { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error("npm pack returned empty output"); + } + + let payload: unknown; + try { + payload = JSON.parse(trimmed); + } catch (error) { + throw new Error(`Failed to parse npm pack output: ${error}`); + } + + if (!Array.isArray(payload) || payload.length === 0) { + throw new Error("npm pack output did not include package details"); + } + + const packResult = payload[0] as { + name?: string; + version?: string; + filename?: string; + size?: number; + unpackedSize?: number; + }; + + if (typeof packResult.unpackedSize !== "number") { + throw new Error("npm pack output missing unpackedSize"); + } + + return { + name: packResult.name ?? "unknown", + version: packResult.version ?? "unknown", + filename: packResult.filename ?? null, + tarballBytes: typeof packResult.size === "number" ? packResult.size : null, + unpackedBytes: packResult.unpackedSize + }; +} + +function writeReport(outputPath: string, report: BenchmarkReport): void { + const dir = path.dirname(outputPath); + if (dir && dir !== ".") { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); +} + +function runBenchmark(config: ThresholdConfig, outputPath: string | null): BenchmarkReport { + ensureBuildOutput(); + + let packFilename: string | null = null; + + try { + runCommand(["bun", "run", "prepublishOnly"]); + + const packResult = runCommand(["npm", "pack", "--json"]); + const packInfo = parsePackOutput(packResult.stdout); + packFilename = packInfo.filename; + + const result = checkThreshold(packInfo.unpackedBytes, config.thresholds.unpackedBytes); + const violations: string[] = []; + + if (!result.passed) { + violations.push( + `Unpacked size ${packInfo.unpackedBytes} bytes exceeds threshold ${config.thresholds.unpackedBytes} bytes` + ); + } + + const report: BenchmarkReport = { + timestamp: new Date().toISOString(), + passed: result.passed, + results: { + unpackedSize: result + }, + thresholds: config.thresholds, + package: { + name: packInfo.name, + version: packInfo.version, + filename: packInfo.filename, + tarballBytes: packInfo.tarballBytes, + unpackedBytes: packInfo.unpackedBytes + }, + violations + }; + + if (outputPath) { + writeReport(outputPath, report); + } + + if (!report.passed) { + const details = + report.violations.length > 0 + ? `\n${report.violations.map(violation => `- ${violation}`).join("\n")}` + : ""; + throw new Error(`NPM unpacked size benchmark failed - threshold exceeded${details}`); + } + + console.log( + `NPM unpacked size: ${packInfo.unpackedBytes} bytes (threshold: ${config.thresholds.unpackedBytes} bytes)` + ); + + return report; + } finally { + if (packFilename && fs.existsSync(packFilename)) { + fs.unlinkSync(packFilename); + } + runCommand(["bun", "run", "postpublish"], true); + } +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + const config = loadThresholdConfig(options.configPath); + + try { + runBenchmark(config, options.outputPath); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +main(); diff --git a/scripts/benchmark-startup.sh b/scripts/benchmark-startup.sh new file mode 100755 index 000000000..116cbf1fc --- /dev/null +++ b/scripts/benchmark-startup.sh @@ -0,0 +1,1234 @@ +#!/usr/bin/env bash + +# Benchmark script to measure MCP server and daemon startup time. +# +# Usage: +# bun run benchmark-startup [--cold] [--warm] [--server-only] [--daemon-only] [--verbose] +# [--output path/to/report.json] [--compare path/to/baseline.json] [--threshold 1.3] +# +# Options: +# --cold Run cold-start measurement +# --warm Run warm-start measurement (includes a warm-up run) +# --server-only Skip daemon benchmarking +# --daemon-only Skip MCP server benchmarking +# --verbose Print MCP/daemon stdio as it is read +# --output Write JSON report to file +# --compare Compare against a baseline JSON file +# --threshold Regression multiplier (default 1.3) +# +# Exit codes: +# 0 - All benchmarks passed or no comparisons were requested +# 1 - One or more regressions detected or error occurred + +DEFAULT_TIMEOUT_MS=15000 +GLOBAL_TIMEOUT_MS=30000 +MCP_PROTOCOL_VERSION="2024-11-05" +STDERR_TAIL_LINES=20 +verbose="false" +script_start_ms="" +current_operation="" +child_pids=() +# File-backed PID tracking so subshells (from command substitutions) can +# share tracked PIDs with the main shell's cleanup handlers. +_pid_tracking_file=$(mktemp) + +# Track child process PIDs for cleanup +track_child_pid() { + child_pids+=("$1") + echo "$1" >> "$_pid_tracking_file" +} + +untrack_child_pid() { + local pid="$1" + child_pids=("${child_pids[@]/$pid}") + # Also remove from the file-backed tracker so _kill_all_tracked_pids + # won't signal a reused PID during cleanup. + if [[ -f "$_pid_tracking_file" ]]; then + local tmp="${_pid_tracking_file}.tmp" + grep -vxF "$pid" "$_pid_tracking_file" > "$tmp" 2>/dev/null || true + mv "$tmp" "$_pid_tracking_file" + fi +} + +# Kill all tracked child processes (from both in-memory array and PID file). +# Also kills direct children of this script to catch command-substitution +# subshells whose PIDs are never explicitly tracked. +# shellcheck disable=SC2317,SC2329 # Function is invoked indirectly via cleanup/timeout handlers +_kill_all_tracked_pids() { + local signal="${1:-KILL}" + local seen="" + + # In-memory array (only has PIDs tracked in the main shell) + for pid in "${child_pids[@]}"; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + pkill "-$signal" -P "$pid" 2>/dev/null || true + kill "-$signal" "$pid" 2>/dev/null || true + seen="$seen $pid" + fi + done + + # PID file (has PIDs tracked in subshells too) + if [[ -f "$_pid_tracking_file" ]]; then + while IFS= read -r pid; do + if [[ -n "$pid" && "$seen" != *" $pid"* ]] && kill -0 "$pid" 2>/dev/null; then + pkill "-$signal" -P "$pid" 2>/dev/null || true + kill "-$signal" "$pid" 2>/dev/null || true + fi + done < "$_pid_tracking_file" + fi + + # Kill direct children of this script (catches command-substitution subshells) + pkill "-$signal" -P $$ 2>/dev/null || true +} + +# Global timeout handler - logs diagnostics and exits +# shellcheck disable=SC2317,SC2329 # Function is invoked indirectly via trap +global_timeout_handler() { + local elapsed_ms=$(( $(get_time_ms) - script_start_ms )) + + echo "" >&2 + echo "========================================" >&2 + echo "GLOBAL TIMEOUT EXCEEDED (${GLOBAL_TIMEOUT_MS}ms)" >&2 + echo "========================================" >&2 + echo "" >&2 + echo "Diagnostic Information:" >&2 + echo " Elapsed time: ${elapsed_ms}ms" >&2 + echo " Current operation: ${current_operation:-unknown}" >&2 + echo "" >&2 + + # Show process tree + echo "Process tree:" >&2 + if command -v pstree >/dev/null 2>&1; then + pstree -p $$ 2>/dev/null | sed 's/^/ /' >&2 || true + else + echo " (pstree not available)" >&2 + echo " Child PIDs (in-memory): ${child_pids[*]:-none}" >&2 + if [[ -f "$_pid_tracking_file" && -s "$_pid_tracking_file" ]]; then + echo " Child PIDs (file): $(tr '\n' ' ' < "$_pid_tracking_file")" >&2 + fi + local all_pids + all_pids=$(cat "$_pid_tracking_file" 2>/dev/null; printf '%s\n' "${child_pids[@]}") + for pid in $(echo "$all_pids" | sort -u); do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + echo " PID $pid: running" >&2 + fi + done + fi + echo "" >&2 + + # Show any stderr logs collected + for log_file in /tmp/auto-mobile-*/stderr.log; do + if [[ -f "$log_file" && -s "$log_file" ]]; then + echo "Stderr log ($log_file):" >&2 + tail -n 30 "$log_file" 2>/dev/null | sed 's/^/ /' >&2 || true + echo "" >&2 + fi + done + + # Environment info + local env_info + env_info=$(environment_summary 2>/dev/null || echo "unknown") + echo "Environment: $env_info" >&2 + echo "" >&2 + + # Kill all tracked child processes (including those tracked in subshells) + echo "Killing child processes..." >&2 + _kill_all_tracked_pids TERM + sleep 0.5 + _kill_all_tracked_pids KILL + + echo "Benchmark terminated due to global timeout." >&2 + exit 1 +} + +# Start global timeout monitor in background +start_global_timeout_monitor() { + script_start_ms=$(get_time_ms) + + # Redirect stdout/stderr to /dev/null so the subshell and its sleep child + # do not inherit the parent's FDs (which on CI are the runner's pipes). + # Orphaned children holding runner FDs prevent the CI step from finishing. + ( + sleep_seconds=$(( GLOBAL_TIMEOUT_MS / 1000 )) + sleep "$sleep_seconds" + # Signal parent to run timeout handler + kill -USR1 $$ 2>/dev/null || true + ) >/dev/null 2>&1 & + global_timeout_pid=$! + disown "$global_timeout_pid" 2>/dev/null || true +} + +# Stop global timeout monitor +stop_global_timeout_monitor() { + # Ignore USR1 first to prevent a race: killing the sleep child causes the + # subshell's wait to return, which fires kill -USR1 before we can kill the + # subshell itself. + trap '' USR1 + if [[ -n "${global_timeout_pid:-}" ]]; then + kill "$global_timeout_pid" 2>/dev/null || true + pkill -P "$global_timeout_pid" 2>/dev/null || true + wait "$global_timeout_pid" 2>/dev/null || true + global_timeout_pid="" + fi +} + +# Set operation for diagnostic purposes +set_current_operation() { + current_operation="$1" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +get_time_ms() { + if command -v gdate >/dev/null 2>&1; then + gdate +%s%3N + return + fi + + local ts + ts=$(date +%s%3N 2>/dev/null) + if [[ "$ts" == *N* ]]; then + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import time; print(int(time.time() * 1000))' + else + echo "python3 is required for timing on this platform" >&2 + exit 1 + fi + else + echo "$ts" + fi +} + +environment_summary() { + local bun_version="unknown" + if command -v bun >/dev/null 2>&1; then + bun_version=$(bun --version 2>/dev/null || echo "unknown") + fi + local os_info="unknown" + if command -v uname >/dev/null 2>&1; then + os_info=$(uname -a 2>/dev/null || echo "unknown") + fi + printf 'bun %s; %s' "$bun_version" "$os_info" +} + +describe_process() { + local pid="$1" + if [[ -z "$pid" ]]; then + echo "unknown" + return + fi + if kill -0 "$pid" >/dev/null 2>&1; then + echo "running (PID $pid)" + else + echo "not running (PID $pid)" + fi +} + +log_line() { + local log_path="$1" + local line="$2" + local prefix="$3" + if [[ -n "$log_path" ]]; then + printf '%s\n' "$line" >> "$log_path" + fi + if [[ "$verbose" == "true" ]]; then + printf '%s%s\n' "$prefix" "$line" >&2 + fi +} + +drain_fd() { + local fd="$1" + local log_path="$2" + local prefix="$3" + local last_line_var="$4" + local line="" + local last_line="" + + while IFS= read -r -t 0 line <&"$fd"; do + log_line "$log_path" "$line" "$prefix" + last_line="$line" + done + + if [[ -n "$last_line" && -n "$last_line_var" ]]; then + printf -v "$last_line_var" '%s' "$last_line" + fi +} + +build_error_message() { + local summary="$1" + local wait_target="$2" + local elapsed_ms="$3" + local pid="$4" + local last_stdout="$5" + local last_stderr="$6" + local stderr_log="$7" + local extra_info="$8" + + local message="ERROR: $summary" + + if [[ -n "$elapsed_ms" ]]; then + if [[ -n "$wait_target" ]]; then + message+=$'\n Waited: '"${elapsed_ms}ms for ${wait_target}" + else + message+=$'\n Waited: '"${elapsed_ms}ms" + fi + elif [[ -n "$wait_target" ]]; then + message+=$'\n Waiting for: '"$wait_target" + fi + + if [[ -n "$pid" ]]; then + message+=$'\n Process state: '"$(describe_process "$pid")" + fi + + if [[ -n "$last_stdout" ]]; then + message+=$'\n Last stdout message: '"$last_stdout" + fi + + if [[ -n "$last_stderr" ]]; then + message+=$'\n Last stderr message: '"$last_stderr" + fi + + if [[ -n "$extra_info" ]]; then + message+=$'\n '"$extra_info" + fi + + if [[ -n "$stderr_log" && -s "$stderr_log" ]]; then + message+=$'\n\n Recent stderr output:' + message+=$'\n' + message+="$(tail -n "$STDERR_TAIL_LINES" "$stderr_log" | sed 's/^/ /')" + fi + + local env_info + env_info=$(environment_summary) + if [[ -n "$env_info" ]]; then + message+=$'\n\n Environment: '"$env_info" + fi + + printf '%s\n' "$message" +} + +wait_for_startup_report() { + local fd="$1" + local timeout_ms="$2" + local stderr_log="$3" + local report_var="$4" + local last_line_var="$5" + local prefix="$6" + local start_ms + start_ms=$(get_time_ms) + local last_non_report="" + + while true; do + local line="" + if IFS= read -r -t 1 line <&"$fd"; then + log_line "$stderr_log" "$line" "$prefix" + if [[ "$line" == STARTUP_BENCHMARK* ]]; then + local report="${line#STARTUP_BENCHMARK }" + if [[ -n "$report_var" ]]; then + printf -v "$report_var" '%s' "$report" + else + printf '%s\n' "$report" + fi + if [[ -n "$last_line_var" ]]; then + printf -v "$last_line_var" '%s' "$last_non_report" + fi + return 0 + fi + last_non_report="$line" + fi + + local now_ms + now_ms=$(get_time_ms) + if (( now_ms - start_ms >= timeout_ms )); then + if [[ -n "$last_line_var" ]]; then + printf -v "$last_line_var" '%s' "$last_non_report" + fi + return 1 + fi + done +} + +stop_process() { + local pid="$1" + if ! kill -0 "$pid" >/dev/null 2>&1; then + return + fi + + # Kill child processes first so they don't become orphans holding FDs open + pkill -TERM -P "$pid" 2>/dev/null || true + kill -TERM "$pid" >/dev/null 2>&1 || true + local start_ms + start_ms=$(get_time_ms) + + while kill -0 "$pid" >/dev/null 2>&1; do + if (( $(get_time_ms) - start_ms >= 5000 )); then + pkill -KILL -P "$pid" 2>/dev/null || true + kill -KILL "$pid" >/dev/null 2>&1 || true + break + fi + sleep 0.1 + done + + wait "$pid" >/dev/null 2>&1 || true +} + +mcp_request_id=0 + +mcp_next_id() { + mcp_request_id=$((mcp_request_id + 1)) + echo "$mcp_request_id" +} + +mcp_send() { + local fd="$1" + local payload="$2" + printf '%s\n' "$payload" >&"$fd" +} + +mcp_read() { + local fd="$1" + local timeout_sec="$2" + local out_var="$3" + local line="" + if IFS= read -r -t "$timeout_sec" line <&"$fd"; then + if [[ "$verbose" == "true" ]]; then + printf 'mcp-stdout> %s\n' "$line" >&2 + fi + if [[ -n "$out_var" ]]; then + printf -v "$out_var" '%s' "$line" + else + printf '%s\n' "$line" + fi + return 0 + fi + return 1 +} + +mcp_read_resource() { + local fd_in="$1" + local fd_out="$2" + local uri="$3" + local timeout_sec="$4" + local out_var="$5" + local request_id + request_id=$(mcp_next_id) + + local request + request=$(jq -c -n \ + --argjson id "$request_id" \ + --arg uri "$uri" \ + '{jsonrpc:"2.0", id:$id, method:"resources/read", params:{uri:$uri}}') + + mcp_send "$fd_in" "$request" + local response="" + if ! mcp_read "$fd_out" "$timeout_sec" response; then + return 1 + fi + if [[ -n "$out_var" ]]; then + printf -v "$out_var" '%s' "$response" + else + printf '%s\n' "$response" + fi +} + +find_available_port() { + local port + for port in $(seq 3000 3010); do + if command -v lsof >/dev/null 2>&1; then + if ! lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "$port" + return 0 + fi + else + if ! nc -z -w 1 localhost "$port" >/dev/null 2>&1; then + echo "$port" + return 0 + fi + fi + done + return 1 +} + +run_adb() { + if command -v timeout >/dev/null 2>&1; then + timeout 5 adb "$@" + else + adb "$@" + fi +} + +get_adb_status() { + if ! command -v adb >/dev/null 2>&1; then + echo "false|0|adb not found" + return + fi + + local output + if ! output=$(run_adb devices -l 2>/dev/null); then + echo "false|0|adb devices failed" + return + fi + + local device_count + device_count=$(echo "$output" | tail -n +2 | awk '$2 == "device" {count++} END {print count+0}') + echo "true|$device_count|" +} + +daemon_call() { + local socket_path="$1" + local request_id + request_id="req-$(get_time_ms)-$$" + local request + request=$(jq -c -n \ + --arg id "$request_id" \ + '{id:$id, type:"mcp_request", method:"resources/read", params:{uri:"automobile:devices/booted"}}') + + local response + if [[ "$daemon_socket_client" == "python" ]]; then + response=$(python3 - "$socket_path" "$request" <<'PY' +import select +import socket +import sys +import time + +socket_path = sys.argv[1] +request = sys.argv[2] + +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.settimeout(2.0) + +try: + sock.connect(socket_path) + sock.sendall((request + "\n").encode()) + buffer = b"" + start = time.time() + timeout = 2.0 + while time.time() - start < timeout: + remaining = max(0.0, timeout - (time.time() - start)) + readable, _, _ = select.select([sock], [], [], remaining) + if not readable: + break + chunk = sock.recv(4096) + if not chunk: + break + buffer += chunk + if b"\n" in buffer: + line = buffer.split(b"\n", 1)[0] + sys.stdout.write(line.decode()) + sys.exit(0) + sys.exit(1) +except Exception: + sys.exit(1) +finally: + try: + sock.close() + except Exception: + pass +PY + ) || response="" + else + response=$(printf '%s\n' "$request" | nc -U "$socket_path" -w 2 2>/dev/null | head -n 1 || true) + fi + if [[ -z "$response" ]]; then + return 1 + fi + if jq -e '.success == true' >/dev/null 2>&1 <<<"$response"; then + return 0 + fi + return 1 +} + +wait_for_daemon_responsive() { + local socket_path="$1" + local timeout_ms="$2" + local start_ms="$3" + local out_var="$4" + local stderr_fd="$5" + local stderr_log="$6" + local last_line_var="$7" + local prefix="$8" + + while (( $(get_time_ms) - start_ms < timeout_ms )); do + if daemon_call "$socket_path"; then + local elapsed_ms=$(( $(get_time_ms) - start_ms )) + if [[ -n "$out_var" ]]; then + printf -v "$out_var" '%s' "$elapsed_ms" + else + echo "$elapsed_ms" + fi + return 0 + fi + if [[ -n "$stderr_fd" ]]; then + drain_fd "$stderr_fd" "$stderr_log" "$prefix" "$last_line_var" + fi + sleep 0.1 + done + + return 1 +} + +metrics_json="{}" +skips_json="[]" +violations_json="[]" +comparisons_json="null" +server_runs_json="[]" +daemon_runs_json="[]" +device_discovery_json="null" +daemon_socket_client="" + +metrics_add() { + local key="$1" + local value="$2" + metrics_json=$(jq --arg k "$key" --argjson v "$value" '. + {($k): $v}' <<<"$metrics_json") +} + +skips_add() { + local message="$1" + skips_json=$(jq --arg msg "$message" '. + [$msg]' <<<"$skips_json") +} + +measure_device_discovery() { + local fd_in="$1" + local fd_out="$2" + local adb_available="$3" + local device_count="$4" + + if [[ "$adb_available" != "true" ]]; then + local reason="$5" + jq -n --arg reason "${reason:-adb unavailable}" '{skipped:true, reason:$reason, scenarios:[]}' + return 0 + fi + + if [[ "$device_count" -eq 0 ]]; then + jq -n --arg reason "No devices connected" '{skipped:true, reason:$reason, scenarios:[]}' + return 0 + fi + + local scenarios="[]" + local response="" + local start_ms + local duration_ms + + if [[ "$device_count" -eq 1 ]]; then + start_ms=$(get_time_ms) + if ! mcp_read_resource "$fd_in" "$fd_out" "automobile:devices/booted" 10 response; then + jq -n --arg reason "booted devices resource read failed" '{skipped:true, reason:$reason, scenarios:[]}' + return 0 + fi + if jq -e '.error' >/dev/null 2>&1 <<<"$response"; then + jq -n --arg reason "booted devices resource returned error" '{skipped:true, reason:$reason, scenarios:[]}' + return 0 + fi + duration_ms=$(( $(get_time_ms) - start_ms )) + scenarios=$(jq --argjson duration "$duration_ms" --argjson count "$device_count" \ + '. + [{name:"singleDevice", durationMs:$duration, deviceCount:$count}]' <<<"$scenarios") + else + start_ms=$(get_time_ms) + if ! mcp_read_resource "$fd_in" "$fd_out" "automobile:devices/booted" 10 response; then + jq -n --arg reason "booted devices resource read failed" '{skipped:true, reason:$reason, scenarios:[]}' + return 0 + fi + if jq -e '.error' >/dev/null 2>&1 <<<"$response"; then + jq -n --arg reason "booted devices resource returned error" '{skipped:true, reason:$reason, scenarios:[]}' + return 0 + fi + duration_ms=$(( $(get_time_ms) - start_ms )) + scenarios=$(jq --argjson duration "$duration_ms" --argjson count "$device_count" \ + '. + [{name:"multipleDevices", durationMs:$duration, deviceCount:$count}]' <<<"$scenarios") + fi + + if run_adb kill-server >/dev/null 2>&1; then + start_ms=$(get_time_ms) + response="" + mcp_read_resource "$fd_in" "$fd_out" "automobile:devices/booted" 10 response || response="" + if [[ -n "$response" ]] && ! jq -e '.error' >/dev/null 2>&1 <<<"$response"; then + duration_ms=$(( $(get_time_ms) - start_ms )) + scenarios=$(jq --argjson duration "$duration_ms" --argjson count "$device_count" \ + '. + [{name:"adbColdStart", durationMs:$duration, deviceCount:$count, note:"adb kill-server before measurement"}]' \ + <<<"$scenarios") + fi + fi + + jq -n --argjson scenarios "$scenarios" '{skipped:false, scenarios:$scenarios}' +} + +run_mcp_server() { + local mode="$1" + local include_device_discovery="$2" + local adb_available="$3" + local adb_device_count="$4" + local adb_error="$5" + + local fifo_dir + fifo_dir=$(mktemp -d) + local stdin_fifo="$fifo_dir/stdin" + local stdout_fifo="$fifo_dir/stdout" + local stderr_fifo="$fifo_dir/stderr" + local stderr_log="$fifo_dir/stderr.log" + mkfifo "$stdin_fifo" "$stdout_fifo" "$stderr_fifo" + : > "$stderr_log" + + local start_ms + start_ms=$(get_time_ms) + + AUTOMOBILE_STARTUP_BENCHMARK=1 \ + AUTOMOBILE_STARTUP_BENCHMARK_LABEL="mcp-server-$mode" \ + bun run dist/src/index.js --startup-benchmark --no-daemon \ + <"$stdin_fifo" >"$stdout_fifo" 2>"$stderr_fifo" & + local server_pid=$! + track_child_pid "$server_pid" + + exec 3>"$stdin_fifo" + exec 4<"$stdout_fifo" + exec 5<"$stderr_fifo" + local last_stdout_message="" + local last_stderr_message="" + + mcp_request_id=0 + local init_id + init_id=$(mcp_next_id) + local init_request + init_request=$(jq -c -n \ + --argjson id "$init_id" \ + --arg version "$MCP_PROTOCOL_VERSION" \ + '{jsonrpc:"2.0", id:$id, method:"initialize", params:{protocolVersion:$version, capabilities:{}, clientInfo:{name:"startup-bench", version:"1.0.0"}}}') + mcp_send 3 "$init_request" + + local init_response="" + if ! mcp_read 4 10 init_response; then + drain_fd 5 "$stderr_log" "mcp-stderr> " last_stderr_message + local elapsed_ms=$(( $(get_time_ms) - start_ms )) + local error_message + error_message=$(build_error_message \ + "Failed to read initialize response" \ + "initialize response on stdout (fd 4)" \ + "$elapsed_ms" \ + "$server_pid" \ + "$last_stdout_message" \ + "$last_stderr_message" \ + "$stderr_log" \ + "Mode: $mode") + untrack_child_pid "$server_pid" + stop_process "$server_pid" + exec 3>&- + exec 4<&- + exec 5<&- + rm -rf "$fifo_dir" + printf '%s\n' "$error_message" + return 1 + fi + last_stdout_message="$init_response" + local time_to_first_connection_ms + time_to_first_connection_ms=$(( $(get_time_ms) - start_ms )) + + mcp_send 3 '{"jsonrpc":"2.0","method":"notifications/initialized"}' + + local booted_response="" + local time_to_first_tool_call_ms="" + if mcp_read_resource 3 4 "automobile:devices/booted" 10 booted_response; then + last_stdout_message="$booted_response" + # Check for JSON-RPC error (e.g. daemon not running with --no-daemon) + if jq -e '.error' >/dev/null 2>&1 <<<"$booted_response"; then + echo "resources/read returned JSON-RPC error (skipping timeToFirstToolCallMs)" >&2 + else + time_to_first_tool_call_ms=$(( $(get_time_ms) - start_ms )) + fi + else + echo "resources/read failed (skipping timeToFirstToolCallMs)" >&2 + fi + + local startup_report + if ! wait_for_startup_report 5 "$DEFAULT_TIMEOUT_MS" "$stderr_log" startup_report last_stderr_message "mcp-stderr> "; then + drain_fd 5 "$stderr_log" "mcp-stderr> " last_stderr_message + local elapsed_ms=$(( $(get_time_ms) - start_ms )) + local error_message + error_message=$(build_error_message \ + "Timed out waiting for startup report" \ + "startup report on stderr (fd 5)" \ + "$elapsed_ms" \ + "$server_pid" \ + "$last_stdout_message" \ + "$last_stderr_message" \ + "$stderr_log" \ + "Mode: $mode") + untrack_child_pid "$server_pid" + stop_process "$server_pid" + exec 3>&- + exec 4<&- + exec 5<&- + rm -rf "$fifo_dir" + printf '%s\n' "$error_message" + return 1 + fi + + local phases + local marks + local memory + phases=$(jq -c '.phases // {}' <<<"$startup_report") + marks=$(jq -c '.marks // {}' <<<"$startup_report") + memory=$(jq -c '.memoryUsage // {}' <<<"$startup_report") + + if [[ "$include_device_discovery" == "true" ]]; then + device_discovery_json=$(measure_device_discovery 3 4 "$adb_available" "$adb_device_count" "$adb_error") + if jq -e '.skipped == true' >/dev/null 2>&1 <<<"$device_discovery_json"; then + local reason + reason=$(jq -r '.reason // "unknown"' <<<"$device_discovery_json") + skips_add "deviceDiscovery: $reason" + else + local scenario_count + scenario_count=$(jq -r '.scenarios | length' <<<"$device_discovery_json") + if [[ "$scenario_count" -gt 0 ]]; then + local index + for index in $(seq 0 $((scenario_count - 1))); do + local name + local duration + name=$(jq -r ".scenarios[$index].name" <<<"$device_discovery_json") + duration=$(jq -r ".scenarios[$index].durationMs" <<<"$device_discovery_json") + metrics_add "mcpServer.deviceDiscovery.${name}.durationMs" "$duration" + done + fi + fi + fi + + untrack_child_pid "$server_pid" + stop_process "$server_pid" + exec 3>&- + exec 4<&- + exec 5<&- + rm -rf "$fifo_dir" + + local run_json + run_json=$(jq -c -n \ + --arg mode "$mode" \ + --argjson timeToReadyMs "$time_to_first_connection_ms" \ + --argjson timeToFirstConnectionMs "$time_to_first_connection_ms" \ + --argjson phases "$phases" \ + --argjson marks "$marks" \ + --argjson memoryUsage "$memory" \ + '{mode:$mode, timeToReadyMs:$timeToReadyMs, timeToFirstConnectionMs:$timeToFirstConnectionMs, phases:$phases, marks:$marks, memoryUsage:$memoryUsage}') + if [[ -n "$time_to_first_tool_call_ms" ]]; then + run_json=$(jq -c --argjson v "$time_to_first_tool_call_ms" '.timeToFirstToolCallMs = $v' <<<"$run_json") + fi + + echo "$run_json" +} + +run_daemon() { + local mode="$1" + local port + if ! port=$(find_available_port); then + echo "No available daemon port in range 3000-3010" >&2 + return 1 + fi + + local token + token="$(get_time_ms)-$$" + local socket_path="/tmp/auto-mobile-daemon-bench-${token}.sock" + local pid_path="/tmp/auto-mobile-daemon-bench-${token}.pid" + + local run_dir + run_dir=$(mktemp -d) + local stderr_fifo="$run_dir/stderr" + local stderr_log="$run_dir/stderr.log" + mkfifo "$stderr_fifo" + : > "$stderr_log" + + local spawn_start + spawn_start=$(get_time_ms) + AUTOMOBILE_DAEMON_SOCKET_PATH="$socket_path" \ + AUTOMOBILE_DAEMON_PID_FILE_PATH="$pid_path" \ + AUTOMOBILE_STARTUP_BENCHMARK=1 \ + AUTOMOBILE_STARTUP_BENCHMARK_LABEL="daemon-$mode" \ + bun run dist/src/index.js --daemon-mode --startup-benchmark --port "$port" \ + >/dev/null 2>"$stderr_fifo" & + local daemon_pid=$! + track_child_pid "$daemon_pid" + local spawn_ms + spawn_ms=$(( $(get_time_ms) - spawn_start )) + + exec 5<"$stderr_fifo" + local last_stderr_message="" + + local startup_report + if ! wait_for_startup_report 5 "$DEFAULT_TIMEOUT_MS" "$stderr_log" startup_report last_stderr_message "daemon-stderr> "; then + drain_fd 5 "$stderr_log" "daemon-stderr> " last_stderr_message + local elapsed_ms=$(( $(get_time_ms) - spawn_start )) + local error_message + error_message=$(build_error_message \ + "Timed out waiting for daemon startup report" \ + "startup report on stderr (fd 5)" \ + "$elapsed_ms" \ + "$daemon_pid" \ + "" \ + "$last_stderr_message" \ + "$stderr_log" \ + "Mode: $mode; Port: $port; Socket: $socket_path") + untrack_child_pid "$daemon_pid" + stop_process "$daemon_pid" + exec 5<&- + rm -rf "$run_dir" + printf '%s\n' "$error_message" + return 1 + fi + + local time_to_ready_ms + time_to_ready_ms=$(( $(get_time_ms) - spawn_start )) + + local time_to_responsive_ms + if ! wait_for_daemon_responsive \ + "$socket_path" \ + "$DEFAULT_TIMEOUT_MS" \ + "$spawn_start" \ + time_to_responsive_ms \ + 5 \ + "$stderr_log" \ + last_stderr_message \ + "daemon-stderr> "; then + drain_fd 5 "$stderr_log" "daemon-stderr> " last_stderr_message + local elapsed_ms=$(( $(get_time_ms) - spawn_start )) + local error_message + error_message=$(build_error_message \ + "Timed out waiting for daemon responsiveness" \ + "daemon responsiveness on socket ${socket_path}" \ + "$elapsed_ms" \ + "$daemon_pid" \ + "" \ + "$last_stderr_message" \ + "$stderr_log" \ + "Mode: $mode; Port: $port; Socket: $socket_path") + untrack_child_pid "$daemon_pid" + stop_process "$daemon_pid" + exec 5<&- + rm -rf "$run_dir" "$socket_path" "$pid_path" + printf '%s\n' "$error_message" + return 1 + fi + + local phases + local marks + local memory + phases=$(jq -c '.phases // {}' <<<"$startup_report") + marks=$(jq -c '.marks // {}' <<<"$startup_report") + memory=$(jq -c '.memoryUsage // {}' <<<"$startup_report") + + untrack_child_pid "$daemon_pid" + stop_process "$daemon_pid" + exec 5<&- + rm -rf "$run_dir" "$socket_path" "$pid_path" + + local run_json + run_json=$(jq -c -n \ + --arg mode "$mode" \ + --argjson spawnMs "$spawn_ms" \ + --argjson timeToReadyMs "$time_to_ready_ms" \ + --argjson timeToResponsiveMs "$time_to_responsive_ms" \ + --argjson phases "$phases" \ + --argjson marks "$marks" \ + --argjson memoryUsage "$memory" \ + --argjson port "$port" \ + --arg socketPath "$socket_path" \ + '{mode:$mode, spawnMs:$spawnMs, timeToReadyMs:$timeToReadyMs, timeToResponsiveMs:$timeToResponsiveMs, phases:$phases, marks:$marks, memoryUsage:$memoryUsage, port:$port, socketPath:$socketPath}') + + echo "$run_json" +} + +output_path="" +baseline_path="" +threshold_multiplier="1.3" +threshold_provided="false" +run_cold="false" +run_warm="false" +run_server="true" +run_daemon="true" + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + output_path="$2" + shift 2 + ;; + --compare) + baseline_path="$2" + shift 2 + ;; + --threshold) + threshold_multiplier="$2" + threshold_provided="true" + shift 2 + ;; + --cold) + run_cold="true" + shift + ;; + --warm) + run_warm="true" + shift + ;; + --server-only) + run_daemon="false" + shift + ;; + --daemon-only) + run_server="false" + shift + ;; + --verbose) + verbose="true" + shift + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ "$run_cold" != "true" && "$run_warm" != "true" ]]; then + run_cold="true" + run_warm="true" +fi + +if [[ "$threshold_provided" == "true" ]]; then + if ! jq -e --arg value "$threshold_multiplier" '($value | tonumber) >= 0' >/dev/null 2>&1; then + echo "Invalid --threshold value: $threshold_multiplier" >&2 + exit 1 + fi +fi + +require_command bun +require_command jq + +# Cleanup handler to kill all tracked children on exit +# shellcheck disable=SC2317,SC2329 # Function is invoked indirectly via trap +cleanup_on_exit() { + # Stop the timeout monitor first to prevent a USR1 race when pkill kills + # the timeout subshell inside _kill_all_tracked_pids. + stop_global_timeout_monitor + _kill_all_tracked_pids KILL + rm -f "$_pid_tracking_file" +} + +# Setup global timeout handler +trap 'global_timeout_handler' USR1 +trap 'cleanup_on_exit' EXIT +start_global_timeout_monitor + +if [[ "$run_daemon" == "true" ]]; then + if command -v python3 >/dev/null 2>&1; then + daemon_socket_client="python" + else + require_command nc + if ! nc -h 2>&1 | grep -q -- '-U'; then + echo "netcat does not support Unix sockets (-U)" >&2 + exit 1 + fi + daemon_socket_client="nc" + fi +fi + +adb_available="false" +adb_device_count="0" +adb_error="" +if [[ "$run_server" == "true" ]]; then + device_discovery_json=$(jq -n '{skipped:true, reason:"not run", scenarios:[]}') + IFS="|" read -r adb_available adb_device_count adb_error < <(get_adb_status) +fi + +if [[ "$run_server" == "true" ]]; then + if [[ "$run_warm" == "true" ]]; then + set_current_operation "MCP server warm-up run" + if ! run_error=$(run_mcp_server "warm" "false" "$adb_available" "$adb_device_count" "$adb_error"); then + printf '%s\n' "$run_error" >&2 + echo "Warm-up MCP server run failed (see details above)" >&2 + stop_global_timeout_monitor + exit 1 + fi + fi + + if [[ "$run_cold" == "true" ]]; then + set_current_operation "MCP server cold benchmark" + if ! run_json=$(run_mcp_server "cold" "true" "$adb_available" "$adb_device_count" "$adb_error"); then + printf '%s\n' "$run_json" >&2 + echo "Cold MCP server benchmark failed (see details above)" >&2 + stop_global_timeout_monitor + exit 1 + fi + server_runs_json=$(jq --argjson run "$run_json" '. + [$run]' <<<"$server_runs_json") + metrics_add "mcpServer.cold.timeToReadyMs" "$(jq -r '.timeToReadyMs' <<<"$run_json")" + metrics_add "mcpServer.cold.timeToFirstConnectionMs" "$(jq -r '.timeToFirstConnectionMs' <<<"$run_json")" + cold_tool_call_ms=$(jq -r '.timeToFirstToolCallMs // empty' <<<"$run_json") + if [[ -n "$cold_tool_call_ms" ]]; then + metrics_add "mcpServer.cold.timeToFirstToolCallMs" "$cold_tool_call_ms" + fi + metrics_add "mcpServer.cold.memory.heapUsedBytes" "$(jq -r '.memoryUsage.heapUsed // 0' <<<"$run_json")" + fi + + if [[ "$run_warm" == "true" ]]; then + set_current_operation "MCP server warm benchmark" + if ! run_json=$(run_mcp_server "warm" "false" "$adb_available" "$adb_device_count" "$adb_error"); then + printf '%s\n' "$run_json" >&2 + echo "Warm MCP server benchmark failed (see details above)" >&2 + stop_global_timeout_monitor + exit 1 + fi + server_runs_json=$(jq --argjson run "$run_json" '. + [$run]' <<<"$server_runs_json") + metrics_add "mcpServer.warm.timeToReadyMs" "$(jq -r '.timeToReadyMs' <<<"$run_json")" + metrics_add "mcpServer.warm.timeToFirstConnectionMs" "$(jq -r '.timeToFirstConnectionMs' <<<"$run_json")" + warm_tool_call_ms=$(jq -r '.timeToFirstToolCallMs // empty' <<<"$run_json") + if [[ -n "$warm_tool_call_ms" ]]; then + metrics_add "mcpServer.warm.timeToFirstToolCallMs" "$warm_tool_call_ms" + fi + metrics_add "mcpServer.warm.memory.heapUsedBytes" "$(jq -r '.memoryUsage.heapUsed // 0' <<<"$run_json")" + fi +else + device_discovery_json=$(jq -n '{skipped:true, reason:"server benchmark skipped", scenarios:[]}') +fi + +if [[ "$run_daemon" == "true" ]]; then + if [[ "$run_warm" == "true" ]]; then + set_current_operation "Daemon warm-up run" + if ! run_error=$(run_daemon "warm"); then + printf '%s\n' "$run_error" >&2 + echo "Warm-up daemon run failed (see details above)" >&2 + stop_global_timeout_monitor + exit 1 + fi + fi + + if [[ "$run_cold" == "true" ]]; then + set_current_operation "Daemon cold benchmark" + if ! run_json=$(run_daemon "cold"); then + printf '%s\n' "$run_json" >&2 + echo "Cold daemon benchmark failed (see details above)" >&2 + stop_global_timeout_monitor + exit 1 + fi + daemon_runs_json=$(jq --argjson run "$run_json" '. + [$run]' <<<"$daemon_runs_json") + metrics_add "daemon.cold.spawnMs" "$(jq -r '.spawnMs' <<<"$run_json")" + metrics_add "daemon.cold.timeToReadyMs" "$(jq -r '.timeToReadyMs' <<<"$run_json")" + metrics_add "daemon.cold.timeToResponsiveMs" "$(jq -r '.timeToResponsiveMs' <<<"$run_json")" + metrics_add "daemon.cold.memory.heapUsedBytes" "$(jq -r '.memoryUsage.heapUsed // 0' <<<"$run_json")" + fi + + if [[ "$run_warm" == "true" ]]; then + set_current_operation "Daemon warm benchmark" + if ! run_json=$(run_daemon "warm"); then + printf '%s\n' "$run_json" >&2 + echo "Warm daemon benchmark failed (see details above)" >&2 + stop_global_timeout_monitor + exit 1 + fi + daemon_runs_json=$(jq --argjson run "$run_json" '. + [$run]' <<<"$daemon_runs_json") + metrics_add "daemon.warm.spawnMs" "$(jq -r '.spawnMs' <<<"$run_json")" + metrics_add "daemon.warm.timeToReadyMs" "$(jq -r '.timeToReadyMs' <<<"$run_json")" + metrics_add "daemon.warm.timeToResponsiveMs" "$(jq -r '.timeToResponsiveMs' <<<"$run_json")" + metrics_add "daemon.warm.memory.heapUsedBytes" "$(jq -r '.memoryUsage.heapUsed // 0' <<<"$run_json")" + fi +fi + +results_json="{}" +if [[ "$run_server" == "true" ]]; then + results_json=$(jq --argjson runs "$server_runs_json" --argjson discovery "$device_discovery_json" \ + '. + {mcpServer:{runs:$runs, deviceDiscovery:$discovery}}' <<<"$results_json") +fi +if [[ "$run_daemon" == "true" ]]; then + results_json=$(jq --argjson runs "$daemon_runs_json" '. + {daemon:{runs:$runs}}' <<<"$results_json") +fi + +passed="true" +threshold_effective="$threshold_multiplier" + +if [[ -n "$baseline_path" ]]; then + if [[ ! -f "$baseline_path" ]]; then + echo "Baseline not found: $baseline_path" >&2 + exit 1 + fi + + if [[ "$threshold_provided" != "true" ]]; then + threshold_effective=$(jq -r '.thresholdMultiplier // empty' "$baseline_path") + if [[ -z "$threshold_effective" || "$threshold_effective" == "null" ]]; then + threshold_effective="$threshold_multiplier" + fi + fi + + baseline_metrics=$(jq -c '.metrics' "$baseline_path") + skipped_metrics="[]" + regressions="[]" + + while IFS= read -r key; do + baseline_value=$(jq -r --arg k "$key" '.[$k]' <<<"$baseline_metrics") + actual_value=$(jq -r --arg k "$key" '.[$k] // empty' <<<"$metrics_json") + + if [[ -z "$actual_value" || "$actual_value" == "null" ]]; then + skipped_metrics=$(jq --arg k "$key" '. + [$k]' <<<"$skipped_metrics") + continue + fi + + if ! jq -e --argjson v "$baseline_value" '$v > 0' >/dev/null 2>&1; then + skipped_metrics=$(jq --arg k "$key" '. + [$k]' <<<"$skipped_metrics") + continue + fi + + threshold_value=$(jq -n --argjson base "$baseline_value" --argjson mult "$threshold_effective" '$base * $mult') + regression_value=$(jq -n --argjson actual "$actual_value" --argjson base "$baseline_value" \ + '(($actual - $base) / $base) * 100') + metric_passed=$(jq -n --argjson actual "$actual_value" --argjson threshold "$threshold_value" '$actual <= $threshold') + + regressions=$(jq --arg metric "$key" \ + --argjson baseline "$baseline_value" \ + --argjson actual "$actual_value" \ + --argjson regression "$regression_value" \ + --argjson threshold "$threshold_value" \ + --argjson passed "$metric_passed" \ + '. + [{metric:$metric, baseline:$baseline, actual:$actual, regression:$regression, threshold:$threshold, passed:$passed}]' \ + <<<"$regressions") + + if [[ "$metric_passed" != "true" ]]; then + passed="false" + violation=$(printf "%s: %.2f exceeds baseline %.2f (%.1f%% regression, threshold %.2f)" \ + "$key" "$actual_value" "$baseline_value" "$regression_value" "$threshold_value") + violations_json=$(jq --arg msg "$violation" '. + [$msg]' <<<"$violations_json") + fi + done < <(jq -r 'keys[]' <<<"$baseline_metrics") + + comparisons_json=$(jq -n \ + --arg baselinePath "$baseline_path" \ + --argjson regressions "$regressions" \ + --argjson skippedMetrics "$skipped_metrics" \ + '{baselinePath:$baselinePath, regressions:$regressions, skippedMetrics:$skippedMetrics}') +fi + +timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +report_json=$(jq -n \ + --arg timestamp "$timestamp" \ + --argjson passed "$passed" \ + --argjson thresholdMultiplier "$threshold_effective" \ + --argjson results "$results_json" \ + --argjson metrics "$metrics_json" \ + --argjson comparisons "$comparisons_json" \ + --argjson violations "$violations_json" \ + --argjson skips "$skips_json" \ + '{timestamp:$timestamp, passed:$passed, thresholdMultiplier:$thresholdMultiplier, results:$results, metrics:$metrics, violations:$violations, skips:$skips} + (if $comparisons == null then {} else {comparisons:$comparisons} end)') + +if [[ -n "$output_path" ]]; then + mkdir -p "$(dirname "$output_path")" + echo "$report_json" > "$output_path" + echo "Benchmark report written to: $output_path" +fi + +set_current_operation "Benchmark complete" +stop_global_timeout_monitor + +if [[ "$passed" != "true" ]]; then + echo "Startup benchmark regressions detected:" >&2 + jq -r '.[]' <<<"$violations_json" | sed 's/^/ - /' >&2 + exit 1 +fi + +exit 0 diff --git a/scripts/build-ios-assets.js b/scripts/build-ios-assets.js index 34cae0d98..ad4b4381b 100644 --- a/scripts/build-ios-assets.js +++ b/scripts/build-ios-assets.js @@ -4,34 +4,37 @@ const fs = require('fs-extra'); const path = require('path'); async function copyIOSAssets() { - const sourceWDADir = path.join(__dirname, '..', 'ios', 'WebDriverAgent'); - const destWDADir = path.join(__dirname, '..', 'dist', 'ios', 'WebDriverAgent'); + const sourceXCTestDir = path.join(__dirname, '..', 'ios', 'XCTestService'); + const destXCTestDir = path.join(__dirname, '..', 'dist', 'ios', 'XCTestService'); try { - console.log('Copying WebDriverAgent project to dist directory...'); + console.log('Copying XCTestService project to dist directory...'); // Ensure the destination directory exists - await fs.ensureDir(path.dirname(destWDADir)); + await fs.ensureDir(path.dirname(destXCTestDir)); - // Copy the entire WebDriverAgent directory - await fs.copy(sourceWDADir, destWDADir, { + // Copy the entire XCTestService directory + await fs.copy(sourceXCTestDir, destXCTestDir, { filter: (src) => { // Skip node_modules and other unnecessary directories return !src.includes('node_modules') && !src.includes('.git') && - !src.includes('scratch'); + !src.includes('scratch') && + !src.includes('.build') && + !src.includes('DerivedData'); } }); - console.log('WebDriverAgent project copied successfully to dist/ios/WebDriverAgent'); + console.log('XCTestService project copied successfully to dist/ios/XCTestService'); } catch (error) { - console.error('Failed to copy WebDriverAgent project:', error); + console.error('Failed to copy XCTestService project:', error); process.exit(1); } } if (require.main === module) { - copyIOSAssets(); + // TODO: Enable once we resume iOS development + // copyIOSAssets(); } module.exports = {copyIOSAssets}; diff --git a/scripts/changelog/update_changelog_from_issues.sh b/scripts/changelog/update_changelog_from_issues.sh new file mode 100755 index 000000000..4ac573605 --- /dev/null +++ b/scripts/changelog/update_changelog_from_issues.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHANGELOG_FILE="${CHANGELOG_FILE:-CHANGELOG.md}" +CURRENT_TAG="${CURRENT_TAG:-}" +SINCE_TAG="${SINCE_TAG:-}" +GH_REPO="${GITHUB_REPOSITORY:-}" + +if [ -z "$GH_REPO" ]; then + echo "GITHUB_REPOSITORY is required." >&2 + exit 1 +fi + +if [ -z "$CURRENT_TAG" ]; then + echo "CURRENT_TAG is required." >&2 + exit 1 +fi + +git fetch --tags --force >/dev/null 2>&1 || true + +if [ -z "$SINCE_TAG" ]; then + if git tag --list "$CURRENT_TAG" | grep -q .; then + SINCE_TAG=$(git tag --sort=-creatordate | awk -v current="$CURRENT_TAG" '$0 != current {print; exit}') + else + SINCE_TAG=$(git tag --sort=-creatordate | head -n1) + fi +fi + +if [ -n "$SINCE_TAG" ]; then + SINCE_DATE=$(git show -s --format=%cI "$SINCE_TAG") +else + ROOT_COMMIT=$(git rev-list --max-parents=0 HEAD) + SINCE_DATE=$(git show -s --format=%cI "$ROOT_COMMIT") +fi + +DATE=$(date -u +%Y-%m-%d) +QUERY="repo:${GH_REPO} is:issue is:closed closed:>${SINCE_DATE}" + +ISSUES_FILE=$(mktemp) +trap 'rm -f "$ISSUES_FILE"' EXIT + +gh api \ + -H "Accept: application/vnd.github+json" \ + -X GET search/issues \ + -f q="$QUERY" \ + -f sort="closed" \ + -f order="asc" \ + --paginate \ + --jq '.items[] | @json' > "$ISSUES_FILE" + +python - "$ISSUES_FILE" "$CHANGELOG_FILE" "$CURRENT_TAG" "$DATE" <<'PY' +import json +import re +import sys +from pathlib import Path + +issues_path = Path(sys.argv[1]) +changelog_path = Path(sys.argv[2]) +current_tag = sys.argv[3] +date = sys.argv[4] + +content = "" +if changelog_path.exists(): + content = changelog_path.read_text(encoding="utf-8") + +if f"## [{current_tag}]" in content: + print(f"Changelog already contains {current_tag}, skipping.") + sys.exit(0) + +issues = [] +for line in issues_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + issues.append(json.loads(line)) + +SECTION_ORDER = ["added", "changed", "deprecated", "removed", "fixed", "security", "other"] +SECTION_TITLES = { + "added": "Added", + "changed": "Changed", + "deprecated": "Deprecated", + "removed": "Removed", + "fixed": "Fixed", + "security": "Security", + "other": "Other", +} + +CATEGORY_KEYWORDS = { + "security": ["security"], + "removed": ["remove", "removed"], + "deprecated": ["deprecate", "deprecated"], + "fixed": ["bug", "fix", "fixed"], + "added": ["feature", "enhancement", "add", "added"], + "changed": ["change", "changed", "refactor", "chore", "maintenance", "internal"], +} + +def classify(labels): + lowered = [label.lower() for label in labels] + for category in ["security", "removed", "deprecated", "fixed", "added", "changed"]: + keywords = CATEGORY_KEYWORDS.get(category, []) + if any(keyword in label for label in lowered for keyword in keywords): + return category + return "other" + +def filter_labels(labels, category): + filtered = [] + keywords = CATEGORY_KEYWORDS.get(category, []) + for label in labels: + lowered = label.lower() + if any(keyword in lowered for keyword in keywords): + continue + filtered.append(label) + return filtered + +sections = {section: [] for section in SECTION_ORDER} + +for issue in issues: + labels = [label.get("name", "") for label in issue.get("labels", [])] + category = classify(labels) + extra_labels = filter_labels(labels, category) + label_suffix = f" ({', '.join(extra_labels)})" if extra_labels else "" + number = issue.get("number") + title = issue.get("title", "").strip() + url = issue.get("html_url") + item = f"- {title} ([#{number}]({url})){label_suffix}" + sections[category].append(item) + +lines = [f"## [{current_tag}] - {date}"] + +for section in SECTION_ORDER: + items = sections[section] + if not items: + continue + lines.append(f"### {SECTION_TITLES[section]}") + lines.extend(items) + +if not any(sections.values()): + lines.append("### Other") + lines.append("- No changes.") + +section = "\n".join(lines) + "\n" + +if not content: + new_content = f"# Changelog\n\n{section}" +else: + content_lines = content.splitlines() + if content_lines and content_lines[0].strip() == "# Changelog": + remainder = "\n".join(content_lines[1:]).lstrip("\n") + new_content = f"# Changelog\n\n{section}\n{remainder}".rstrip() + "\n" + else: + new_content = f"# Changelog\n\n{section}\n{content.strip()}\n" + +changelog_path.write_text(new_content, encoding="utf-8") +PY diff --git a/scripts/ci/validate-ios-swift.sh b/scripts/ci/validate-ios-swift.sh new file mode 100755 index 000000000..398dc06ab --- /dev/null +++ b/scripts/ci/validate-ios-swift.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# CI validation script for iOS Swift components +# Validates that all Swift packages can build successfully + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "=========================================" +echo "iOS Swift Component Validation (CI)" +echo "=========================================" +echo "" + +# Check if running on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "⚠️ Warning: iOS Swift validation requires macOS" + echo " Skipping Swift package validation" + exit 0 +fi + +# Check for Swift +if ! command -v swift &>/dev/null; then + echo "❌ Error: swift not found" + echo " Please install Xcode from the App Store" + exit 1 +fi + +echo "✓ Swift version: $(swift --version | head -n 1)" +echo "" + +# Component directories +COMPONENTS=( + "ios/AccessibilityService" + "ios/AXeAutomation" + "ios/XCTestRunner" + "ios/XcodeCompanion" + "ios/XcodeExtension" +) + +FAILED_COMPONENTS=() +PASSED_COMPONENTS=() + +# Validate each Swift component +for component in "${COMPONENTS[@]}"; do + component_path="${PROJECT_ROOT}/${component}" + + if [[ ! -d "${component_path}" ]]; then + echo "⚠️ Warning: ${component} not found, skipping" + continue + fi + + echo "Validating ${component}..." + echo "---" + + # Build the Swift package + if (cd "${component_path}" && swift build 2>&1); then + echo "✓ ${component} build successful" + PASSED_COMPONENTS+=("${component}") + else + echo "❌ ${component} build failed" + FAILED_COMPONENTS+=("${component}") + fi + + echo "" +done + +# Summary +echo "=========================================" +echo "Validation Summary" +echo "=========================================" +echo "" +echo "Passed: ${#PASSED_COMPONENTS[@]}" +for component in "${PASSED_COMPONENTS[@]}"; do + echo " ✓ ${component}" +done +echo "" + +if [[ ${#FAILED_COMPONENTS[@]} -gt 0 ]]; then + echo "Failed: ${#FAILED_COMPONENTS[@]}" + for component in "${FAILED_COMPONENTS[@]}"; do + echo " ❌ ${component}" + done + echo "" + exit 1 +fi + +echo "✓ All iOS Swift components validated successfully" +exit 0 diff --git a/scripts/ci/validate-ios-typescript.sh b/scripts/ci/validate-ios-typescript.sh new file mode 100755 index 000000000..e69eb6aa7 --- /dev/null +++ b/scripts/ci/validate-ios-typescript.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# CI validation script for iOS TypeScript components +# Validates that TypeScript code for iOS integration builds successfully + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "=========================================" +echo "iOS TypeScript Component Validation (CI)" +echo "=========================================" +echo "" + +# Check for bun +if ! command -v bun &>/dev/null; then + echo "❌ Error: bun not found" + echo " Please install bun: https://bun.sh" + exit 1 +fi + +echo "✓ Bun version: $(bun --version)" +echo "" + +# Validate Simctl Integration +SIMCTL_PATH="${PROJECT_ROOT}/ios/SimctlIntegration" + +if [[ ! -d "${SIMCTL_PATH}" ]]; then + echo "⚠️ Warning: SimctlIntegration not found, skipping" + exit 0 +fi + +echo "Validating ios/SimctlIntegration..." +echo "---" + +# Install dependencies +echo "Installing dependencies..." +(cd "${SIMCTL_PATH}" && bun install) + +# Build TypeScript +echo "Building TypeScript..." +if (cd "${SIMCTL_PATH}" && bun run build); then + echo "✓ SimctlIntegration build successful" +else + echo "❌ SimctlIntegration build failed" + exit 1 +fi + +echo "" + +# Run tests (if not on macOS, skip tests that require simctl) +if [[ "$(uname)" == "Darwin" ]]; then + echo "Running tests..." + if (cd "${SIMCTL_PATH}" && bun test); then + echo "✓ SimctlIntegration tests passed" + else + echo "❌ SimctlIntegration tests failed" + exit 1 + fi +else + echo "⚠️ Skipping tests (requires macOS)" +fi + +echo "" +echo "=========================================" +echo "✓ iOS TypeScript components validated successfully" +echo "=========================================" +exit 0 diff --git a/scripts/ci/validate-ios.sh b/scripts/ci/validate-ios.sh new file mode 100755 index 000000000..19de89f5c --- /dev/null +++ b/scripts/ci/validate-ios.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Master CI validation script for all iOS components +# Runs both Swift and TypeScript validations + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "=========================================" +echo "iOS Components Validation (CI)" +echo "=========================================" +echo "" + +FAILED=0 + +# Run Swift validation +echo "Running Swift component validation..." +if "${SCRIPT_DIR}/validate-ios-swift.sh"; then + echo "✓ Swift validation passed" +else + echo "❌ Swift validation failed" + FAILED=1 +fi + +echo "" +echo "---" +echo "" + +# Run TypeScript validation +echo "Running TypeScript component validation..." +if "${SCRIPT_DIR}/validate-ios-typescript.sh"; then + echo "✓ TypeScript validation passed" +else + echo "❌ TypeScript validation failed" + FAILED=1 +fi + +echo "" +echo "=========================================" + +if [[ ${FAILED} -eq 0 ]]; then + echo "✓ All iOS validations passed" + exit 0 +else + echo "❌ Some iOS validations failed" + exit 1 +fi diff --git a/scripts/ci/verify-artifact-sha256.sh b/scripts/ci/verify-artifact-sha256.sh new file mode 100755 index 000000000..36974ef76 --- /dev/null +++ b/scripts/ci/verify-artifact-sha256.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Verify that a built artifact's SHA256 matches the checksum stored in src/constants/release.ts. +# +# Usage: verify-artifact-sha256.sh +# +# Example: +# verify-artifact-sha256.sh /tmp/control-proxy-debug.apk APK_SHA256_CHECKSUM +# verify-artifact-sha256.sh /tmp/XCTestService.ipa XCTESTSERVICE_SHA256_CHECKSUM +set -euo pipefail + +ARTIFACT_PATH="${1:?Usage: verify-artifact-sha256.sh }" +CONSTANT_NAME="${2:?Usage: verify-artifact-sha256.sh }" + +RELEASE_TS="src/constants/release.ts" + +if [ ! -f "$ARTIFACT_PATH" ]; then + echo "ERROR: Artifact not found at $ARTIFACT_PATH" + exit 1 +fi + +if [ ! -f "$RELEASE_TS" ]; then + echo "ERROR: Release constants file not found at $RELEASE_TS" + exit 1 +fi + +BUILT_SHA256=$(sha256sum "$ARTIFACT_PATH" | cut -d' ' -f1) +echo "Built artifact SHA256: $BUILT_SHA256" + +SOURCE_SHA256=$(grep "$CONSTANT_NAME" "$RELEASE_TS" | sed 's/.*"\([^"]*\)".*/\1/') +echo "Source SHA256: $SOURCE_SHA256" + +if [ -z "$SOURCE_SHA256" ]; then + echo "" + echo "ERROR: No SHA256 checksum found for $CONSTANT_NAME in source." + echo "" + echo "A release cannot proceed without a checksum in $RELEASE_TS." + echo "Please:" + echo "1. Trigger the nightly workflow to generate a checksum update PR" + echo "2. Merge the update PR before releasing" + exit 1 +fi + +if [ "$BUILT_SHA256" != "$SOURCE_SHA256" ]; then + echo "" + echo "ERROR: SHA256 mismatch for $CONSTANT_NAME!" + echo "" + echo "The built artifact has a different checksum than what's in source." + echo "This likely means the source code changed after the last" + echo "checksum update PR was merged." + echo "" + echo "Please:" + echo "1. Check if there's a pending SHA256 update PR" + echo "2. If not, trigger the nightly workflow to generate one" + echo "3. Merge the update PR before releasing" + exit 1 +fi + +echo "" +echo "SHA256 verified successfully." +echo "checksum=$BUILT_SHA256" diff --git a/scripts/ci/verify-transit-sha256.sh b/scripts/ci/verify-transit-sha256.sh new file mode 100755 index 000000000..679014c33 --- /dev/null +++ b/scripts/ci/verify-transit-sha256.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Verify that a downloaded artifact's SHA256 matches an expected checksum. +# Used to detect artifact corruption during upload/download transit. +# +# Usage: verify-transit-sha256.sh +# +# Example: +# verify-transit-sha256.sh /tmp/control-proxy-debug.apk abc123... +set -euo pipefail + +FILE_PATH="${1:?Usage: verify-transit-sha256.sh }" +EXPECTED="${2:?Usage: verify-transit-sha256.sh }" + +if [ ! -f "$FILE_PATH" ]; then + echo "ERROR: File not found at $FILE_PATH" + exit 1 +fi + +ACTUAL=$(sha256sum "$FILE_PATH" | cut -d' ' -f1) +echo "Expected: $EXPECTED" +echo "Actual: $ACTUAL" + +if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "::error::SHA256 mismatch after download for $(basename "$FILE_PATH")" + exit 1 +fi + +echo "SHA256 verified successfully." diff --git a/scripts/claude/validate_plugin.sh b/scripts/claude/validate_plugin.sh new file mode 100755 index 000000000..2bd84d04c --- /dev/null +++ b/scripts/claude/validate_plugin.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Validate Claude plugin structure +# This script validates the .claude-plugin/ directory structure and manifest files. + +PLUGIN_DIR=".claude-plugin" + +echo "Validating Claude plugin..." + +# Check that plugin directory exists +if [[ ! -d "$PLUGIN_DIR" ]]; then + echo "ERROR: Plugin directory '$PLUGIN_DIR' not found" + exit 1 +fi + +# Check required files exist +required_files=( + "$PLUGIN_DIR/plugin.json" + "$PLUGIN_DIR/marketplace.json" + "$PLUGIN_DIR/hooks.json" +) + +for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo "ERROR: Required file '$file' not found" + exit 1 + fi +done + +# Check skills directory exists and has files +if [[ ! -d "$PLUGIN_DIR/skills" ]]; then + echo "ERROR: Skills directory '$PLUGIN_DIR/skills' not found" + exit 1 +fi + +skill_count=$(find "$PLUGIN_DIR/skills" -name "*.md" -type f | wc -l | tr -d ' ') +if [[ "$skill_count" -eq 0 ]]; then + echo "ERROR: No skill files found in '$PLUGIN_DIR/skills'" + exit 1 +fi + +echo "Found $skill_count skill file(s)" + +# Validate JSON files +echo "Validating JSON syntax..." +for json_file in "$PLUGIN_DIR"/*.json; do + if ! python3 -c "import json; json.load(open('$json_file'))" 2>/dev/null; then + echo "ERROR: Invalid JSON in '$json_file'" + exit 1 + fi + echo " ✓ $json_file" +done + +# Validate skill files have required frontmatter +echo "Validating skill files..." +for skill_file in "$PLUGIN_DIR/skills"/*.md; do + skill_name=$(basename "$skill_file") + + # Check for YAML frontmatter + if ! head -1 "$skill_file" | grep -q '^---$'; then + echo "ERROR: Skill '$skill_name' missing YAML frontmatter" + exit 1 + fi + + # Check for description field + if ! grep -q '^description:' "$skill_file"; then + echo "ERROR: Skill '$skill_name' missing 'description' field" + exit 1 + fi + + # Check for allowed-tools field + if ! grep -q '^allowed-tools:' "$skill_file"; then + echo "ERROR: Skill '$skill_name' missing 'allowed-tools' field" + exit 1 + fi + + echo " ✓ $skill_name" +done + +# Validate version consistency between package.json and plugin.json +echo "Validating version consistency..." +package_version=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") +plugin_version=$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['version'])") + +if [[ "$package_version" != "$plugin_version" ]]; then + echo "ERROR: Version mismatch - package.json ($package_version) != plugin.json ($plugin_version)" + exit 1 +fi + +echo " ✓ Versions match: $package_version" + +# Use claude CLI if available for additional validation +if command -v claude &>/dev/null; then + echo "Running Claude CLI validation..." + if ! claude plugin validate .; then + echo "ERROR: Claude CLI validation failed" + exit 1 + fi +else + echo "Note: Claude CLI not installed, skipping advanced validation" +fi + +echo "" +echo "✓ Claude plugin validation passed" diff --git a/scripts/clean-env-uninstall.sh b/scripts/clean-env-uninstall.sh new file mode 100755 index 000000000..df67676d0 --- /dev/null +++ b/scripts/clean-env-uninstall.sh @@ -0,0 +1,522 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Handle Ctrl-C (SIGINT) - exit immediately +trap 'echo ""; echo "Clean environment uninstall cancelled."; exit 130' INT + +# Handle piped execution +if [[ -n "${BASH_SOURCE[0]:-}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +else + SCRIPT_DIR="$(pwd)" +fi + +# ============================================================================ +# Global State +# ============================================================================ +ALL=false +DRY_RUN=false +FORCE=false +CHANGES_MADE=false + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +DIM='\033[2m' +RESET='\033[0m' + +# ============================================================================ +# Utility Functions +# ============================================================================ +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +log_info() { + echo -e "${GREEN}INFO${RESET} $*" +} + +log_warn() { + echo -e "${YELLOW}WARN${RESET} $*" +} + +log_error() { + echo -e "${RED}ERROR${RESET} $*" +} + +log_dry() { + echo -e "${DIM}[DRY-RUN]${RESET} $*" +} + +# ============================================================================ +# CLI Argument Parsing +# ============================================================================ +show_help() { + cat << 'EOF' +AutoMobile Clean Environment Uninstaller + +Removes AutoMobile AND all environment dependencies so you can verify the +install script bootstraps a machine from scratch. + +Usage: ./scripts/clean-env-uninstall.sh [OPTIONS] + +Options: + --all Remove all categories (skip interactive prompts) + --dry-run Show what would be removed without making changes + --force Skip confirmation prompts + -h, --help Show this help message + +Categories: + 1. AutoMobile components (delegates to scripts/uninstall.sh --all) + 2. Bun (~/.bun/, Homebrew tap, npm global) + 3. Node.js / nvm (~/.nvm/, Homebrew node) + 4. Homebrew packages (ripgrep, shellcheck, jq, ffmpeg, xmlstarlet, + swiftformat, swiftlint, xcodegen, yq, gum, + hadolint, vips) + 5. Java 21 (Homebrew zulu-jdk or detected JDK) + 6. Manual tool installs (lychee, ktfmt, hadolint, swiftformat, swiftlint + in ~/.local/bin; xcpretty gem) + +Examples: + ./scripts/clean-env-uninstall.sh # Interactive mode + ./scripts/clean-env-uninstall.sh --dry-run # Preview what would be removed + ./scripts/clean-env-uninstall.sh --all --dry-run # Preview all categories + ./scripts/clean-env-uninstall.sh --all --force # Remove everything, no prompts + +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all|-a) + ALL=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --force|-f) + FORCE=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + log_error "Unknown option: $1" + show_help + exit 1 + ;; + esac + done +} + +# ============================================================================ +# Confirmation helper +# ============================================================================ +confirm_category() { + local category_name="$1" + local details="$2" + + if [[ "${ALL}" == "true" && "${FORCE}" == "true" ]]; then + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + return 0 + fi + + if [[ "${ALL}" == "true" ]]; then + return 0 + fi + + echo "" + echo -e "${BOLD}${category_name}${RESET}" + if [[ -n "${details}" ]]; then + echo -e "${DIM}${details}${RESET}" + fi + + if [[ "${FORCE}" == "true" ]]; then + return 0 + fi + + read -p "Remove? [y/N] " -n 1 -r + echo + [[ $REPLY =~ ^[Yy]$ ]] +} + +# ============================================================================ +# Execute or dry-run a command +# ============================================================================ +run_cmd() { + if [[ "${DRY_RUN}" == "true" ]]; then + log_dry "Would run: $*" + return 0 + fi + "$@" + CHANGES_MADE=true +} + +# ============================================================================ +# Category 1: AutoMobile components (delegate to uninstall.sh) +# ============================================================================ +remove_automobile() { + local details="Delegates to scripts/uninstall.sh --all" + + if ! confirm_category "1. AutoMobile components" "${details}"; then + log_info "Skipping AutoMobile components" + return 0 + fi + + local uninstall_script="${SCRIPT_DIR}/uninstall.sh" + if [[ ! -x "${uninstall_script}" ]]; then + log_warn "scripts/uninstall.sh not found or not executable, skipping" + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + log_dry "Would run: ${uninstall_script} --all --force --dry-run" + "${uninstall_script}" --all --force --dry-run 2>/dev/null || true + else + log_info "Running AutoMobile uninstaller..." + "${uninstall_script}" --all --force || true + CHANGES_MADE=true + fi +} + +# ============================================================================ +# Category 2: Bun +# ============================================================================ +remove_bun() { + local found=() + + if [[ -d "${HOME}/.bun" ]]; then + # shellcheck disable=SC2088 # Tilde is intentional for display purposes + found+=("~/.bun/ directory") + fi + if command_exists brew && brew list oven-sh/bun/bun >/dev/null 2>&1; then + found+=("Homebrew: oven-sh/bun/bun") + fi + if command_exists npm && npm list -g bun >/dev/null 2>&1; then + found+=("npm global bun package") + fi + + if [[ ${#found[@]} -eq 0 ]]; then + log_info "Bun: not found" + return 0 + fi + + local details + details=$(printf ' - %s\n' "${found[@]}") + + if ! confirm_category "2. Bun" "${details}"; then + log_info "Skipping Bun" + return 0 + fi + + # Remove Homebrew bun and tap + if command_exists brew; then + if brew list oven-sh/bun/bun >/dev/null 2>&1; then + run_cmd brew uninstall oven-sh/bun/bun || true + fi + if brew tap 2>/dev/null | grep -q "oven-sh/bun"; then + run_cmd brew untap oven-sh/bun || true + fi + fi + + # Remove npm global bun if present + if command_exists npm && npm list -g bun >/dev/null 2>&1; then + run_cmd npm uninstall -g bun || true + fi + + # Remove ~/.bun directory + if [[ -d "${HOME}/.bun" ]]; then + run_cmd rm -rf "${HOME}/.bun" + fi + + log_info "Bun removed" +} + +# ============================================================================ +# Category 3: Node.js / nvm +# ============================================================================ +remove_node_nvm() { + local found=() + + if [[ -d "${HOME}/.nvm" ]]; then + # shellcheck disable=SC2088 # Tilde is intentional for display purposes + found+=("~/.nvm/ directory") + fi + if command_exists brew && brew list node >/dev/null 2>&1; then + found+=("Homebrew: node") + fi + + if [[ ${#found[@]} -eq 0 ]]; then + log_info "Node.js / nvm: not found" + return 0 + fi + + local details + details=$(printf ' - %s\n' "${found[@]}") + + if ! confirm_category "3. Node.js / nvm" "${details}"; then + log_info "Skipping Node.js / nvm" + return 0 + fi + + # Remove Homebrew node + if command_exists brew && brew list node >/dev/null 2>&1; then + run_cmd brew uninstall --ignore-dependencies node || true + fi + + # Remove nvm directory + if [[ -d "${HOME}/.nvm" ]]; then + run_cmd rm -rf "${HOME}/.nvm" + fi + + log_info "Node.js / nvm removed" +} + +# ============================================================================ +# Category 4: Homebrew packages +# ============================================================================ +remove_homebrew_packages() { + if ! command_exists brew; then + log_info "Homebrew packages: brew not found, skipping" + return 0 + fi + + local packages=( + ripgrep + shellcheck + jq + ffmpeg + xmlstarlet + swiftformat + swiftlint + xcodegen + yq + gum + hadolint + vips + ) + + local installed=() + for pkg in "${packages[@]}"; do + if brew list "${pkg}" >/dev/null 2>&1; then + installed+=("${pkg}") + fi + done + + if [[ ${#installed[@]} -eq 0 ]]; then + log_info "Homebrew packages: none of the target packages installed" + return 0 + fi + + local details + details=$(printf ' - %s\n' "${installed[@]}") + + if ! confirm_category "4. Homebrew packages" "${details}"; then + log_info "Skipping Homebrew packages" + return 0 + fi + + for pkg in "${installed[@]}"; do + run_cmd brew uninstall --ignore-dependencies "${pkg}" || true + done + + log_info "Homebrew packages removed" +} + +# ============================================================================ +# Category 5: Java 21 +# ============================================================================ +remove_java() { + local found=() + + # Check for Homebrew zulu-jdk + if command_exists brew; then + # Zulu JDK cask names + for cask in zulu-jdk21 zulu-jdk zulu21 zulu; do + if brew list --cask "${cask}" >/dev/null 2>&1; then + found+=("Homebrew cask: ${cask}") + fi + done + fi + + # Check for JDK 21 in standard macOS location + local jvm_dir="/Library/Java/JavaVirtualMachines" + if [[ -d "${jvm_dir}" ]]; then + while IFS= read -r -d '' jdk; do + local jdk_name + jdk_name=$(basename "${jdk}") + if [[ "${jdk_name}" == *"21"* || "${jdk_name}" == *"zulu"* ]]; then + found+=("${jvm_dir}/${jdk_name}") + fi + done < <(find "${jvm_dir}" -maxdepth 1 -type d \( -name '*21*' -o -name '*zulu*' \) -print0 2>/dev/null) + fi + + if [[ ${#found[@]} -eq 0 ]]; then + log_info "Java 21: not found" + return 0 + fi + + local details + details=$(printf ' - %s\n' "${found[@]}") + + if ! confirm_category "5. Java 21" "${details}"; then + log_info "Skipping Java 21" + return 0 + fi + + # Remove Homebrew casks + if command_exists brew; then + for cask in zulu-jdk21 zulu-jdk zulu21 zulu; do + if brew list --cask "${cask}" >/dev/null 2>&1; then + run_cmd brew uninstall --cask "${cask}" || true + fi + done + fi + + # Remove JDK directories (requires sudo) + if [[ -d "${jvm_dir}" ]]; then + while IFS= read -r -d '' jdk; do + local jdk_name + jdk_name=$(basename "${jdk}") + if [[ "${jdk_name}" == *"21"* || "${jdk_name}" == *"zulu"* ]]; then + if [[ "${DRY_RUN}" == "true" ]]; then + log_dry "Would run: sudo rm -rf ${jdk}" + else + log_info "Removing ${jdk_name} (requires sudo)..." + sudo rm -rf "${jdk}" + CHANGES_MADE=true + fi + fi + done < <(find "${jvm_dir}" -maxdepth 1 -type d \( -name '*21*' -o -name '*zulu*' \) -print0 2>/dev/null) + fi + + log_info "Java 21 removed" +} + +# ============================================================================ +# Category 6: Manual tool installs +# ============================================================================ +remove_manual_tools() { + local found=() + local local_bin="${HOME}/.local/bin" + + # Check ~/.local/bin for manually installed tools + local manual_binaries=(lychee ktfmt hadolint swiftformat swiftlint) + for tool in "${manual_binaries[@]}"; do + if [[ -f "${local_bin}/${tool}" ]]; then + # shellcheck disable=SC2088 + found+=("~/.local/bin/${tool}") + fi + done + + # Check for ktfmt JAR files + for jar in "${local_bin}"/ktfmt-*.jar; do + if [[ -f "${jar}" ]]; then + local jar_name + jar_name=$(basename "${jar}") + # shellcheck disable=SC2088 + found+=("~/.local/bin/${jar_name}") + fi + done + + # Check for xcpretty gem + if command_exists xcpretty; then + found+=("xcpretty (Ruby gem)") + fi + + if [[ ${#found[@]} -eq 0 ]]; then + log_info "Manual tool installs: nothing found" + return 0 + fi + + local details + details=$(printf ' - %s\n' "${found[@]}") + + if ! confirm_category "6. Manual tool installs" "${details}"; then + log_info "Skipping manual tool installs" + return 0 + fi + + # Remove binaries from ~/.local/bin + for tool in "${manual_binaries[@]}"; do + if [[ -f "${local_bin}/${tool}" ]]; then + run_cmd rm -f "${local_bin}/${tool}" + fi + done + + # Remove ktfmt JARs + for jar in "${local_bin}"/ktfmt-*.jar; do + if [[ -f "${jar}" ]]; then + run_cmd rm -f "${jar}" + fi + done + + # Remove xcpretty gem + if command_exists xcpretty; then + run_cmd gem uninstall xcpretty --all --executables || true + fi + + log_info "Manual tool installs removed" +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + parse_args "$@" + + echo "" + echo -e "${BOLD}AutoMobile Clean Environment Uninstaller${RESET}" + echo -e "${DIM}Removes AutoMobile and all environment dependencies${RESET}" + echo "" + + if [[ "${DRY_RUN}" == "true" ]]; then + echo -e "${YELLOW}${BOLD}DRY-RUN MODE: No changes will be made${RESET}" + echo "" + fi + + echo -e "${YELLOW}WARNING: This script removes development tools and SDKs from your system.${RESET}" + echo -e "${YELLOW}It is intended for verifying the install script on a clean environment.${RESET}" + echo "" + + if [[ "${ALL}" != "true" && "${DRY_RUN}" != "true" && "${FORCE}" != "true" ]]; then + read -p "Continue? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Cancelled." + exit 0 + fi + fi + + remove_automobile + remove_bun + remove_node_nvm + remove_homebrew_packages + remove_java + remove_manual_tools + + # Summary + echo "" + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "Dry-run complete. No changes were made." + elif [[ "${CHANGES_MADE}" == "true" ]]; then + log_info "Clean environment uninstall complete." + echo "" + log_info "Open a new terminal for PATH changes to take effect." + else + log_info "No changes were necessary." + fi +} + +main "$@" diff --git a/scripts/context-thresholds.json b/scripts/context-thresholds.json new file mode 100644 index 000000000..daea01c91 --- /dev/null +++ b/scripts/context-thresholds.json @@ -0,0 +1,20 @@ +{ + "version": "1.0.0", + "metadata": { + "generatedAt": "2026-01-13", + "description": "MCP context usage thresholds with manual headroom for resource/template growth", + "baseline": { + "tools": 10382, + "resources": 431, + "resourceTemplates": 1412, + "total": 12225 + }, + "buffer": "custom" + }, + "thresholds": { + "tools": 14000, + "resources": 1000, + "resourceTemplates": 2000, + "total": 17000 + } +} diff --git a/scripts/coverage/generate-badge.sh b/scripts/coverage/generate-badge.sh new file mode 100755 index 000000000..723d1d039 --- /dev/null +++ b/scripts/coverage/generate-badge.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Generate a shields.io-compatible JSON badge from lcov coverage data. +# Usage: bash scripts/coverage/generate-badge.sh [LCOV_FILE] [OUTPUT_FILE] [LABEL] [LABEL_COLOR] +set -euo pipefail + +LCOV_FILE="${1:-coverage/lcov.info}" +OUTPUT_FILE="${2:-coverage/coverage-badge.json}" +LABEL="${3:-coverage}" +LABEL_COLOR="${4:-}" + +if [[ ! -f "$LCOV_FILE" ]]; then + echo "Error: $LCOV_FILE not found. Run 'bun run test:coverage' first." >&2 + exit 1 +fi + +lines_found=0 +lines_hit=0 + +while IFS= read -r line; do + case "$line" in + LF:*) lines_found=$((lines_found + ${line#LF:})) ;; + LH:*) lines_hit=$((lines_hit + ${line#LH:})) ;; + esac +done < "$LCOV_FILE" + +if [[ "$lines_found" -eq 0 ]]; then + echo "Error: no line data found in $LCOV_FILE" >&2 + exit 1 +fi + +# Compute percentage (integer) +pct=$(( (lines_hit * 100) / lines_found )) + +# Determine badge color +if [[ "$pct" -ge 80 ]]; then + color="brightgreen" +elif [[ "$pct" -ge 60 ]]; then + color="yellow" +else + color="red" +fi + +mkdir -p "$(dirname "$OUTPUT_FILE")" + +if [[ -n "$LABEL_COLOR" ]]; then + cat > "$OUTPUT_FILE" < "$OUTPUT_FILE" <&2 + exit 1 +fi + +total=$((total_missed + total_covered)) +if [[ "$total" -eq 0 ]]; then + echo "Error: no line data found in JaCoCo reports" >&2 + exit 1 +fi + +pct=$(( (total_covered * 100) / total )) + +if [[ "$pct" -ge 80 ]]; then + color="brightgreen" +elif [[ "$pct" -ge 60 ]]; then + color="yellow" +else + color="red" +fi + +mkdir -p "$(dirname "$OUTPUT_FILE")" + +cat > "$OUTPUT_FILE" <&1); then + echo "Error: ${package} tests failed" >&2 + exit 1 + fi + + # Find the coverage profile + build_dir=$(cd "${package_dir}" && swift build --show-bin-path 2>/dev/null) + profile="${build_dir}/codecov/default.profdata" + if [[ ! -f "$profile" ]]; then + echo "Warning: no profdata found for ${package}" >&2 + continue + fi + + # Find the test binary (the Mach-O executable inside the .xctest bundle) + xctest_bundle="${build_dir}/${package}PackageTests.xctest" + test_binary="${xctest_bundle}/Contents/MacOS/${package}PackageTests" + if [[ ! -f "$test_binary" ]]; then + # Fallback: search for any matching xctest bundle + xctest_bundle=$(find "${build_dir}" -name "${package}PackageTests.xctest" -maxdepth 1 2>/dev/null | head -1) + if [[ -n "$xctest_bundle" ]]; then + test_binary="${xctest_bundle}/Contents/MacOS/${package}PackageTests" + fi + fi + if [[ ! -f "$test_binary" ]]; then + echo "Warning: no test binary found for ${package}" >&2 + continue + fi + + # Extract line coverage using llvm-cov export (binary must precede -instr-profile) + coverage_json=$(xcrun llvm-cov export "$test_binary" -instr-profile "$profile" -summary-only 2>/dev/null || true) + if [[ -z "$coverage_json" ]]; then + echo "Warning: llvm-cov export failed for ${package}" >&2 + continue + fi + + # Parse totals using python3 (available on macOS) + read -r pkg_lines pkg_covered <<< "$(echo "$coverage_json" | python3 -c " +import json, sys +data = json.load(sys.stdin) +totals = data['data'][0]['totals']['lines'] +print(totals['count'], totals['covered']) +" 2>/dev/null || echo "0 0")" + + if [[ "$pkg_lines" -gt 0 ]]; then + total_lines=$((total_lines + pkg_lines)) + total_covered=$((total_covered + pkg_covered)) + packages_found=$((packages_found + 1)) + pkg_pct=$(( (pkg_covered * 100) / pkg_lines )) + echo "${package}: ${pkg_pct}% (${pkg_covered}/${pkg_lines} lines)" + fi +done + +if [[ "$packages_found" -eq 0 ]]; then + echo "Error: no Swift coverage data collected" >&2 + exit 1 +fi + +if [[ "$total_lines" -eq 0 ]]; then + echo "Error: no line data found in Swift coverage" >&2 + exit 1 +fi + +pct=$(( (total_covered * 100) / total_lines )) + +if [[ "$pct" -ge 80 ]]; then + color="brightgreen" +elif [[ "$pct" -ge 60 ]]; then + color="yellow" +else + color="red" +fi + +mkdir -p "$(dirname "$OUTPUT_FILE")" + +cat > "$OUTPUT_FILE" <] [--output-dir=] +# +# Options: +# --json Output results in JSON format +# --threshold=N Exit with error if more than N issues found (default: no limit) +# --output-dir=DIR Write reports to specified directory +# +# Exit codes: +# 0 - Success (no dead code or below threshold) +# 1 - Error running tools +# 2 - Dead code found above threshold + +set -euo pipefail + +# Parse command-line arguments +JSON_OUTPUT=false +THRESHOLD="" +OUTPUT_DIR="" + +for arg in "$@"; do + case $arg in + --json) + JSON_OUTPUT=true + shift + ;; + --threshold=*) + THRESHOLD="${arg#*=}" + shift + ;; + --output-dir=*) + OUTPUT_DIR="${arg#*=}" + shift + ;; + *) + echo "Unknown option: $arg" + exit 1 + ;; + esac +done + +# Check for required commands +if ! command -v jq &> /dev/null; then + echo "❌ Error: jq is required but not installed" + echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)" + exit 1 +fi + +if ! command -v npx &> /dev/null; then + echo "❌ Error: npx is required but not installed" + exit 1 +fi + +# Temporary files for storing results +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +TS_PRUNE_OUTPUT="$TEMP_DIR/ts-prune.txt" +KNIP_OUTPUT="$TEMP_DIR/knip.json" +ISSUES_JSON="$TEMP_DIR/issues.json" + +# Initialize issues array +echo '[]' > "$ISSUES_JSON" + +echo "🔍 Running TypeScript dead code detection..." +echo "" + +# ============================================================================ +# Run ts-prune +# ============================================================================ +echo "📦 Running ts-prune..." + +# Run ts-prune (it exits with non-zero when issues found, that's okay) +set +e # Temporarily disable exit on error +npx ts-prune --error --project tsconfig.dead-code.json > "$TS_PRUNE_OUTPUT" 2>&1 +TS_PRUNE_EXIT=$? +set -e # Re-enable exit on error + +if [ $TS_PRUNE_EXIT -eq 0 ] || [ -s "$TS_PRUNE_OUTPUT" ]; then + # Parse ts-prune output and convert to JSON + TS_PRUNE_COUNT=0 + while IFS= read -r line; do + if [[ -z "$line" ]]; then + continue + fi + + # Parse: src/file.ts:123 - exportName (used in module) + if [[ "$line" =~ ^(.+):([0-9]+)[[:space:]]*-[[:space:]]*(.+)$ ]]; then + file="${BASH_REMATCH[1]}" + linenum="${BASH_REMATCH[2]}" + name="${BASH_REMATCH[3]}" + + # Remove optional (used in module) suffix using parameter expansion + name="${name%% \(*\)}" + # Trim whitespace from name + name=$(echo "$name" | xargs) + + # Add to issues JSON + jq --arg file "$file" \ + --arg location "${file}:${linenum}" \ + --arg type "unused export" \ + --arg name "$name" \ + --arg tool "ts-prune" \ + '. += [{ + file: $file, + location: $location, + type: $type, + name: $name, + tool: $tool + }]' "$ISSUES_JSON" > "$TEMP_DIR/issues.tmp" && mv "$TEMP_DIR/issues.tmp" "$ISSUES_JSON" + + ((TS_PRUNE_COUNT++)) || true + fi + done < "$TS_PRUNE_OUTPUT" + + echo " Found $TS_PRUNE_COUNT issues" +else + echo "❌ ts-prune failed" + exit 1 +fi + +echo "" + +# ============================================================================ +# Run knip +# ============================================================================ +echo "🔪 Running knip..." + +# Run knip with JSON reporter (it exits with non-zero when issues found, that's okay) +set +e # Temporarily disable exit on error +npx knip --reporter json > "$KNIP_OUTPUT" 2>&1 +KNIP_EXIT=$? +set -e # Re-enable exit on error + +if [ $KNIP_EXIT -eq 0 ] || [ -s "$KNIP_OUTPUT" ]; then + KNIP_COUNT=0 + + # Parse unused files + if jq -e '.files' "$KNIP_OUTPUT" > /dev/null 2>&1; then + while IFS= read -r file; do + if [[ -n "$file" ]]; then + basename=$(basename "$file") + jq --arg file "$file" \ + --arg location "$file" \ + --arg type "unused file" \ + --arg name "$basename" \ + --arg tool "knip" \ + '. += [{ + file: $file, + location: $location, + type: $type, + name: $name, + tool: $tool + }]' "$ISSUES_JSON" > "$TEMP_DIR/issues.tmp" && mv "$TEMP_DIR/issues.tmp" "$ISSUES_JSON" + ((KNIP_COUNT++)) || true + fi + done < <(jq -r '.files[]?' "$KNIP_OUTPUT") + fi + + # Parse unused exports + if jq -e '.exports' "$KNIP_OUTPUT" > /dev/null 2>&1; then + while IFS='|' read -r file export; do + if [[ -n "$file" && -n "$export" ]]; then + jq --arg file "$file" \ + --arg location "$file" \ + --arg type "unused export" \ + --arg name "$export" \ + --arg tool "knip" \ + '. += [{ + file: $file, + location: $location, + type: $type, + name: $name, + tool: $tool + }]' "$ISSUES_JSON" > "$TEMP_DIR/issues.tmp" && mv "$TEMP_DIR/issues.tmp" "$ISSUES_JSON" + ((KNIP_COUNT++)) || true + fi + done < <(jq -r '.exports | to_entries[] | "\(.key)|\(.value[])"' "$KNIP_OUTPUT" 2>/dev/null || true) + fi + + # Parse unused dependencies + if jq -e '.dependencies' "$KNIP_OUTPUT" > /dev/null 2>&1; then + while IFS= read -r dep; do + if [[ -n "$dep" ]]; then + jq --arg file "package.json" \ + --arg location "package.json" \ + --arg type "unused dependency" \ + --arg name "$dep" \ + --arg tool "knip" \ + '. += [{ + file: $file, + location: $location, + type: $type, + name: $name, + tool: $tool + }]' "$ISSUES_JSON" > "$TEMP_DIR/issues.tmp" && mv "$TEMP_DIR/issues.tmp" "$ISSUES_JSON" + ((KNIP_COUNT++)) || true + fi + done < <(jq -r '.dependencies[]?' "$KNIP_OUTPUT") + fi + + # Parse unused devDependencies + if jq -e '.devDependencies' "$KNIP_OUTPUT" > /dev/null 2>&1; then + while IFS= read -r dep; do + if [[ -n "$dep" ]]; then + jq --arg file "package.json" \ + --arg location "package.json" \ + --arg type "unused devDependency" \ + --arg name "$dep" \ + --arg tool "knip" \ + '. += [{ + file: $file, + location: $location, + type: $type, + name: $name, + tool: $tool + }]' "$ISSUES_JSON" > "$TEMP_DIR/issues.tmp" && mv "$TEMP_DIR/issues.tmp" "$ISSUES_JSON" + ((KNIP_COUNT++)) || true + fi + done < <(jq -r '.devDependencies[]?' "$KNIP_OUTPUT") + fi + + echo " Found $KNIP_COUNT issues" +else + echo "❌ knip failed" + exit 1 +fi + +echo "" + +# ============================================================================ +# Filter allowlisted issues and bash-referenced TypeScript files +# ============================================================================ +ALLOWLIST_FILE="dead-code-allowlist.json" +ALLOWLIST_JSON="{}" +if [ -f "$ALLOWLIST_FILE" ]; then + ALLOWLIST_JSON=$(cat "$ALLOWLIST_FILE") +fi + +BASH_USED_PATHS="$TEMP_DIR/bash-used-paths.txt" +: > "$BASH_USED_PATHS" +if command -v rg &> /dev/null; then + SHELL_FILES=$(rg --files -g "*.sh" .) + if [ -n "$SHELL_FILES" ]; then + while IFS= read -r file; do + rg -o "(src|scripts|test)/[A-Za-z0-9_./-]+\\.tsx?|build\\.ts" "$file" >> "$BASH_USED_PATHS" || true + done <<< "$SHELL_FILES" + fi +fi + +PACKAGE_SCRIPTS_MAP="$TEMP_DIR/package-scripts.tsv" +if [ -f "package.json" ]; then + jq -r '.scripts | to_entries[] | "\(.key)\t\(.value)"' package.json > "$PACKAGE_SCRIPTS_MAP" +fi + +SHELL_INVOKED_SCRIPTS="$TEMP_DIR/shell-invoked-scripts.txt" +: > "$SHELL_INVOKED_SCRIPTS" +if [ -n "${SHELL_FILES:-}" ]; then + while IFS= read -r file; do + rg -o "\\b(bun|npm|pnpm)\\s+run\\s+[A-Za-z0-9:_-]+" "$file" | awk '{print $NF}' >> "$SHELL_INVOKED_SCRIPTS" || true + rg -o "\\byarn\\s+run\\s+[A-Za-z0-9:_-]+" "$file" | awk '{print $3}' >> "$SHELL_INVOKED_SCRIPTS" || true + rg -o "\\byarn\\s+[A-Za-z0-9:_-]+" "$file" | awk '{print $2}' >> "$SHELL_INVOKED_SCRIPTS" || true + done <<< "$SHELL_FILES" +fi +sort -u "$SHELL_INVOKED_SCRIPTS" -o "$SHELL_INVOKED_SCRIPTS" + +if [ -s "$PACKAGE_SCRIPTS_MAP" ] && [ -s "$SHELL_INVOKED_SCRIPTS" ]; then + while IFS= read -r script_name; do + script_cmd=$(awk -F '\t' -v script="$script_name" '$1 == script {print $2}' "$PACKAGE_SCRIPTS_MAP") + if [ -n "$script_cmd" ]; then + printf "%s\n" "$script_cmd" | rg -o "(src|scripts|test)/[A-Za-z0-9_./-]+\\.tsx?|build\\.ts" >> "$BASH_USED_PATHS" || true + fi + done < "$SHELL_INVOKED_SCRIPTS" +fi + +sort -u "$BASH_USED_PATHS" -o "$BASH_USED_PATHS" +BASH_USED_JSON=$(jq -R -s 'split("\n") | map(select(length > 0))' "$BASH_USED_PATHS") + +jq --argjson allow "$ALLOWLIST_JSON" --argjson bashUsed "$BASH_USED_JSON" ' + ($allow.ignorePaths // []) as $ignorePaths + | ($allow.ignorePathPatterns // []) as $ignorePathPatterns + | ($allow.ignoreEntries // []) as $ignoreEntries + | ($ignorePaths + $bashUsed | unique) as $ignorePaths + | map( + . as $issue + | ($ignorePaths | index($issue.file)) as $pathIndex + | ($ignorePathPatterns | map(. as $pat | $issue.file | test($pat)) | any) as $patternMatch + | ($ignoreEntries | map(select(.file == $issue.file and (.name == null or .name == $issue.name))) | length) as $entryMatch + | select($pathIndex == null and ($patternMatch | not) and $entryMatch == 0) + ) +' "$ISSUES_JSON" > "$TEMP_DIR/issues.filtered.json" && mv "$TEMP_DIR/issues.filtered.json" "$ISSUES_JSON" + +# ============================================================================ +# Generate report +# ============================================================================ + +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") +TOTAL_ISSUES=$(jq 'length' "$ISSUES_JSON") + +# Count by tool +TS_PRUNE_TOTAL=$(jq '[.[] | select(.tool == "ts-prune")] | length' "$ISSUES_JSON") +KNIP_TOTAL=$(jq '[.[] | select(.tool == "knip")] | length' "$ISSUES_JSON") + +# Count by type +UNUSED_EXPORTS=$(jq '[.[] | select(.type == "unused export")] | length' "$ISSUES_JSON") +UNUSED_FILES=$(jq '[.[] | select(.type == "unused file")] | length' "$ISSUES_JSON") +UNUSED_DEPS=$(jq '[.[] | select(.type == "unused dependency" or .type == "unused devDependency")] | length' "$ISSUES_JSON") +OTHER=$((TOTAL_ISSUES - UNUSED_EXPORTS - UNUSED_FILES - UNUSED_DEPS)) + +# Build full report JSON +REPORT_JSON=$(jq -n \ + --arg timestamp "$TIMESTAMP" \ + --argjson totalIssues "$TOTAL_ISSUES" \ + --argjson tsPrune "$TS_PRUNE_TOTAL" \ + --argjson knip "$KNIP_TOTAL" \ + --argjson unusedExports "$UNUSED_EXPORTS" \ + --argjson unusedFiles "$UNUSED_FILES" \ + --argjson unusedDependencies "$UNUSED_DEPS" \ + --argjson other "$OTHER" \ + --slurpfile issues "$ISSUES_JSON" \ + '{ + timestamp: $timestamp, + totalIssues: $totalIssues, + byTool: { + tsPrune: $tsPrune, + knip: $knip + }, + byType: ( + $issues[0] | group_by(.type) | map({key: .[0].type, value: length}) | from_entries + ), + issues: $issues[0], + summary: { + unusedExports: $unusedExports, + unusedFiles: $unusedFiles, + unusedDependencies: $unusedDependencies, + other: $other + } + }') + +# ============================================================================ +# Output report +# ============================================================================ +if [ "$JSON_OUTPUT" = true ]; then + echo "$REPORT_JSON" | jq . +else + # Pretty print report + echo "" + echo "╔═══════════════════════════════════════════════════════════╗" + echo "║ TypeScript Dead Code Detection Report ║" + echo "╚═══════════════════════════════════════════════════════════╝" + echo "" + echo "📅 Timestamp: $TIMESTAMP" + echo "📊 Total Issues: $TOTAL_ISSUES" + echo "" + echo "┌─────────────────────────────────────────────────────────┐" + echo "│ Summary by Category │" + echo "├─────────────────────────────────────────────────────────┤" + printf "│ Unused Exports: %4d │\n" "$UNUSED_EXPORTS" + printf "│ Unused Files: %4d │\n" "$UNUSED_FILES" + printf "│ Unused Dependencies: %4d │\n" "$UNUSED_DEPS" + printf "│ Other: %4d │\n" "$OTHER" + echo "└─────────────────────────────────────────────────────────┘" + echo "" + echo "┌─────────────────────────────────────────────────────────┐" + echo "│ Summary by Tool │" + echo "├─────────────────────────────────────────────────────────┤" + printf "│ ts-prune: %4d │\n" "$TS_PRUNE_TOTAL" + printf "│ knip: %4d │\n" "$KNIP_TOTAL" + echo "└─────────────────────────────────────────────────────────┘" + echo "" + + if [ "$TOTAL_ISSUES" -gt 0 ]; then + echo "┌─────────────────────────────────────────────────────────┐" + echo "│ Issues Found │" + echo "└─────────────────────────────────────────────────────────┘" + echo "" + + # Group and display by type + for type in "unused export" "unused file" "unused dependency" "unused devDependency"; do + count=$(jq -r "[.[] | select(.type == \"$type\")] | length" "$ISSUES_JSON") + if [ "$count" -gt 0 ]; then + type_upper=$(echo "$type" | tr '[:lower:]' '[:upper:]') + echo "" + echo "📍 $type_upper ($count):" + echo "────────────────────────────────────────────────────────────" + + # Show first 20 issues without triggering pipefail on SIGPIPE + jq -r --arg type "$type" 'map(select(.type == $type)) | .[:20][] | " \(.location) - \(.name)"' "$ISSUES_JSON" + + if [ "$count" -gt 20 ]; then + echo " ... and $((count - 20)) more" + fi + fi + done + fi + + echo "" + echo "" +fi + +# ============================================================================ +# Save reports if output directory specified +# ============================================================================ +if [ -n "$OUTPUT_DIR" ]; then + mkdir -p "$OUTPUT_DIR" + + # Save JSON report + JSON_PATH="$OUTPUT_DIR/dead-code-report.json" + echo "$REPORT_JSON" | jq . > "$JSON_PATH" + echo "📄 JSON report saved to: $JSON_PATH" + + # Generate and save Markdown report + MD_PATH="$OUTPUT_DIR/dead-code-report.md" + { + echo "# TypeScript Dead Code Detection Report" + echo "" + echo "**Timestamp:** $TIMESTAMP" + echo "" + echo "**Total Issues:** $TOTAL_ISSUES" + echo "" + echo "## Summary by Category" + echo "" + echo "| Category | Count |" + echo "|----------|-------|" + echo "| Unused Exports | $UNUSED_EXPORTS |" + echo "| Unused Files | $UNUSED_FILES |" + echo "| Unused Dependencies | $UNUSED_DEPS |" + echo "| Other | $OTHER |" + echo "" + echo "## Summary by Tool" + echo "" + echo "| Tool | Count |" + echo "|------|-------|" + echo "| ts-prune | $TS_PRUNE_TOTAL |" + echo "| knip | $KNIP_TOTAL |" + echo "" + + if [ "$TOTAL_ISSUES" -gt 0 ]; then + echo "## Issues Found" + echo "" + + for type in "unused export" "unused file" "unused dependency" "unused devDependency"; do + count=$(jq -r "[.[] | select(.type == \"$type\")] | length" "$ISSUES_JSON") + if [ "$count" -gt 0 ]; then + # Capitalize first letter + type_cap="$(tr '[:lower:]' '[:upper:]' <<< "${type:0:1}")${type:1}" + echo "### $type_cap ($count)" + echo "" + jq -r ".[] | select(.type == \"$type\") | \"- \`\(.location)\` - \(.name)\"" "$ISSUES_JSON" + echo "" + fi + done + fi + } > "$MD_PATH" + echo "📄 Markdown report saved to: $MD_PATH" +fi + +# ============================================================================ +# Check threshold and exit +# ============================================================================ +if [ -n "$THRESHOLD" ] && [ "$TOTAL_ISSUES" -gt "$THRESHOLD" ]; then + echo "" + echo "❌ Dead code threshold exceeded: $TOTAL_ISSUES > $THRESHOLD" + exit 2 +fi + +if [ "$TOTAL_ISSUES" -eq 0 ]; then + echo "✅ No dead code detected!" + exit 0 +elif [ -n "$THRESHOLD" ] && [ "$TOTAL_ISSUES" -le "$THRESHOLD" ]; then + echo "✅ Found $TOTAL_ISSUES dead code issue(s), but within threshold of $THRESHOLD" + exit 0 +else + echo "⚠️ Found $TOTAL_ISSUES dead code issue(s)" + exit 2 +fi diff --git a/scripts/detect-memory-leaks.ts b/scripts/detect-memory-leaks.ts new file mode 100644 index 000000000..349207647 --- /dev/null +++ b/scripts/detect-memory-leaks.ts @@ -0,0 +1,226 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + createStressHarness, + parseStressArgs, + resolveStressConfig, + runStressOperations +} from "./memory/stress-harness"; + +interface MemoryLeakArgs { + heapGrowthLimitMb: number; + outputPath?: string; + snapshotDir: string; + mode: "strict" | "profile"; + failOnLeak: boolean; +} + +const DEFAULT_HEAP_GROWTH_LIMIT_MB = 50; + +function parseMemoryLeakArgs(argv: string[]): MemoryLeakArgs { + const args: MemoryLeakArgs = { + heapGrowthLimitMb: DEFAULT_HEAP_GROWTH_LIMIT_MB, + snapshotDir: process.cwd(), + mode: "strict", + failOnLeak: true + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = argv[i + 1]; + + if (arg === "--heap-growth-limit-mb") { + args.heapGrowthLimitMb = Number.parseFloat(next); + i++; + continue; + } + + if (arg === "--output") { + args.outputPath = next; + i++; + continue; + } + + if (arg === "--snapshot-dir") { + args.snapshotDir = next; + i++; + continue; + } + + if (arg === "--mode") { + args.mode = next === "profile" ? "profile" : "strict"; + i++; + continue; + } + + if (arg === "--no-fail") { + args.failOnLeak = false; + continue; + } + } + + if (args.mode === "profile") { + args.failOnLeak = false; + } + + return args; +} + +async function writeHeapSnapshot( + heapdumpModule: { writeSnapshot?: (path: string, cb: (err: Error | null, filename?: string) => void) => void } | null, + snapshotDir: string, + label: string +): Promise { + if (!heapdumpModule?.writeSnapshot) { + console.error("[memory-leaks] heapdump is unavailable; skipping heap snapshot."); + return null; + } + const safeLabel = label.replace(/[^a-z0-9-_]+/gi, "_").slice(0, 40); + const filePath = path.join(snapshotDir, `heap-${safeLabel}-${Date.now()}.heapsnapshot`); + await fs.promises.mkdir(snapshotDir, { recursive: true }); + + return new Promise(resolve => { + heapdumpModule.writeSnapshot!(filePath, (error, filename) => { + if (error) { + console.error(`[memory-leaks] Failed to write heap snapshot: ${error}`); + resolve(null); + return; + } + console.error(`[memory-leaks] Heap snapshot saved: ${filename}`); + resolve(filename); + }); + }); +} + +async function main(): Promise { + if (process.versions?.bun) { + console.error("[memory-leaks] This script requires Node.js. Use `bun run test:memory-leaks`."); + process.exit(1); + } + + const memwatchModule = await import("memwatch-next").catch(error => { + console.warn(`[memory-leaks] memwatch-next unavailable: ${error}`); + return null; + }); + const heapdumpModule = await import("heapdump").catch(error => { + console.warn(`[memory-leaks] heapdump unavailable: ${error}`); + return null; + }); + const memwatch = memwatchModule + ? ((memwatchModule as { default?: typeof memwatchModule }).default ?? memwatchModule) + : null; + const heapdump = heapdumpModule + ? ((heapdumpModule as { default?: typeof heapdumpModule }).default ?? heapdumpModule) + : null; + + const argv = process.argv.slice(2); + const stressArgs = parseStressArgs(argv); + const { runConfig, warmupIterations } = resolveStressConfig(stressArgs); + const leakArgs = parseMemoryLeakArgs(argv); + const heapGrowthLimitBytes = leakArgs.heapGrowthLimitMb * 1024 * 1024; + + const harness = await createStressHarness(); + let leakDetected = false; + const leakEvents: unknown[] = []; + let snapshotPromise: Promise | null = null; + + if (memwatch && typeof (memwatch as { on?: unknown }).on === "function") { + (memwatch as { on: (event: string, handler: (info: unknown) => void) => void }).on("leak", info => { + leakDetected = true; + leakEvents.push(info); + if (!snapshotPromise) { + snapshotPromise = writeHeapSnapshot(heapdump, leakArgs.snapshotDir, "memwatch-leak"); + } + console.error("[memory-leaks] Memory leak detected:", info); + }); + } + + try { + if (warmupIterations > 0) { + await runStressOperations(harness, { + ...runConfig, + iterations: warmupIterations, + gcEvery: 0 + }); + } + + if (typeof global.gc === "function") { + global.gc(); + } + + const heapDiff = memwatch && (memwatch as { HeapDiff?: new () => { end: () => any } }).HeapDiff + ? new (memwatch as { HeapDiff: new () => { end: () => any } }).HeapDiff() + : null; + const startUsage = process.memoryUsage(); + const runResult = await runStressOperations(harness, runConfig); + + if (typeof global.gc === "function") { + global.gc(); + } + + const endUsage = process.memoryUsage(); + const diff = heapDiff?.end ? heapDiff.end() : null; + const heapGrowth = endUsage.heapUsed - startUsage.heapUsed; + const diffGrowth = diff?.change?.size_bytes ?? 0; + const effectiveGrowth = Math.max(heapGrowth, diffGrowth); + + const passed = !leakDetected && effectiveGrowth <= heapGrowthLimitBytes; + + const report = { + timestamp: new Date().toISOString(), + passed, + config: { + iterations: runConfig.iterations, + opsPerSecond: runConfig.opsPerSecond, + operations: runConfig.operations, + heapGrowthLimitMb: leakArgs.heapGrowthLimitMb, + warmupIterations, + gcEvery: runConfig.gcEvery + }, + results: { + durationMs: runResult.durationMs, + operationCounts: runResult.operationCounts, + heapUsedStart: startUsage.heapUsed, + heapUsedEnd: endUsage.heapUsed, + heapGrowthBytes: heapGrowth, + heapDiffBytes: diffGrowth, + effectiveGrowthBytes: effectiveGrowth + }, + memwatch: { + leakDetected, + leakEventCount: leakEvents.length + } + }; + + if (leakArgs.outputPath) { + await fs.promises.mkdir(path.dirname(leakArgs.outputPath), { recursive: true }); + await fs.promises.writeFile(leakArgs.outputPath, JSON.stringify(report, null, 2)); + } + + console.log("[memory-leaks] Stress run complete."); + console.log(`[memory-leaks] Heap growth: ${(effectiveGrowth / (1024 * 1024)).toFixed(2)} MB`); + console.log(`[memory-leaks] Leak events: ${leakEvents.length}`); + + if (!passed && leakArgs.failOnLeak) { + if (!snapshotPromise) { + snapshotPromise = writeHeapSnapshot(heapdump, leakArgs.snapshotDir, "threshold"); + } + await snapshotPromise; + console.error("[memory-leaks] Memory leak detection failed."); + process.exitCode = 1; + } else { + console.log("[memory-leaks] Memory leak detection passed."); + } + } catch (error) { + console.error(`[memory-leaks] Unexpected error: ${error}`); + if (!snapshotPromise) { + snapshotPromise = writeHeapSnapshot(heapdump, leakArgs.snapshotDir, "error"); + } + await snapshotPromise; + process.exitCode = 1; + } finally { + await harness.cleanup(); + } +} + +void main(); diff --git a/scripts/docker/host-control-daemon.js b/scripts/docker/host-control-daemon.js new file mode 100755 index 000000000..b1fbc8121 --- /dev/null +++ b/scripts/docker/host-control-daemon.js @@ -0,0 +1,1328 @@ +#!/usr/bin/env node +/** + * Host Control Daemon for Auto-Mobile Docker Integration + * + * This daemon runs on the host machine and provides a simple JSON-RPC interface + * for Docker containers to control Android SDK tools (emulator, avdmanager, etc.) + * and iOS simulators via simctl/xcodebuild (macOS only). + * + * Features: + * - Start/stop Android emulators + * - List available AVDs + * - Run avdmanager commands + * - Run sdkmanager commands + * - Start/stop iOS simulators (macOS only) + * - List available iOS simulators + * - Run simctl commands + * - Run xcodebuild commands + * + * Usage: + * node host-control-daemon.js [--port 15037] [--host 0.0.0.0] + * + * The daemon listens on port 15037 by default and accepts JSON-RPC requests. + */ + +const net = require("net"); +const { spawn, execFile } = require("child_process"); +const { promisify } = require("util"); +const path = require("path"); +const os = require("os"); +const fs = require("fs"); +const crypto = require("crypto"); + +const execFileAsync = promisify(execFile); +const fsp = fs.promises; + +// Configuration +const DEFAULT_PORT = 15037; +const DEFAULT_HOST = "0.0.0.0"; +const COMMAND_TIMEOUT_MS = 30000; + +// Parse command line arguments +const args = process.argv.slice(2); +let port = DEFAULT_PORT; +let host = DEFAULT_HOST; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--host" && args[i + 1]) { + host = args[i + 1]; + i++; + } +} + +// Detect Android SDK location +function getAndroidSdk() { + const sdkRoot = process.env.ANDROID_HOME || + process.env.ANDROID_SDK_ROOT || + path.join(os.homedir(), "Library/Android/sdk"); + return sdkRoot; +} + +function getEmulatorPath() { + return path.join(getAndroidSdk(), "emulator", "emulator"); +} + +function getAvdManagerPath() { + return path.join(getAndroidSdk(), "cmdline-tools", "latest", "bin", "avdmanager"); +} + +function getSdkManagerPath() { + return path.join(getAndroidSdk(), "cmdline-tools", "latest", "bin", "sdkmanager"); +} + +function getAdbPath() { + return path.join(getAndroidSdk(), "platform-tools", "adb"); +} + +// iOS support (macOS only) +const IS_MACOS = os.platform() === "darwin"; + +/** + * Check if Xcode command line tools are available + */ +async function isXcodeAvailable() { + if (!IS_MACOS) return false; + try { + await execFileAsync("xcrun", ["--version"], { timeout: 5000 }); + return true; + } catch { + return false; + } +} + +// Track running emulator processes +const runningEmulators = new Map(); // avdName -> { pid, process } +const runningSimulators = new Map(); // udid -> { name, state } +const runningXCTestServices = new Map(); // deviceId -> { pid, process, port, startedAt, deviceId } +const runningIproxyTunnels = new Map(); // key -> { pid, process, deviceId, localPort, devicePort } + +const sanitizeForLog = value => String(value ?? "").replace(/[\r\n]/g, ""); + +const skipPathSegment = segment => ( + segment === "_CodeSignature" || + segment === "SC_Info" +); + +const skipFileName = name => ( + name === "embedded.mobileprovision" || + name === "PkgInfo" || + name.endsWith(".xcent") +); + +function normalizeDevicePath(rawPath) { + if (rawPath.startsWith("file://")) { + try { + return decodeURIComponent(new URL(rawPath).pathname); + } catch { + return rawPath.replace("file://", ""); + } + } + return rawPath; +} + +function findBundleEntry(data, bundleId) { + if (!data || typeof data !== "object") { + return null; + } + if (Array.isArray(data)) { + for (const item of data) { + const found = findBundleEntry(item, bundleId); + if (found) { + return found; + } + } + return null; + } + + const record = data; + const idValue = record.bundleIdentifier || record.bundleID || record.bundleId || record.BUNDLE_IDENTIFIER; + if (typeof idValue === "string" && idValue === bundleId) { + return record; + } + + for (const value of Object.values(record)) { + const found = findBundleEntry(value, bundleId); + if (found) { + return found; + } + } + return null; +} + +function extractBundlePath(entry) { + const candidates = [ + entry.bundleURL, + entry.bundlePath, + entry.bundleURLString, + entry.bundle_url, + entry.bundle_path, + entry.url, + entry.path + ]; + for (const candidate of candidates) { + if (typeof candidate === "string") { + return normalizeDevicePath(candidate); + } + } + return null; +} + +async function findAppBundleInDir(root) { + const entries = await fsp.readdir(root); + for (const entry of entries) { + const fullPath = path.join(root, entry); + const stats = await fsp.stat(fullPath); + if (stats.isDirectory()) { + if (entry.endsWith(".app")) { + return fullPath; + } + const nested = await findAppBundleInDir(fullPath); + if (nested) { + return nested; + } + } + } + return null; +} + +async function collectBundlePaths(root, current, output) { + const entries = await fsp.readdir(current); + entries.sort(); + for (const entry of entries) { + const fullPath = path.join(current, entry); + const stats = await fsp.stat(fullPath); + const relPath = path.relative(root, fullPath).replace(/\\/g, "/"); + const segments = relPath.split("/").filter(Boolean); + if (segments.some(segment => skipPathSegment(segment))) { + continue; + } + const fileName = segments[segments.length - 1] || ""; + if (skipFileName(fileName)) { + continue; + } + if (stats.isDirectory()) { + await collectBundlePaths(root, fullPath, output); + } else if (stats.isFile()) { + output.push(relPath); + } + } +} + +async function hashAppBundle(bundlePath) { + const hash = crypto.createHash("sha256"); + const files = []; + await collectBundlePaths(bundlePath, bundlePath, files); + files.sort(); + for (const relativePath of files) { + const fullPath = path.join(bundlePath, relativePath); + hash.update(relativePath); + hash.update("\0"); + const contents = await fsp.readFile(fullPath); + hash.update(contents); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function drainChildOutput(child) { + if (child.stdout) { + child.stdout.on("data", () => {}); + child.stdout.resume(); + } + if (child.stderr) { + child.stderr.on("data", () => {}); + child.stderr.resume(); + } +} + +function isProcessRunning(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function findXctestrunPath(requestedPath) { + if (requestedPath && fs.existsSync(requestedPath)) { + return requestedPath; + } + + const cacheDir = path.join(os.homedir(), ".automobile", "xctestservice"); + if (!fs.existsSync(cacheDir)) { + return null; + } + + const stack = [{ dir: cacheDir, depth: 3 }]; + while (stack.length > 0) { + const { dir, depth } = stack.pop(); + if (depth < 0) continue; + + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith(".xctestrun")) { + return entryPath; + } + if (entry.isDirectory()) { + stack.push({ dir: entryPath, depth: depth - 1 }); + } + } + } + + return null; +} + +// Command handlers +const handlers = { + /** + * List available AVDs + */ + async "list-avds"() { + const emulatorPath = getEmulatorPath(); + try { + const { stdout } = await execFileAsync(emulatorPath, ["-list-avds"], { + timeout: COMMAND_TIMEOUT_MS + }); + const avds = stdout.trim().split("\n").filter(line => line.trim()); + return { success: true, avds }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Start an emulator + */ + async "start-emulator"(params) { + const { avd, headless = true, args: extraArgs = [] } = params; + if (!avd) { + return { success: false, error: "Missing required parameter: avd" }; + } + + // Check if already running + if (runningEmulators.has(avd)) { + return { success: true, message: `Emulator ${avd} is already running`, pid: runningEmulators.get(avd).pid }; + } + + const emulatorPath = getEmulatorPath(); + const emulatorArgs = ["-avd", avd]; + + if (headless) { + emulatorArgs.push("-no-window", "-no-audio"); + } + + emulatorArgs.push(...extraArgs); + + try { + const emulatorProcess = spawn(emulatorPath, emulatorArgs, { + detached: true, + stdio: "ignore" + }); + + emulatorProcess.unref(); + + runningEmulators.set(avd, { + pid: emulatorProcess.pid, + process: emulatorProcess + }); + + // Wait a bit for the emulator to start + await new Promise(resolve => setTimeout(resolve, 2000)); + + return { + success: true, + message: `Started emulator ${avd}`, + pid: emulatorProcess.pid + }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Stop an emulator + */ + async "stop-emulator"(params) { + const { avd, deviceId } = params; + + // Try to kill via ADB first (more reliable) + if (deviceId) { + try { + const adbPath = getAdbPath(); + await execFileAsync(adbPath, ["-s", deviceId, "emu", "kill"], { + timeout: 10000 + }); + if (avd) { + runningEmulators.delete(avd); + } + return { success: true, message: `Stopped emulator ${deviceId}` }; + } catch (error) { + // Fall through to process kill + } + } + + // Try to kill by AVD name + if (avd && runningEmulators.has(avd)) { + const { pid } = runningEmulators.get(avd); + try { + process.kill(pid, "SIGTERM"); + runningEmulators.delete(avd); + return { success: true, message: `Stopped emulator ${avd} (pid ${pid})` }; + } catch (error) { + runningEmulators.delete(avd); + return { success: false, error: error.message }; + } + } + + return { success: false, error: "No running emulator found matching criteria" }; + }, + + /** + * List running emulators + */ + async "list-running"() { + const adbPath = getAdbPath(); + try { + const { stdout } = await execFileAsync(adbPath, ["devices"], { + timeout: 5000 + }); + + const devices = []; + const lines = stdout.split("\n").slice(1); + for (const line of lines) { + const match = line.match(/^(emulator-\d+)\s+(\w+)/); + if (match) { + devices.push({ deviceId: match[1], state: match[2] }); + } + } + + return { success: true, devices }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Run an avdmanager command + */ + async "avdmanager"(params) { + const { args = [] } = params; + const avdManagerPath = getAvdManagerPath(); + + try { + const { stdout, stderr } = await execFileAsync(avdManagerPath, args, { + timeout: COMMAND_TIMEOUT_MS + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run an sdkmanager command + */ + async "sdkmanager"(params) { + const { args = [] } = params; + const sdkManagerPath = getSdkManagerPath(); + + try { + const { stdout, stderr } = await execFileAsync(sdkManagerPath, args, { + timeout: COMMAND_TIMEOUT_MS * 10 // SDK operations can be slow + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Get SDK info + */ + async "sdk-info"() { + return { + success: true, + sdkRoot: getAndroidSdk(), + emulatorPath: getEmulatorPath(), + avdManagerPath: getAvdManagerPath(), + sdkManagerPath: getSdkManagerPath(), + adbPath: getAdbPath() + }; + }, + + // ======================================================================== + // iOS Simulator Commands (macOS only) + // ======================================================================== + + /** + * List available iOS simulators + */ + async "list-simulators"() { + if (!IS_MACOS) { + return { success: false, error: "iOS simulators are only available on macOS" }; + } + + try { + const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { + timeout: COMMAND_TIMEOUT_MS + }); + const data = JSON.parse(stdout); + + // Flatten devices from all runtimes + const simulators = []; + for (const [runtime, devices] of Object.entries(data.devices || {})) { + for (const device of devices) { + if (device.isAvailable) { + simulators.push({ + udid: device.udid, + name: device.name, + state: device.state, + runtime: runtime, + deviceTypeIdentifier: device.deviceTypeIdentifier + }); + } + } + } + + return { success: true, simulators }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * List running (booted) iOS simulators + */ + async "list-running-simulators"() { + if (!IS_MACOS) { + return { success: false, error: "iOS simulators are only available on macOS" }; + } + + try { + const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "booted", "--json"], { + timeout: COMMAND_TIMEOUT_MS + }); + const data = JSON.parse(stdout); + + const simulators = []; + for (const [runtime, devices] of Object.entries(data.devices || {})) { + for (const device of devices) { + if (device.state === "Booted") { + simulators.push({ + udid: device.udid, + name: device.name, + state: device.state, + runtime: runtime + }); + // Track running simulators + runningSimulators.set(device.udid, { name: device.name, state: device.state }); + } + } + } + + return { success: true, simulators }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Boot an iOS simulator + */ + async "boot-simulator"(params) { + if (!IS_MACOS) { + return { success: false, error: "iOS simulators are only available on macOS" }; + } + + const { udid } = params; + if (!udid) { + return { success: false, error: "Missing required parameter: udid" }; + } + + try { + // Check if already booted + const listResult = await handlers["list-running-simulators"](); + if (listResult.success && listResult.simulators.some(s => s.udid === udid)) { + return { success: true, message: `Simulator ${udid} is already booted` }; + } + + await execFileAsync("xcrun", ["simctl", "boot", udid], { + timeout: COMMAND_TIMEOUT_MS + }); + + // Wait for simulator to fully boot + await new Promise(resolve => setTimeout(resolve, 2000)); + + return { success: true, message: `Booted simulator ${udid}` }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Shutdown an iOS simulator + */ + async "shutdown-simulator"(params) { + if (!IS_MACOS) { + return { success: false, error: "iOS simulators are only available on macOS" }; + } + + const { udid } = params; + if (!udid) { + return { success: false, error: "Missing required parameter: udid" }; + } + + try { + await execFileAsync("xcrun", ["simctl", "shutdown", udid], { + timeout: COMMAND_TIMEOUT_MS + }); + runningSimulators.delete(udid); + return { success: true, message: `Shutdown simulator ${udid}` }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Run an arbitrary simctl command + */ + async "simctl"(params) { + if (!IS_MACOS) { + return { success: false, error: "iOS simulators are only available on macOS" }; + } + + const { args = [] } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("xcrun", ["simctl", ...args], { + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024 // 10MB for large outputs + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run an xcodebuild command + */ + async "xcodebuild"(params) { + if (!IS_MACOS) { + return { success: false, error: "xcodebuild is only available on macOS" }; + } + + const { args = [] } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("xcodebuild", args, { + timeout: COMMAND_TIMEOUT_MS * 20, // xcodebuild can be very slow + maxBuffer: 50 * 1024 * 1024 // 50MB for large build outputs + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run xcode-select on the host + */ + async "xcode-select"(params) { + if (!IS_MACOS) { + return { success: false, error: "xcode-select is only available on macOS" }; + } + + const { args } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("xcode-select", args, { + timeout: COMMAND_TIMEOUT_MS + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run xcrun on the host + */ + async "xcrun"(params) { + if (!IS_MACOS) { + return { success: false, error: "xcrun is only available on macOS" }; + } + + const { args } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("xcrun", args, { + timeout: COMMAND_TIMEOUT_MS + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run security on the host + */ + async "security"(params) { + if (!IS_MACOS) { + return { success: false, error: "security is only available on macOS" }; + } + + const { args } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("security", args, { + timeout: COMMAND_TIMEOUT_MS + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run idevice_id on the host + */ + async "idevice-id"(params) { + const { args } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("idevice_id", args, { + timeout: COMMAND_TIMEOUT_MS + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Run ideviceinstaller on the host + */ + async "ideviceinstaller"(params) { + const { args } = params; + if (!Array.isArray(args)) { + return { success: false, error: "args must be an array" }; + } + + try { + const { stdout, stderr } = await execFileAsync("ideviceinstaller", args, { + timeout: COMMAND_TIMEOUT_MS + }); + return { success: true, stdout, stderr }; + } catch (error) { + return { success: false, error: error.message, stderr: error.stderr }; + } + }, + + /** + * Start iproxy tunnel on the host + */ + async "iproxy-start"(params) { + const { deviceId, localPort, devicePort } = params || {}; + if (!deviceId || !localPort) { + return { success: false, error: "deviceId and localPort are required" }; + } + + const targetPort = devicePort || localPort; + const key = `${deviceId}:${localPort}:${targetPort}`; + const existing = runningIproxyTunnels.get(key); + if (existing && isProcessRunning(existing.pid)) { + return { success: true, pid: existing.pid, message: "iproxy already running" }; + } + + try { + const child = spawn("iproxy", [String(localPort), String(targetPort), deviceId], { + stdio: ["ignore", "pipe", "pipe"] + }); + if (!child.pid) { + return { success: false, error: "Failed to start iproxy (no PID)" }; + } + + drainChildOutput(child); + runningIproxyTunnels.set(key, { pid: child.pid, process: child, deviceId, localPort, devicePort: targetPort }); + + child.on("exit", () => { + runningIproxyTunnels.delete(key); + }); + + return { success: true, pid: child.pid, message: "iproxy started" }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Stop iproxy tunnel on the host + */ + async "iproxy-stop"(params) { + const { pid, deviceId, localPort, devicePort } = params || {}; + let entry = null; + + if (pid) { + for (const value of runningIproxyTunnels.values()) { + if (value.pid === pid) { + entry = value; + break; + } + } + } else if (deviceId && localPort) { + const targetPort = devicePort || localPort; + const key = `${deviceId}:${localPort}:${targetPort}`; + entry = runningIproxyTunnels.get(key) || null; + } + + if (!entry) { + return { success: false, error: "iproxy process not found" }; + } + + try { + process.kill(entry.pid); + runningIproxyTunnels.forEach((value, key) => { + if (value.pid === entry.pid) { + runningIproxyTunnels.delete(key); + } + }); + return { success: true, message: `Stopped iproxy pid ${entry.pid}` }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Check iproxy status on the host + */ + async "iproxy-status"(params) { + const { pid, deviceId, localPort, devicePort } = params || {}; + let entry = null; + + if (pid) { + for (const value of runningIproxyTunnels.values()) { + if (value.pid === pid) { + entry = value; + break; + } + } + } else if (deviceId && localPort) { + const targetPort = devicePort || localPort; + const key = `${deviceId}:${localPort}:${targetPort}`; + entry = runningIproxyTunnels.get(key) || null; + } + + if (!entry) { + return { success: true, running: false }; + } + + const running = isProcessRunning(entry.pid); + if (!running) { + runningIproxyTunnels.forEach((value, key) => { + if (value.pid === entry.pid) { + runningIproxyTunnels.delete(key); + } + }); + return { success: true, running: false }; + } + + return { + success: true, + running: true, + pid: entry.pid, + deviceId: entry.deviceId, + localPort: entry.localPort, + devicePort: entry.devicePort + }; + }, + + /** + * Compute app bundle hash via devicectl on the host + */ + async "devicectl-app-hash"(params) { + if (!IS_MACOS) { + return { success: false, error: "devicectl is only available on macOS" }; + } + + const { deviceId, bundleId } = params || {}; + if (!deviceId || !bundleId) { + return { success: false, error: "deviceId and bundleId are required" }; + } + + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "automobile-devicectl-")); + const jsonPath = path.join(tempDir, "apps.json"); + let copyDir = null; + + try { + await execFileAsync("xcrun", [ + "devicectl", + "device", + "info", + "apps", + "--device", + deviceId, + "--bundle-id", + bundleId, + "--json-output", + jsonPath, + "--quiet" + ], { timeout: COMMAND_TIMEOUT_MS }); + + const raw = await fsp.readFile(jsonPath, "utf-8"); + const data = JSON.parse(raw); + const entry = findBundleEntry(data, bundleId); + if (!entry) { + return { success: true, hash: null }; + } + + const bundlePath = extractBundlePath(entry); + if (!bundlePath) { + return { success: true, hash: null }; + } + + copyDir = await fsp.mkdtemp(path.join(os.tmpdir(), "automobile-device-app-")); + await execFileAsync("xcrun", [ + "devicectl", + "device", + "copy", + "from", + "--device", + deviceId, + "--source", + bundlePath, + "--destination", + copyDir, + "--quiet" + ], { timeout: COMMAND_TIMEOUT_MS }); + + const bundleOnDisk = await findAppBundleInDir(copyDir); + if (!bundleOnDisk) { + return { success: true, hash: null }; + } + + const hash = await hashAppBundle(bundleOnDisk); + return { success: true, hash }; + } catch (error) { + return { success: false, error: error.message }; + } finally { + try { + await fsp.rm(tempDir, { recursive: true, force: true }); + } catch {} + if (copyDir) { + try { + await fsp.rm(copyDir, { recursive: true, force: true }); + } catch {} + } + } + }, + + /** + * Uninstall an app via devicectl on the host + */ + async "devicectl-uninstall"(params) { + if (!IS_MACOS) { + return { success: false, error: "devicectl is only available on macOS" }; + } + + const { deviceId, bundleId } = params || {}; + if (!deviceId || !bundleId) { + return { success: false, error: "deviceId and bundleId are required" }; + } + + try { + await execFileAsync("xcrun", [ + "devicectl", + "device", + "uninstall", + "app", + "--device", + deviceId, + bundleId, + "--quiet" + ], { timeout: COMMAND_TIMEOUT_MS }); + return { success: true, message: "App uninstalled" }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Start XCTestService via xcodebuild on the host + */ + async "xctest-start"(params) { + if (!IS_MACOS) { + return { success: false, error: "XCTestService is only available on macOS" }; + } + + const { deviceId, port, xctestrunPath, bundleId, timeoutSeconds } = params; + if (!deviceId || !port) { + return { success: false, error: "deviceId and port are required" }; + } + + const existing = runningXCTestServices.get(deviceId); + if (existing && isProcessRunning(existing.pid)) { + return { success: true, pid: existing.pid, message: "XCTestService already running" }; + } + + const resolvedXctestrunPath = findXctestrunPath(xctestrunPath); + const args = []; + if (resolvedXctestrunPath) { + args.push( + "test-without-building", + "-xctestrun", + resolvedXctestrunPath + ); + } else { + const projectRoot = path.resolve(__dirname, "..", ".."); + const projectPath = path.join(projectRoot, "ios", "XCTestService", "XCTestService.xcodeproj"); + args.push( + "test", + "-project", + projectPath, + "-scheme", + "XCTestServiceApp" + ); + } + + args.push( + "-destination", + `id=${deviceId}`, + "-only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService", + `XCTESTSERVICE_PORT=${port}` + ); + + if (bundleId) { + args.push(`XCTESTSERVICE_BUNDLE_ID=${bundleId}`); + } + if (timeoutSeconds) { + args.push(`XCTESTSERVICE_TIMEOUT=${timeoutSeconds}`); + } + + try { + const child = spawn("xcodebuild", args, { stdio: ["ignore", "pipe", "pipe"] }); + if (!child.pid) { + return { success: false, error: "Failed to start xcodebuild (no PID)" }; + } + + drainChildOutput(child); + + const entry = { pid: child.pid, process: child, port, startedAt: Date.now(), deviceId }; + runningXCTestServices.set(deviceId, entry); + + child.on("exit", () => { + runningXCTestServices.delete(deviceId); + }); + + return { success: true, pid: child.pid, message: "XCTestService started" }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Stop XCTestService on the host + */ + async "xctest-stop"(params) { + if (!IS_MACOS) { + return { success: false, error: "XCTestService is only available on macOS" }; + } + + const { deviceId, pid } = params || {}; + let targetEntry = null; + if (deviceId && runningXCTestServices.has(deviceId)) { + targetEntry = runningXCTestServices.get(deviceId); + } else if (pid) { + for (const entry of runningXCTestServices.values()) { + if (entry.pid === pid) { + targetEntry = entry; + break; + } + } + } + + if (!targetEntry) { + return { success: false, error: "XCTestService process not found" }; + } + + try { + process.kill(targetEntry.pid); + runningXCTestServices.delete(deviceId || targetEntry.deviceId); + return { success: true, message: `Stopped XCTestService pid ${targetEntry.pid}` }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + + /** + * Check XCTestService status on the host + */ + async "xctest-status"(params) { + if (!IS_MACOS) { + return { success: false, error: "XCTestService is only available on macOS" }; + } + + const { deviceId, pid, port } = params || {}; + let entry = null; + + if (deviceId && runningXCTestServices.has(deviceId)) { + entry = runningXCTestServices.get(deviceId); + } else if (pid) { + for (const item of runningXCTestServices.values()) { + if (item.pid === pid) { + entry = item; + break; + } + } + } else if (port) { + for (const item of runningXCTestServices.values()) { + if (item.port === port) { + entry = item; + break; + } + } + } + + if (!entry) { + return { success: true, running: false }; + } + + const running = isProcessRunning(entry.pid); + if (!running) { + if (deviceId) { + runningXCTestServices.delete(deviceId); + } + return { success: true, running: false }; + } + + return { + success: true, + running: true, + pid: entry.pid, + port: entry.port, + deviceId: entry.deviceId, + startedAt: entry.startedAt + }; + }, + + /** + * Get iOS tooling info + */ + async "ios-info"() { + if (!IS_MACOS) { + return { success: false, error: "iOS tooling is only available on macOS", isMacOS: false }; + } + + const info = { success: true, isMacOS: true }; + + try { + const { stdout: xcodeVersion } = await execFileAsync("xcodebuild", ["-version"], { timeout: 5000 }); + info.xcodeVersion = xcodeVersion.trim(); + } catch { + info.xcodeVersion = "Not installed"; + } + + try { + const { stdout: simctlVersion } = await execFileAsync("xcrun", ["simctl", "--version"], { timeout: 5000 }); + info.simctlVersion = simctlVersion.trim(); + } catch { + info.simctlVersion = "Not available"; + } + + try { + const { stdout: developerDir } = await execFileAsync("xcode-select", ["-p"], { timeout: 5000 }); + info.developerDir = developerDir.trim(); + } catch { + info.developerDir = "Not set"; + } + + return info; + }, + + /** + * Ping for health check + */ + async "ping"() { + return { success: true, message: "pong", timestamp: Date.now(), isMacOS: IS_MACOS }; + } +}; + +// Handle a single request +async function handleRequest(data) { + try { + const request = JSON.parse(data); + const { id, method, params = {} } = request; + + if (!method || !handlers[method]) { + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` } + }; + } + + const result = await handlers[method](params); + return { + jsonrpc: "2.0", + id, + result + }; + } catch (error) { + return { + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: `Parse error: ${error.message}` } + }; + } +} + +// Start the server +const server = net.createServer(socket => { + console.log(`[${new Date().toISOString()}] Client connected from ${socket.remoteAddress}`); + + let buffer = ""; + + socket.on("data", async data => { + buffer += data.toString(); + + // Process complete lines (newline-delimited JSON) + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + + const response = await handleRequest(line); + socket.write(JSON.stringify(response) + "\n"); + } + }); + + socket.on("end", () => { + console.log(`[${new Date().toISOString()}] Client disconnected`); + }); + + socket.on("error", err => { + // Sanitize error message to prevent log injection + const safeMessage = String(err && err.message || "").replace(/[\r\n]/g, ""); + console.error(`[${new Date().toISOString()}] Socket error:`, safeMessage); + }); +}); + +server.listen(port, host, async () => { + const xcodeAvailable = await isXcodeAvailable(); + const iosStatus = xcodeAvailable ? "Available" : "Not available (macOS only)"; + + console.log(` +╔══════════════════════════════════════════════════════════════╗ +║ Auto-Mobile Host Control Daemon ║ +╠══════════════════════════════════════════════════════════════╣ +║ Listening on: ${host}:${port} +║ Android SDK: ${getAndroidSdk()} +║ iOS Tools: ${iosStatus} +╚══════════════════════════════════════════════════════════════╝ + +Android Commands: + - ping Health check + - list-avds List available Android Virtual Devices + - start-emulator Start an emulator (params: avd, headless, args) + - stop-emulator Stop an emulator (params: avd, deviceId) + - list-running List running Android emulators + - avdmanager Run avdmanager command (params: args) + - sdkmanager Run sdkmanager command (params: args) + - sdk-info Get Android SDK paths and info + +iOS Commands (macOS only): + - list-simulators List available iOS simulators + - list-running-simulators List booted iOS simulators + - boot-simulator Boot a simulator (params: udid) + - shutdown-simulator Shutdown a simulator (params: udid) + - simctl Run simctl command (params: args) + - xcrun Run xcrun command (params: args) + - xcodebuild Run xcodebuild command (params: args) + - xcode-select Run xcode-select command (params: args) + - security Run security command (params: args) + - idevice-id Run idevice_id command (params: args) + - ideviceinstaller Run ideviceinstaller command (params: args) + - iproxy-start Start iproxy tunnel (params: deviceId, localPort, devicePort) + - iproxy-stop Stop iproxy tunnel (params: pid or deviceId, localPort, devicePort) + - iproxy-status Check iproxy status (params: pid or deviceId, localPort, devicePort) + - devicectl-app-hash Compute device app hash (params: deviceId, bundleId) + - devicectl-uninstall Uninstall device app (params: deviceId, bundleId) + - xctest-start Start XCTestService (params: deviceId, port, xctestrunPath, bundleId, timeoutSeconds) + - xctest-stop Stop XCTestService (params: deviceId or pid) + - xctest-status Check XCTestService status (params: deviceId, pid, port) + - ios-info Get iOS tooling info + +Press Ctrl+C to stop. +`); +}); + +// Handle shutdown +process.on("SIGINT", () => { + console.log("\nShutting down..."); + + // Kill any emulators we started + for (const [avd, { pid }] of runningEmulators) { + try { + process.kill(pid, "SIGTERM"); + console.log(`Stopped emulator ${avd} (pid ${pid})`); + } catch { + // Ignore errors + } + } + + // Kill any XCTestService processes we started + for (const [deviceId, { pid }] of runningXCTestServices) { + try { + process.kill(pid, "SIGTERM"); + console.log(`Stopped XCTestService for ${deviceId} (pid ${pid})`); + } catch { + // Ignore errors + } + } + + // Kill any iproxy tunnels we started + for (const { pid, deviceId } of runningIproxyTunnels.values()) { + try { + process.kill(pid, "SIGTERM"); + console.log(`Stopped iproxy for ${sanitizeForLog(deviceId || "unknown device")} (pid ${pid})`); + } catch { + // Ignore errors + } + } + + server.close(() => { + console.log("Server closed"); + process.exit(0); + }); +}); diff --git a/scripts/docker/test_container.sh b/scripts/docker/test_container.sh new file mode 100755 index 000000000..7a6cb404a --- /dev/null +++ b/scripts/docker/test_container.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Container structure and functionality tests + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +IMAGE_NAME="${IMAGE_NAME:-auto-mobile:latest}" +CONTAINER_NAME="auto-mobile-test-$$" +STDIO_CONTAINER_NAME="${CONTAINER_NAME}-stdio" +DOCKER_PLATFORM="${DOCKER_PLATFORM:-linux/amd64}" + +# Cleanup function +cleanup() { + echo -e "${YELLOW}Cleaning up test container...${NC}" + docker rm -f "${CONTAINER_NAME}" 2> /dev/null || true + docker rm -f "${STDIO_CONTAINER_NAME}" 2> /dev/null || true +} +trap cleanup EXIT + +echo -e "${GREEN}Testing Docker image: ${IMAGE_NAME}${NC}" + +# Test 1: Image exists +echo -e "\n${YELLOW}Test 1: Checking if image exists...${NC}" +if docker image inspect "${IMAGE_NAME}" &> /dev/null; then + echo -e "${GREEN}✓ Image exists${NC}" +else + echo -e "${RED}✗ Image not found. Build it first with: docker build -t ${IMAGE_NAME} .${NC}" + exit 1 +fi + +# Test 2: Container starts successfully +echo -e "\n${YELLOW}Test 2: Starting container...${NC}" +if docker run --platform "${DOCKER_PLATFORM}" -d --name "${CONTAINER_NAME}" "${IMAGE_NAME}" sleep 300; then + echo -e "${GREEN}✓ Container started${NC}" +else + echo -e "${RED}✗ Failed to start container${NC}" + exit 1 +fi + +# Test 3: Bun is installed with correct version +echo -e "\n${YELLOW}Test 3: Checking Bun version...${NC}" +BUN_VERSION=$(docker exec "${CONTAINER_NAME}" bun --version) +if [[ "${BUN_VERSION}" =~ ^1\.3\. ]]; then + echo -e "${GREEN}✓ Bun ${BUN_VERSION} is installed${NC}" +else + echo -e "${RED}✗ Expected Bun 1.3.x, got ${BUN_VERSION}${NC}" + exit 1 +fi + +# Test 4: Java is installed +echo -e "\n${YELLOW}Test 4: Checking Java version...${NC}" +JAVA_OUTPUT=$(docker exec "${CONTAINER_NAME}" java -version 2>&1) +if echo "${JAVA_OUTPUT}" | grep -q "openjdk.*21"; then + echo -e "${GREEN}✓ Java 21 is installed${NC}" +else + echo -e "${RED}✗ Java 21 not found${NC}" + exit 1 +fi + +# Test 5: Android SDK is installed +echo -e "\n${YELLOW}Test 5: Checking Android SDK...${NC}" +if docker exec "${CONTAINER_NAME}" test -d /opt/android-sdk; then + echo -e "${GREEN}✓ Android SDK directory exists${NC}" +else + echo -e "${RED}✗ Android SDK not found${NC}" + exit 1 +fi + +# Test 6: ADB is available +echo -e "\n${YELLOW}Test 6: Checking ADB...${NC}" +if docker exec "${CONTAINER_NAME}" which adb &> /dev/null; then + ADB_VERSION=$(docker exec "${CONTAINER_NAME}" adb version | head -1) + echo -e "${GREEN}✓ ADB is available: ${ADB_VERSION}${NC}" +else + echo -e "${RED}✗ ADB not found${NC}" + exit 1 +fi + +# Test 7: Development tools are installed +echo -e "\n${YELLOW}Test 7: Checking development tools...${NC}" +TOOLS=("rg" "ktfmt" "lychee" "shellcheck" "xmlstarlet" "jq") +ALL_TOOLS_OK=true +for tool in "${TOOLS[@]}"; do + if docker exec "${CONTAINER_NAME}" which "${tool}" &> /dev/null; then + echo -e "${GREEN} ✓ ${tool} is installed${NC}" + else + echo -e "${RED} ✗ ${tool} not found${NC}" + ALL_TOOLS_OK=false + fi +done + +if [ "${ALL_TOOLS_OK}" = false ]; then + exit 1 +fi + +# Test 8: Application is built +echo -e "\n${YELLOW}Test 8: Checking if application is built...${NC}" +if docker exec "${CONTAINER_NAME}" test -f /workspace/dist/src/index.js; then + echo -e "${GREEN}✓ Application build exists${NC}" +else + echo -e "${RED}✗ Application build not found${NC}" + exit 1 +fi + +# Test 9: Non-root user +echo -e "\n${YELLOW}Test 9: Checking user configuration...${NC}" +CURRENT_USER=$(docker exec "${CONTAINER_NAME}" whoami) +if [ "${CURRENT_USER}" = "automobile" ]; then + echo -e "${GREEN}✓ Container runs as non-root user: ${CURRENT_USER}${NC}" +else + echo -e "${RED}✗ Container should run as 'automobile', but running as: ${CURRENT_USER}${NC}" + exit 1 +fi + +# Test 10: Tini is installed and used as entrypoint +echo -e "\n${YELLOW}Test 10: Checking init system...${NC}" +if docker exec "${CONTAINER_NAME}" test -f /usr/local/bin/tini; then + echo -e "${GREEN}✓ Tini is installed${NC}" +else + echo -e "${RED}✗ Tini not found${NC}" + exit 1 +fi + +# Test 11: MCP server stdio communication +echo -e "\n${YELLOW}Test 11: Testing MCP stdio protocol...${NC}" +# Create a simple initialize request +INIT_REQUEST='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' +STDIO_TIMEOUT_SECONDS=15 +STDIO_TEST_OUTPUT="$(mktemp)" +STDIO_TEST_ERROR="$(mktemp)" + +# Test stdio communication (run container with -i flag, send request, check for response) +STDIO_EXIT_CODE=0 +if ! timeout "${STDIO_TIMEOUT_SECONDS}"s docker run --platform "${DOCKER_PLATFORM}" -i --rm \ + --name "${STDIO_CONTAINER_NAME}" "${IMAGE_NAME}" <<< "${INIT_REQUEST}" \ + > "${STDIO_TEST_OUTPUT}" 2> "${STDIO_TEST_ERROR}"; then + STDIO_EXIT_CODE=$? +fi +RESPONSE=$(head -1 "${STDIO_TEST_OUTPUT}" || true) + +if [ "${STDIO_EXIT_CODE}" -ne 0 ]; then + if [ "${STDIO_EXIT_CODE}" -eq 124 ]; then + echo -e "${YELLOW}⚠ MCP stdio test timed out after ${STDIO_TIMEOUT_SECONDS}s${NC}" + else + echo -e "${YELLOW}⚠ MCP stdio test exited with status ${STDIO_EXIT_CODE}${NC}" + fi + + if docker ps -a --format '{{.Names}}' | grep -q "^${STDIO_CONTAINER_NAME}$"; then + echo -e "${YELLOW} Container ${STDIO_CONTAINER_NAME} is still present; recent logs:${NC}" + docker logs --tail 50 "${STDIO_CONTAINER_NAME}" || true + fi + + echo -e "${YELLOW} Stdout (first 5 lines):${NC}" + sed -n '1,5p' "${STDIO_TEST_OUTPUT}" + echo -e "${YELLOW} Stderr (first 5 lines):${NC}" + sed -n '1,5p' "${STDIO_TEST_ERROR}" +fi + +if echo "${RESPONSE}" | grep -q '"jsonrpc":"2.0"'; then + echo -e "${GREEN}✓ MCP server responds to stdio protocol${NC}" + echo -e " Response: ${RESPONSE:0:100}..." +else + echo -e "${YELLOW}⚠ MCP stdio test inconclusive (server may need initialization time)${NC}" + echo -e " Response: ${RESPONSE}" +fi + +rm -f "${STDIO_TEST_OUTPUT}" "${STDIO_TEST_ERROR}" + +# Test 12: Android SDK components +echo -e "\n${YELLOW}Test 12: Verifying Android SDK components...${NC}" +SDK_COMPONENTS=("platform-tools" "build-tools;35.0.0" "platforms;android-36") +ALL_COMPONENTS_OK=true +SDK_LIST=$(docker exec "${CONTAINER_NAME}" sdkmanager --list_installed 2> /dev/null) +for component in "${SDK_COMPONENTS[@]}"; do + if echo "${SDK_LIST}" | grep -q "${component}"; then + echo -e "${GREEN} ✓ ${component} is installed${NC}" + else + echo -e "${RED} ✗ ${component} not found${NC}" + ALL_COMPONENTS_OK=false + fi +done + +if [ "${ALL_COMPONENTS_OK}" = false ]; then + exit 1 +fi + +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}All tests passed! ✓${NC}" +echo -e "${GREEN}========================================${NC}" diff --git a/scripts/docker/test_host_control_android.sh b/scripts/docker/test_host_control_android.sh new file mode 100755 index 000000000..e392e11a4 --- /dev/null +++ b/scripts/docker/test_host_control_android.sh @@ -0,0 +1,758 @@ +#!/usr/bin/env bash +# Validate that the slim Docker image can use host-installed emulators via MCP. + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +IMAGE_NAME="${IMAGE_NAME:-auto-mobile:latest}" +DOCKER_PLATFORM="${DOCKER_PLATFORM:-linux/amd64}" +MCP_PROTOCOL_VERSION="${MCP_PROTOCOL_VERSION:-2024-11-05}" +MCP_RESPONSE_TIMEOUT="${MCP_RESPONSE_TIMEOUT:-600}" +HOST_ANDROID_SDK="${HOST_ANDROID_SDK:-}" +HOST_ANDROID_SDK_MOUNT_MODE="${HOST_ANDROID_SDK_MOUNT_MODE:-rw}" +AUTOMOBILE_EMULATOR_HEADLESS="${AUTOMOBILE_EMULATOR_HEADLESS:-true}" +AUTOMOBILE_EMULATOR_ARGS="${AUTOMOBILE_EMULATOR_ARGS:-}" +FORCE_DOCKER_BUILD="${FORCE_DOCKER_BUILD:-false}" +HOST_EMULATOR_ARGS="${HOST_EMULATOR_ARGS:-}" +HOST_EMULATOR_CONSOLE_PORT="${HOST_EMULATOR_CONSOLE_PORT:-5554}" +HOST_EMULATOR_ADB_PORT="${HOST_EMULATOR_ADB_PORT:-}" +HOST_GATEWAY="${HOST_GATEWAY:-host.docker.internal}" +CONTAINER_DEVICE_ID="" +STARTED_HOST_EMULATOR="false" +ACTIVE_DEVICE_ID="" + +# ADB Server Tunnel Mode - connect to host's ADB server instead of direct device ports +# Set USE_ADB_SERVER_TUNNEL=true to enable +USE_ADB_SERVER_TUNNEL="${USE_ADB_SERVER_TUNNEL:-false}" +ADB_SERVER_PORT="${ADB_SERVER_PORT:-5037}" + +# Host Control Daemon - for emulator start/stop from container +HOST_CONTROL_PORT="${HOST_CONTROL_PORT:-15037}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CONTAINER_NAME="auto-mobile-host-emulator-test-$$" +PIPE_DIR="" +MCP_INPUT_PIPE="" +MCP_OUTPUT_PIPE="" + +cleanup() { + echo -e "${YELLOW}Cleaning up test container...${NC}" + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + if [[ -n "${MCP_PID:-}" ]]; then + kill "${MCP_PID}" >/dev/null 2>&1 || true + wait "${MCP_PID}" >/dev/null 2>&1 || true + fi + if [[ "${STARTED_HOST_EMULATOR}" == "true" && -n "${HOST_EMULATOR_PID:-}" ]]; then + kill "${HOST_EMULATOR_PID}" >/dev/null 2>&1 || true + fi + exec 3>&- || true + exec 4>&- || true + if [[ -n "${PIPE_DIR}" ]]; then + rm -rf "${PIPE_DIR}" || true + fi +} +trap cleanup EXIT + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo -e "${RED}Missing required command: $1${NC}" >&2 + exit 1 + fi +} + +encode_uri_component() { + jq -nr --arg value "$1" '$value|@uri' +} + +detect_host_sdk() { + if [[ -n "${HOST_ANDROID_SDK}" ]]; then + echo "${HOST_ANDROID_SDK}" + return 0 + fi + if [[ -n "${ANDROID_SDK_ROOT:-}" && -d "${ANDROID_SDK_ROOT}" ]]; then + echo "${ANDROID_SDK_ROOT}" + return 0 + fi + if [[ -n "${ANDROID_HOME:-}" && -d "${ANDROID_HOME}" ]]; then + echo "${ANDROID_HOME}" + return 0 + fi + if [[ -d "${HOME}/Library/Android/sdk" ]]; then + echo "${HOME}/Library/Android/sdk" + return 0 + fi + if [[ -d "${HOME}/Android/Sdk" ]]; then + echo "${HOME}/Android/Sdk" + return 0 + fi + return 1 +} + +require_command docker +require_command jq + +HOST_OS="$(uname -s)" +IS_LINUX="false" +IS_DARWIN="false" +if [[ "${HOST_OS}" == "Linux" ]]; then + IS_LINUX="true" +elif [[ "${HOST_OS}" == "Darwin" ]]; then + IS_DARWIN="true" +else + echo -e "${RED}Unsupported host OS: ${HOST_OS}.${NC}" >&2 + exit 1 +fi + +if [[ -z "${HOST_EMULATOR_ADB_PORT}" ]]; then + HOST_EMULATOR_ADB_PORT="$((HOST_EMULATOR_CONSOLE_PORT + 1))" +fi + +HOST_ANDROID_SDK="$(detect_host_sdk || true)" + +HOST_ADB_BIN="" +if [[ -n "${HOST_ANDROID_SDK}" && -x "${HOST_ANDROID_SDK}/platform-tools/adb" ]]; then + HOST_ADB_BIN="${HOST_ANDROID_SDK}/platform-tools/adb" +elif command -v adb >/dev/null 2>&1; then + HOST_ADB_BIN="$(command -v adb)" +fi + +HOST_EMULATOR_BIN="" +if [[ -n "${HOST_ANDROID_SDK}" && -x "${HOST_ANDROID_SDK}/emulator/emulator" ]]; then + HOST_EMULATOR_BIN="${HOST_ANDROID_SDK}/emulator/emulator" +elif command -v emulator >/dev/null 2>&1; then + HOST_EMULATOR_BIN="$(command -v emulator)" +fi + +if [[ ! -d "${HOME}/.android/avd" ]]; then + echo -e "${RED}No AVDs found at ${HOME}/.android/avd.${NC}" >&2 + echo -e "${YELLOW}Create one with: avdmanager create avd ...${NC}" >&2 + exit 1 +fi + +if ! ls "${HOME}/.android/avd"/*.ini >/dev/null 2>&1; then + echo -e "${RED}No AVD definitions (*.ini) found in ${HOME}/.android/avd.${NC}" >&2 + exit 1 +fi + +if [[ "${IS_LINUX}" == "true" && ( -z "${HOST_ANDROID_SDK}" || ! -d "${HOST_ANDROID_SDK}" ) ]]; then + echo -e "${RED}Android SDK not found. Set HOST_ANDROID_SDK or ANDROID_SDK_ROOT.${NC}" >&2 + exit 1 +fi + +if [[ "${IS_LINUX}" == "true" && ! -x "${HOST_ANDROID_SDK}/emulator/emulator" ]]; then + echo -e "${RED}Emulator binary not found at ${HOST_ANDROID_SDK}/emulator/emulator.${NC}" >&2 + echo -e "${YELLOW}Install it with: sdkmanager --install emulator${NC}" >&2 + exit 1 +fi + +if [[ "${IS_DARWIN}" == "true" && -z "${HOST_EMULATOR_BIN}" ]]; then + echo -e "${RED}Host emulator binary not found. Install Android Emulator via Android Studio or SDK Manager.${NC}" >&2 + exit 1 +fi + +image_missing="false" +if ! docker image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then + image_missing="true" +fi + +if [[ "${FORCE_DOCKER_BUILD}" == "true" || "${image_missing}" == "true" ]]; then + if [[ "${image_missing}" == "true" ]]; then + echo -e "${YELLOW}Image ${IMAGE_NAME} not found; building without emulator...${NC}" + else + echo -e "${YELLOW}Rebuilding image ${IMAGE_NAME} without emulator...${NC}" + fi + docker build --platform "${DOCKER_PLATFORM}" \ + --build-arg ANDROID_INSTALL_EMULATOR=false \ + -t "${IMAGE_NAME}" \ + "${PROJECT_ROOT}" +else + image_id="$(docker image inspect --format '{{.Id}}' "${IMAGE_NAME}" 2>/dev/null || true)" + image_created="$(docker image inspect --format '{{.Created}}' "${IMAGE_NAME}" 2>/dev/null || true)" + echo -e "${GREEN}Using existing image ${IMAGE_NAME}${NC} ${image_id} ${image_created}" +fi + +echo -e "${GREEN}Starting MCP server container: ${IMAGE_NAME}${NC}" + +PIPE_DIR="$(mktemp -d)" +MCP_INPUT_PIPE="${PIPE_DIR}/mcp_in" +MCP_OUTPUT_PIPE="${PIPE_DIR}/mcp_out" +mkfifo "${MCP_INPUT_PIPE}" "${MCP_OUTPUT_PIPE}" +exec 3<> "${MCP_INPUT_PIPE}" +exec 4<> "${MCP_OUTPUT_PIPE}" + +docker_args=(--platform "${DOCKER_PLATFORM}" -i --rm --name "${CONTAINER_NAME}") +if [[ "${IS_LINUX}" == "true" ]]; then + docker_args+=(--network host) + docker_args+=(-v "${HOST_ANDROID_SDK}:/opt/android-sdk:${HOST_ANDROID_SDK_MOUNT_MODE}") +fi +docker_args+=( + -e ANDROID_HOME=/opt/android-sdk + -e ANDROID_SDK_ROOT=/opt/android-sdk + -e AUTOMOBILE_EMULATOR_HEADLESS="${AUTOMOBILE_EMULATOR_HEADLESS}" +) +if [[ -n "${AUTOMOBILE_EMULATOR_ARGS}" ]]; then + docker_args+=(-e "AUTOMOBILE_EMULATOR_ARGS=${AUTOMOBILE_EMULATOR_ARGS}") +fi +if [[ "${IS_DARWIN}" == "true" ]]; then + docker_args+=(-e "AUTOMOBILE_EMULATOR_EXTERNAL=true") + # ADB Server Tunnel Mode - connect to host's ADB server for full ADB support + if [[ "${USE_ADB_SERVER_TUNNEL}" == "true" ]]; then + docker_args+=(-e "AUTOMOBILE_ADB_SERVER_HOST=${HOST_GATEWAY}") + docker_args+=(-e "AUTOMOBILE_ADB_SERVER_PORT=${ADB_SERVER_PORT}") + echo -e "${GREEN}Using ADB server tunnel to ${HOST_GATEWAY}:${ADB_SERVER_PORT}${NC}" + fi + # Host Control Daemon - for emulator management from container + docker_args+=(-e "AUTOMOBILE_HOST_CONTROL_HOST=${HOST_GATEWAY}") + docker_args+=(-e "AUTOMOBILE_HOST_CONTROL_PORT=${HOST_CONTROL_PORT}") +fi +docker_args+=( + -v "${HOME}/.android:/home/automobile/.android:rw" + -v "${HOME}/.auto-mobile:/home/automobile/.auto-mobile:rw" + "${IMAGE_NAME}" +) + +docker run "${docker_args[@]}" < "${MCP_INPUT_PIPE}" > "${MCP_OUTPUT_PIPE}" 2>&1 & +MCP_PID=$! + +send_request() { + printf '%s\n' "$1" >&3 +} + +read_for_id() { + local target_id="$1" + local start_time="${SECONDS}" + local line + + while true; do + if IFS= read -r -t 1 -u 4 line; then + if [[ -z "${line}" ]]; then + continue + fi + if ! echo "${line}" | jq -e . >/dev/null 2>&1; then + echo -e "${YELLOW}Ignoring non-JSON output: ${line}${NC}" >&2 + continue + fi + local id + id="$(echo "${line}" | jq -r '.id // empty')" + if [[ "${id}" == "${target_id}" ]]; then + echo "${line}" + return 0 + fi + if echo "${line}" | jq -e '.method == "progress"' >/dev/null 2>&1; then + local msg + msg="$(echo "${line}" | jq -r '.params.message // empty')" + if [[ -n "${msg}" ]]; then + echo -e "${YELLOW}progress: ${msg}${NC}" >&2 + fi + fi + else + if (( SECONDS - start_time > MCP_RESPONSE_TIMEOUT )); then + echo -e "${RED}Timed out waiting for response id ${target_id}.${NC}" >&2 + return 1 + fi + fi + done +} + +LAST_RESOURCE_ERROR_MESSAGE="" +LAST_RESOURCE_RESPONSE="" + +read_resource_text() { + local uri="$1" + local request_id="$2" + local request + request=$(jq -c -n --arg uri "${uri}" \ + '{"jsonrpc":"2.0","id":'"${request_id}"',"method":"resources/read","params":{"uri":$uri}}') + send_request "${request}" + local response + response="$(read_for_id "${request_id}")" + + LAST_RESOURCE_RESPONSE="${response}" + LAST_RESOURCE_ERROR_MESSAGE="$(echo "${response}" | jq -r '.error.message // empty')" + + if echo "${response}" | jq -e '.error' >/dev/null 2>&1; then + return 1 + fi + + local payload + payload="$(echo "${response}" | jq -r '.result.contents[0].text')" + if [[ -z "${payload}" || "${payload}" == "null" ]]; then + return 1 + fi + if echo "${payload}" | jq -e '.error' >/dev/null 2>&1; then + LAST_RESOURCE_ERROR_MESSAGE="resource payload error" + return 1 + fi + + echo "${payload}" + return 0 +} + +start_host_emulator() { + local avd="$1" + local log_file + local args=("-avd" "${avd}" "-port" "${HOST_EMULATOR_CONSOLE_PORT}") + if [[ "${AUTOMOBILE_EMULATOR_HEADLESS}" == "true" ]]; then + args+=("-no-window" "-no-audio") + fi + if [[ -n "${HOST_EMULATOR_ARGS}" ]]; then + local host_args=() + read -r -a host_args <<< "${HOST_EMULATOR_ARGS}" + args+=("${host_args[@]}") + fi + + log_file="$(mktemp)" + "${HOST_EMULATOR_BIN}" "${args[@]}" >"${log_file}" 2>&1 & + HOST_EMULATOR_PID=$! + + sleep 2 + if ! kill -0 "${HOST_EMULATOR_PID}" >/dev/null 2>&1; then + if grep -q "Running multiple emulators with the same AVD" "${log_file}"; then + echo -e "${YELLOW}Host emulator already running; continuing.${NC}" + STARTED_HOST_EMULATOR="false" + return 0 + fi + echo -e "${RED}Host emulator failed to start. Log: ${log_file}${NC}" >&2 + tail -n 40 "${log_file}" >&2 || true + return 1 + fi + STARTED_HOST_EMULATOR="true" + return 0 +} + +find_running_emulator_for_avd() { + local avd="$1" + if [[ -z "${HOST_ADB_BIN}" ]]; then + return 1 + fi + + local serials + serials="$("${HOST_ADB_BIN}" devices | awk 'NR>1 {print $1}' | grep '^emulator-' || true)" + if [[ -z "${serials}" ]]; then + return 1 + fi + + local serial + while read -r serial; do + if [[ -z "${serial}" ]]; then + continue + fi + local name + name="$("${HOST_ADB_BIN}" -s "${serial}" emu avd name 2>/dev/null | head -1 | tr -d '\r')" + if [[ "${name}" == "${avd}" ]]; then + echo "${serial}" + return 0 + fi + done <<< "${serials}" + + return 1 +} + +derive_ports_from_serial() { + local serial="$1" + local console_port="${serial#emulator-}" + if [[ -n "${console_port}" && "${console_port}" =~ ^[0-9]+$ ]]; then + HOST_EMULATOR_CONSOLE_PORT="${console_port}" + HOST_EMULATOR_ADB_PORT="$((console_port + 1))" + fi +} + +wait_for_auto_connect() { + # Wait for the container's auto-connect service to detect the host emulator + local target="$1" + local attempts=30 + local delay=2 + + echo -e "${YELLOW}Waiting for auto-connect to detect host emulator...${NC}" + for ((i=1; i<=attempts; i++)); do + if docker exec --user automobile "${CONTAINER_NAME}" adb devices 2>/dev/null | grep -q "${target}"; then + echo -e "${GREEN}Auto-connect detected host emulator: ${target}${NC}" + return 0 + fi + sleep "${delay}" + done + + echo -e "${YELLOW}Auto-connect did not detect device; falling back to manual connection${NC}" + return 1 +} + +connect_container_to_emulator() { + local target="$1" + local attempts=30 + local delay=2 + + # First try to wait for auto-connect (enabled via AUTOMOBILE_EMULATOR_EXTERNAL=true) + if wait_for_auto_connect "${target}"; then + return 0 + fi + + # Fallback to manual connection if auto-connect doesn't work + echo -e "${YELLOW}Attempting manual ADB connection to ${target}...${NC}" + for ((i=1; i<=attempts; i++)); do + if docker exec --user automobile "${CONTAINER_NAME}" adb connect "${target}" >/dev/null 2>&1; then + if docker exec --user automobile "${CONTAINER_NAME}" adb devices | grep -q "${target}"; then + echo -e "${GREEN}Manually connected container ADB to ${target}${NC}" + return 0 + fi + fi + sleep "${delay}" + done + + echo -e "${RED}Failed to connect container ADB to ${target}.${NC}" >&2 + return 1 +} + +wait_for_container_device() { + local device_id="$1" + local attempts=60 + local delay=2 + + for ((i=1; i<=attempts; i++)); do + if docker exec --user automobile "${CONTAINER_NAME}" adb -s "${device_id}" get-state >/dev/null 2>&1; then + return 0 + fi + sleep "${delay}" + done + + echo -e "${RED}Device ${device_id} did not become ready in time.${NC}" >&2 + return 1 +} + +read_booted_devices_resource() { + local uri="automobile:devices/booted/android" + local request + request=$(jq -c -n --arg uri "${uri}" \ + '{"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":$uri}}') + send_request "${request}" + local response + response="$(read_for_id 10)" + + if echo "${response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}booted devices resource read failed:${NC}" >&2 + echo "${response}" | jq . >&2 + return 1 + fi + + local payload + payload="$(echo "${response}" | jq -r '.result.contents[0].text')" + if [[ -z "${payload}" || "${payload}" == "null" ]]; then + echo -e "${RED}booted devices resource did not return any content.${NC}" >&2 + echo "${response}" | jq . >&2 + return 1 + fi + if echo "${payload}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}booted devices resource returned an error:${NC}" >&2 + echo "${payload}" | jq . >&2 + return 1 + fi + + echo "${payload}" +} + +wait_for_booted_device() { + local attempts=6 + local delay=4 + local payload + local device_id + + for ((i=1; i<=attempts; i++)); do + payload="$(read_booted_devices_resource)" || true + device_id="$(echo "${payload:-}" | jq -r '.devices[0].deviceId // empty')" + if [[ -n "${device_id}" ]]; then + echo "${device_id}" + return 0 + fi + + if [[ "${i}" -lt "${attempts}" ]]; then + echo -e "${YELLOW}No booted devices reported yet; retrying in ${delay}s...${NC}" >&2 + sleep "${delay}" + fi + done + + echo -e "${RED}No booted devices reported by MCP after waiting.${NC}" >&2 + return 1 +} + +sanitize_device_id() { + local input="$1" + local cleaned + local device_id="" + cleaned="$(printf '%s' "${input}" | sed -E 's/\x1B\[[0-9;]*m//g')" + + while IFS= read -r line; do + line="${line//$'\r'/}" + if [[ "${line}" =~ ^emulator-[0-9]+$ || "${line}" =~ ^[A-Za-z0-9._-]+:[0-9]+$ ]]; then + device_id="${line}" + fi + done <<< "${cleaned}" + + printf '%s' "${device_id}" +} + +set_active_device() { + local device_id="$1" + local attempts=3 + local delay=6 + local response + local error_msg + + for ((i=1; i<=attempts; i++)); do + local set_active_request + set_active_request=$(jq -c -n \ + --arg deviceId "${device_id}" \ + --arg platform "android" \ + '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"setActiveDevice","arguments":{"deviceId":$deviceId,"platform":$platform}}}') + send_request "${set_active_request}" + response="$(read_for_id 5)" + + if ! echo "${response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${GREEN}Active device set: ${device_id}${NC}" + return 0 + fi + + error_msg="$(echo "${response}" | jq -r '.error.message // empty')" + if [[ "${i}" -lt "${attempts}" ]] && [[ "${error_msg}" == *"not found"* || "${error_msg}" == *"Available android devices: none"* ]]; then + echo -e "${YELLOW}Active device not visible yet; retrying in ${delay}s...${NC}" >&2 + sleep "${delay}" + continue + fi + + echo -e "${RED}setActiveDevice failed:${NC}" >&2 + echo "${response}" | jq . >&2 + return 1 + done + + return 1 +} + +init_request=$(jq -c -n \ + --arg version "${MCP_PROTOCOL_VERSION}" \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":$version,"capabilities":{},"clientInfo":{"name":"docker-host-emulator-test","version":"1.0.0"}}}') +send_request "${init_request}" +read_for_id 1 >/dev/null + +# Run doctor diagnostics for Android +echo -e "${GREEN}Running doctor diagnostics...${NC}" +doctor_request=$(jq -c -n \ + '{"jsonrpc":"2.0","id":100,"method":"tools/call","params":{"name":"doctor","arguments":{"android":true}}}') +send_request "${doctor_request}" +doctor_response="$(read_for_id 100)" + +if echo "${doctor_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${YELLOW}doctor returned error:${NC}" + echo "${doctor_response}" | jq -r '.error.message // .error' >&2 +else + doctor_payload="$(echo "${doctor_response}" | jq -r '.result.content[0].text')" + total_checks="$(echo "${doctor_payload}" | jq -r '.summary.total // 0')" + passed_checks="$(echo "${doctor_payload}" | jq -r '.summary.passed // 0')" + failed_checks="$(echo "${doctor_payload}" | jq -r '.summary.failed // 0')" + warn_checks="$(echo "${doctor_payload}" | jq -r '.summary.warnings // 0')" + if [[ "${failed_checks}" -gt 0 ]]; then + echo -e "${RED}doctor: ${passed_checks}/${total_checks} passed, ${failed_checks} failed, ${warn_checks} warnings${NC}" + elif [[ "${warn_checks}" -gt 0 ]]; then + echo -e "${YELLOW}doctor: ${passed_checks}/${total_checks} passed, ${warn_checks} warnings${NC}" + else + echo -e "${GREEN}doctor: ${passed_checks}/${total_checks} passed${NC}" + fi + # Show any failed or warning checks + echo "${doctor_payload}" | jq -r ' + [.system.checks[]?, .android.checks[]?, .autoMobile.checks[]?] + | map(select(.status == "fail" or .status == "warn")) + | .[] + | " - \(.name): \(.status) - \(.message // "")" + ' 2>/dev/null || true +fi + +list_request=$(jq -c -n \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"listDeviceImages","arguments":{"platform":"android"}}}') +send_request "${list_request}" +list_response="$(read_for_id 2)" + +if echo "${list_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}listDeviceImages failed:${NC}" >&2 + echo "${list_response}" | jq . >&2 + exit 1 +fi + +list_payload="$(echo "${list_response}" | jq -r '.result.content[0].text')" +avd_name="$(echo "${list_payload}" | jq -r '.images[0].name // empty')" + +if [[ -z "${avd_name}" || "${avd_name}" == "null" ]]; then + echo -e "${RED}No AVDs returned by listDeviceImages.${NC}" >&2 + echo "${list_payload}" | jq . >&2 + exit 1 +fi + +echo -e "${GREEN}Selected AVD: ${avd_name}${NC}" + +if [[ "${IS_DARWIN}" == "true" ]]; then + running_serial="$(find_running_emulator_for_avd "${avd_name}" || true)" + if [[ -n "${running_serial}" ]]; then + echo -e "${GREEN}Found running host emulator: ${running_serial}${NC}" + derive_ports_from_serial "${running_serial}" + else + echo -e "${YELLOW}Starting host emulator for AVD ${avd_name}...${NC}" + start_host_emulator "${avd_name}" + fi + connect_container_to_emulator "${HOST_GATEWAY}:${HOST_EMULATOR_ADB_PORT}" + CONTAINER_DEVICE_ID="${HOST_GATEWAY}:${HOST_EMULATOR_ADB_PORT}" + wait_for_container_device "${CONTAINER_DEVICE_ID}" + raw_device_id="$(wait_for_booted_device || true)" + ACTIVE_DEVICE_ID="$(sanitize_device_id "${raw_device_id}")" + if [[ -z "${ACTIVE_DEVICE_ID}" ]]; then + ACTIVE_DEVICE_ID="${CONTAINER_DEVICE_ID}" + fi +fi + +if [[ "${IS_DARWIN}" == "true" ]]; then + echo -e "${GREEN}Using emulator via ADB: ${CONTAINER_DEVICE_ID}${NC}" +else + start_request=$(jq -c -n \ + --arg name "${avd_name}" \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"startDevice","arguments":{"device":{"name":$name,"platform":"android"}}}}') + send_request "${start_request}" + + start_response="$(read_for_id 3)" + + if echo "${start_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}startDevice failed:${NC}" >&2 + echo "${start_response}" | jq . >&2 + exit 1 + fi + + start_payload="$(echo "${start_response}" | jq -r '.result.content[0].text')" + device_id="$(echo "${start_payload}" | jq -r '.deviceId // empty')" + + if [[ -z "${device_id}" || "${device_id}" == "null" ]]; then + echo -e "${RED}startDevice did not return a deviceId.${NC}" >&2 + echo "${start_payload}" | jq . >&2 + exit 1 + fi + + ACTIVE_DEVICE_ID="${device_id}" + echo -e "${GREEN}Emulator running: ${device_id}${NC}" +fi + +if [[ -z "${ACTIVE_DEVICE_ID}" ]]; then + echo -e "${RED}No active device ID available.${NC}" >&2 + exit 1 +fi + +set_active_device "${ACTIVE_DEVICE_ID}" + +apps_payload="" +apps_uri_raw="automobile:apps?deviceId=${ACTIVE_DEVICE_ID}&platform=android" +if ! apps_payload="$(read_resource_text "${apps_uri_raw}" 6)"; then + apps_uri_encoded="automobile:apps?deviceId=$(encode_uri_component "${ACTIVE_DEVICE_ID}")&platform=android" + if ! apps_payload="$(read_resource_text "${apps_uri_encoded}" 11)"; then + if [[ "${LAST_RESOURCE_ERROR_MESSAGE}" == *"Resource not found"* ]]; then + fallback_uri_raw="automobile:devices/${ACTIVE_DEVICE_ID}/apps" + if ! apps_payload="$(read_resource_text "${fallback_uri_raw}" 12)"; then + fallback_uri_encoded="automobile:devices/$(encode_uri_component "${ACTIVE_DEVICE_ID}")/apps" + if ! apps_payload="$(read_resource_text "${fallback_uri_encoded}" 13)"; then + echo -e "${RED}apps resource read failed:${NC}" >&2 + echo "${LAST_RESOURCE_RESPONSE}" | jq . >&2 + echo -e "${YELLOW}Apps resources are missing. Rebuild the image with FORCE_DOCKER_BUILD=true.${NC}" >&2 + exit 1 + fi + fi + else + echo -e "${RED}apps resource read failed:${NC}" >&2 + echo "${LAST_RESOURCE_RESPONSE}" | jq . >&2 + exit 1 + fi + fi +fi + +clock_app_id="$(echo "${apps_payload}" | jq -r ' + def packages: + if has("apps") then .apps + elif has("devices") and (.devices | length > 0) then .devices[0].apps + else [] end + | map(.packageName); + (packages | map(select(. == "com.android.deskclock" or . == "com.google.android.deskclock")) | .[0]) + // (packages | map(select(test("deskclock"; "i"))) | .[0]) + // (packages | map(select(test("clock"; "i"))) | .[0]) + // empty +')" + +if [[ -z "${clock_app_id}" || "${clock_app_id}" == "null" ]]; then + echo -e "${YELLOW}Clock app not found in apps resource; falling back to default package names.${NC}" >&2 + clock_app_id="com.android.deskclock" +fi + +echo -e "${GREEN}Clock app package: ${clock_app_id}${NC}" + +observe_request=$(jq -c -n \ + --arg platform "android" \ + '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"observe","arguments":{"platform":$platform}}}') +send_request "${observe_request}" +observe_response="$(read_for_id 7)" + +if echo "${observe_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}observe failed:${NC}" >&2 + echo "${observe_response}" | jq . >&2 + exit 1 +fi + +echo -e "${GREEN}observe succeeded.${NC}" + +launch_request=$(jq -c -n \ + --arg appId "${clock_app_id}" \ + '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"launchApp","arguments":{"appId":$appId}}}') +send_request "${launch_request}" +launch_response="$(read_for_id 8)" + +if echo "${launch_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}launchApp failed:${NC}" >&2 + echo "${launch_response}" | jq . >&2 + exit 1 +fi + +echo -e "${GREEN}Clock app launched.${NC}" + +terminate_request=$(jq -c -n \ + --arg appId "${clock_app_id}" \ + '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"terminateApp","arguments":{"appId":$appId}}}') +send_request "${terminate_request}" +terminate_response="$(read_for_id 9)" + +if echo "${terminate_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}terminateApp failed:${NC}" >&2 + echo "${terminate_response}" | jq . >&2 + exit 1 +fi + +echo -e "${GREEN}Clock app terminated.${NC}" + +if [[ "${IS_DARWIN}" == "true" ]]; then + # On Darwin, we connect to a host emulator via network (host.docker.internal:5555). + # killDevice uses 'adb emu kill' which requires console port access (5554), not available + # over the Docker network. Skip killing from within the container. + if [[ "${STARTED_HOST_EMULATOR}" == "true" ]]; then + echo -e "${YELLOW}Leaving host emulator running (cannot kill via network ADB).${NC}" + else + echo -e "${YELLOW}Leaving existing emulator running.${NC}" + fi +else + kill_request=$(jq -c -n \ + --arg name "${avd_name}" \ + --arg deviceId "${device_id}" \ + '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"killDevice","arguments":{"device":{"name":$name,"deviceId":$deviceId,"platform":"android"}}}}') + send_request "${kill_request}" + kill_response="$(read_for_id 4)" + + if echo "${kill_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${RED}killDevice failed:${NC}" >&2 + echo "${kill_response}" | jq . >&2 + exit 1 + fi + + echo -e "${GREEN}Emulator stopped successfully.${NC}" +fi diff --git a/scripts/docker/test_host_control_ios.sh b/scripts/docker/test_host_control_ios.sh new file mode 100755 index 000000000..142e63c13 --- /dev/null +++ b/scripts/docker/test_host_control_ios.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# Validate that the Docker image can control iOS simulators via the host control daemon. +# This test requires macOS with Xcode installed. + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +IMAGE_NAME="${IMAGE_NAME:-auto-mobile:latest}" +DOCKER_PLATFORM="${DOCKER_PLATFORM:-linux/amd64}" +MCP_PROTOCOL_VERSION="${MCP_PROTOCOL_VERSION:-2024-11-05}" +MCP_RESPONSE_TIMEOUT="${MCP_RESPONSE_TIMEOUT:-120}" +HOST_GATEWAY="${HOST_GATEWAY:-host.docker.internal}" +HOST_CONTROL_PORT="${HOST_CONTROL_PORT:-15037}" +FORCE_DOCKER_BUILD="${FORCE_DOCKER_BUILD:-false}" +BOOT_SIMULATOR="${BOOT_SIMULATOR:-false}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CONTAINER_NAME="auto-mobile-host-simulator-test-$$" +PIPE_DIR="" +MCP_INPUT_PIPE="" +MCP_OUTPUT_PIPE="" +DAEMON_PID="" +STARTED_DAEMON="false" +BOOTED_SIMULATOR_UDID="" + +cleanup() { + echo -e "${YELLOW}Cleaning up...${NC}" + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + if [[ -n "${MCP_PID:-}" ]]; then + kill "${MCP_PID}" >/dev/null 2>&1 || true + wait "${MCP_PID}" >/dev/null 2>&1 || true + fi + if [[ "${STARTED_DAEMON}" == "true" && -n "${DAEMON_PID}" ]]; then + echo -e "${YELLOW}Stopping host control daemon...${NC}" + kill "${DAEMON_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${BOOTED_SIMULATOR_UDID}" ]]; then + echo -e "${YELLOW}Shutting down simulator ${BOOTED_SIMULATOR_UDID}...${NC}" + xcrun simctl shutdown "${BOOTED_SIMULATOR_UDID}" >/dev/null 2>&1 || true + fi + exec 3>&- 2>/dev/null || true + exec 4>&- 2>/dev/null || true + if [[ -n "${PIPE_DIR}" ]]; then + rm -rf "${PIPE_DIR}" || true + fi +} +trap cleanup EXIT + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo -e "${RED}Missing required command: $1${NC}" >&2 + exit 1 + fi +} + +# Check requirements +require_command docker +require_command jq +require_command node + +# macOS only +if [[ "$(uname -s)" != "Darwin" ]]; then + echo -e "${RED}This test requires macOS with Xcode installed.${NC}" >&2 + exit 1 +fi + +# Check for Xcode +if ! command -v xcrun >/dev/null 2>&1; then + echo -e "${RED}Xcode command line tools not found. Install with: xcode-select --install${NC}" >&2 + exit 1 +fi + +# Check for simulators +if ! xcrun simctl list devices --json >/dev/null 2>&1; then + echo -e "${RED}Cannot list iOS simulators. Ensure Xcode is properly installed.${NC}" >&2 + exit 1 +fi + +# Check if Docker image exists +image_missing="false" +if ! docker image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then + image_missing="true" +fi + +if [[ "${FORCE_DOCKER_BUILD}" == "true" || "${image_missing}" == "true" ]]; then + if [[ "${image_missing}" == "true" ]]; then + echo -e "${YELLOW}Image ${IMAGE_NAME} not found; building...${NC}" + else + echo -e "${YELLOW}Rebuilding image ${IMAGE_NAME}...${NC}" + fi + docker build --platform "${DOCKER_PLATFORM}" \ + --build-arg ANDROID_INSTALL_EMULATOR=false \ + -t "${IMAGE_NAME}" \ + "${PROJECT_ROOT}" +else + image_id="$(docker image inspect --format '{{.Id}}' "${IMAGE_NAME}" 2>/dev/null || true)" + echo -e "${GREEN}Using existing image ${IMAGE_NAME}${NC} ${image_id}" +fi + +# Check if host control daemon is already running +if nc -z localhost "${HOST_CONTROL_PORT}" 2>/dev/null; then + echo -e "${GREEN}Host control daemon already running on port ${HOST_CONTROL_PORT}${NC}" +else + echo -e "${YELLOW}Starting host control daemon...${NC}" + node "${SCRIPT_DIR}/host-control-daemon.js" --port "${HOST_CONTROL_PORT}" & + DAEMON_PID=$! + STARTED_DAEMON="true" + sleep 2 + + if ! nc -z localhost "${HOST_CONTROL_PORT}" 2>/dev/null; then + echo -e "${RED}Failed to start host control daemon${NC}" >&2 + exit 1 + fi + echo -e "${GREEN}Host control daemon started on port ${HOST_CONTROL_PORT}${NC}" +fi + +# Test daemon iOS commands directly first +echo -e "${GREEN}Testing host control daemon iOS commands...${NC}" + +test_daemon_command() { + local method="$1" + local params="${2:-{}}" + local request + request=$(jq -c -n --arg method "${method}" --argjson params "${params}" \ + '{"jsonrpc":"2.0","id":1,"method":$method,"params":$params}') + + node -e " +const net = require('net'); +const client = new net.Socket(); +client.connect(${HOST_CONTROL_PORT}, 'localhost', () => { + client.write('${request}\n'); +}); +let buffer = ''; +client.on('data', (data) => { + buffer += data.toString(); + if (buffer.includes('\n')) { + console.log(buffer.trim()); + client.destroy(); + } +}); +client.setTimeout(10000, () => { + console.log('{\"error\":\"timeout\"}'); + client.destroy(); +}); +client.on('error', (err) => { + console.log(JSON.stringify({error: err.message})); +}); +" +} + +# Test ping +ping_result=$(test_daemon_command "ping") +if echo "${ping_result}" | jq -e '.result.isMacOS == true' >/dev/null 2>&1; then + echo -e "${GREEN} ping: OK (macOS detected)${NC}" +else + echo -e "${RED} ping: FAILED${NC}" >&2 + echo "${ping_result}" >&2 + exit 1 +fi + +# Test ios-info +ios_info_result=$(test_daemon_command "ios-info") +if echo "${ios_info_result}" | jq -e '.result.success == true' >/dev/null 2>&1; then + xcode_version=$(echo "${ios_info_result}" | jq -r '.result.xcodeVersion // "unknown"' | head -1) + echo -e "${GREEN} ios-info: OK (Xcode: ${xcode_version})${NC}" +else + echo -e "${RED} ios-info: FAILED${NC}" >&2 + echo "${ios_info_result}" >&2 + exit 1 +fi + +# Test list-simulators +simulators_result=$(test_daemon_command "list-simulators") +simulator_count=$(echo "${simulators_result}" | jq -r '.result.simulators | length') +if [[ "${simulator_count}" -gt 0 ]]; then + echo -e "${GREEN} list-simulators: OK (${simulator_count} simulators available)${NC}" +else + echo -e "${RED} list-simulators: FAILED (no simulators found)${NC}" >&2 + exit 1 +fi + +# First check for already running simulators +running_result=$(test_daemon_command "list-running-simulators") +running_count=$(echo "${running_result}" | jq -r '.result.simulators | length // 0') + +if [[ "${running_count}" -gt 0 ]]; then + # Prefer a running iPhone simulator + first_simulator=$(echo "${running_result}" | jq -r '.result.simulators | map(select(.name | contains("iPhone"))) | .[0] // .result.simulators[0]') + if [[ "${first_simulator}" != "null" && -n "${first_simulator}" ]]; then + simulator_udid=$(echo "${first_simulator}" | jq -r '.udid') + simulator_name=$(echo "${first_simulator}" | jq -r '.name') + simulator_state=$(echo "${first_simulator}" | jq -r '.state') + echo -e "${GREEN}Found running simulator: ${simulator_name} (${simulator_udid}) - ${simulator_state}${NC}" + else + # No running iPhone, use first running simulator + first_simulator=$(echo "${running_result}" | jq -r '.result.simulators[0]') + simulator_udid=$(echo "${first_simulator}" | jq -r '.udid') + simulator_name=$(echo "${first_simulator}" | jq -r '.name') + simulator_state=$(echo "${first_simulator}" | jq -r '.state') + echo -e "${GREEN}Found running simulator: ${simulator_name} (${simulator_udid}) - ${simulator_state}${NC}" + fi +else + # No running simulators, get first available iPhone simulator + first_simulator=$(echo "${simulators_result}" | jq -r '.result.simulators | map(select(.name | contains("iPhone"))) | .[0]') + simulator_udid=$(echo "${first_simulator}" | jq -r '.udid') + simulator_name=$(echo "${first_simulator}" | jq -r '.name') + simulator_state=$(echo "${first_simulator}" | jq -r '.state') + + echo -e "${YELLOW}No running simulators. Selected: ${simulator_name} (${simulator_udid}) - ${simulator_state}${NC}" + + # Optionally boot the simulator + if [[ "${BOOT_SIMULATOR}" == "true" && "${simulator_state}" == "Shutdown" ]]; then + echo -e "${YELLOW}Booting simulator ${simulator_name}...${NC}" + boot_result=$(test_daemon_command "boot-simulator" "{\"udid\":\"${simulator_udid}\"}") + if echo "${boot_result}" | jq -e '.result.success == true' >/dev/null 2>&1; then + echo -e "${GREEN} boot-simulator: OK${NC}" + BOOTED_SIMULATOR_UDID="${simulator_udid}" + # Wait for boot + sleep 5 + else + echo -e "${RED} boot-simulator: FAILED${NC}" >&2 + echo "${boot_result}" >&2 + fi + fi +fi + +# Now test via Docker container MCP +echo "" +echo -e "${GREEN}Testing iOS control via Docker MCP...${NC}" + +PIPE_DIR="$(mktemp -d)" +MCP_INPUT_PIPE="${PIPE_DIR}/mcp_in" +MCP_OUTPUT_PIPE="${PIPE_DIR}/mcp_out" +mkfifo "${MCP_INPUT_PIPE}" "${MCP_OUTPUT_PIPE}" +exec 3<> "${MCP_INPUT_PIPE}" +exec 4<> "${MCP_OUTPUT_PIPE}" + +docker_args=( + --platform "${DOCKER_PLATFORM}" + -i --rm + --name "${CONTAINER_NAME}" + -e "AUTOMOBILE_EMULATOR_EXTERNAL=true" + -e "AUTOMOBILE_HOST_CONTROL_HOST=${HOST_GATEWAY}" + -e "AUTOMOBILE_HOST_CONTROL_PORT=${HOST_CONTROL_PORT}" + -v "${HOME}/.android:/home/automobile/.android:rw" + -v "${HOME}/.auto-mobile:/home/automobile/.auto-mobile:rw" + "${IMAGE_NAME}" +) + +docker run "${docker_args[@]}" < "${MCP_INPUT_PIPE}" > "${MCP_OUTPUT_PIPE}" 2>&1 & +MCP_PID=$! + +send_request() { + printf '%s\n' "$1" >&3 +} + +read_for_id() { + local target_id="$1" + local start_time="${SECONDS}" + local line + + while true; do + if IFS= read -r -t 1 -u 4 line; then + if [[ -z "${line}" ]]; then + continue + fi + if ! echo "${line}" | jq -e . >/dev/null 2>&1; then + echo -e "${YELLOW}Ignoring non-JSON: ${line:0:100}...${NC}" >&2 + continue + fi + local id + id="$(echo "${line}" | jq -r '.id // empty')" + if [[ "${id}" == "${target_id}" ]]; then + echo "${line}" + return 0 + fi + else + if (( SECONDS - start_time > MCP_RESPONSE_TIMEOUT )); then + echo -e "${RED}Timeout waiting for response id ${target_id}${NC}" >&2 + return 1 + fi + fi + done +} + +# Initialize MCP +init_request=$(jq -c -n \ + --arg version "${MCP_PROTOCOL_VERSION}" \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":$version,"capabilities":{},"clientInfo":{"name":"docker-ios-test","version":"1.0.0"}}}') +send_request "${init_request}" +init_response=$(read_for_id 1) + +if echo "${init_response}" | jq -e '.result.serverInfo' >/dev/null 2>&1; then + server_name=$(echo "${init_response}" | jq -r '.result.serverInfo.name') + server_version=$(echo "${init_response}" | jq -r '.result.serverInfo.version') + echo -e "${GREEN}MCP initialized: ${server_name} v${server_version}${NC}" +else + echo -e "${RED}MCP initialization failed${NC}" >&2 + echo "${init_response}" >&2 + exit 1 +fi + +# Run doctor diagnostics for iOS +echo -e "${GREEN}Running doctor diagnostics...${NC}" +doctor_request=$(jq -c -n \ + '{"jsonrpc":"2.0","id":100,"method":"tools/call","params":{"name":"doctor","arguments":{"ios":true}}}') +send_request "${doctor_request}" +doctor_response="$(read_for_id 100)" + +if echo "${doctor_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${YELLOW}doctor returned error:${NC}" + echo "${doctor_response}" | jq -r '.error.message // .error' >&2 +else + doctor_payload="$(echo "${doctor_response}" | jq -r '.result.content[0].text')" + total_checks="$(echo "${doctor_payload}" | jq -r '.summary.total // 0')" + passed_checks="$(echo "${doctor_payload}" | jq -r '.summary.passed // 0')" + failed_checks="$(echo "${doctor_payload}" | jq -r '.summary.failed // 0')" + warn_checks="$(echo "${doctor_payload}" | jq -r '.summary.warnings // 0')" + if [[ "${failed_checks}" -gt 0 ]]; then + echo -e "${RED}doctor: ${passed_checks}/${total_checks} passed, ${failed_checks} failed, ${warn_checks} warnings${NC}" + elif [[ "${warn_checks}" -gt 0 ]]; then + echo -e "${YELLOW}doctor: ${passed_checks}/${total_checks} passed, ${warn_checks} warnings${NC}" + else + echo -e "${GREEN}doctor: ${passed_checks}/${total_checks} passed${NC}" + fi + # Show any failed or warning checks + echo "${doctor_payload}" | jq -r ' + [.system.checks[]?, .ios.checks[]?, .autoMobile.checks[]?] + | map(select(.status == "fail" or .status == "warn")) + | .[] + | " - \(.name): \(.status) - \(.message // "")" + ' 2>/dev/null || true +fi + +# List device images (should include iOS simulators via host control) +list_request=$(jq -c -n \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"listDeviceImages","arguments":{"platform":"ios"}}}') +send_request "${list_request}" +list_response=$(read_for_id 2) + +if echo "${list_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${YELLOW}listDeviceImages for iOS returned error (expected if not implemented):${NC}" + echo "${list_response}" | jq -r '.error.message // .error' >&2 +else + list_payload=$(echo "${list_response}" | jq -r '.result.content[0].text // empty') + if [[ -n "${list_payload}" ]]; then + image_count=$(echo "${list_payload}" | jq -r '.images | length // 0') + echo -e "${GREEN}listDeviceImages: ${image_count} iOS images${NC}" + fi +fi + +# List booted devices +booted_request=$(jq -c -n \ + '{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"automobile:devices/booted/ios"}}') +send_request "${booted_request}" +booted_response=$(read_for_id 3) + +if echo "${booted_response}" | jq -e '.error' >/dev/null 2>&1; then + echo -e "${YELLOW}Booted iOS devices resource not available (expected if not implemented)${NC}" +else + booted_payload=$(echo "${booted_response}" | jq -r '.result.contents[0].text // empty') + if [[ -n "${booted_payload}" ]]; then + device_count=$(echo "${booted_payload}" | jq -r '.devices | length // 0') + echo -e "${GREEN}Booted iOS devices: ${device_count}${NC}" + fi +fi + +# ======================================================================== +# iOS Reminders App Exploration (only if a simulator is booted) +# ======================================================================== +if [[ "${simulator_state}" == "Booted" ]]; then + echo "" + echo -e "${GREEN}Testing iOS Reminders app exploration...${NC}" + + # Launch Reminders app + echo -e "${YELLOW}Launching Reminders app...${NC}" + launch_request=$(jq -c -n \ + --arg udid "${simulator_udid}" \ + '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"launchApp","arguments":{"appId":"com.apple.reminders","deviceId":$udid}}}') + send_request "${launch_request}" + launch_response=$(read_for_id 10) + + if echo "${launch_response}" | jq -e '.result.content[0].text' >/dev/null 2>&1; then + launch_payload=$(echo "${launch_response}" | jq -r '.result.content[0].text') + if echo "${launch_payload}" | jq -e '.observation' >/dev/null 2>&1; then + echo -e "${GREEN} launchApp: OK - Reminders launched${NC}" + else + echo -e "${YELLOW} launchApp: Returned but no observation${NC}" + echo "${launch_payload}" | jq -r '.message // .' | head -3 + fi + else + echo -e "${YELLOW} launchApp: Response format unexpected${NC}" + echo "${launch_response}" | jq -r '.error.message // .error // .' | head -3 + fi + + # Wait for app to fully launch + sleep 2 + + # Observe the screen + echo -e "${YELLOW}Observing screen...${NC}" + observe_request=$(jq -c -n \ + --arg udid "${simulator_udid}" \ + '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"observe","arguments":{"platform":"ios","deviceId":$udid}}}') + send_request "${observe_request}" + observe_response=$(read_for_id 11) + + if echo "${observe_response}" | jq -e '.result.content[0].text' >/dev/null 2>&1; then + observe_payload=$(echo "${observe_response}" | jq -r '.result.content[0].text') + + # Check for elements + clickable_count=$(echo "${observe_payload}" | jq -r '.elements.clickable | length // 0') + text_count=$(echo "${observe_payload}" | jq -r '.elements.text | length // 0') + active_app=$(echo "${observe_payload}" | jq -r '.activeWindow.appId // "unknown"') + screen_size=$(echo "${observe_payload}" | jq -r '"\(.screenSize.width)x\(.screenSize.height)"') + + echo -e "${GREEN} observe: OK${NC}" + echo -e "${GREEN} Active app: ${active_app}${NC}" + echo -e "${GREEN} Screen size: ${screen_size}${NC}" + echo -e "${GREEN} Clickable elements: ${clickable_count}${NC}" + echo -e "${GREEN} Text elements: ${text_count}${NC}" + + # Show some UI elements found + echo -e "${YELLOW} Sample UI elements:${NC}" + echo "${observe_payload}" | jq -r '.elements.clickable[:5][] | " - \(.label // .text // .id // "unnamed") [\(.type // "unknown")]"' 2>/dev/null || true + + # Try to tap on "Add List" or similar if available + add_button=$(echo "${observe_payload}" | jq -r '.elements.clickable[] | select(.label == "Add List" or .text == "Add List" or .label == "New Reminder" or .accessibilityLabel == "Add List") | .id // .label // .text' | head -1) + + if [[ -n "${add_button}" && "${add_button}" != "null" ]]; then + echo -e "${YELLOW} Found 'Add List' button, attempting tap...${NC}" + tap_request=$(jq -c -n \ + --arg udid "${simulator_udid}" \ + --arg text "Add List" \ + '{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"tapOn","arguments":{"text":$text,"platform":"ios","deviceId":$udid}}}') + send_request "${tap_request}" + tap_response=$(read_for_id 12) + + if echo "${tap_response}" | jq -e '.result.content[0].text' >/dev/null 2>&1; then + echo -e "${GREEN} tapOn: OK${NC}" + else + echo -e "${YELLOW} tapOn: ${tap_response}${NC}" + fi + fi + else + echo -e "${YELLOW} observe: Response format unexpected${NC}" + echo "${observe_response}" | jq -r '.error.message // .error // .' | head -3 + fi + + echo -e "${GREEN}iOS Reminders exploration complete${NC}" +else + echo -e "${YELLOW}Skipping Reminders exploration (no booted simulator)${NC}" +fi + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}iOS Host Control Integration Test PASSED${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "Summary:" +echo " - Host control daemon: Working" +echo " - iOS info: Detected" +echo " - Simulators available: ${simulator_count}" +echo " - Docker MCP: Connected" +if [[ "${simulator_state}" == "Booted" ]]; then + echo " - Reminders exploration: Attempted" +fi +echo "" diff --git a/scripts/docker/validate_dockerfile.sh b/scripts/docker/validate_dockerfile.sh new file mode 100755 index 000000000..cb3b77176 --- /dev/null +++ b/scripts/docker/validate_dockerfile.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Validate Dockerfile using hadolint + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if hadolint is installed +if ! command -v hadolint &>/dev/null; then + echo -e "${YELLOW}hadolint not found. Installing...${NC}" + + # Detect OS and architecture + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "${ARCH}" in + x86_64) + ARCH="x86_64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo -e "${RED}Unsupported architecture: ${ARCH}${NC}" + exit 1 + ;; + esac + + HADOLINT_VERSION="2.12.0" + DOWNLOAD_URL="https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-${OS^}-${ARCH}" + + echo "Downloading hadolint from: ${DOWNLOAD_URL}" + + # Download to temporary location + TMP_DIR=$(mktemp -d) + trap 'rm -rf "${TMP_DIR}"' EXIT + + if curl -sL "${DOWNLOAD_URL}" -o "${TMP_DIR}/hadolint"; then + # Install to user bin + mkdir -p "${HOME}/bin" + mv "${TMP_DIR}/hadolint" "${HOME}/bin/hadolint" + chmod +x "${HOME}/bin/hadolint" + + # Add to PATH if not already there + if [[ ":${PATH}:" != *":${HOME}/bin:"* ]]; then + export PATH="${HOME}/bin:${PATH}" + echo -e "${YELLOW}Added ${HOME}/bin to PATH${NC}" + echo -e "${YELLOW}Add 'export PATH=\"\${HOME}/bin:\${PATH}\"' to your shell profile${NC}" + fi + + echo -e "${GREEN}hadolint installed successfully${NC}" + else + echo -e "${RED}Failed to download hadolint${NC}" + exit 1 + fi +fi + +# Validate Dockerfile +echo -e "${GREEN}Validating Dockerfile...${NC}" + +if hadolint --config .hadolint.yaml Dockerfile; then + echo -e "${GREEN}✓ Dockerfile validation passed${NC}" + exit 0 +else + echo -e "${RED}✗ Dockerfile validation failed${NC}" + exit 1 +fi diff --git a/scripts/estimate-context-usage.ts b/scripts/estimate-context-usage.ts new file mode 100644 index 000000000..f1664c450 --- /dev/null +++ b/scripts/estimate-context-usage.ts @@ -0,0 +1,377 @@ +#!/usr/bin/env bun +/** + * Script to estimate MCP context usage across tool definitions, resources, and operations. + * + * Usage: + * bun scripts/estimate-context-usage.ts [--traces path/to/traces.json] + * + * Options: + * --traces Path to JSON file containing recorded operation traces + * + * Output: + * Prints a detailed report including: + * - Tool list definitions token count (with per-tool breakdown) + * - Resource list token count (with per-resource breakdown) + * - Operation traces token count (with per-operation breakdown, if provided) + * - Total estimated context usage + */ + +import { Tiktoken } from "js-tiktoken/lite"; +import cl100k_base from "js-tiktoken/ranks/cl100k_base"; +import { ToolRegistry } from "../src/server/toolRegistry"; +import { ResourceRegistry } from "../src/server/resourceRegistry"; + +// Import all tool registration functions to populate the registry +import { registerObserveTools } from "../src/server/observeTools"; +import { registerInteractionTools } from "../src/server/interactionTools"; +import { registerAppTools } from "../src/server/appTools"; +import { registerUtilityTools } from "../src/server/utilityTools"; +import { registerDeviceTools } from "../src/server/deviceTools"; +import { registerDeepLinkTools } from "../src/server/deepLinkTools"; +import { registerNavigationTools } from "../src/server/navigationTools"; +import { registerPlanTools } from "../src/server/planTools"; +import { registerDoctorTools } from "../src/server/doctorTools"; +import { registerFeatureFlagTools } from "../src/server/featureFlagTools"; + +// Import resource registration functions +import { registerObservationResources } from "../src/server/observationResources"; +import { registerBootedDeviceResources } from "../src/server/bootedDeviceResources"; +import { registerDeviceImageResources } from "../src/server/deviceImageResources"; +import { registerAppResources } from "../src/server/appResources"; +import { registerNavigationResources } from "../src/server/navigationResources"; + +import fs from "node:fs"; + +// Token encoder for Claude models (cl100k_base is used by Claude) +const tokenizer = new Tiktoken(cl100k_base); + +interface TokenEstimate { + text: string; + tokenCount: number; +} + +interface ToolEstimate extends TokenEstimate { + name: string; +} + +interface ResourceEstimate extends TokenEstimate { + uri: string; + name: string; +} + +interface OperationEstimate extends TokenEstimate { + index: number; + operation?: string; +} + +interface EstimationReport { + tools: { + total: number; + items: ToolEstimate[]; + }; + resources: { + total: number; + items: ResourceEstimate[]; + }; + resourceTemplates: { + total: number; + items: ResourceEstimate[]; + }; + operations?: { + total: number; + items: OperationEstimate[]; + }; + grandTotal: number; +} + +/** + * Estimate token count for a text string + */ +function estimateTokens(text: string): number { + try { + const tokens = tokenizer.encode(text); + return tokens.length; + } catch (error) { + console.error(`Error encoding text: ${error}`); + return 0; + } +} + +/** + * Register all tools in the registry + */ +function registerAllTools(): void { + registerObserveTools(); + registerInteractionTools(); + registerAppTools(); + registerUtilityTools(); + registerDeviceTools(); + registerDeepLinkTools(); + registerNavigationTools(); + registerPlanTools(); + registerDoctorTools(); + registerFeatureFlagTools(); + + // Only register debug tools if debug mode is enabled + // For estimation purposes, we'll skip them to match typical production usage + // registerDebugTools(); +} + +/** + * Register all resources in the registry + */ +function registerAllResources(): void { + registerObservationResources(); + registerBootedDeviceResources(); + registerDeviceImageResources(); + registerAppResources(); + registerNavigationResources(); +} + +/** + * Estimate tokens for all tool definitions + */ +function estimateToolTokens(): { total: number; items: ToolEstimate[] } { + const toolDefinitions = ToolRegistry.getToolDefinitions(); + const items: ToolEstimate[] = []; + + for (const tool of toolDefinitions) { + const toolJson = JSON.stringify(tool, null, 2); + const tokenCount = estimateTokens(toolJson); + items.push({ + name: tool.name, + text: toolJson, + tokenCount + }); + } + + const total = items.reduce((sum, item) => sum + item.tokenCount, 0); + return { total, items }; +} + +/** + * Estimate tokens for all resource definitions + */ +function estimateResourceTokens(): { total: number; items: ResourceEstimate[] } { + const resourceDefinitions = ResourceRegistry.getResourceDefinitions(); + const items: ResourceEstimate[] = []; + + for (const resource of resourceDefinitions) { + const resourceJson = JSON.stringify(resource, null, 2); + const tokenCount = estimateTokens(resourceJson); + items.push({ + uri: resource.uri, + name: resource.name, + text: resourceJson, + tokenCount + }); + } + + const total = items.reduce((sum, item) => sum + item.tokenCount, 0); + return { total, items }; +} + +/** + * Estimate tokens for all resource template definitions + */ +function estimateResourceTemplateTokens(): { total: number; items: ResourceEstimate[] } { + const templateDefinitions = ResourceRegistry.getTemplateDefinitions(); + const items: ResourceEstimate[] = []; + + for (const template of templateDefinitions) { + const templateJson = JSON.stringify(template, null, 2); + const tokenCount = estimateTokens(templateJson); + items.push({ + uri: template.uriTemplate, + name: template.name, + text: templateJson, + tokenCount + }); + } + + const total = items.reduce((sum, item) => sum + item.tokenCount, 0); + return { total, items }; +} + +/** + * Load and estimate tokens for operation traces + */ +function estimateOperationTokens(tracePath: string): { total: number; items: OperationEstimate[] } | null { + if (!fs.existsSync(tracePath)) { + console.error(`Trace file not found: ${tracePath}`); + return null; + } + + try { + const traceContent = fs.readFileSync(tracePath, "utf-8"); + const traces = JSON.parse(traceContent); + + if (!Array.isArray(traces)) { + console.error("Trace file must contain an array of operations"); + return null; + } + + const items: OperationEstimate[] = []; + + for (let i = 0; i < traces.length; i++) { + const operation = traces[i]; + const operationJson = JSON.stringify(operation, null, 2); + const tokenCount = estimateTokens(operationJson); + + items.push({ + index: i, + operation: operation.method || operation.tool || `operation-${i}`, + text: operationJson, + tokenCount + }); + } + + const total = items.reduce((sum, item) => sum + item.tokenCount, 0); + return { total, items }; + } catch (error) { + console.error(`Error reading trace file: ${error}`); + return null; + } +} + +/** + * Format number with thousand separators + */ +function formatNumber(num: number): string { + return num.toLocaleString(); +} + +/** + * Print a formatted estimation report + */ +function printReport(report: EstimationReport): void { + console.log("\n" + "=".repeat(80)); + console.log("MCP CONTEXT USAGE ESTIMATION REPORT"); + console.log("=".repeat(80) + "\n"); + + // Tool definitions section + console.log(`TOOL DEFINITIONS: ${formatNumber(report.tools.total)} tokens`); + console.log("-".repeat(80)); + const sortedTools = [...report.tools.items].sort((a, b) => b.tokenCount - a.tokenCount); + for (const tool of sortedTools) { + console.log(` ${tool.name.padEnd(30)} ${formatNumber(tool.tokenCount).padStart(10)} tokens`); + } + console.log(""); + + // Resource definitions section + console.log(`RESOURCE DEFINITIONS: ${formatNumber(report.resources.total)} tokens`); + console.log("-".repeat(80)); + if (report.resources.items.length === 0) { + console.log(" (no static resources registered)"); + } else { + const sortedResources = [...report.resources.items].sort((a, b) => b.tokenCount - a.tokenCount); + for (const resource of sortedResources) { + console.log(` ${resource.name.padEnd(30)} ${formatNumber(resource.tokenCount).padStart(10)} tokens`); + } + } + console.log(""); + + // Resource template definitions section + console.log(`RESOURCE TEMPLATES: ${formatNumber(report.resourceTemplates.total)} tokens`); + console.log("-".repeat(80)); + if (report.resourceTemplates.items.length === 0) { + console.log(" (no resource templates registered)"); + } else { + const sortedTemplates = [...report.resourceTemplates.items].sort((a, b) => b.tokenCount - a.tokenCount); + for (const template of sortedTemplates) { + console.log(` ${template.name.padEnd(30)} ${formatNumber(template.tokenCount).padStart(10)} tokens`); + } + } + console.log(""); + + // Operations section (if provided) + if (report.operations) { + console.log(`OPERATION TRACES: ${formatNumber(report.operations.total)} tokens`); + console.log("-".repeat(80)); + const sortedOps = [...report.operations.items].sort((a, b) => b.tokenCount - a.tokenCount); + for (const op of sortedOps.slice(0, 20)) { // Show top 20 + const label = ` [${op.index}] ${op.operation || "unknown"}`; + console.log(`${label.padEnd(40)} ${formatNumber(op.tokenCount).padStart(10)} tokens`); + } + if (sortedOps.length > 20) { + console.log(` ... and ${sortedOps.length - 20} more operations`); + } + console.log(""); + } + + // Summary section + console.log("=".repeat(80)); + console.log("SUMMARY"); + console.log("=".repeat(80)); + console.log(` Tool definitions: ${formatNumber(report.tools.total).padStart(10)} tokens (${report.tools.items.length} tools)`); + console.log(` Resource definitions: ${formatNumber(report.resources.total).padStart(10)} tokens (${report.resources.items.length} resources)`); + console.log(` Resource templates: ${formatNumber(report.resourceTemplates.total).padStart(10)} tokens (${report.resourceTemplates.items.length} templates)`); + if (report.operations) { + console.log(` Operation traces: ${formatNumber(report.operations.total).padStart(10)} tokens (${report.operations.items.length} operations)`); + } + console.log("-".repeat(80)); + console.log(` TOTAL ESTIMATED TOKENS: ${formatNumber(report.grandTotal).padStart(10)}`); + console.log("=".repeat(80) + "\n"); +} + +/** + * Main execution + */ +async function main() { + const args = process.argv.slice(2); + let tracePath: string | null = null; + + // Parse command line arguments + for (let i = 0; i < args.length; i++) { + if (args[i] === "--traces" && i + 1 < args.length) { + tracePath = args[i + 1]; + i++; + } + } + + console.log("Initializing MCP server components..."); + + // Register all tools and resources + registerAllTools(); + registerAllResources(); + + console.log("Estimating token usage...\n"); + + // Estimate tool tokens + const toolEstimate = estimateToolTokens(); + + // Estimate resource tokens + const resourceEstimate = estimateResourceTokens(); + + // Estimate resource template tokens + const resourceTemplateEstimate = estimateResourceTemplateTokens(); + + // Estimate operation tokens if trace file provided + let operationEstimate: { total: number; items: OperationEstimate[] } | null = null; + if (tracePath) { + operationEstimate = estimateOperationTokens(tracePath); + } + + // Calculate grand total + let grandTotal = toolEstimate.total + resourceEstimate.total + resourceTemplateEstimate.total; + if (operationEstimate) { + grandTotal += operationEstimate.total; + } + + // Create report + const report: EstimationReport = { + tools: toolEstimate, + resources: resourceEstimate, + resourceTemplates: resourceTemplateEstimate, + operations: operationEstimate || undefined, + grandTotal + }; + + // Print report + printReport(report); +} + +main().catch(error => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/filter-ts-prune-allowlist.sh b/scripts/filter-ts-prune-allowlist.sh new file mode 100755 index 000000000..84469f1e2 --- /dev/null +++ b/scripts/filter-ts-prune-allowlist.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Filters ts-prune output (from stdin) against dead-code-allowlist.json ignorePaths. +# Lines matching any allowlisted file path are excluded from the output. + +set -euo pipefail + +ALLOWLIST="dead-code-allowlist.json" + +if [ ! -f "$ALLOWLIST" ]; then + cat # No allowlist found, pass through + exit 0 +fi + +if ! command -v jq &> /dev/null; then + echo "Warning: jq not installed, skipping allowlist filtering" >&2 + cat + exit 0 +fi + +# Build grep exclusion pattern from all ignorePaths +PATTERN=$(jq -r '.ignorePaths[]' "$ALLOWLIST" \ + | sed "s/[.[\\*^\$()+?{|]/\\\\&/g" \ + | paste -sd '|' -) + +if [ -z "$PATTERN" ]; then + cat # No patterns, pass through +else + grep -vE "$PATTERN" || true +fi diff --git a/scripts/generate-release-constants.sh b/scripts/generate-release-constants.sh new file mode 100755 index 000000000..7f6919221 --- /dev/null +++ b/scripts/generate-release-constants.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +set -euo pipefail + +constants_path="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/src/constants/release.ts" +release_version="${RELEASE_VERSION:-}" +checksum="${APK_SHA256_CHECKSUM:-}" +xctestservice_release_version="${XCTESTSERVICE_RELEASE_VERSION:-}" +xctestservice_checksum="${XCTESTSERVICE_SHA256_CHECKSUM:-}" +xctestservice_app_hash="${XCTESTSERVICE_APP_HASH:-}" +xctestservice_runner_sha256="${XCTESTSERVICE_RUNNER_SHA256:-}" + +if [ -z "$release_version" ] && [ -z "$checksum" ] && [ -z "$xctestservice_release_version" ] && [ -z "$xctestservice_checksum" ] && [ -z "$xctestservice_app_hash" ] && [ -z "$xctestservice_runner_sha256" ]; then + echo "INFO: No release environment variables set - using default constants" + exit 0 +fi + +if [ -n "$checksum" ] && ! [[ "$checksum" =~ ^[a-f0-9]{64}$ ]]; then + echo "ERROR: APK_SHA256_CHECKSUM must be a valid SHA256 hash (64 hex characters)" + echo " Got: ${checksum}" + exit 1 +fi + +if [ -n "$xctestservice_checksum" ] && ! [[ "$xctestservice_checksum" =~ ^[a-f0-9]{64}$ ]]; then + echo "ERROR: XCTESTSERVICE_SHA256_CHECKSUM must be a valid SHA256 hash (64 hex characters)" + echo " Got: ${xctestservice_checksum}" + exit 1 +fi + +if [ -n "$xctestservice_app_hash" ] && ! [[ "$xctestservice_app_hash" =~ ^[a-f0-9]{64}$ ]]; then + echo "ERROR: XCTESTSERVICE_APP_HASH must be a valid SHA256 hash (64 hex characters)" + echo " Got: ${xctestservice_app_hash}" + exit 1 +fi + +if [ -n "$xctestservice_runner_sha256" ] && ! [[ "$xctestservice_runner_sha256" =~ ^[a-f0-9]{64}$ ]]; then + echo "ERROR: XCTESTSERVICE_RUNNER_SHA256 must be a valid SHA256 hash (64 hex characters)" + echo " Got: ${xctestservice_runner_sha256}" + exit 1 +fi + +tmp_file="$(mktemp)" +trap 'rm -f "$tmp_file"' EXIT + +cp "$constants_path" "$tmp_file" + +if [ -n "$release_version" ]; then + sed -E -i "" -e "s/^export const RELEASE_VERSION: string = \".*\";/export const RELEASE_VERSION: string = \"${release_version}\";/" "$tmp_file" 2>/dev/null \ + || sed -E -i -e "s/^export const RELEASE_VERSION: string = \".*\";/export const RELEASE_VERSION: string = \"${release_version}\";/" "$tmp_file" +fi + +if [ -n "$checksum" ]; then + sed -E -i "" -e "s/^export const APK_SHA256_CHECKSUM: string = \".*\";/export const APK_SHA256_CHECKSUM: string = \"${checksum}\";/" "$tmp_file" 2>/dev/null \ + || sed -E -i -e "s/^export const APK_SHA256_CHECKSUM: string = \".*\";/export const APK_SHA256_CHECKSUM: string = \"${checksum}\";/" "$tmp_file" +fi + +if [ -n "$xctestservice_release_version" ]; then + sed -E -i "" -e "s/^export const XCTESTSERVICE_RELEASE_VERSION: string = \".*\";/export const XCTESTSERVICE_RELEASE_VERSION: string = \"${xctestservice_release_version}\";/" "$tmp_file" 2>/dev/null \ + || sed -E -i -e "s/^export const XCTESTSERVICE_RELEASE_VERSION: string = \".*\";/export const XCTESTSERVICE_RELEASE_VERSION: string = \"${xctestservice_release_version}\";/" "$tmp_file" +fi + +if [ -n "$xctestservice_checksum" ]; then + sed -E -i "" -e "s/^export const XCTESTSERVICE_SHA256_CHECKSUM: string = \".*\";/export const XCTESTSERVICE_SHA256_CHECKSUM: string = \"${xctestservice_checksum}\";/" "$tmp_file" 2>/dev/null \ + || sed -E -i -e "s/^export const XCTESTSERVICE_SHA256_CHECKSUM: string = \".*\";/export const XCTESTSERVICE_SHA256_CHECKSUM: string = \"${xctestservice_checksum}\";/" "$tmp_file" +fi + +if [ -n "$xctestservice_app_hash" ]; then + sed -E -i "" -e "s/^export const XCTESTSERVICE_APP_HASH: string = \".*\";/export const XCTESTSERVICE_APP_HASH: string = \"${xctestservice_app_hash}\";/" "$tmp_file" 2>/dev/null \ + || sed -E -i -e "s/^export const XCTESTSERVICE_APP_HASH: string = \".*\";/export const XCTESTSERVICE_APP_HASH: string = \"${xctestservice_app_hash}\";/" "$tmp_file" +fi + +if [ -n "$xctestservice_runner_sha256" ]; then + sed -E -i "" -e "s/^export const XCTESTSERVICE_RUNNER_SHA256: string = \".*\";/export const XCTESTSERVICE_RUNNER_SHA256: string = \"${xctestservice_runner_sha256}\";/" "$tmp_file" 2>/dev/null \ + || sed -E -i -e "s/^export const XCTESTSERVICE_RUNNER_SHA256: string = \".*\";/export const XCTESTSERVICE_RUNNER_SHA256: string = \"${xctestservice_runner_sha256}\";/" "$tmp_file" +fi + +if cmp -s "$constants_path" "$tmp_file"; then + echo "INFO: Release constants already up to date" + exit 0 +fi + +mv "$tmp_file" "$constants_path" +trap - EXIT + +echo "Updated release constants:" +if [ -n "$release_version" ]; then + echo " Version: ${release_version}" +fi +if [ -n "$checksum" ]; then + echo " APK checksum: ${checksum}" +fi +if [ -n "$xctestservice_release_version" ]; then + echo " XCTestService version: ${xctestservice_release_version}" +fi +if [ -n "$xctestservice_checksum" ]; then + echo " XCTestService checksum: ${xctestservice_checksum}" +fi +if [ -n "$xctestservice_app_hash" ]; then + echo " XCTestService app hash: ${xctestservice_app_hash}" +fi +if [ -n "$xctestservice_runner_sha256" ]; then + echo " XCTestService runner SHA256: ${xctestservice_runner_sha256}" +fi +echo " File: ${constants_path}" diff --git a/scripts/generate-tool-definitions.ts b/scripts/generate-tool-definitions.ts new file mode 100644 index 000000000..7f80733dc --- /dev/null +++ b/scripts/generate-tool-definitions.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env bun +/** + * Generate MCP tool definitions for IDE YAML completion. + * + * Usage: + * bun scripts/generate-tool-definitions.ts + */ + +import fs from "node:fs"; +import path from "node:path"; +import { ToolRegistry } from "../src/server/toolRegistry"; +import { registerObserveTools } from "../src/server/observeTools"; +import { registerInteractionTools } from "../src/server/interactionTools"; +import { registerAppTools } from "../src/server/appTools"; +import { registerUtilityTools } from "../src/server/utilityTools"; +import { registerDeviceTools } from "../src/server/deviceTools"; +import { registerDeepLinkTools } from "../src/server/deepLinkTools"; +import { registerNavigationTools } from "../src/server/navigationTools"; +import { registerNotificationTools } from "../src/server/notificationTools"; +import { registerPlanTools } from "../src/server/planTools"; +import { registerDoctorTools } from "../src/server/doctorTools"; +import { registerFeatureFlagTools } from "../src/server/featureFlagTools"; +import { registerCriticalSectionTools } from "../src/server/criticalSectionTools"; +import { registerVideoRecordingTools } from "../src/server/videoRecordingTools"; +import { registerSnapshotTools } from "../src/server/snapshotTools"; +import { registerBiometricTools } from "../src/server/biometricTools"; +import { registerHighlightTools } from "../src/server/highlightTools"; +import { registerDebugTools } from "../src/server/debugTools"; + +const OUTPUT_PATH = "schemas/tool-definitions.json"; + +function registerAllTools(): void { + registerObserveTools(); + registerInteractionTools(); + registerAppTools(); + registerUtilityTools(); + registerDeviceTools(); + registerDeepLinkTools(); + registerNavigationTools(); + registerNotificationTools(); + registerPlanTools(); + registerDoctorTools(); + registerFeatureFlagTools(); + registerCriticalSectionTools(); + registerVideoRecordingTools(); + registerSnapshotTools(); + registerBiometricTools(); + registerHighlightTools(); + registerDebugTools(); +} + +function writeToolDefinitions(outputPath: string): void { + const toolDefinitions = ToolRegistry.getToolDefinitions() + .slice() + .sort((left, right) => left.name.localeCompare(right.name)); + const resolvedPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedPath), { recursive: true }); + fs.writeFileSync( + resolvedPath, + `${JSON.stringify(toolDefinitions, null, 2)}\n`, + "utf8" + ); + console.log( + `Wrote ${toolDefinitions.length} tool definitions to ${resolvedPath}` + ); +} + +registerAllTools(); +writeToolDefinitions(OUTPUT_PATH); diff --git a/scripts/github/deploy_pages.py b/scripts/github/deploy_pages.py index 5b95dd4c0..1f3198ad4 100755 --- a/scripts/github/deploy_pages.py +++ b/scripts/github/deploy_pages.py @@ -64,7 +64,7 @@ def copy_required_files(): # Files to copy: (source_path, target_filename) files_to_copy = [ (project_root / "CHANGELOG.md", "changelog.md"), - (project_root / ".github" / "CONTRIBUTING.md", "contributing/index.md") + (project_root / ".github" / "CONTRIBUTING.md", "contributing.md") ] for source_path, target_filename in files_to_copy: @@ -186,6 +186,10 @@ def deploy_docs(): def serve_docs(): """Serve docs locally for testing""" print_status("Starting local documentation server...") + + # Copy required files first + copy_required_files() + print_status("Documentation will be available at: http://127.0.0.1:8000") print_status("Press Ctrl+C to stop the server") diff --git a/scripts/github/uv.lock b/scripts/github/uv.lock index 7455c5ade..189406f0a 100644 --- a/scripts/github/uv.lock +++ b/scripts/github/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 2 requires-python = ">=3.11" [[package]] @@ -27,23 +26,23 @@ requires-dist = [ name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, ] [[package]] name = "backrefs" version = "5.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857 } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, - { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, - { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267 }, + { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072 }, + { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947 }, + { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843 }, + { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762 }, + { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265 }, ] [[package]] @@ -54,66 +53,66 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, ] [[package]] name = "certifi" version = "2025.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753 } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650 }, ] [[package]] name = "charset-normalizer" version = "3.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, - { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, - { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, - { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, - { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, - { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794 }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846 }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350 }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657 }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260 }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164 }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571 }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952 }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959 }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030 }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015 }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, ] [[package]] @@ -121,35 +120,35 @@ name = "click" version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "csscompressor" version = "0.9.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808 } [[package]] name = "editorconfig" version = "0.17.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695 } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" }, + { url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360 }, ] [[package]] @@ -159,9 +158,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, ] [[package]] @@ -171,9 +170,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smmap" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, ] [[package]] @@ -183,9 +182,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 }, ] [[package]] @@ -193,16 +192,16 @@ name = "htmlmin2" version = "0.1.13" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486, upload-time = "2023-03-14T21:28:30.388Z" }, + { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486 }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] @@ -212,9 +211,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] [[package]] @@ -225,81 +224,81 @@ dependencies = [ { name = "editorconfig" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707 }, ] [[package]] name = "jsmin" version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925 } [[package]] name = "markdown" version = "3.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071 } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" }, + { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827 }, ] [[package]] name = "markupsafe" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, ] [[package]] @@ -308,7 +307,7 @@ version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, { name = "ghp-import" }, { name = "jinja2" }, { name = "markdown" }, @@ -321,9 +320,9 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, ] [[package]] @@ -335,9 +334,9 @@ dependencies = [ { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, ] [[package]] @@ -350,9 +349,9 @@ dependencies = [ { name = "mkdocs" }, { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f8/a17ec39a4fc314d40cc96afdc1d401e393ebd4f42309d454cc940a2cf38a/mkdocs_git_revision_date_localized_plugin-1.4.7.tar.gz", hash = "sha256:10a49eff1e1c3cb766e054b9d8360c904ce4fe8c33ac3f6cc083ac6459c91953", size = 450473, upload-time = "2025-05-28T18:26:20.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f8/a17ec39a4fc314d40cc96afdc1d401e393ebd4f42309d454cc940a2cf38a/mkdocs_git_revision_date_localized_plugin-1.4.7.tar.gz", hash = "sha256:10a49eff1e1c3cb766e054b9d8360c904ce4fe8c33ac3f6cc083ac6459c91953", size = 450473 } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/b6/106fcc15287e7228658fbd0ad9e8b0d775becced0a089cc39984641f4a0f/mkdocs_git_revision_date_localized_plugin-1.4.7-py3-none-any.whl", hash = "sha256:056c0a90242409148f1dc94d5c9d2c25b5b8ddd8de45489fa38f7fa7ccad2bc4", size = 25382, upload-time = "2025-05-28T18:26:18.907Z" }, + { url = "https://files.pythonhosted.org/packages/53/b6/106fcc15287e7228658fbd0ad9e8b0d775becced0a089cc39984641f4a0f/mkdocs_git_revision_date_localized_plugin-1.4.7-py3-none-any.whl", hash = "sha256:056c0a90242409148f1dc94d5c9d2c25b5b8ddd8de45489fa38f7fa7ccad2bc4", size = 25382 }, ] [[package]] @@ -372,18 +371,18 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fa/0101de32af88f87cf5cc23ad5f2e2030d00995f74e616306513431b8ab4b/mkdocs_material-9.6.14.tar.gz", hash = "sha256:39d795e90dce6b531387c255bd07e866e027828b7346d3eba5ac3de265053754", size = 3951707, upload-time = "2025-05-13T13:27:57.173Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fa/0101de32af88f87cf5cc23ad5f2e2030d00995f74e616306513431b8ab4b/mkdocs_material-9.6.14.tar.gz", hash = "sha256:39d795e90dce6b531387c255bd07e866e027828b7346d3eba5ac3de265053754", size = 3951707 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/a1/7fdb959ad592e013c01558822fd3c22931a95a0f08cf0a7c36da13a5b2b5/mkdocs_material-9.6.14-py3-none-any.whl", hash = "sha256:3b9cee6d3688551bf7a8e8f41afda97a3c39a12f0325436d76c86706114b721b", size = 8703767, upload-time = "2025-05-13T13:27:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a1/7fdb959ad592e013c01558822fd3c22931a95a0f08cf0a7c36da13a5b2b5/mkdocs_material-9.6.14-py3-none-any.whl", hash = "sha256:3b9cee6d3688551bf7a8e8f41afda97a3c39a12f0325436d76c86706114b721b", size = 8703767 }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, ] [[package]] @@ -398,9 +397,9 @@ dependencies = [ { name = "requests" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/1a/f580733da1924ebc9b4bb04a34ca63ae62a50b0e62eeb016e78d9dee6d69/mkdocs_mermaid2_plugin-1.2.1.tar.gz", hash = "sha256:9c7694c73a65905ac1578f966e5c193325c4d5a5bc1836727e74ac9f99d0e921", size = 16104, upload-time = "2024-11-02T06:27:36.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/1a/f580733da1924ebc9b4bb04a34ca63ae62a50b0e62eeb016e78d9dee6d69/mkdocs_mermaid2_plugin-1.2.1.tar.gz", hash = "sha256:9c7694c73a65905ac1578f966e5c193325c4d5a5bc1836727e74ac9f99d0e921", size = 16104 } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/ce/c8a41cb0f3044990c8afbdc20c853845a9e940995d4e0cffecafbb5e927b/mkdocs_mermaid2_plugin-1.2.1-py3-none-any.whl", hash = "sha256:22d2cf2c6867d4959a5e0903da2dde78d74581fc0b107b791bc4c7ceb9ce9741", size = 17260, upload-time = "2024-11-02T06:27:34.652Z" }, + { url = "https://files.pythonhosted.org/packages/24/ce/c8a41cb0f3044990c8afbdc20c853845a9e940995d4e0cffecafbb5e927b/mkdocs_mermaid2_plugin-1.2.1-py3-none-any.whl", hash = "sha256:22d2cf2c6867d4959a5e0903da2dde78d74581fc0b107b791bc4c7ceb9ce9741", size = 17260 }, ] [[package]] @@ -413,67 +412,67 @@ dependencies = [ { name = "jsmin" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723 }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, ] [[package]] name = "platformdirs" version = "4.3.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] [[package]] name = "pymdown-extensions" -version = "10.16" +version = "10.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277 } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" }, + { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178 }, ] [[package]] @@ -483,53 +482,53 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] [[package]] @@ -539,9 +538,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, ] [[package]] @@ -554,88 +553,88 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, ] [[package]] name = "setuptools" version = "80.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] [[package]] name = "smmap" version = "5.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303 }, ] [[package]] name = "soupsieve" version = "2.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, ] [[package]] name = "typing-extensions" version = "4.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, ] diff --git a/scripts/hadolint/install_hadolint.sh b/scripts/hadolint/install_hadolint.sh new file mode 100755 index 000000000..f9ccefcf1 --- /dev/null +++ b/scripts/hadolint/install_hadolint.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash + +set -euo pipefail + +HADOLINT_VERSION="2.12.0" # Change this to the desired version + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Detect operating system +detect_os() { + case "$(uname -s)" in + Darwin*) + echo "macos" + ;; + Linux*) + echo "linux" + ;; + CYGWIN*|MINGW*|MSYS*) + echo "windows" + ;; + *) + echo "unknown" + ;; + esac +} + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) + echo "x86_64" + ;; + aarch64|arm64) + echo "arm64" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Install hadolint on macOS +install_macos() { + echo -e "${YELLOW}Installing hadolint on macOS...${NC}" + + if command_exists brew; then + echo -e "${GREEN}Using Homebrew to install hadolint${NC}" + brew install hadolint + return $? + else + echo -e "${YELLOW}Homebrew not found. Falling back to manual installation...${NC}" + install_manual + return $? + fi +} + +# Install hadolint on Linux +install_linux() { + echo -e "${YELLOW}Installing hadolint on Linux...${NC}" + + # Check for package managers in order of preference + if command_exists apt-get; then + echo -e "${YELLOW}APT package manager detected, but hadolint may not be available in default repositories.${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + return $? + elif command_exists yum; then + echo -e "${YELLOW}YUM package manager detected, but hadolint may not be available in default repositories.${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + return $? + elif command_exists dnf; then + echo -e "${YELLOW}DNF package manager detected, but hadolint may not be available in default repositories.${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + return $? + elif command_exists pacman; then + echo -e "${YELLOW}Pacman package manager detected, but hadolint may not be available in default repositories.${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + return $? + else + echo -e "${YELLOW}No supported package manager found. Using manual installation...${NC}" + install_manual + return $? + fi +} + +# Install hadolint on Windows +install_windows() { + echo -e "${YELLOW}Installing hadolint on Windows...${NC}" + + if command_exists scoop; then + echo -e "${GREEN}Using Scoop to install hadolint${NC}" + scoop install hadolint + return $? + elif command_exists choco; then + echo -e "${YELLOW}Chocolatey detected, but hadolint may not be available. Falling back to manual installation...${NC}" + install_manual + return $? + elif command_exists winget; then + echo -e "${YELLOW}Winget detected, but hadolint may not be available. Falling back to manual installation...${NC}" + install_manual + return $? + else + echo -e "${YELLOW}No supported package manager found. Using manual installation...${NC}" + echo -e "${YELLOW}Consider installing Scoop for easier package management: https://scoop.sh/${NC}" + install_manual + return $? + fi +} + +# Manual installation by downloading binary +install_manual() { + echo -e "${YELLOW}Installing hadolint manually...${NC}" + + local os + os=$(detect_os) + local arch + arch=$(detect_arch) + + # Map OS and architecture to GitHub release naming + case "$os" in + macos) + os_name="Darwin" + ;; + linux) + os_name="Linux" + ;; + windows) + os_name="Windows" + ;; + *) + echo -e "${RED}Unsupported operating system: $os${NC}" + return 1 + ;; + esac + + case "$arch" in + x86_64) + arch_name="x86_64" + ;; + arm64) + arch_name="arm64" + ;; + *) + echo -e "${RED}Unsupported architecture: $arch${NC}" + return 1 + ;; + esac + + # Create installation directory + install_dir="$HOME/.local/bin" + mkdir -p "$install_dir" + + # Download URL + binary_name="hadolint-${os_name}-${arch_name}" + download_url="https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/${binary_name}" + + echo -e "${GREEN}Downloading hadolint binary from GitHub releases...${NC}" + echo "URL: $download_url" + + # Create temporary directory + temp_dir=$(mktemp -d) + trap 'rm -rf "$temp_dir"' EXIT + + # Download the binary + if command_exists curl; then + if ! curl -sL -o "$temp_dir/hadolint" "$download_url"; then + echo -e "${RED}Failed to download hadolint binary${NC}" + return 1 + fi + elif command_exists wget; then + if ! wget -q -O "$temp_dir/hadolint" "$download_url"; then + echo -e "${RED}Failed to download hadolint binary${NC}" + return 1 + fi + else + echo -e "${RED}Neither curl nor wget found. Please install one of them or download manually from:${NC}" + echo "$download_url" + return 1 + fi + + # Make executable and move to install directory + chmod +x "$temp_dir/hadolint" + mv "$temp_dir/hadolint" "$install_dir/hadolint" + + echo -e "${GREEN}hadolint installed successfully to $install_dir${NC}" + echo -e "${YELLOW}Make sure $install_dir is in your PATH environment variable.${NC}" + + # Check if directory is in PATH + if [[ ":$PATH:" != *":$install_dir:"* ]]; then + echo -e "${YELLOW}To add $install_dir to your PATH, add this line to your shell configuration:${NC}" + echo "export PATH=\"\$PATH:$install_dir\"" + fi + + return 0 +} + +# Verify installation +verify_installation() { + echo -e "${YELLOW}Verifying hadolint installation...${NC}" + + if command_exists hadolint; then + echo -e "${GREEN}hadolint is installed and available in PATH${NC}" + hadolint --version + return 0 + else + echo -e "${RED}hadolint is not available in PATH${NC}" + return 1 + fi +} + +# Main installation function +main() { + echo -e "${GREEN}Hadolint Installation Script${NC}" + echo -e "${GREEN}============================${NC}" + + # Check if hadolint is already installed + if command_exists hadolint; then + echo -e "${GREEN}hadolint is already installed${NC}" + hadolint --version + echo -e "${YELLOW}To reinstall, remove the existing installation first${NC}" + exit 0 + fi + + local os + os=$(detect_os) + local arch + arch=$(detect_arch) + echo -e "${YELLOW}Detected OS: $os${NC}" + echo -e "${YELLOW}Detected Architecture: $arch${NC}" + + case $os in + macos) + install_macos + ;; + linux) + install_linux + ;; + windows) + install_windows + ;; + *) + echo -e "${RED}Unsupported operating system: $os${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + ;; + esac + + install_result=$? + + if [[ $install_result -eq 0 ]]; then + echo -e "${GREEN}Installation completed successfully!${NC}" + verify_installation + else + echo -e "${RED}Installation failed!${NC}" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/scripts/hadolint/validate_hadolint.sh b/scripts/hadolint/validate_hadolint.sh new file mode 100755 index 000000000..0d8112277 --- /dev/null +++ b/scripts/hadolint/validate_hadolint.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -euo pipefail + +INSTALL_HADOLINT_WHEN_MISSING=${INSTALL_HADOLINT_WHEN_MISSING:-false} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Get project root (parent directory of scripts) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Check for required commands and install missing commands if allowed +echo -e "${YELLOW}Checking for required commands...${NC}" + +# Check if hadolint is installed +if ! command_exists hadolint; then + echo -e "${RED}hadolint is not installed${NC}" + if [[ "${INSTALL_HADOLINT_WHEN_MISSING}" == "true" ]]; then + echo -e "${YELLOW}Installing hadolint...${NC}" + if [[ -f "$PROJECT_ROOT/scripts/hadolint/install_hadolint.sh" ]]; then + if ! bash "$PROJECT_ROOT/scripts/hadolint/install_hadolint.sh"; then + echo -e "${RED}Failed to install hadolint${NC}" + exit 1 + fi + else + echo -e "${RED}hadolint installation script not found${NC}" + exit 1 + fi + else + echo -e "${RED}hadolint is required. Set INSTALL_HADOLINT_WHEN_MISSING=true to auto-install or install manually${NC}" + exit 1 + fi +fi + +# Verify hadolint is available +if ! command_exists hadolint; then + echo -e "${RED}hadolint is still not available after installation attempt${NC}" + exit 1 +fi + +echo -e "${GREEN}hadolint is available${NC}" + +# Verify hadolint works with --version +echo -e "${YELLOW}Verifying hadolint version...${NC}" +if hadolint --version; then + echo -e "${GREEN}hadolint version check passed${NC}" +else + echo -e "${RED}hadolint version check failed${NC}" + exit 1 +fi + +# Run hadolint on the Dockerfile as a validation test +echo -e "${YELLOW}Running hadolint on Dockerfile as validation test...${NC}" + +# Check if Dockerfile exists +if [[ ! -f "$PROJECT_ROOT/Dockerfile" ]]; then + echo -e "${YELLOW}No Dockerfile found at $PROJECT_ROOT/Dockerfile${NC}" + echo -e "${GREEN}Skipping Dockerfile validation test${NC}" + exit 0 +fi + +# Check if .hadolint.yaml config exists +config_args=() +if [[ -f "$PROJECT_ROOT/.hadolint.yaml" ]]; then + config_args=(--config "$PROJECT_ROOT/.hadolint.yaml") + echo -e "${GREEN}Using hadolint config: $PROJECT_ROOT/.hadolint.yaml${NC}" +fi + +# Run hadolint on Dockerfile +hadolint_result=0 +if [[ ${#config_args[@]} -gt 0 ]]; then + hadolint "${config_args[@]}" "$PROJECT_ROOT/Dockerfile" || hadolint_result=$? +else + hadolint "$PROJECT_ROOT/Dockerfile" || hadolint_result=$? +fi + +if [[ $hadolint_result -eq 0 ]]; then + echo -e "${GREEN}Dockerfile validation passed${NC}" + echo -e "${GREEN}hadolint is correctly installed and working${NC}" + exit 0 +else + echo -e "${RED}Dockerfile validation failed${NC}" + echo -e "${YELLOW}Note: This may indicate linting issues in the Dockerfile, not a problem with hadolint itself${NC}" + exit 1 +fi diff --git a/scripts/ide-plugin/install_from_source.sh b/scripts/ide-plugin/install_from_source.sh new file mode 100755 index 000000000..3e705a3f8 --- /dev/null +++ b/scripts/ide-plugin/install_from_source.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IDE_PLUGIN_DIR="${ANDROID_STUDIO_PLUGINS_DIR:-${IDEA_PLUGINS_DIR:-}}" + +# Auto-detect IntelliJ/Android Studio plugins directory on macOS +if [[ -z "$IDE_PLUGIN_DIR" ]]; then + OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')" + if [[ "$OS_NAME" == "darwin" ]]; then + # Search for JetBrains IDE plugins directories + JETBRAINS_DIR="$HOME/Library/Application Support/JetBrains" + if [[ -d "$JETBRAINS_DIR" ]]; then + # Find most recent IntelliJ or Android Studio directory + IDE_PLUGIN_DIR=$(find "$JETBRAINS_DIR" -maxdepth 1 -type d \( -name "IntelliJIdea*" -o -name "AndroidStudio*" \) 2>/dev/null | sort -r | head -n 1 || true) + if [[ -n "$IDE_PLUGIN_DIR" ]]; then + IDE_PLUGIN_DIR="$IDE_PLUGIN_DIR/plugins" + fi + fi + fi +fi + +if [[ -z "$IDE_PLUGIN_DIR" ]]; then + echo "Could not auto-detect IDE plugins directory." + echo "Set ANDROID_STUDIO_PLUGINS_DIR or IDEA_PLUGINS_DIR to your IDE plugins directory." + echo "Example (macOS):" + echo " export IDEA_PLUGINS_DIR=\"\$HOME/Library/Application Support/JetBrains/IntelliJIdea2025.3/plugins\"" + echo " export ANDROID_STUDIO_PLUGINS_DIR=\"\$HOME/Library/Application Support/Google/AndroidStudio2025.2/plugins\"" + exit 1 +fi + +if [[ ! -d "$IDE_PLUGIN_DIR" ]]; then + echo "Plugins directory not found: $IDE_PLUGIN_DIR" + echo "Make sure your IDE has been run at least once to create the plugins directory." + exit 1 +fi + +echo "Using plugins directory: $IDE_PLUGIN_DIR" + +# Build the plugin using its own gradlew +echo "Building IDE plugin..." +( + cd "$ROOT_DIR/android/ide-plugin" + ./gradlew buildPlugin +) + +PLUGIN_ZIP=$(find "$ROOT_DIR/android/ide-plugin/build/distributions" -maxdepth 1 -name '*.zip' -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -n 1 || true) +if [[ -z "$PLUGIN_ZIP" ]]; then + echo "No plugin zip found in android/ide-plugin/build/distributions" + exit 1 +fi + +PLUGIN_NAME="auto-mobile-ide-plugin" + +# Remove old version and install new +echo "Installing plugin..." +rm -rf "${IDE_PLUGIN_DIR:?}/${PLUGIN_NAME:?}" +mkdir -p "$IDE_PLUGIN_DIR" +unzip -q "$PLUGIN_ZIP" -d "$IDE_PLUGIN_DIR" + +echo "Installed $PLUGIN_NAME to $IDE_PLUGIN_DIR/$PLUGIN_NAME" + +OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')" + +select_from_list() { + local prompt="$1" + shift + local options=("$@") + local count="${#options[@]}" + + if [[ "$count" -eq 0 ]]; then + return 1 + fi + + echo "$prompt" + local i=1 + for option in "${options[@]}"; do + echo " [$i] $option" + i=$((i + 1)) + done + read -r -p "Choose an option (1-$count): " selection + if [[ -z "$selection" ]] || ! [[ "$selection" =~ ^[0-9]+$ ]]; then + return 1 + fi + if (( selection < 1 || selection > count )); then + return 1 + fi + echo "${options[$((selection - 1))]}" +} + +restart_ide_macos() { + local app_name="$1" + if [[ -z "$app_name" ]]; then + local known_apps=("Android Studio" "Android Studio Preview" "IntelliJ IDEA" "IntelliJ IDEA Ultimate" "IntelliJ IDEA Community") + local running + running="$(osascript -e 'tell application "System Events" to get name of (processes whose background only is false)' 2>/dev/null || true)" + local matches=() + for app in "${known_apps[@]}"; do + if echo "$running" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -Fxq "$app"; then + matches+=("$app") + fi + done + if [[ "${#matches[@]}" -eq 1 ]]; then + app_name="${matches[0]}" + elif [[ "${#matches[@]}" -gt 1 ]]; then + app_name="$(select_from_list "Multiple IDEs are running. Which should be restarted?" "${matches[@]}")" + fi + fi + + if [[ -z "$app_name" ]]; then + read -r -p "Enter the IDE app name to restart (e.g., IntelliJ IDEA): " app_name + fi + + if [[ -z "$app_name" ]]; then + echo "Skipping restart: no IDE app name provided." + echo "Please restart your IDE manually to load the plugin." + return 0 + fi + + echo "Restarting $app_name..." + pkill -f "$app_name" 2>/dev/null || true + sleep 1 + open -a "$app_name" +} + +restart_ide_linux() { + local ide_cmd="${IDE_CMD:-}" + if [[ -z "$ide_cmd" ]]; then + echo "Set IDE_CMD to the launch command for your IDE (e.g., idea.sh or studio.sh)." + read -r -p "Enter IDE command to launch (leave empty to skip): " ide_cmd + fi + if [[ -z "$ide_cmd" ]]; then + echo "Skipping restart: no IDE command provided." + echo "Please restart your IDE manually to load the plugin." + return 0 + fi + + echo "Restarting IDE via: $ide_cmd" + pkill -f "studio|idea|intellij|android-studio" || true + nohup "$ide_cmd" >/dev/null 2>&1 & +} + +restart_ide_windows() { + local ide_cmd="${IDE_CMD:-}" + if [[ -z "$ide_cmd" ]]; then + echo "Set IDE_CMD to the launch command for your IDE (e.g., idea64.exe or studio64.exe)." + read -r -p "Enter IDE command to launch (leave empty to skip): " ide_cmd + fi + if [[ -z "$ide_cmd" ]]; then + echo "Skipping restart: no IDE command provided." + echo "Please restart your IDE manually to load the plugin." + return 0 + fi + + echo "Restarting IDE via: $ide_cmd" + taskkill //F //IM idea64.exe 2>/dev/null || true + taskkill //F //IM studio64.exe 2>/dev/null || true + cmd.exe /C start "" "$ide_cmd" +} + +echo "" +echo "Plugin installed successfully!" +echo "Restart your IDE to load the plugin." +echo "" + +if [[ "$OS_NAME" == "darwin" ]]; then + restart_ide_macos "${IDE_APP_NAME:-}" +elif [[ "$OS_NAME" == "linux" ]]; then + restart_ide_linux +else + restart_ide_windows +fi diff --git a/scripts/ide-plugin/validate.sh b/scripts/ide-plugin/validate.sh new file mode 100755 index 000000000..3a2050313 --- /dev/null +++ b/scripts/ide-plugin/validate.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +( + cd "$ROOT_DIR/android" + ./gradlew -p ide-plugin build + ./gradlew -p ide-plugin buildPlugin + ./gradlew -p ide-plugin verifyPlugin +) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..3004b4968 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,3774 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Handle Ctrl-C (SIGINT) - exit immediately +trap 'echo ""; echo "Installation cancelled."; exit 130' INT + +# Handle piped execution (curl | bash) where BASH_SOURCE is empty +if [[ -n "${BASH_SOURCE[0]:-}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +else + SCRIPT_DIR="" + PROJECT_ROOT="$(pwd)" +fi +if [[ ! -f "${PROJECT_ROOT}/package.json" ]]; then + PROJECT_ROOT="$(pwd)" +fi + +IS_REPO=false +if [[ -f "${PROJECT_ROOT}/package.json" && -d "${PROJECT_ROOT}/android" ]]; then + IS_REPO=true +fi + +# ============================================================================ +# New CLI Options and Global State +# ============================================================================ +DRY_RUN=false +DRY_RUN_LOG=() +NON_INTERACTIVE=false +RECORD_MODE=false +PRESET="" +CONFIGURE_MCP_CLIENTS=false +RUN_NPM_INSTALL=false +ENV_FILE="" + +# Gum bundling configuration +GUM_VERSION="0.17.0" +GUM_INSTALL_DIR="${HOME}/.automobile/bin" +GUM_BINARY="${GUM_INSTALL_DIR}/gum" +GUM_VERSION_FILE="${GUM_INSTALL_DIR}/.gum-version" + +# Config backup directory +BACKUP_DIR="${HOME}/.automobile/backups" +BACKUP_TIMESTAMP="" + +# MCP client detection (parallel arrays for bash 3.x compatibility) +# Format: "client_name|config_path|format|scope" +MCP_CLIENT_LIST=() +SELECTED_MCP_CLIENTS=() +PRESET_CLIENT_FILTER="" # When set, auto-select clients matching this prefix +IOS_RUNTIME_NAMES=() + +# ============================================================================ +# Original Global State +# ============================================================================ +INSTALL_BUN=false +BUN_INSTALLED=false +ANDROID_SDK_DETECTED=false +INSTALL_IDE_PLUGIN=false +IDE_PLUGIN_METHOD="" +IDE_PLUGIN_ZIP_URL="" +IDE_PLUGIN_DIR="" +INSTALL_AUTOMOBILE_CLI=false +INSTALL_CLAUDE_MARKETPLACE=false +START_DAEMON=false +DAEMON_STARTED=false +AUTO_MOBILE_CMD=() + +# Early detection state +CLI_ALREADY_INSTALLED=false +DAEMON_ALREADY_RUNNING=false +CLAUDE_CLI_INSTALLED=false +CLAUDE_MARKETPLACE_INSTALLED=false +IOS_SETUP_OK=false +ANDROID_SETUP_OK=false + +# Track if any changes were made +CHANGES_MADE=false + +# Track if ANDROID_HOME was already set in environment +ANDROID_HOME_FROM_ENV=false +if [[ -n "${ANDROID_HOME:-}" ]]; then + ANDROID_HOME_FROM_ENV=true +fi + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Compare semver versions: returns 0 if $1 >= $2 +version_gte() { + local v1="$1" + local v2="$2" + local sorted + sorted=$(printf '%s\n%s\n' "$v1" "$v2" | sort -V | head -n1) + [[ "$sorted" == "$v2" ]] +} + +# Required versions (populated by parse_required_versions) +REQUIRED_BUN_VERSION="" +REQUIRED_NODE_MAJOR="" + +# Parse required versions from package.json (only when IS_REPO=true) +parse_required_versions() { + if [[ "${IS_REPO}" != "true" ]]; then + return 0 + fi + + local package_json="${PROJECT_ROOT}/package.json" + if [[ ! -f "${package_json}" ]]; then + return 0 + fi + + # Extract bun version from packageManager field (e.g., "bun@1.3.6") + REQUIRED_BUN_VERSION=$(grep -o '"packageManager":[[:space:]]*"bun@[^"]*"' "${package_json}" | \ + sed 's/.*bun@\([^"]*\).*/\1/' || true) + + if [[ -z "${REQUIRED_BUN_VERSION}" ]]; then + # Fallback to engines.bun field + REQUIRED_BUN_VERSION=$(grep -o '"bun":[[:space:]]*"[^"]*"' "${package_json}" | \ + head -1 | sed 's/.*">=\{0,1\}\([0-9.]*\).*/\1/' || true) + fi + + # Extract node major version from @types/node (e.g., "^25.0.9" -> 25) + REQUIRED_NODE_MAJOR=$(grep -o '"@types/node":[[:space:]]*"[^"]*"' "${package_json}" | \ + sed 's/.*"\^\{0,1\}\([0-9]*\).*/\1/' || true) +} + +# Write environment state to a file for the caller to source +write_env_file() { + if [[ -z "${ENV_FILE}" ]]; then + return 0 + fi + + { + echo "export PATH=\"${PATH}\"" + if [[ -n "${NVM_DIR:-}" ]]; then + echo "export NVM_DIR=\"${NVM_DIR}\"" + fi + if [[ -n "${ANDROID_HOME:-}" ]]; then + echo "export ANDROID_HOME=\"${ANDROID_HOME}\"" + fi + } > "${ENV_FILE}" +} + +# Check if auto-mobile CLI is installed +is_cli_installed() { + command_exists auto-mobile +} + +# Check if MCP daemon is running (fast check - just verify socket exists) +is_daemon_running() { + local socket_path + socket_path="/tmp/auto-mobile-daemon-$(id -u).sock" + [[ -S "${socket_path}" ]] +} + +# Check if Claude CLI is installed +is_claude_cli_installed() { + command_exists claude +} + +# Check if auto-mobile marketplace plugin is already installed +is_claude_marketplace_installed() { + if ! command_exists claude; then + return 1 + fi + # Check if auto-mobile marketplace is in the list + claude plugin marketplace list 2>/dev/null | grep -q "auto-mobile" 2>/dev/null +} + +# Perform early detection of installed components (fast checks only, before gum) +detect_existing_setup() { + if is_cli_installed; then + CLI_ALREADY_INSTALLED=true + fi + + if is_daemon_running; then + DAEMON_ALREADY_RUNNING=true + fi + + if is_claude_cli_installed; then + CLAUDE_CLI_INSTALLED=true + # Note: marketplace check is deferred to after gum is available (slow network call) + fi +} + +# ============================================================================ +# CLI Argument Parsing +# ============================================================================ +show_help() { + cat << 'EOF' +AutoMobile Installer + +Usage: ./scripts/install.sh [OPTIONS] + +Options: + --dry-run Show what would happen without making changes + --record-mode Auto-select defaults and run (for demo recording) + --preset NAME Use preset configuration (minimal, development, local-dev) + --non-interactive Skip interactive prompts, use defaults + --env-file PATH Write environment state (PATH, NVM_DIR, ANDROID_HOME) to file + -h, --help Show this help message + +Presets: + minimal - CLI + MCP client configuration only + development - Full setup with debug flags and IDE plugin (if available) + local-dev - Dependencies for hot-reload development (bun, npm install) + +Examples: + ./scripts/install.sh --dry-run + ./scripts/install.sh --record-mode + ./scripts/install.sh --preset development + ./scripts/install.sh --preset development --non-interactive + ./scripts/install.sh --preset local-dev --non-interactive --env-file /tmp/env + +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + shift + ;; + --preset) + if [[ -z "${2:-}" ]]; then + plain_error "Missing value for --preset" + exit 1 + fi + PRESET="$2" + shift 2 + ;; + --non-interactive|-y) + NON_INTERACTIVE=true + shift + ;; + --record-mode) + RECORD_MODE=true + shift + ;; + --env-file) + if [[ -z "${2:-}" ]]; then + plain_error "Missing value for --env-file" + exit 1 + fi + ENV_FILE="$2" + shift 2 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + plain_error "Unknown option: $1" + show_help + exit 1 + ;; + esac + done + + # Validate preset if provided + if [[ -n "${PRESET}" ]]; then + case "${PRESET}" in + minimal|development|local-dev) + ;; + *) + plain_error "Unknown preset: ${PRESET}. Valid options: minimal, development, local-dev" + exit 1 + ;; + esac + fi +} + +# ============================================================================ +# Dry-Run Wrapper Functions +# ============================================================================ +# Execute a command or log it in dry-run mode +execute() { + local description="$1" + shift + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] ${description}") + if command_exists gum; then + gum log --level info "[DRY-RUN] Would: ${description}" + else + printf '[DRY-RUN] Would: %s\n' "${description}" + fi + return 0 + fi + + "$@" +} + +# Execute with spinner or log in dry-run mode +execute_spinner() { + local title="$1" + shift + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] ${title}") + if command_exists gum; then + gum log --level info "[DRY-RUN] Would: ${title}" + else + printf '[DRY-RUN] Would: %s\n' "${title}" + fi + return 0 + fi + + run_spinner "${title}" "$@" +} + +# Write file with diff preview and user approval +write_file() { + local path="$1" + local content="$2" + local description="${3:-Write to ${path}}" + + # Get existing content if file exists + local existing_content="" + if [[ -f "${path}" ]]; then + existing_content=$(cat "${path}" 2>/dev/null || echo "") + fi + + # Check if content is actually different + if [[ "${existing_content}" == "${content}" ]]; then + log_info "No changes needed for ${path}" + return 0 + fi + + # Show the diff + if [[ -f "${path}" ]]; then + show_colored_diff "${existing_content}" "${content}" "${path}" + else + show_new_file "${content}" "${path}" + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] ${description}") + log_info "[DRY-RUN] Would write to: ${path}" + return 0 + fi + + # Ask for approval (skip in non-interactive mode) + if [[ "${NON_INTERACTIVE}" != "true" ]]; then + if ! gum confirm "Apply these changes to ${path}?"; then + log_info "Skipped changes to ${path}" + return 0 + fi + fi + + # Create parent directory if needed + local parent_dir + parent_dir=$(dirname "${path}") + if [[ ! -d "${parent_dir}" ]]; then + mkdir -p "${parent_dir}" + fi + + printf '%s\n' "${content}" > "${path}" + log_info "Updated ${path}" +} + +# Print dry-run summary at the end +print_dry_run_summary() { + if [[ "${DRY_RUN}" != "true" ]]; then + return 0 + fi + + if [[ ${#DRY_RUN_LOG[@]} -eq 0 ]]; then + log_info "Dry-run complete. No actions would be taken." + return 0 + fi + + echo "" + gum style --bold --foreground 214 "Dry-Run Summary" + gum style --faint "The following actions would be performed:" + echo "" + + local i=1 + for action in "${DRY_RUN_LOG[@]}"; do + # Strip [DRY-RUN] prefix for cleaner output + local clean_action="${action#\[DRY-RUN\] }" + printf ' %d. %s\n' "${i}" "${clean_action}" + ((i++)) + done + + echo "" + gum style --foreground 214 "Run without --dry-run to execute these actions." +} + +# Terminal colors for diffs +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +RESET='\033[0m' +BOLD='\033[1m' + +# Show a colored diff between old and new content +show_colored_diff() { + local old_content="$1" + local new_content="$2" + local file_path="$3" + + local temp_old temp_new + temp_old=$(mktemp) + temp_new=$(mktemp) + + printf '%s\n' "${old_content}" > "${temp_old}" + printf '%s\n' "${new_content}" > "${temp_new}" + + echo "" + printf '%b--- %s (current)%b\n' "${BOLD}" "${file_path}" "${RESET}" + printf '%b+++ %s (proposed)%b\n' "${BOLD}" "${file_path}" "${RESET}" + + # Generate unified diff (diff returns 1 when files differ, which is expected) + local diff_output + diff_output=$(diff -u "${temp_old}" "${temp_new}" 2>/dev/null || true) + + # Skip the first 2 lines (header) and colorize + echo "${diff_output}" | tail -n +3 | while IFS= read -r line; do + case "${line}" in + -*) + printf '%b%s%b\n' "${RED}" "${line}" "${RESET}" + ;; + +*) + printf '%b%s%b\n' "${GREEN}" "${line}" "${RESET}" + ;; + @*) + printf '%b%s%b\n' "${CYAN}" "${line}" "${RESET}" + ;; + *) + printf '%s\n' "${line}" + ;; + esac + done + + rm -f "${temp_old}" "${temp_new}" + echo "" +} + +# Show new file content (all green) +show_new_file() { + local content="$1" + local file_path="$2" + + echo "" + printf '%b+++ %s (new file)%b\n' "${BOLD}" "${file_path}" "${RESET}" + echo "" + while IFS= read -r line; do + printf '%b+%s%b\n' "${GREEN}" "${line}" "${RESET}" + done <<< "${content}" + echo "" +} + +plain_info() { + printf '[INFO] %s\n' "$1" +} + +plain_warn() { + printf '[WARN] %s\n' "$1" +} + +plain_error() { + printf '[ERROR] %s\n' "$1" >&2 +} + +# Test whether we can actually open /dev/tty (controlling terminal). +# The device node may exist even when no controlling terminal is attached +# (CI, cron, detached shells), so we must try opening it. +has_controlling_tty() { + : < /dev/tty 2>/dev/null +} + +prompt_confirm_plain() { + local prompt="$1" + local reply="" + # Read from /dev/tty so prompts work even when stdin is a pipe (curl | bash) + read -r -p "${prompt} [y/N] " reply < /dev/tty + case "${reply}" in + y|Y|yes|YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Offer to set ANDROID_HOME in a shell profile +# Called when SDK is detected but ANDROID_HOME is not set in environment +offer_android_home_shell_setup() { + local detected_path="$1" + + # Common shell profile files + local profile_files=( + "${HOME}/.zshrc" + "${HOME}/.zprofile" + "${HOME}/.bash_profile" + "${HOME}/.bashrc" + "${HOME}/.profile" + ) + + # First, check if ANDROID_HOME is already configured in any profile file + local configured_in="" + for profile in "${profile_files[@]}"; do + if [[ -f "${profile}" ]] && grep -q "ANDROID_HOME" "${profile}" 2>/dev/null; then + configured_in="${profile}" + break + fi + done + + # If already configured in a profile, offer to source it + if [[ -n "${configured_in}" ]]; then + local short_name="${configured_in#"${HOME}"/}" + log_info "ANDROID_HOME is configured in ~/${short_name} but not loaded in current shell" + + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_info "Run: source ~/${short_name}" + return 0 + fi + + # Extract ANDROID_HOME value from the file (don't source - may have shell-specific syntax) + local extracted_value + extracted_value=$(grep -E '^\s*(export\s+)?ANDROID_HOME=' "${configured_in}" 2>/dev/null | head -1 | sed -E 's/.*ANDROID_HOME=["'\'']?([^"'\'']+)["'\'']?.*/\1/') + + # Expand $HOME if present in the value + extracted_value="${extracted_value/\$HOME/${HOME}}" + extracted_value="${extracted_value/\~/${HOME}}" + + if [[ -n "${extracted_value}" ]]; then + log_info "Found: ANDROID_HOME=${extracted_value}" + + if gum confirm "Set ANDROID_HOME for this session?"; then + export ANDROID_HOME="${extracted_value}" + log_info "ANDROID_HOME set for current session" + # Update our tracking variable since it's now set + ANDROID_HOME_FROM_ENV=true + else + log_info "Skipped setting ANDROID_HOME" + log_info "Run 'source ~/${short_name}' in your shell to load it" + fi + else + log_warn "Could not extract ANDROID_HOME value from ~/${short_name}" + log_info "Run 'source ~/${short_name}' manually in your shell" + fi + return 0 + fi + + # ANDROID_HOME not configured anywhere - offer to add it + # Skip in non-interactive mode + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_warn "ANDROID_HOME is not set in your environment" + log_info "Add this to your shell profile: export ANDROID_HOME=\"${detected_path}\"" + return 0 + fi + + log_warn "ANDROID_HOME is not configured in any shell profile" + log_info "The Android SDK was found at: ${detected_path}" + echo "" + + # Build options list - existing files first, then creatable files + local existing_files=() + local creatable_files=() + + for profile in "${profile_files[@]}"; do + local short_name="${profile#"${HOME}"/}" + # shellcheck disable=SC2088 # Tilde is intentional for display purposes + if [[ -f "${profile}" ]]; then + existing_files+=("~/${short_name}") + else + creatable_files+=("~/${short_name} (create)") + fi + done + + # Build final options array (handle empty arrays safely with set -u) + local options=() + if [[ ${#existing_files[@]} -gt 0 ]]; then + options+=("${existing_files[@]}") + fi + if [[ ${#creatable_files[@]} -gt 0 ]]; then + options+=("${creatable_files[@]}") + fi + options+=("Skip (I'll set it manually)") + + local choice + choice=$(printf '%s\n' "${options[@]}" | gum choose --header "Add ANDROID_HOME to shell profile?") + + if [[ -z "${choice}" || "${choice}" == "Skip (I'll set it manually)" ]]; then + log_info "Skipped ANDROID_HOME shell setup" + log_info "You can add this manually: export ANDROID_HOME=\"${detected_path}\"" + return 0 + fi + + # Extract the file path from the choice + local selected_file="${choice% (create)}" # Remove "(create)" suffix if present + selected_file="${selected_file/#\~/${HOME}}" # Expand ~ to HOME + + # Prepare the export line + local export_line="export ANDROID_HOME=\"${detected_path}\"" + # shellcheck disable=SC2016 # Single quotes intentional - we want literal $ANDROID_HOME in file + local path_line='export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"' + + # Check if already present + if [[ -f "${selected_file}" ]] && grep -q "ANDROID_HOME" "${selected_file}" 2>/dev/null; then + log_info "ANDROID_HOME is already configured in ${choice% (create)}" + return 0 + fi + + # Prepare the content to append + local append_content + append_content=$(cat << EOF + +# Android SDK (added by auto-mobile installer) +${export_line} +${path_line} +EOF +) + + # Show what will be added + local current_content="" + if [[ -f "${selected_file}" ]]; then + current_content=$(cat "${selected_file}" 2>/dev/null || true) + fi + local new_content="${current_content}${append_content}" + + if [[ -f "${selected_file}" ]]; then + show_colored_diff "${current_content}" "${new_content}" "${selected_file}" + else + show_new_file "${new_content}" "${selected_file}" + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] Add ANDROID_HOME to ${selected_file}") + log_info "[DRY-RUN] Would add ANDROID_HOME to: ${selected_file}" + return 0 + fi + + if ! gum confirm "Apply these changes to ${selected_file}?"; then + log_info "Skipped ANDROID_HOME shell setup" + return 0 + fi + + # Append to file (create if doesn't exist) + printf '%s\n' "${append_content}" >> "${selected_file}" + log_info "Added ANDROID_HOME to ${selected_file}" + log_info "Run 'source ${selected_file}' or open a new terminal to apply" + CHANGES_MADE=true +} + +detect_os() { + case "$(uname -s)" in + Darwin*) + echo "macos" + ;; + Linux*) + echo "linux" + ;; + MINGW*|MSYS*|CYGWIN*) + echo "linux" + ;; + *) + echo "unknown" + ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) + echo "x86_64" + ;; + arm64|aarch64) + echo "arm64" + ;; + *) + echo "unknown" + ;; + esac +} + +download_file() { + local url="$1" + local destination="$2" + + if command_exists curl; then + curl -fsSL "${url}" -o "${destination}" + elif command_exists wget; then + wget -qO "${destination}" "${url}" + else + return 1 + fi +} + +fetch_gum_version() { + local version="" + + if command_exists curl; then + version=$(curl -s "https://api.github.com/repos/charmbracelet/gum/releases/latest" \ + | sed -nE 's/.*"tag_name": "v?([^"]+)".*/\1/p' \ + | head -n 1) + elif command_exists wget; then + version=$(wget -qO- "https://api.github.com/repos/charmbracelet/gum/releases/latest" \ + | sed -nE 's/.*"tag_name": "v?([^"]+)".*/\1/p' \ + | head -n 1) + fi + + if [[ -z "${version}" ]]; then + version="0.17.0" + fi + + echo "${version}" +} + +# Check if bundled gum is installed and current version +is_bundled_gum_current() { + if [[ ! -x "${GUM_BINARY}" ]]; then + return 1 + fi + + if [[ ! -f "${GUM_VERSION_FILE}" ]]; then + return 1 + fi + + local installed_version + installed_version=$(cat "${GUM_VERSION_FILE}" 2>/dev/null || echo "") + + [[ "${installed_version}" == "${GUM_VERSION}" ]] +} + +# Install gum to ~/.automobile/bin (bundled approach) +install_bundled_gum() { + local os="$1" + local arch="$2" + local os_label="" + local arch_label="" + + case "${os}" in + macos) + os_label="Darwin" + ;; + linux) + os_label="Linux" + ;; + *) + plain_error "Unsupported OS for gum install: ${os}" + return 1 + ;; + esac + + case "${arch}" in + x86_64) + arch_label="x86_64" + ;; + arm64) + arch_label="arm64" + ;; + *) + plain_error "Unsupported architecture for gum install: ${arch}" + return 1 + ;; + esac + + if ! command_exists curl && ! command_exists wget; then + plain_error "Missing curl or wget; install one to download gum." + return 1 + fi + + local download_url="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_${os_label}_${arch_label}.tar.gz" + + plain_info "Downloading gum ${GUM_VERSION} to ${GUM_INSTALL_DIR}..." + + local temp_dir + temp_dir=$(mktemp -d) + local archive_path="${temp_dir}/gum.tar.gz" + + if ! download_file "${download_url}" "${archive_path}"; then + plain_error "Failed to download gum from ${download_url}" + rm -rf "${temp_dir}" + return 1 + fi + + tar -xzf "${archive_path}" -C "${temp_dir}" + + mkdir -p "${GUM_INSTALL_DIR}" + mv "${temp_dir}"/gum_*/gum "${GUM_BINARY}" + chmod +x "${GUM_BINARY}" + echo "${GUM_VERSION}" > "${GUM_VERSION_FILE}" + rm -rf "${temp_dir}" + + if [[ ":${PATH}:" != *":${GUM_INSTALL_DIR}:"* ]]; then + export PATH="${GUM_INSTALL_DIR}:${PATH}" + fi + + plain_info "gum ${GUM_VERSION} installed to ${GUM_INSTALL_DIR}" +} + +# Legacy function for backward compatibility - now redirects to bundled install +install_gum_manual() { + local os="$1" + local arch="$2" + install_bundled_gum "${os}" "${arch}" +} + +install_gum() { + local os + os=$(detect_os) + local arch + arch=$(detect_arch) + + if [[ "${os}" == "unknown" || "${arch}" == "unknown" ]]; then + plain_error "Unsupported platform: ${os}/${arch}" + return 1 + fi + + if command_exists brew; then + plain_info "Installing gum with Homebrew..." + brew install gum + return 0 + fi + + if [[ "${os}" == "linux" ]]; then + if install_gum_linux; then + return 0 + fi + fi + + install_gum_manual "${os}" "${arch}" +} + +ensure_gum() { + # 1. Check bundled gum first (preferred) + if is_bundled_gum_current; then + export PATH="${GUM_INSTALL_DIR}:${PATH}" + return 0 + fi + + # 2. Check system gum + if command_exists gum; then + return 0 + fi + + # 3. Check ~/.local/bin (previous install location) + if [[ -x "${HOME}/.local/bin/gum" ]]; then + export PATH="${HOME}/.local/bin:${PATH}" + return 0 + fi + + # 4. Need to install - prompt user + plain_warn "gum is required for the interactive installer." + + local os + os=$(detect_os) + local arch + arch=$(detect_arch) + + if [[ "${NON_INTERACTIVE}" == "true" ]] || ! has_controlling_tty; then + # In non-interactive mode or when no controlling terminal is available, just install bundled gum + plain_info "Installing bundled gum ${GUM_VERSION}..." + if ! install_bundled_gum "${os}" "${arch}"; then + plain_error "gum installation failed." + exit 1 + fi + else + if ! prompt_confirm_plain "Install gum ${GUM_VERSION} to ${GUM_INSTALL_DIR}?"; then + plain_error "gum is required to continue." + exit 1 + fi + + # Try bundled install first + if ! install_bundled_gum "${os}" "${arch}"; then + # Fall back to system package manager + plain_warn "Bundled install failed, trying system package manager..." + if ! install_gum; then + plain_error "gum installation failed." + exit 1 + fi + fi + fi + + # Verify gum is now available + if ! command_exists gum; then + plain_error "gum is still not available on PATH." + exit 1 + fi +} + +log_info() { + gum log --level info "$1" +} + +log_warn() { + gum log --level warn "$1" +} + +log_error() { + gum log --level error "$1" +} + +install_gum_linux() { + local -a sudo_cmd=() + + if [[ "${EUID}" -ne 0 ]]; then + if command_exists sudo; then + sudo_cmd=(sudo) + else + plain_warn "sudo not available; falling back to manual gum install." + return 1 + fi + fi + + if command_exists apt-get; then + plain_info "Installing gum with apt-get..." + if ! "${sudo_cmd[@]}" apt-get update; then + plain_warn "apt-get update failed; falling back to manual gum install." + return 1 + fi + if "${sudo_cmd[@]}" apt-get install -y gum; then + return 0 + fi + plain_warn "apt-get install failed; falling back to manual gum install." + elif command_exists dnf; then + plain_info "Installing gum with dnf..." + if "${sudo_cmd[@]}" dnf install -y gum; then + return 0 + fi + plain_warn "dnf install failed; falling back to manual gum install." + elif command_exists yum; then + plain_info "Installing gum with yum..." + if "${sudo_cmd[@]}" yum install -y gum; then + return 0 + fi + plain_warn "yum install failed; falling back to manual gum install." + elif command_exists pacman; then + plain_info "Installing gum with pacman..." + if "${sudo_cmd[@]}" pacman -S --noconfirm gum; then + return 0 + fi + plain_warn "pacman install failed; falling back to manual gum install." + elif command_exists zypper; then + plain_info "Installing gum with zypper..." + if "${sudo_cmd[@]}" zypper --non-interactive install gum; then + return 0 + fi + plain_warn "zypper install failed; falling back to manual gum install." + elif command_exists apk; then + plain_info "Installing gum with apk..." + if "${sudo_cmd[@]}" apk add --no-cache gum; then + return 0 + fi + plain_warn "apk install failed; falling back to manual gum install." + fi + + return 1 +} + +run_spinner() { + local title="$1" + shift + gum spin --spinner dot --title "${title}" -- "$@" +} + +# Run command with spinner, show output on failure +run_with_error_output() { + local title="$1" + shift + + local output + local status=0 + output=$("$@" 2>&1) || status=$? + + if [[ ${status} -ne 0 ]]; then + log_error "${title} failed" + if [[ -n "${output}" ]]; then + echo "${output}" + fi + return ${status} + fi + + log_info "${title}: ok" + return 0 +} + +spin_check() { + local label="$1" + local check_cmd="$2" + + if run_spinner "${label}" bash -c "${check_cmd}"; then + log_info "${label}: ok" + return 0 + fi + + log_warn "${label}: missing" + return 1 +} + +run_with_progress() { + local title="$1" + shift + # gum doesn't have a progress command, use spinner instead + run_spinner "${title}" "$@" +} + +run_download_with_progress() { + local title="$1" + shift + + run_spinner "Preparing ${title}" sleep 0.3 + run_with_progress "${title}" "$@" +} + +# Display the AutoMobile logo with animation +# Uses only unicode symbols known to work with agg (DejaVu Sans fallback) +play_logo_animation() { + local RED=$'\033[31m' + local GRAY=$'\033[90m' + local BOLD=$'\033[1m' + local RESET=$'\033[0m' + + # Truck ASCII art (5 lines, ~17 chars wide) + local line1=" ${RED}┌───┐${RESET} " + local line2=" ${RED}╱ │${RESET} " + local line3="${RED}┌─╱ └══════╦${RESET}" + local line4="${RED}│ ┌──┐ ┌──┐ ║${RESET}" + local line5="${RED}└──┘${GRAY}()${RED}└───┘${GRAY}()${RED}└─╝${RESET}" + + local car_height=5 + local car_width=17 + local frame_count=12 + local delay=0.045 + + # Check terminal capabilities + if ! command_exists tput || [[ ! -t 1 ]]; then + echo "" + echo -e "${line1}" + echo -e "${line2}" + echo -e "${line3}" + echo -e "${line4} ${BOLD}AutoMobile${RESET}" + echo -e "${line5}" + echo "" + return 0 + fi + + local term_cols + term_cols=$(tput cols 2>/dev/null || echo 80) + + # Need space for animation + if (( term_cols < 40 )); then + echo "" + echo -e "${line1}" + echo -e "${line2}" + echo -e "${line3}" + echo -e "${line4} ${BOLD}AutoMobile${RESET}" + echo -e "${line5}" + echo "" + return 0 + fi + + local start_pos=$((term_cols - car_width - 2)) + local end_pos=3 + + # Hide cursor + tput civis 2>/dev/null || true + + # Print empty lines for car + echo "" + local i + for ((i = 0; i < car_height; i++)); do + echo "" + done + + # Animation loop + for ((frame = 0; frame <= frame_count; frame++)); do + local pos=$((start_pos - (start_pos - end_pos) * frame / frame_count)) + + # Move cursor up + printf "\033[%dA" "${car_height}" + + # Draw each line with position offset + local lines=("$line1" "$line2" "$line3" "$line4" "$line5") + for line in "${lines[@]}"; do + printf "\033[2K" + if (( pos > 0 )); then + printf "%*s" "${pos}" "" + fi + echo -e "${line}" + done + + sleep "${delay}" + done + + # Final frame with title + printf "\033[%dA" "${car_height}" + printf "\033[2K%*s%s\n" "${end_pos}" "" "${line1}" + printf "\033[2K%*s%s\n" "${end_pos}" "" "${line2}" + printf "\033[2K%*s%s\n" "${end_pos}" "" "${line3}" + printf "\033[2K%*s%s ${BOLD}AutoMobile${RESET}\n" "${end_pos}" "" "${line4}" + printf "\033[2K%*s%s\n" "${end_pos}" "" "${line5}" + echo "" + + # Show cursor + tput cnorm 2>/dev/null || true +} + +# ============================================================================ +# AI Agent Installation Detection +# ============================================================================ + +# Check if Claude Code CLI is installed +is_claude_code_installed() { + command_exists claude +} + +# Check if Claude Desktop is installed +is_claude_desktop_installed() { + local os + os=$(detect_os) + if [[ "${os}" == "macos" ]]; then + [[ -d "${HOME}/Library/Application Support/Claude" ]] || [[ -d "/Applications/Claude.app" ]] + elif [[ "${os}" == "linux" ]]; then + [[ -d "${HOME}/.config/Claude" ]] + else + return 1 + fi +} + +# Check if Cursor is installed +is_cursor_installed() { + [[ -d "${HOME}/.cursor" ]] || command_exists cursor +} + +# Check if Windsurf is installed +is_windsurf_installed() { + [[ -d "${HOME}/.codeium/windsurf" ]] || [[ -d "${HOME}/.codeium" ]] || command_exists windsurf +} + +# Check if VS Code is installed +is_vscode_installed() { + local os + os=$(detect_os) + if command_exists code; then + return 0 + elif [[ "${os}" == "macos" && -d "/Applications/Visual Studio Code.app" ]]; then + return 0 + elif [[ "${os}" == "linux" && -d "${HOME}/.vscode" ]]; then + return 0 + fi + return 1 +} + +# Check if Codex (OpenAI) is installed +is_codex_installed() { + [[ -d "${HOME}/.codex" ]] || command_exists codex +} + +# Check if Firebender IntelliJ plugin is installed +is_firebender_installed() { + # Check for Firebender config directory + if [[ -d "${HOME}/.firebender" ]]; then + return 0 + fi + + # Check for Firebender plugin in IntelliJ-based IDEs + local plugin_dirs=( + "${HOME}/Library/Application Support/Google/AndroidStudio"*"/plugins" + "${HOME}/Library/Application Support/JetBrains/IntelliJIdea"*"/plugins" + "${HOME}/Library/Application Support/JetBrains/IdeaIC"*"/plugins" + "${HOME}/.local/share/Google/AndroidStudio"*"/plugins" + "${HOME}/.local/share/JetBrains/IntelliJIdea"*"/plugins" + "${HOME}/.local/share/JetBrains/IdeaIC"*"/plugins" + ) + + # Use ripgrep to search for firebender in plugin directories + for pattern in "${plugin_dirs[@]}"; do + # shellcheck disable=SC2086 # Glob expansion is intentional + for dir in ${pattern}; do + if [[ -d "${dir}" ]]; then + if rg -q -i "firebender" "${dir}" 2>/dev/null; then + return 0 + fi + # Also check directory names using glob pattern + # shellcheck disable=SC2231 # Glob in loop is intentional + for plugin in "${dir}"/*[Ff]irebender* "${dir}"/*[Ff]ire[Bb]ender*; do + if [[ -e "${plugin}" ]]; then + return 0 + fi + done + fi + done + done + + return 1 +} + +# Check if Goose is installed +is_goose_installed() { + [[ -d "${HOME}/.config/goose" ]] || command_exists goose +} + +# ============================================================================ +# MCP Client Detection and Configuration +# ============================================================================ + +# Add a client to the detection list +# Format: "client_name|config_path|format|scope" +add_mcp_client() { + local name="$1" + local path="$2" + local format="$3" + local scope="$4" + MCP_CLIENT_LIST+=("${name}|${path}|${format}|${scope}") +} + +# Detect all installed MCP clients +detect_mcp_clients() { + local os + os=$(detect_os) + + MCP_CLIENT_LIST=() + + # Claude Code - uses ~/.claude.json for global, .mcp.json for project + if [[ -d "${HOME}/.claude" ]]; then + add_mcp_client "Claude Code (Global)" "${HOME}/.claude.json" "json" "global" + fi + # Always offer project-local option if in a directory + if [[ -d "${PROJECT_ROOT}" ]]; then + add_mcp_client "Claude Code (Project)" "${PROJECT_ROOT}/.mcp.json" "json" "local" + fi + + # Claude Desktop - platform-specific + local claude_desktop_config="" + if [[ "${os}" == "macos" ]]; then + claude_desktop_config="${HOME}/Library/Application Support/Claude/claude_desktop_config.json" + if [[ -d "${HOME}/Library/Application Support/Claude" ]] || [[ -f "${claude_desktop_config}" ]]; then + add_mcp_client "Claude Desktop" "${claude_desktop_config}" "json" "global" + fi + elif [[ "${os}" == "linux" ]]; then + claude_desktop_config="${HOME}/.config/Claude/claude_desktop_config.json" + if [[ -d "${HOME}/.config/Claude" ]] || [[ -f "${claude_desktop_config}" ]]; then + add_mcp_client "Claude Desktop" "${claude_desktop_config}" "json" "global" + fi + fi + + # Cursor - ~/.cursor/mcp.json for global, .cursor/mcp.json for project + if [[ -d "${HOME}/.cursor" ]]; then + add_mcp_client "Cursor (Global)" "${HOME}/.cursor/mcp.json" "json" "global" + fi + if [[ -d "${PROJECT_ROOT}" ]]; then + add_mcp_client "Cursor (Project)" "${PROJECT_ROOT}/.cursor/mcp.json" "json" "local" + fi + + # Windsurf (Codeium) - ~/.codeium/windsurf/mcp_config.json + if [[ -d "${HOME}/.codeium/windsurf" ]] || [[ -d "${HOME}/.codeium" ]]; then + add_mcp_client "Windsurf" "${HOME}/.codeium/windsurf/mcp_config.json" "json" "global" + fi + + # VS Code - check for VS Code installation + local vscode_installed=false + if command_exists code; then + vscode_installed=true + elif [[ "${os}" == "macos" && -d "/Applications/Visual Studio Code.app" ]]; then + vscode_installed=true + elif [[ "${os}" == "linux" && -d "${HOME}/.vscode" ]]; then + vscode_installed=true + fi + + if [[ "${vscode_installed}" == "true" ]]; then + if [[ -d "${PROJECT_ROOT}" ]]; then + add_mcp_client "VS Code (Project)" "${PROJECT_ROOT}/.vscode/mcp.json" "json" "local" + fi + fi + + # Codex (OpenAI) - ~/.codex/config.toml (TOML format!) + if [[ -d "${HOME}/.codex" ]]; then + add_mcp_client "Codex" "${HOME}/.codex/config.toml" "toml" "global" + fi + + # Firebender - ~/.firebender/firebender.json for global, firebender.json for project + if [[ -d "${HOME}/.firebender" ]]; then + add_mcp_client "Firebender (Global)" "${HOME}/.firebender/firebender.json" "json" "global" + fi + if [[ -d "${PROJECT_ROOT}" ]]; then + add_mcp_client "Firebender (Project)" "${PROJECT_ROOT}/firebender.json" "json" "local" + fi + + # Goose - ~/.config/goose/config.yaml (YAML format!) + if [[ -d "${HOME}/.config/goose" ]]; then + add_mcp_client "Goose" "${HOME}/.config/goose/config.yaml" "yaml" "global" + fi +} + +# Get list of detected client names for display +get_detected_client_names() { + for entry in "${MCP_CLIENT_LIST[@]}"; do + echo "${entry}" | cut -d'|' -f1 + done | sort +} + +# Find client entry by name +find_client_entry() { + local name="$1" + for entry in "${MCP_CLIENT_LIST[@]}"; do + local entry_name + entry_name=$(echo "${entry}" | cut -d'|' -f1) + if [[ "${entry_name}" == "${name}" ]]; then + echo "${entry}" + return 0 + fi + done + return 1 +} + +# Get config path for a client +get_client_config_path() { + local client="$1" + local entry + entry=$(find_client_entry "${client}") + if [[ -n "${entry}" ]]; then + echo "${entry}" | cut -d'|' -f2 + fi +} + +# Get config format for a client (json or yaml) +get_client_config_format() { + local client="$1" + local entry + entry=$(find_client_entry "${client}") + if [[ -n "${entry}" ]]; then + echo "${entry}" | cut -d'|' -f3 + fi +} + +# Get config scope for a client (global or local) +get_client_config_scope() { + local client="$1" + local entry + entry=$(find_client_entry "${client}") + if [[ -n "${entry}" ]]; then + echo "${entry}" | cut -d'|' -f4 + fi +} + +# Check if a client config file already has auto-mobile configured +client_has_auto_mobile() { + local client="$1" + local config_path + config_path=$(get_client_config_path "${client}") + local format + format=$(get_client_config_format "${client}") + + if [[ ! -f "${config_path}" ]]; then + return 1 + fi + + if [[ "${format}" == "toml" ]]; then + grep -q '\[mcp_servers.auto-mobile\]' "${config_path}" 2>/dev/null + elif [[ "${format}" == "yaml" ]]; then + grep -q 'auto-mobile:' "${config_path}" 2>/dev/null + else + # JSON - check for "auto-mobile" key in mcpServers + grep -q '"auto-mobile"' "${config_path}" 2>/dev/null + fi +} + +# Interactive MCP client selection +select_mcp_clients() { + detect_mcp_clients + + local available_clients + available_clients=$(get_detected_client_names) + + if [[ -z "${available_clients}" ]]; then + log_warn "No MCP clients detected. Manual configuration may be required." + return 1 + fi + + # Check which clients already have auto-mobile configured + local clients_with_auto_mobile=() + local clients_without_auto_mobile=() + + while IFS= read -r client; do + if client_has_auto_mobile "${client}"; then + clients_with_auto_mobile+=("${client}") + else + clients_without_auto_mobile+=("${client}") + fi + done <<< "${available_clients}" + + gum style --bold "Detected MCP Clients:" + echo "" + + # Show what's detected with their config paths and auto-mobile status + while IFS= read -r client; do + local path + path=$(get_client_config_path "${client}") + local status_marker="" + if [[ -f "${path}" ]]; then + if client_has_auto_mobile "${client}"; then + status_marker=" (auto-mobile configured)" + else + status_marker=" (config exists)" + fi + fi + gum style --faint " ${client}: ${path}${status_marker}" + done <<< "${available_clients}" + + echo "" + + # If some clients already have auto-mobile, offer different options + if [[ ${#clients_with_auto_mobile[@]} -gt 0 ]]; then + local action_choice + action_choice=$(gum choose \ + "Leave existing configurations" \ + "Update existing configurations to use @latest" \ + "Configure new clients only" \ + --header "Some clients already have auto-mobile configured:") + + case "${action_choice}" in + "Leave existing configurations") + log_info "Keeping existing configurations unchanged." + return 1 + ;; + "Update existing configurations to use @latest") + # Select all clients that have auto-mobile for update + SELECTED_MCP_CLIENTS=("${clients_with_auto_mobile[@]}") + log_info "Will update ${#SELECTED_MCP_CLIENTS[@]} existing configuration(s)" + return 0 + ;; + "Configure new clients only") + if [[ ${#clients_without_auto_mobile[@]} -eq 0 ]]; then + log_info "All detected clients already have auto-mobile configured." + return 1 + fi + # Fall through to select from unconfigured clients + available_clients=$(printf '%s\n' "${clients_without_auto_mobile[@]}") + ;; + *) + log_info "No action selected. Skipping MCP configuration." + return 1 + ;; + esac + fi + + echo "" + gum style --italic --foreground 243 "Press SPACE to select/deselect, ENTER to confirm, ESC to skip" + echo "" + + # Multi-select with gum choose + # Use filter for better UX - it allows typing to filter and space to select + local selected + selected=$(printf '%s\n' "${available_clients}" | gum filter --no-limit --placeholder "Type to filter, SPACE to select..." --header "Select clients to configure:") + + if [[ -z "${selected}" ]]; then + log_info "No clients selected. Skipping MCP configuration." + return 1 + fi + + # Store selected clients + SELECTED_MCP_CLIENTS=() + while IFS= read -r client; do + if [[ -n "${client}" ]]; then + SELECTED_MCP_CLIENTS+=("${client}") + fi + done <<< "${selected}" + + if [[ ${#SELECTED_MCP_CLIENTS[@]} -eq 0 ]]; then + log_info "No clients selected. Skipping MCP configuration." + return 1 + fi + + log_info "Selected ${#SELECTED_MCP_CLIENTS[@]} client(s) for configuration" + return 0 +} + +# ============================================================================ +# JSON/YAML Configuration Management +# ============================================================================ + +# Validate JSON file +validate_json() { + local file="$1" + + if [[ ! -f "${file}" ]]; then + return 1 + fi + + if command_exists python3; then + python3 -c "import json; json.load(open('${file}'))" 2>/dev/null + return $? + elif command_exists jq; then + jq empty "${file}" 2>/dev/null + return $? + fi + + return 1 +} + +# Read existing mcpServers from a JSON config or return empty object +get_existing_mcp_servers() { + local config_file="$1" + + if [[ ! -f "${config_file}" ]]; then + echo "{}" + return 0 + fi + + if ! validate_json "${config_file}"; then + echo "{}" + return 1 + fi + + if command_exists python3; then + python3 -c ' +import json, sys +try: + with open(sys.argv[1]) as f: + data = json.load(f) + print(json.dumps(data.get("mcpServers", {}))) +except Exception: + print("{}") +' "${config_file}" + elif command_exists jq; then + jq -r '.mcpServers // {}' "${config_file}" 2>/dev/null || echo "{}" + else + echo "{}" + fi +} + +# Create backup of config file +backup_config() { + local config_file="$1" + + if [[ ! -f "${config_file}" ]]; then + return 0 + fi + + if [[ -z "${BACKUP_TIMESTAMP}" ]]; then + BACKUP_TIMESTAMP=$(date +%Y%m%d_%H%M%S) + fi + + execute "Create backup directory" mkdir -p "${BACKUP_DIR}" + + local backup_name + backup_name=$(basename "${config_file}") + local backup_path="${BACKUP_DIR}/${backup_name}.${BACKUP_TIMESTAMP}" + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] Backup ${config_file} to ${backup_path}") + log_info "[DRY-RUN] Would backup ${config_file} to ${backup_path}" + else + cp "${config_file}" "${backup_path}" + log_info "Backed up to ${backup_path}" + fi +} + +# Merge auto-mobile config into existing JSON config +merge_mcp_config() { + local config_file="$1" + local auto_mobile_config="$2" # JSON string for auto-mobile server + + # Handle case where file doesn't exist + if [[ ! -f "${config_file}" ]]; then + echo "{\"mcpServers\":{\"auto-mobile\":${auto_mobile_config}}}" + return 0 + fi + + # Handle invalid JSON + if ! validate_json "${config_file}"; then + log_warn "Invalid JSON in ${config_file}, will create fresh config" + echo "{\"mcpServers\":{\"auto-mobile\":${auto_mobile_config}}}" + return 0 + fi + + if command_exists python3; then + python3 -c ' +import json, sys + +config_file = sys.argv[1] +new_auto_mobile = json.loads(sys.argv[2]) + +try: + with open(config_file) as f: + existing = json.load(f) +except Exception: + existing = {} + +# Ensure mcpServers exists +if "mcpServers" not in existing: + existing["mcpServers"] = {} + +# Check if auto-mobile already exists +if "auto-mobile" in existing["mcpServers"]: + print("INFO:auto-mobile already configured, will be updated", file=sys.stderr) + +# Merge (overwrites existing auto-mobile) +existing["mcpServers"]["auto-mobile"] = new_auto_mobile + +print(json.dumps(existing, indent=2)) +' "${config_file}" "${auto_mobile_config}" + elif command_exists jq; then + jq --argjson new "${auto_mobile_config}" '.mcpServers["auto-mobile"] = $new' "${config_file}" + else + log_error "Neither python3 nor jq available for JSON manipulation" + return 1 + fi +} + +# Merge auto-mobile config into existing TOML config (for Codex) +merge_toml_config() { + local config_file="$1" + local auto_mobile_toml="$2" # TOML string for auto-mobile server + + # Handle case where file doesn't exist + if [[ ! -f "${config_file}" ]]; then + echo "${auto_mobile_toml}" + return 0 + fi + + if command_exists python3; then + python3 -c ' +import sys + +config_file = sys.argv[1] +new_toml = sys.argv[2] + +try: + with open(config_file) as f: + existing = f.read() +except Exception: + existing = "" + +# Check if auto-mobile already configured +if "[mcp_servers.auto-mobile]" in existing: + print("INFO:auto-mobile already configured in TOML, will be updated", file=sys.stderr) + # Remove existing auto-mobile section (lines from [mcp_servers.auto-mobile] until next section or EOF) + lines = existing.split("\n") + result = [] + skip = False + for line in lines: + stripped = line.strip() + # Start skipping when we hit the exact auto-mobile section header + if stripped == "[mcp_servers.auto-mobile]": + skip = True + continue + # Continue skipping auto-mobile subsections (e.g. [mcp_servers.auto-mobile.env]) + if skip and stripped.startswith("[mcp_servers.auto-mobile."): + continue + # Stop skipping when we hit any other section header + # This correctly preserves [mcp_servers.auto-mobile-dev] etc. + if skip and stripped.startswith("["): + skip = False + if not skip: + result.append(line) + existing = "\n".join(result).strip() + +# Append new config +if existing: + print(existing + "\n\n" + new_toml) +else: + print(new_toml) +' "${config_file}" "${auto_mobile_toml}" + else + log_error "python3 required for TOML manipulation" + return 1 + fi +} + +# Show diff between old and new config (uses colored diff) +show_config_diff() { + local old_content="$1" + local new_content="$2" + local config_path="$3" + + if [[ -z "${old_content}" ]] || [[ "${old_content}" == "{}" ]]; then + show_new_file "${new_content}" "${config_path}" + return 0 + fi + + # Check if content is the same + if [[ "${old_content}" == "${new_content}" ]]; then + log_info "No changes needed for ${config_path}" + return 0 + fi + + show_colored_diff "${old_content}" "${new_content}" "${config_path}" +} + +# Generate auto-mobile MCP server config based on preset +generate_auto_mobile_config() { + local preset="${1:-minimal}" + local android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + + case "${preset}" in + minimal) + cat << 'EOF' +{"command":"npx","args":["-y","@kaeawc/auto-mobile@latest"]} +EOF + ;; + development) + # Only add ANDROID_HOME to env if it wasn't already set in the environment + if [[ -n "${android_home}" && "${ANDROID_HOME_FROM_ENV}" != "true" ]]; then + cat << EOF +{"command":"npx","args":["-y","@kaeawc/auto-mobile@latest","--debug","--debug-perf"],"env":{"ANDROID_HOME":"${android_home}"}} +EOF + else + cat << 'EOF' +{"command":"npx","args":["-y","@kaeawc/auto-mobile@latest","--debug","--debug-perf"]} +EOF + fi + ;; + *) + # Default to minimal + cat << 'EOF' +{"command":"npx","args":["-y","@kaeawc/auto-mobile@latest"]} +EOF + ;; + esac +} + +# Generate auto-mobile MCP server config in TOML format (for Codex) +generate_auto_mobile_config_toml() { + local preset="${1:-minimal}" + local android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + + case "${preset}" in + minimal) + cat << 'EOF' +[mcp_servers.auto-mobile] +command = "npx" +args = ["-y", "@kaeawc/auto-mobile@latest"] +EOF + ;; + development) + # Only add ANDROID_HOME to env if it wasn't already set in the environment + if [[ -n "${android_home}" && "${ANDROID_HOME_FROM_ENV}" != "true" ]]; then + cat << EOF +[mcp_servers.auto-mobile] +command = "npx" +args = ["-y", "@kaeawc/auto-mobile@latest", "--debug", "--debug-perf"] + +[mcp_servers.auto-mobile.env] +ANDROID_HOME = "${android_home}" +EOF + else + cat << 'EOF' +[mcp_servers.auto-mobile] +command = "npx" +args = ["-y", "@kaeawc/auto-mobile@latest", "--debug", "--debug-perf"] +EOF + fi + ;; + *) + # Default to minimal + cat << 'EOF' +[mcp_servers.auto-mobile] +command = "npx" +args = ["-y", "@kaeawc/auto-mobile@latest"] +EOF + ;; + esac +} + +# Check if yq is installed +is_yq_installed() { + command_exists yq +} + +# Install yq for YAML processing +install_yq() { + local os + os=$(detect_os) + + if [[ "${NON_INTERACTIVE}" != "true" ]]; then + if ! gum confirm "yq is required for YAML configuration. Install yq now?"; then + log_info "Skipped yq installation" + return 1 + fi + fi + + if [[ "${os}" == "macos" ]] && command_exists brew; then + if ! run_spinner "Installing yq via Homebrew" brew install yq; then + log_error "Failed to install yq" + return 1 + fi + elif command_exists go; then + if ! run_spinner "Installing yq via go install" go install github.com/mikefarah/yq/v4@latest; then + log_error "Failed to install yq" + return 1 + fi + else + # Try direct binary download + local arch + arch=$(detect_arch) + local yq_binary="yq_${os}_${arch}" + local yq_url="https://github.com/mikefarah/yq/releases/latest/download/${yq_binary}" + + local install_dir="${HOME}/.local/bin" + mkdir -p "${install_dir}" + + if command_exists curl; then + if ! run_spinner "Downloading yq" curl -fsSL "${yq_url}" -o "${install_dir}/yq"; then + log_error "Failed to download yq" + return 1 + fi + elif command_exists wget; then + if ! run_spinner "Downloading yq" wget -qO "${install_dir}/yq" "${yq_url}"; then + log_error "Failed to download yq" + return 1 + fi + else + log_error "curl or wget required to download yq" + return 1 + fi + + chmod +x "${install_dir}/yq" + export PATH="${install_dir}:${PATH}" + fi + + if command_exists yq; then + log_info "yq installed: $(yq --version 2>&1 | head -1)" + return 0 + else + log_error "yq installation failed" + return 1 + fi +} + +# Ensure yq is available, installing if needed +ensure_yq() { + if is_yq_installed; then + return 0 + fi + install_yq +} + +# Generate auto-mobile MCP server config in YAML format (for Goose) +generate_auto_mobile_config_yaml() { + local preset="${1:-minimal}" + local android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + + case "${preset}" in + minimal) + cat << 'EOF' +extensions: + auto-mobile: + name: auto-mobile + type: stdio + enabled: true + cmd: npx + args: + - "-y" + - "@kaeawc/auto-mobile@latest" +EOF + ;; + development) + # Only add ANDROID_HOME to env if it wasn't already set in the environment + if [[ -n "${android_home}" && "${ANDROID_HOME_FROM_ENV}" != "true" ]]; then + cat << EOF +extensions: + auto-mobile: + name: auto-mobile + type: stdio + enabled: true + cmd: npx + args: + - "-y" + - "@kaeawc/auto-mobile@latest" + - "--debug" + - "--debug-perf" + env: + ANDROID_HOME: "${android_home}" +EOF + else + cat << 'EOF' +extensions: + auto-mobile: + name: auto-mobile + type: stdio + enabled: true + cmd: npx + args: + - "-y" + - "@kaeawc/auto-mobile@latest" + - "--debug" + - "--debug-perf" +EOF + fi + ;; + *) + # Default to minimal + cat << 'EOF' +extensions: + auto-mobile: + name: auto-mobile + type: stdio + enabled: true + cmd: npx + args: + - "-y" + - "@kaeawc/auto-mobile@latest" +EOF + ;; + esac +} + +# Merge auto-mobile config into existing YAML config (for Goose) +merge_mcp_config_yaml() { + local config_file="$1" + local auto_mobile_yaml="$2" + + # Handle case where file doesn't exist + if [[ ! -f "${config_file}" ]]; then + echo "${auto_mobile_yaml}" + return 0 + fi + + # Use yq to merge the configs + if ! command_exists yq; then + log_error "yq required for YAML configuration" + return 1 + fi + + # Check if auto-mobile already exists in the config + if yq -e '.extensions.auto-mobile' "${config_file}" &>/dev/null; then + log_info "auto-mobile already configured in YAML, will be updated" + fi + + # Create a temp file with the new auto-mobile config + local temp_new + temp_new=$(mktemp) + echo "${auto_mobile_yaml}" > "${temp_new}" + + # Merge: existing config + new auto-mobile extension + # This overwrites .extensions.auto-mobile with the new config + local merged + merged=$(yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' "${config_file}" "${temp_new}" 2>/dev/null) + + rm -f "${temp_new}" + + if [[ -z "${merged}" ]]; then + log_error "Failed to merge YAML configs" + return 1 + fi + + echo "${merged}" +} + +# Update a single MCP client's configuration +update_mcp_client_config() { + local client_name="$1" + local config_path="$2" + local auto_mobile_config="$3" + local format="${4:-json}" + + log_info "Configuring ${client_name}..." + + # Read existing config + local existing_content="" + if [[ -f "${config_path}" ]]; then + existing_content=$(cat "${config_path}" 2>/dev/null || echo "") + fi + + # Generate merged config based on format + local new_content + if [[ "${format}" == "yaml" ]]; then + if ! new_content=$(merge_mcp_config_yaml "${config_path}" "${auto_mobile_config}"); then + log_error "Failed to generate YAML config for ${client_name}" + return 1 + fi + elif [[ "${format}" == "toml" ]]; then + if ! new_content=$(merge_toml_config "${config_path}" "${auto_mobile_config}"); then + log_error "Failed to generate TOML config for ${client_name}" + return 1 + fi + else + if ! new_content=$(merge_mcp_config "${config_path}" "${auto_mobile_config}"); then + log_error "Failed to generate config for ${client_name}" + return 1 + fi + fi + + # Check if there are any changes needed + if [[ "${existing_content}" == "${new_content}" ]]; then + log_info "No changes needed for ${client_name}" + return 0 + fi + + # Show diff + show_config_diff "${existing_content}" "${new_content}" "${config_path}" + + # Reset terminal after colored output + printf '%s' "${RESET}" + + # In non-interactive mode, just apply + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + backup_config "${config_path}" + printf '%s\n' "${new_content}" > "${config_path}" + log_info "${client_name} configured successfully" + CHANGES_MADE=true + return 0 + fi + + # Confirm with user + local confirm_prompt + if [[ -n "${existing_content}" ]]; then + confirm_prompt="Apply changes to ${config_path}?" + else + confirm_prompt="Create ${config_path}?" + fi + + if ! gum confirm "${confirm_prompt}"; then + log_info "Skipping ${client_name} configuration" + return 0 + fi + + # Backup and write (skip confirmation since we already confirmed above) + backup_config "${config_path}" + + # Create parent directory if needed + local parent_dir + parent_dir=$(dirname "${config_path}") + if [[ ! -d "${parent_dir}" ]]; then + mkdir -p "${parent_dir}" + fi + + printf '%s\n' "${new_content}" > "${config_path}" + log_info "${client_name} configured successfully" + CHANGES_MADE=true +} + +# Configure all selected MCP clients +configure_selected_mcp_clients() { + if [[ ${#SELECTED_MCP_CLIENTS[@]} -eq 0 ]]; then + log_info "No MCP clients selected for configuration" + return 0 + fi + + # Determine which preset config to use + local config_preset="${PRESET:-minimal}" + if [[ -z "${PRESET}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]]; then + # Ask user which preset to use for MCP config + local preset_choice + preset_choice=$(gum choose \ + "Minimal (basic setup)" \ + "Development (debug flags enabled)" \ + --header "Select configuration preset for MCP servers:") + + case "${preset_choice}" in + "Minimal"*) + config_preset="minimal" + ;; + "Development"*) + config_preset="development" + ;; + esac + fi + + local auto_mobile_config_json + auto_mobile_config_json=$(generate_auto_mobile_config "${config_preset}") + local auto_mobile_config_toml + auto_mobile_config_toml=$(generate_auto_mobile_config_toml "${config_preset}") + local auto_mobile_config_yaml + auto_mobile_config_yaml=$(generate_auto_mobile_config_yaml "${config_preset}") + + gum style --bold "Using ${config_preset} preset configuration" + echo "" + + for client in "${SELECTED_MCP_CLIENTS[@]}"; do + local config_path + config_path=$(get_client_config_path "${client}") + local format + format=$(get_client_config_format "${client}") + + if [[ "${format}" == "yaml" ]]; then + # Ensure yq is available for YAML processing + if ! ensure_yq; then + log_warn "YAML configuration for ${client} requires yq. Skipping." + log_info "Manual configuration required for: ${config_path}" + continue + fi + update_mcp_client_config "${client}" "${config_path}" "${auto_mobile_config_yaml}" "yaml" + elif [[ "${format}" == "toml" ]]; then + update_mcp_client_config "${client}" "${config_path}" "${auto_mobile_config_toml}" "toml" + else + update_mcp_client_config "${client}" "${config_path}" "${auto_mobile_config_json}" "json" + fi + echo "" + done +} + +resolve_ide_plugin_url() { + local url="" + + if command_exists curl; then + url=$(curl -fsSL "https://api.github.com/repos/kaeawc/auto-mobile/releases/latest" 2>/dev/null \ + | sed -nE 's/.*"browser_download_url": "([^"]*auto-mobile-ide-plugin[^"]*\.zip)".*/\1/p' \ + | head -n 1 || true) + elif command_exists wget; then + url=$(wget -qO- "https://api.github.com/repos/kaeawc/auto-mobile/releases/latest" 2>/dev/null \ + | sed -nE 's/.*"browser_download_url": "([^"]*auto-mobile-ide-plugin[^"]*\.zip)".*/\1/p' \ + | head -n 1 || true) + fi + + echo "${url}" +} + +detect_ide_plugins_dir() { + if [[ -n "${ANDROID_STUDIO_PLUGINS_DIR:-}" ]]; then + echo "${ANDROID_STUDIO_PLUGINS_DIR}" + return 0 + fi + if [[ -n "${IDEA_PLUGINS_DIR:-}" ]]; then + echo "${IDEA_PLUGINS_DIR}" + return 0 + fi + + local os + os=$(detect_os) + + if [[ "${os}" == "macos" ]]; then + local jetbrains_dir="${HOME}/Library/Application Support/JetBrains" + local google_dir="${HOME}/Library/Application Support/Google" + local candidate="" + + if [[ -d "${jetbrains_dir}" ]]; then + candidate=$(find "${jetbrains_dir}" -maxdepth 1 -type d \( -name "IntelliJIdea*" -o -name "AndroidStudio*" \) 2>/dev/null | sort -r | head -n 1 || true) + if [[ -n "${candidate}" ]]; then + echo "${candidate}/plugins" + return 0 + fi + fi + + if [[ -d "${google_dir}" ]]; then + candidate=$(find "${google_dir}" -maxdepth 1 -type d -name "AndroidStudio*" 2>/dev/null | sort -r | head -n 1 || true) + if [[ -n "${candidate}" ]]; then + echo "${candidate}/plugins" + return 0 + fi + fi + fi + + if [[ "${os}" == "linux" ]]; then + local jetbrains_dir="${HOME}/.local/share/JetBrains" + local google_dir="${HOME}/.local/share/Google" + local candidate="" + + if [[ -d "${jetbrains_dir}" ]]; then + candidate=$(find "${jetbrains_dir}" -maxdepth 1 -type d \( -name "IntelliJIdea*" -o -name "AndroidStudio*" \) 2>/dev/null | sort -r | head -n 1 || true) + if [[ -n "${candidate}" ]]; then + echo "${candidate}/plugins" + return 0 + fi + fi + + if [[ -d "${google_dir}" ]]; then + candidate=$(find "${google_dir}" -maxdepth 1 -type d -name "AndroidStudio*" 2>/dev/null | sort -r | head -n 1 || true) + if [[ -n "${candidate}" ]]; then + echo "${candidate}/plugins" + return 0 + fi + fi + fi + + return 1 +} + +resolve_auto_mobile_command() { + # Prefer npx/bunx over global install to avoid requiring npm install -g + if command_exists npx; then + AUTO_MOBILE_CMD=("npx" "-y" "@kaeawc/auto-mobile@latest") + return 0 + fi + + if command_exists bunx; then + AUTO_MOBILE_CMD=("bunx" "-y" "@kaeawc/auto-mobile@latest") + return 0 + fi + + return 1 +} + +ensure_auto_mobile_command() { + if resolve_auto_mobile_command; then + return 0 + fi + + log_error "AutoMobile CLI not available. Install it or ensure bunx/npx is on PATH." + return 1 +} + +run_auto_mobile_cli() { + if ! ensure_auto_mobile_command; then + return 1 + fi + + "${AUTO_MOBILE_CMD[@]}" --cli "$@" +} + +extract_device_ids() { + local raw="$1" + + if command_exists python3; then + python3 -c 'import json,sys +raw=sys.stdin.read() +try: + data=json.loads(raw) +except json.JSONDecodeError: + sys.exit(1) +def unwrap(payload): + if isinstance(payload, dict): + content=payload.get("content") + if isinstance(content, list) and content: + item=content[0] + if isinstance(item, dict) and item.get("type")=="text": + text=item.get("text","") + try: + return json.loads(text) + except json.JSONDecodeError: + return {} + return payload +data=unwrap(data) +devices=data.get("devices", []) if isinstance(data, dict) else [] +for device in devices: + if isinstance(device, dict): + device_id=device.get("deviceId") + if device_id: + print(device_id)' <<<"${raw}" + return $? + fi + + if command_exists jq; then + echo "${raw}" | jq -r '.content[0].text | fromjson | .devices[]? | .deviceId' 2>/dev/null + return 0 + fi + + return 1 +} + +extract_device_images() { + local raw="$1" + + if command_exists python3; then + python3 -c 'import json,sys +raw=sys.stdin.read() +try: + data=json.loads(raw) +except json.JSONDecodeError: + sys.exit(1) +def unwrap(payload): + if isinstance(payload, dict): + content=payload.get("content") + if isinstance(content, list) and content: + item=content[0] + if isinstance(item, dict) and item.get("type")=="text": + text=item.get("text","") + try: + return json.loads(text) + except json.JSONDecodeError: + return {} + return payload +data=unwrap(data) +images=data.get("images", []) if isinstance(data, dict) else [] +for image in images: + if isinstance(image, dict): + name=image.get("name") or image.get("deviceId") + if name: + print(name) + elif isinstance(image, str): + print(image)' <<<"${raw}" + return $? + fi + + if command_exists jq; then + echo "${raw}" | jq -r '.content[0].text | fromjson | .images[]? | if type == "object" then (.name // .deviceId // empty) else . end' 2>/dev/null + return 0 + fi + + return 1 +} + +ensure_mcp_daemon() { + if [[ "${DAEMON_STARTED}" == "true" ]]; then + return 0 + fi + + if ! start_mcp_daemon; then + return 1 + fi + + DAEMON_STARTED=true +} + +install_auto_mobile_cli() { + if command_exists auto-mobile; then + log_info "AutoMobile CLI already installed." + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + if command_exists bun; then + DRY_RUN_LOG+=("[DRY-RUN] Install AutoMobile CLI with Bun") + log_info "[DRY-RUN] Would install AutoMobile CLI with: bun add -g @kaeawc/auto-mobile@latest" + elif command_exists npm; then + DRY_RUN_LOG+=("[DRY-RUN] Install AutoMobile CLI with npm") + log_info "[DRY-RUN] Would install AutoMobile CLI with: npm install -g @kaeawc/auto-mobile@latest" + else + log_error "Bun or npm is required to install AutoMobile CLI." + return 1 + fi + return 0 + fi + + if command_exists bun; then + local install_output + local install_status=0 + install_output=$(bun add -g @kaeawc/auto-mobile@latest 2>&1) || install_status=$? + + if [[ ${install_status} -eq 0 ]]; then + log_info "AutoMobile CLI installed with Bun" + CHANGES_MADE=true + return 0 + fi + + # Try alternative bun install command + install_output=$(bun install -g @kaeawc/auto-mobile@latest 2>&1) || install_status=$? + + if [[ ${install_status} -eq 0 ]]; then + log_info "AutoMobile CLI installed with Bun" + CHANGES_MADE=true + return 0 + fi + + log_error "AutoMobile CLI installation failed with Bun:" + echo "${install_output}" + return 1 + fi + + if command_exists npm; then + local install_output + local install_status=0 + install_output=$(npm install -g @kaeawc/auto-mobile@latest 2>&1) || install_status=$? + + if [[ ${install_status} -ne 0 ]]; then + log_error "AutoMobile CLI installation failed with npm:" + echo "${install_output}" + return 1 + fi + log_info "AutoMobile CLI installed with npm" + CHANGES_MADE=true + return 0 + fi + + log_error "Bun or npm is required to install AutoMobile CLI." + return 1 +} + +install_claude_marketplace() { + if [[ "${CLAUDE_MARKETPLACE_INSTALLED}" == "true" ]]; then + return 0 + fi + + if [[ "${CLAUDE_CLI_INSTALLED}" != "true" ]]; then + log_error "Claude CLI is required to install marketplace plugin" + return 1 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] Install Claude Marketplace plugin") + log_info "[DRY-RUN] Would run: claude plugin marketplace add kaeawc/auto-mobile" + return 0 + fi + + log_info "Installing Claude Marketplace plugin..." + local install_output + local install_status=0 + install_output=$(claude plugin marketplace add kaeawc/auto-mobile 2>&1) || install_status=$? + + if [[ ${install_status} -ne 0 ]]; then + log_error "Failed to install Claude Marketplace plugin:" + echo "${install_output}" + return 1 + fi + + log_info "Claude Marketplace plugin installed successfully" + CLAUDE_MARKETPLACE_INSTALLED=true + CHANGES_MADE=true + return 0 +} + +install_ide_plugin() { + if [[ -z "${IDE_PLUGIN_DIR}" ]]; then + log_error "IDE plugin directory not set. Skipping IDE plugin install." + return 1 + fi + + if [[ ! -d "${IDE_PLUGIN_DIR}" ]]; then + log_warn "IDE plugins directory not found: ${IDE_PLUGIN_DIR}. Creating it." + mkdir -p "${IDE_PLUGIN_DIR}" + fi + + if ! command_exists unzip; then + log_error "unzip is required to install the IDE plugin." + return 1 + fi + + local plugin_zip="" + local temp_dir="" + local build_log_path="" + + if [[ "${IDE_PLUGIN_METHOD}" == "source" ]]; then + if [[ "${IS_REPO}" != "true" ]]; then + log_error "Plugin build from source requires a local repository checkout." + return 1 + fi + + build_log_path=$(mktemp) + if ! run_with_progress "Building IDE plugin" \ + bash -c "cd \"${PROJECT_ROOT}/android/ide-plugin\" && ./gradlew buildPlugin >\"${build_log_path}\" 2>&1"; then + log_error "IDE plugin build failed. Logs: ${build_log_path}" + return 1 + fi + + plugin_zip=$(find "${PROJECT_ROOT}/android/ide-plugin/build/distributions" -maxdepth 1 -name '*.zip' -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -n 1 || true) + if [[ -z "${plugin_zip}" ]]; then + log_error "No IDE plugin zip found after build." + return 1 + fi + else + if [[ -z "${IDE_PLUGIN_ZIP_URL}" ]]; then + log_error "IDE plugin download URL not provided." + return 1 + fi + + temp_dir=$(mktemp -d) + plugin_zip="${temp_dir}/auto-mobile-ide-plugin.zip" + + if command_exists curl; then + if ! run_download_with_progress "Downloading IDE plugin" \ + curl -fsSL "${IDE_PLUGIN_ZIP_URL}" -o "${plugin_zip}"; then + log_error "Failed to download IDE plugin." + rm -rf "${temp_dir}" + return 1 + fi + elif command_exists wget; then + if ! run_download_with_progress "Downloading IDE plugin" \ + wget -qO "${plugin_zip}" "${IDE_PLUGIN_ZIP_URL}"; then + log_error "Failed to download IDE plugin." + rm -rf "${temp_dir}" + return 1 + fi + else + log_error "curl or wget is required to download the IDE plugin." + rm -rf "${temp_dir}" + return 1 + fi + fi + + local plugin_name="auto-mobile-ide-plugin" + rm -rf "${IDE_PLUGIN_DIR:?}/${plugin_name:?}" + if ! run_spinner "Installing IDE plugin" unzip -q "${plugin_zip}" -d "${IDE_PLUGIN_DIR}"; then + log_error "Failed to unzip IDE plugin." + return 1 + fi + + if [[ -n "${temp_dir}" ]]; then + rm -rf "${temp_dir}" + fi + if [[ -n "${build_log_path}" ]]; then + rm -f "${build_log_path}" + fi + + log_info "Installed IDE plugin to ${IDE_PLUGIN_DIR}/${plugin_name}" + log_info "Restart your IDE to load the AutoMobile plugin." +} + +start_mcp_daemon() { + if ! resolve_auto_mobile_command; then + log_error "AutoMobile CLI not available. Install it or ensure bunx/npx is on PATH." + return 1 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] Start MCP daemon") + DRY_RUN_LOG+=("[DRY-RUN] Check daemon health") + log_info "[DRY-RUN] Would start MCP daemon" + log_info "[DRY-RUN] Would check daemon health" + return 0 + fi + + local daemon_output + local daemon_status=0 + daemon_output=$("${AUTO_MOBILE_CMD[@]}" --daemon start 2>&1) || daemon_status=$? + + if [[ ${daemon_status} -ne 0 ]]; then + # Check for corrupted migrations error + if echo "${daemon_output}" | grep -q "corrupted migrations"; then + # Extract just the migration error message (from the "error:" line, not source code) + local migration_error + migration_error=$(echo "${daemon_output}" | grep "^error: corrupted migrations:" | sed 's/^error: //' | head -1) + if [[ -z "${migration_error}" ]]; then + # Fallback if format is different + migration_error="corrupted migrations (version mismatch)" + fi + log_warn "Database has ${migration_error}" + echo "" + + local should_reset=false + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + # Default to reset in non-interactive mode + should_reset=true + log_info "Resetting database automatically..." + else + if gum confirm "Reset the AutoMobile database to fix this?" --default=true; then + should_reset=true + fi + fi + + if [[ "${should_reset}" == "true" ]]; then + local db_dir="${HOME}/.auto-mobile" + if [[ "${DRY_RUN}" == "true" ]]; then + DRY_RUN_LOG+=("[DRY-RUN] Remove database files in ${db_dir}") + log_info "[DRY-RUN] Would remove database files: ${db_dir}/*.db*" + log_info "[DRY-RUN] Would retry daemon start" + else + # Remove all database files (main db, WAL, SHM) + rm -f "${db_dir}"/*.db* 2>/dev/null || true + log_info "Database files removed from ${db_dir}" + + # Retry daemon start (reset status first) + daemon_status=0 + daemon_output=$("${AUTO_MOBILE_CMD[@]}" --daemon start 2>&1) || daemon_status=$? + if [[ ${daemon_status} -ne 0 ]]; then + log_error "Failed to start MCP daemon after database reset:" + echo "${daemon_output}" + return 1 + fi + log_info "MCP daemon started after database reset" + fi + else + log_error "Cannot start daemon with corrupted database. Exiting." + return 1 + fi + else + log_error "Failed to start MCP daemon:" + echo "${daemon_output}" + return 1 + fi + fi + + local health_output + local health_status=0 + health_output=$("${AUTO_MOBILE_CMD[@]}" --daemon health 2>&1) || health_status=$? + + if [[ ${health_status} -ne 0 ]]; then + log_error "Daemon health check failed:" + echo "${health_output}" + return 1 + fi + + log_info "MCP daemon is running and healthy." +} + +# Install runtime dependencies needed for AutoMobile features. +# These are not development/CI tools — they are required for end-user functionality: +# ffmpeg - video recording and encoding (videoRecording tool) +# vips - native image processing via sharp (observe screenshots, image comparison) +install_runtime_deps() { + local os + os=$(detect_os) + + # ffmpeg — required for video recording features + if ! command_exists ffmpeg; then + if [[ "${os}" == "macos" ]] && command_exists brew; then + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_info "Installing ffmpeg (required for video recording)..." + if run_spinner "Installing ffmpeg" brew install ffmpeg; then + CHANGES_MADE=true + else + log_warn "ffmpeg install failed — video recording will be unavailable" + fi + elif gum confirm "Install ffmpeg? (required for video recording)"; then + if run_spinner "Installing ffmpeg" brew install ffmpeg; then + CHANGES_MADE=true + else + log_warn "ffmpeg install failed — video recording will be unavailable" + fi + else + log_info "Skipped ffmpeg — install later with: brew install ffmpeg" + fi + else + log_warn "ffmpeg not found — video recording will be unavailable" + if [[ "${os}" == "macos" ]]; then + log_info "Install with: brew install ffmpeg" + else + log_info "Install with: apt-get install ffmpeg (or your package manager)" + fi + fi + fi + + # vips — required by sharp for screenshot processing + if ! command_exists vips; then + if [[ "${os}" == "macos" ]] && command_exists brew; then + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_info "Installing vips (required for image processing)..." + if run_spinner "Installing vips" brew install vips; then + CHANGES_MADE=true + else + log_warn "vips install failed — image processing may fall back to slower paths" + fi + elif gum confirm "Install vips? (required for image processing)"; then + if run_spinner "Installing vips" brew install vips; then + CHANGES_MADE=true + else + log_warn "vips install failed — image processing may fall back to slower paths" + fi + else + log_info "Skipped vips — install later with: brew install vips" + fi + else + log_warn "vips not found — image processing may fall back to slower paths" + if [[ "${os}" == "macos" ]]; then + log_info "Install with: brew install vips" + else + log_info "Install with: apt-get install libvips42 libvips-dev (or your package manager)" + fi + fi + fi +} + +handle_bun_setup() { + # Enforce version requirement even if bun is already installed + if [[ "${BUN_INSTALLED}" == "true" ]] && [[ -n "${REQUIRED_BUN_VERSION}" ]]; then + local current_bun_version + current_bun_version=$(bun --version 2>/dev/null || true) + if [[ -n "${current_bun_version}" ]] && ! version_gte "${current_bun_version}" "${REQUIRED_BUN_VERSION}"; then + log_warn "Bun v${current_bun_version} found but v${REQUIRED_BUN_VERSION} required" + BUN_INSTALLED=false + fi + fi + + if [[ "${BUN_INSTALLED}" == "true" ]]; then + return 0 + fi + + # If INSTALL_BUN was explicitly set (e.g., by development preset), skip Yes/No confirmation + if [[ "${INSTALL_BUN}" == "true" ]]; then + if install_bun "true"; then + if command_exists bun; then + BUN_INSTALLED=true + CHANGES_MADE=true + fi + fi + return 0 + fi + + # Otherwise, prompt the user (install_bun handles the Yes/No prompt) + if [[ "${NON_INTERACTIVE}" != "true" ]]; then + if install_bun; then + if command_exists bun; then + BUN_INSTALLED=true + CHANGES_MADE=true + fi + fi + fi + + return 0 +} + +check_android_sdk() { + if [[ "${ANDROID_SDK_DETECTED}" == "true" ]]; then + return 0 + fi + log_warn "Android SDK not detected. Install Android Studio or SDK manually for device support." + log_warn "See https://developer.android.com/studio for installation instructions." + return 1 +} + +ios_log_heading() { + gum style --bold "iOS Setup" + echo "" +} + +ios_check_xcode() { + if [[ "$(detect_os)" != "macos" ]]; then + log_warn "iOS setup requires macOS." + return 1 + fi + + if spin_check "Checking Xcode" "command -v xcodebuild >/dev/null 2>&1"; then + local xcode_version + xcode_version=$(xcodebuild -version 2>/dev/null | head -1 || true) + if [[ -n "${xcode_version}" ]]; then + log_info "Xcode detected: ${xcode_version}" + else + log_info "Xcode detected." + fi + return 0 + fi + + log_warn "Xcode not detected. Install Xcode from the App Store." + return 1 +} + +ios_install_command_line_tools() { + if [[ "$(detect_os)" != "macos" ]]; then + return 1 + fi + + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_warn "Command Line Tools missing. Run: xcode-select --install" + return 1 + fi + + if gum confirm "Command Line Tools missing. Install now?"; then + if execute "Install Command Line Tools" xcode-select --install; then + log_info "Command Line Tools installer started." + log_info "Complete the installer prompt, then re-run this setup." + return 0 + fi + log_warn "Command Line Tools install failed. Run: xcode-select --install" + return 1 + fi + + log_warn "Skipping Command Line Tools install. Run: xcode-select --install" + return 1 +} + +ios_check_command_line_tools() { + if [[ "$(detect_os)" != "macos" ]]; then + return 1 + fi + + if spin_check "Checking Command Line Tools" "xcode-select -p >/dev/null 2>&1"; then + local developer_dir + developer_dir=$(xcode-select -p 2>/dev/null || true) + if [[ -n "${developer_dir}" ]]; then + log_info "Command Line Tools path: ${developer_dir}" + fi + return 0 + fi + + return 1 +} + +ios_get_installed_runtimes() { + local runtime_output="" + local runtimes="" + + if runtime_output=$(xcrun simctl list runtimes -j 2>/dev/null); then + if command_exists python3; then + runtimes=$(python3 -c ' +import json, sys +data=json.loads(sys.stdin.read()) +runtimes=[] +for runtime in data.get("runtimes", []): + name=runtime.get("name", "") + if name.startswith("iOS") and runtime.get("isAvailable", True): + runtimes.append(name) +print("\n".join(runtimes)) +' <<<"${runtime_output}") + else + runtime_output="" + fi + fi + + if [[ -z "${runtime_output}" ]] && runtime_output=$(xcrun simctl list runtimes 2>/dev/null); then + runtimes=$(printf '%s\n' "${runtime_output}" | grep -E "^iOS" | sed 's/ - .*//' | sed 's/[[:space:]]*$//') + fi + + if [[ -z "${runtimes}" ]]; then + return 1 + fi + + IOS_RUNTIME_NAMES=() + while IFS= read -r runtime; do + if [[ -n "${runtime}" ]]; then + IOS_RUNTIME_NAMES+=("${runtime}") + fi + done <<< "${runtimes}" + + return 0 +} + +ios_show_available_runtimes() { + if [[ "$(detect_os)" != "macos" ]]; then + return 1 + fi + + if command_exists xcodebuild; then + if xcodebuild -downloadPlatform iOS -list 2>/dev/null; then + return 0 + fi + fi + + log_warn "Unable to list downloadable runtimes from xcodebuild." + log_info "Open Xcode > Settings > Platforms to view available runtimes." + return 1 +} + +ios_download_runtime() { + local runtime_version="${1:-}" + if [[ -n "${runtime_version}" ]]; then + execute_spinner "Downloading iOS runtime ${runtime_version}" \ + xcodebuild -downloadPlatform iOS -buildVersion "${runtime_version}" + else + execute_spinner "Downloading latest iOS runtime" \ + xcodebuild -downloadPlatform iOS + fi +} + +ios_prompt_download_runtimes() { + if [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_warn "No iOS runtimes installed. Run: xcodebuild -downloadPlatform iOS" + log_info "Or install via Xcode > Settings > Platforms." + return 1 + fi + + gum style --faint "Missing iOS simulator runtimes." + echo "" + + local choice + choice=$(gum choose \ + "Yes, install latest iOS runtime" \ + "Choose runtime version" \ + "Show available runtimes" \ + "No, skip") + + case "${choice}" in + "Yes, install latest iOS runtime") + ios_download_runtime + ;; + "Choose runtime version") + if ios_show_available_runtimes; then + local version + version=$(gum input --prompt "Runtime build version (e.g. 21E213): " --value "") + if [[ -n "${version}" ]]; then + ios_download_runtime "${version}" + else + log_warn "No version provided. Skipping runtime install." + fi + else + local version + version=$(gum input --prompt "Runtime build version (optional): " --value "") + if [[ -n "${version}" ]]; then + ios_download_runtime "${version}" + else + ios_download_runtime + fi + fi + ;; + "Show available runtimes") + ios_show_available_runtimes + ios_prompt_download_runtimes + ;; + *) + log_warn "Skipping runtime installation." + ;; + esac +} + +ios_check_simulator_runtimes() { + if [[ "$(detect_os)" != "macos" ]]; then + return 1 + fi + + if ! command_exists xcrun; then + log_warn "xcrun not available. Install Xcode Command Line Tools." + return 1 + fi + + if ! ios_get_installed_runtimes; then + log_warn "No iOS simulator runtimes available." + ios_prompt_download_runtimes + return 1 + fi + + local runtime_list + runtime_list=$(IFS=", "; printf '%s' "${IOS_RUNTIME_NAMES[*]}") + log_info "iOS runtimes available: ${runtime_list}" + return 0 +} + +ios_check_xctestservice_build() { + if [[ "$(detect_os)" != "macos" ]]; then + return 1 + fi + + if [[ "${IS_REPO}" != "true" ]]; then + log_info "XCTestService build handled by AutoMobile on first use." + return 0 + fi + + local xctest_dir="${PROJECT_ROOT}/ios/XCTestService" + if [[ ! -d "${xctest_dir}" ]]; then + log_warn "XCTestService project not found in repo." + log_info "AutoMobile will install/build XCTestService when needed." + return 1 + fi + + if run_spinner "Validating XCTestService project" bash -c "cd \"${xctest_dir}\" && xcodebuild -list -json >/dev/null 2>&1"; then + log_info "XCTestService project detected. AutoMobile will build on demand." + return 0 + fi + + log_warn "Unable to query XCTestService project. AutoMobile will build on demand." + return 1 +} + +run_ios_setup() { + if [[ "$(detect_os)" != "macos" ]]; then + log_warn "iOS setup skipped (macOS required)." + return 0 + fi + + ios_log_heading + + if ! ios_check_xcode; then + log_warn "Skipping iOS setup because Xcode is missing." + return 0 + fi + + if ! ios_check_command_line_tools; then + ios_install_command_line_tools + fi + + ios_check_simulator_runtimes + ios_check_xctestservice_build + return 0 +} + +collect_choices() { + if [[ "${BUN_INSTALLED}" == "false" ]]; then + if gum confirm "Bun is required for AutoMobile. Install Bun now?"; then + INSTALL_BUN=true + fi + fi + + # Check if IDE plugin is available in latest release + if [[ "${platform_choice}" == "Android" || "${platform_choice}" == "Both" ]]; then + local ide_plugin_url + ide_plugin_url=$(resolve_ide_plugin_url || true) + if [[ -n "${ide_plugin_url}" ]]; then + if gum confirm "Install AutoMobile IntelliJ/Android Studio plugin?"; then + INSTALL_IDE_PLUGIN=true + IDE_PLUGIN_METHOD="release" + IDE_PLUGIN_ZIP_URL="${ide_plugin_url}" + + IDE_PLUGIN_DIR=$(detect_ide_plugins_dir || true) + if [[ -z "${IDE_PLUGIN_DIR}" ]]; then + IDE_PLUGIN_DIR=$(gum input --prompt "IDE plugins directory: " --value "") + fi + fi + fi + fi + + # Only ask about CLI install if not already installed + if [[ "${CLI_ALREADY_INSTALLED}" != "true" ]]; then + if gum confirm "Install AutoMobile CLI (auto-mobile command) globally?"; then + INSTALL_AUTOMOBILE_CLI=true + fi + fi + + # Only ask about daemon if not already running + if [[ "${DAEMON_ALREADY_RUNNING}" != "true" ]]; then + if gum confirm "Start MCP daemon and verify health?"; then + START_DAEMON=true + fi + fi +} + +resolve_android_sdk_root() { + if [[ -n "${ANDROID_HOME:-}" ]]; then + echo "${ANDROID_HOME}" + return 0 + fi + if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + echo "${ANDROID_SDK_ROOT}" + return 0 + fi + if [[ -n "${ANDROID_SDK_HOME:-}" ]]; then + echo "${ANDROID_SDK_HOME}" + return 0 + fi + + if [[ "$(detect_os)" == "macos" ]]; then + echo "${HOME}/Library/Android/sdk" + else + echo "${HOME}/Android/Sdk" + fi +} + +install_bun_curl() { + local temp_dir + temp_dir=$(mktemp -d) + local installer_path="${temp_dir}/bun-install.sh" + local log_path="${temp_dir}/bun-install.log" + + if command_exists curl; then + if ! run_spinner "Downloading Bun installer" \ + curl -fsSL "https://bun.sh/install" -o "${installer_path}"; then + log_error "Failed to download Bun installer." + rm -rf "${temp_dir}" + return 1 + fi + elif command_exists wget; then + if ! run_spinner "Downloading Bun installer" \ + wget -qO "${installer_path}" "https://bun.sh/install"; then + log_error "Failed to download Bun installer." + rm -rf "${temp_dir}" + return 1 + fi + else + log_error "curl or wget is required to download Bun." + rm -rf "${temp_dir}" + return 1 + fi + + chmod +x "${installer_path}" + + if ! run_with_progress "Installing Bun" bash -c "bash \"${installer_path}\" >\"${log_path}\" 2>&1"; then + log_error "Bun installation failed. Logs: ${log_path}" + return 1 + fi + + export PATH="${HOME}/.bun/bin:${PATH}" + rm -rf "${temp_dir}" + return 0 +} + +install_bun_homebrew() { + # Add the oven-sh/bun tap if not already added + if ! brew tap 2>/dev/null | grep -q "oven-sh/bun"; then + if ! run_spinner "Adding Homebrew tap oven-sh/bun" brew tap oven-sh/bun; then + log_error "Failed to add Homebrew tap." + return 1 + fi + fi + + if ! run_spinner "Installing Bun via Homebrew" brew install oven-sh/bun/bun; then + log_error "Bun installation via Homebrew failed." + return 1 + fi + + return 0 +} + +install_bun_npm() { + if ! run_spinner "Installing Bun via npm" npm install -g bun; then + log_error "Bun installation via npm failed." + return 1 + fi + + return 0 +} + +install_bun() { + local skip_confirm="${1:-false}" # If true, skip the Yes/No prompt (already confirmed) + local os + os=$(detect_os) + local install_method="curl" + + # Build list of available installation methods + local options=() + options+=("Official installer (curl | bash)") + if [[ "${os}" == "macos" ]] && command_exists brew; then + options+=("Homebrew (brew install)") + fi + if command_exists npm; then + options+=("npm (npm install -g)") + fi + + # Ask user which method to use + if [[ "${NON_INTERACTIVE}" != "true" ]]; then + if [[ ${#options[@]} -gt 1 ]]; then + # Multiple options - show choose menu with Skip option + options+=("Skip") + local choice + choice=$(printf '%s\n' "${options[@]}" | gum choose --header "How would you like to install Bun?") + + if [[ -z "${choice}" || "${choice}" == "Skip" ]]; then + log_info "Skipped Bun installation" + return 1 + fi + + case "${choice}" in + "Homebrew"*) + install_method="homebrew" + ;; + "npm"*) + install_method="npm" + ;; + *) + install_method="curl" + ;; + esac + else + # Only one option - ask Yes/No confirmation unless already confirmed + if [[ "${skip_confirm}" != "true" ]]; then + if ! gum confirm "Install Bun via official installer (curl | bash)?"; then + log_info "Skipped Bun installation" + return 1 + fi + fi + fi + fi + + local install_status=0 + case "${install_method}" in + homebrew) + install_bun_homebrew || install_status=$? + ;; + npm) + install_bun_npm || install_status=$? + ;; + *) + install_bun_curl || install_status=$? + ;; + esac + + if [[ "${install_status}" -ne 0 ]]; then + return 1 + fi + + if command_exists bun; then + log_info "Bun installed: $(bun --version)" + else + log_warn "Bun installed but not on PATH. Restart your shell or add bun to PATH." + fi + + return 0 +} + +# ============================================================================ +# Preset System +# ============================================================================ + +# Apply a preset configuration +apply_preset() { + local preset_name="$1" + + case "${preset_name}" in + minimal) + # MCP client config only - no CLI, no daemon, no IDE plugin + INSTALL_BUN=false + INSTALL_IDE_PLUGIN=false + INSTALL_AUTOMOBILE_CLI=false + START_DAEMON=false + CONFIGURE_MCP_CLIENTS=true + ;; + marketplace) + # Claude Marketplace plugin + INSTALL_BUN=false + INSTALL_IDE_PLUGIN=false + INSTALL_AUTOMOBILE_CLI=false + START_DAEMON=false + CONFIGURE_MCP_CLIENTS=false + INSTALL_CLAUDE_MARKETPLACE=true + ;; + development) + INSTALL_BUN=true + # IDE plugin only installed if available in release + local ide_url + ide_url=$(resolve_ide_plugin_url || true) + if [[ -n "${ide_url}" ]]; then + INSTALL_IDE_PLUGIN=true + IDE_PLUGIN_METHOD="release" + IDE_PLUGIN_ZIP_URL="${ide_url}" + IDE_PLUGIN_DIR=$(detect_ide_plugins_dir || true) + else + INSTALL_IDE_PLUGIN=false + fi + # Skip CLI install if already installed + if [[ "${CLI_ALREADY_INSTALLED}" == "true" ]]; then + INSTALL_AUTOMOBILE_CLI=false + else + INSTALL_AUTOMOBILE_CLI=true + fi + # Skip daemon start if already running + if [[ "${DAEMON_ALREADY_RUNNING}" == "true" ]]; then + START_DAEMON=false + else + START_DAEMON=true + fi + CONFIGURE_MCP_CLIENTS=true + ;; + local-dev) + # Dependencies for hot-reload local development + INSTALL_BUN=true + RUN_NPM_INSTALL=true + INSTALL_IDE_PLUGIN=false + INSTALL_AUTOMOBILE_CLI=false + START_DAEMON=false + CONFIGURE_MCP_CLIENTS=false + INSTALL_CLAUDE_MARKETPLACE=false + ;; + *) + log_error "Unknown preset: ${preset_name}" + return 1 + ;; + esac +} + +# Check if a client base name has auto-mobile configured +# Matches "Cursor" to "Cursor (Global)" etc. +client_base_has_config() { + local base_name="$1" + detect_mcp_clients + for entry in "${MCP_CLIENT_LIST[@]}"; do + local entry_name + entry_name=$(echo "${entry}" | cut -d'|' -f1) + if [[ "${entry_name}" == "${base_name}"* ]]; then + if client_has_auto_mobile "${entry_name}"; then + return 0 + fi + fi + done + return 1 +} + +# Interactive preset selection +select_preset() { + local choice + local options=() + local has_existing_config=false + + # Check if any AI agent already has auto-mobile configured + if [[ "${CLAUDE_MARKETPLACE_INSTALLED}" == "true" ]]; then + has_existing_config=true + fi + if [[ "${has_existing_config}" != "true" ]]; then + for agent in "Claude Code (Global)" "Claude Desktop" "Cursor" "Windsurf" "VS Code" "Codex" "Firebender" "Goose"; do + if client_base_has_config "${agent}"; then + has_existing_config=true + break + fi + done + fi + + # Keep current setup option first (only if there are existing configs) + if [[ "${has_existing_config}" == "true" ]]; then + options+=("Keep current AI agent setup") + fi + + # Claude marketplace option if Claude CLI is installed + if [[ "${CLAUDE_CLI_INSTALLED}" == "true" ]]; then + if [[ "${CLAUDE_MARKETPLACE_INSTALLED}" == "true" ]]; then + options+=("Claude Marketplace (configured)") + else + options+=("Claude Marketplace") + fi + fi + + # AI Agent MCP client options - only show installed agents with config status + if is_claude_code_installed; then + if client_base_has_config "Claude Code (Global)"; then + options+=("Claude Code (configured)") + else + options+=("Claude Code") + fi + fi + if is_claude_desktop_installed; then + if client_base_has_config "Claude Desktop"; then + options+=("Claude Desktop (configured)") + else + options+=("Claude Desktop") + fi + fi + if is_cursor_installed; then + if client_base_has_config "Cursor"; then + options+=("Cursor (configured)") + else + options+=("Cursor") + fi + fi + if is_windsurf_installed; then + if client_base_has_config "Windsurf"; then + options+=("Windsurf (configured)") + else + options+=("Windsurf") + fi + fi + if is_vscode_installed; then + if client_base_has_config "VS Code"; then + options+=("VS Code (configured)") + else + options+=("VS Code") + fi + fi + if is_codex_installed; then + if client_base_has_config "Codex"; then + options+=("Codex (configured)") + else + options+=("Codex") + fi + fi + if is_firebender_installed; then + if client_base_has_config "Firebender"; then + options+=("Firebender (configured)") + else + options+=("Firebender") + fi + fi + if is_goose_installed; then + if client_base_has_config "Goose"; then + options+=("Goose (configured)") + else + options+=("Goose") + fi + fi + + # Development option (red color via ANSI) + options+=($'\033[31mDevelopment\033[0m') + + # In dry-run or record mode, auto-select Claude Marketplace for demo recording + if [[ "${DRY_RUN}" == "true" || "${RECORD_MODE}" == "true" ]]; then + gum style --bold "Select installation preset:" + sleep 0.3 + echo "" + gum style --foreground 212 "> Claude Marketplace" + sleep 0.2 + choice="Claude Marketplace" + else + choice=$(printf '%s\n' "${options[@]}" | gum filter --header "Select installation preset:" --placeholder "Type to filter...") || true + fi + + # Handle Ctrl+C or empty selection - exit script + if [[ -z "${choice}" ]]; then + echo "" + echo "Installation cancelled." + exit 130 + fi + + case "${choice}" in + "Keep current AI agent setup") + log_info "Keeping current AI agent setup" + log_info "No changes necessary" + exit 0 + ;; + "Claude Marketplace"*) + PRESET="marketplace" + apply_preset "marketplace" + return 0 + ;; + "Claude Code"*|"Claude Desktop"*|"Cursor"*|"Windsurf"*|"VS Code"*|"Codex"*|"Firebender"*|"Goose"*) + # Configure specific AI agent + PRESET="minimal" + apply_preset "minimal" + # Extract base client name - strip " (configured)" suffix if present + PRESET_CLIENT_FILTER="${choice% (configured)}" + return 0 + ;; + *"Development"*) + PRESET="development" + apply_preset "development" + return 0 + ;; + esac + + return 1 +} + +main() { + # Parse command line arguments first (before gum is available) + parse_args "$@" + + # Early detection of existing setup (before gum) + detect_existing_setup + + ensure_gum + + gum style --bold "AutoMobile Interactive Installer" + play_logo_animation + + # Show mode indicators + if [[ "${DRY_RUN}" == "true" ]]; then + echo "" + gum style --foreground 214 --bold "DRY-RUN MODE: No changes will be made" + echo "" + elif [[ "${RECORD_MODE}" == "true" ]]; then + echo "" + gum style --foreground 212 --bold "RECORD MODE: Auto-selecting defaults" + echo "" + fi + + local os + os=$(detect_os) + if [[ "${os}" == "unknown" ]]; then + log_error "This installer supports macOS and Linux only." + exit 1 + fi + + log_info "Starting setup from ${PROJECT_ROOT}" + + # Parse required versions from package.json (when running from repo) + parse_required_versions + + # ========================================================================= + # Detect current setup BEFORE asking any questions + # ========================================================================= + echo "" + gum style --bold "Current Setup" + + # Check Bun + if spin_check "Checking Bun" "command -v bun >/dev/null 2>&1"; then + BUN_INSTALLED=true + else + BUN_INSTALLED=false + fi + + # Check nvm and Node.js + # Source nvm if available but not yet loaded + if [[ -z "${NVM_DIR:-}" ]] && [[ -d "${HOME}/.nvm" ]]; then + export NVM_DIR="${HOME}/.nvm" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && source "${NVM_DIR}/nvm.sh" + fi + + if spin_check "Checking Node.js (nvm)" "command -v node >/dev/null 2>&1"; then + local node_version + node_version=$(node --version 2>/dev/null || echo "unknown") + if [[ -n "${NVM_DIR:-}" ]]; then + log_info "Node.js: ${node_version} (via nvm)" + else + log_info "Node.js: ${node_version}" + fi + + # Enforce node version requirement + if [[ -n "${REQUIRED_NODE_MAJOR}" ]]; then + local current_node_major + current_node_major=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) + if [[ -n "${current_node_major}" ]] && [[ "${current_node_major}" -lt "${REQUIRED_NODE_MAJOR}" ]]; then + log_warn "Node.js v${current_node_major}.x found but v${REQUIRED_NODE_MAJOR}.x required" + # Try nvm switch if available + if [[ -n "${NVM_DIR:-}" ]] && [[ -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + source "${NVM_DIR}/nvm.sh" + if nvm ls "${REQUIRED_NODE_MAJOR}" >/dev/null 2>&1; then + log_info "Switching to Node.js ${REQUIRED_NODE_MAJOR} via nvm..." + nvm use "${REQUIRED_NODE_MAJOR}" >/dev/null 2>&1 || true + elif [[ "${NON_INTERACTIVE}" == "true" ]]; then + log_info "Installing Node.js ${REQUIRED_NODE_MAJOR} via nvm..." + nvm install "${REQUIRED_NODE_MAJOR}" >/dev/null 2>&1 || true + nvm use "${REQUIRED_NODE_MAJOR}" >/dev/null 2>&1 || true + fi + fi + fi + fi + fi + + # Check runtime dependencies + spin_check "Checking ffmpeg" "command -v ffmpeg >/dev/null 2>&1" || true + spin_check "Checking vips" "command -v vips >/dev/null 2>&1" || true + + # Check Android SDK + local adb_check="command -v adb >/dev/null 2>&1 || [[ -x \"${ANDROID_HOME:-}/platform-tools/adb\" ]] || [[ -x \"${ANDROID_SDK_ROOT:-}/platform-tools/adb\" ]] || [[ -x \"${HOME}/Library/Android/sdk/platform-tools/adb\" ]] || [[ -x \"${HOME}/Android/Sdk/platform-tools/adb\" ]]" + if spin_check "Checking Android SDK (adb)" "${adb_check}"; then + ANDROID_SDK_DETECTED=true + ANDROID_SETUP_OK=true + + # Detect ANDROID_HOME + local detected_android_home="" + if [[ -n "${ANDROID_HOME:-}" ]]; then + detected_android_home="${ANDROID_HOME}" + elif [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + detected_android_home="${ANDROID_SDK_ROOT}" + elif [[ -d "${HOME}/Library/Android/sdk" ]]; then + detected_android_home="${HOME}/Library/Android/sdk" + elif [[ -d "${HOME}/Android/Sdk" ]]; then + detected_android_home="${HOME}/Android/Sdk" + fi + + if [[ -n "${detected_android_home}" ]]; then + log_info "Android SDK path: ${detected_android_home}" + + # Offer to set ANDROID_HOME in shell profile if not already set + if [[ "${ANDROID_HOME_FROM_ENV}" != "true" ]]; then + offer_android_home_shell_setup "${detected_android_home}" + fi + + # List available AVDs with API levels + local emulator_path="${detected_android_home}/emulator/emulator" + if [[ -x "${emulator_path}" ]]; then + local avd_list + avd_list=$("${emulator_path}" -list-avds 2>/dev/null | head -10 || true) + if [[ -n "${avd_list}" ]]; then + # Get API levels for each AVD + local avd_info="" + while IFS= read -r avd_name; do + if [[ -n "${avd_name}" ]]; then + local avd_ini="${HOME}/.android/avd/${avd_name}.avd/config.ini" + local api_level="" + if [[ -f "${avd_ini}" ]]; then + api_level=$(grep -o 'image.sysdir.1=.*android-[0-9]*' "${avd_ini}" 2>/dev/null | grep -o 'android-[0-9]*' | head -1 || true) + api_level="${api_level#android-}" + fi + if [[ -n "${api_level}" ]]; then + avd_info="${avd_info}${avd_name} (API ${api_level})," + else + avd_info="${avd_info}${avd_name}," + fi + fi + done <<< "${avd_list}" + avd_info="${avd_info%,}" # Remove trailing comma + if [[ -n "${avd_info}" ]]; then + log_info "Android AVDs available: ${avd_info}" + fi + fi + fi + fi + else + ANDROID_SDK_DETECTED=false + fi + + # Check iOS setup (macOS only) + if [[ "${os}" == "macos" ]]; then + # Check Xcode + if spin_check "Checking Xcode" "command -v xcodebuild >/dev/null 2>&1"; then + local xcode_version + xcode_version=$(xcodebuild -version 2>/dev/null | head -1 || true) + if [[ -n "${xcode_version}" ]]; then + log_info "Xcode detected: ${xcode_version}" + fi + + # Check Command Line Tools + if spin_check "Checking Command Line Tools" "xcode-select -p >/dev/null 2>&1"; then + local clt_path + clt_path=$(xcode-select -p 2>/dev/null || true) + log_info "Command Line Tools path: ${clt_path}" + + # Check iOS runtimes + local runtimes + runtimes=$(xcrun simctl list runtimes 2>/dev/null | grep -o 'iOS [0-9.]*' | tr '\n' ',' | sed 's/,$//' || true) + if [[ -n "${runtimes}" ]]; then + log_info "iOS runtimes available: ${runtimes}" + fi + + IOS_SETUP_OK=true + fi + fi + fi + + # Check AutoMobile CLI + if [[ "${CLI_ALREADY_INSTALLED}" == "true" ]]; then + log_info "Checking AutoMobile CLI: installed" + else + log_info "Checking AutoMobile CLI: not installed" + fi + + # Check MCP daemon + if [[ "${DAEMON_ALREADY_RUNNING}" == "true" ]]; then + log_info "Checking MCP daemon: running" + else + log_info "Checking MCP daemon: not running" + fi + + # Check Claude CLI and marketplace + if [[ "${CLAUDE_CLI_INSTALLED}" == "true" ]]; then + # Check marketplace plugin (deferred from early detection because it's a slow network call) + if spin_check "Checking Claude marketplace plugin" "claude plugin marketplace list 2>/dev/null | grep -q 'auto-mobile' 2>/dev/null"; then + CLAUDE_MARKETPLACE_INSTALLED=true + log_info "Claude CLI: installed (marketplace plugin installed)" + else + log_info "Claude CLI: installed" + fi + else + log_info "Checking Claude CLI: not installed" + fi + + echo "" + + # ========================================================================= + # Handle preset mode + # ========================================================================= + if [[ -n "${PRESET}" ]]; then + apply_preset "${PRESET}" + elif [[ "${NON_INTERACTIVE}" == "true" ]]; then + # Default to minimal in non-interactive mode without preset + apply_preset "minimal" + else + # Interactive mode - offer preset selection + if ! select_preset; then + # User chose "Custom" - continue to interactive selection + : + fi + fi + + # Only do interactive platform/component selection if using Custom preset + local platform_choice="Skip platform setup" + if [[ -z "${PRESET}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]] && [[ "${CONFIGURE_MCP_CLIENTS}" != "true" || "${INSTALL_BUN}" != "true" ]]; then + # Determine if we need to ask about platform setup + local need_platform_choice=false + local platform_options=() + + if [[ "${os}" == "macos" ]]; then + # macOS can have both Android and iOS + if [[ "${ANDROID_SETUP_OK}" == "true" && "${IOS_SETUP_OK}" == "true" ]]; then + # Both platforms fully setup - skip the question + log_info "Both Android and iOS environments detected and ready" + platform_choice="Both" + else + need_platform_choice=true + # Build options based on what's missing + if [[ "${ANDROID_SETUP_OK}" != "true" ]]; then + platform_options+=("Android") + fi + if [[ "${IOS_SETUP_OK}" != "true" ]]; then + platform_options+=("iOS") + fi + if [[ "${ANDROID_SETUP_OK}" != "true" && "${IOS_SETUP_OK}" != "true" ]]; then + platform_options+=("Both") + fi + + # Add skip option with current status + local skip_label="Skip" + if [[ "${ANDROID_SETUP_OK}" == "true" ]]; then + skip_label="Skip (Android ready)" + elif [[ "${IOS_SETUP_OK}" == "true" ]]; then + skip_label="Skip (iOS ready)" + else + skip_label="Skip (no platform setup)" + fi + platform_options+=("${skip_label}") + fi + else + # Non-macOS - only Android is available + if [[ "${ANDROID_SETUP_OK}" == "true" ]]; then + log_info "Android environment detected and ready" + platform_choice="Android" + else + need_platform_choice=true + platform_options+=("Android") + platform_options+=("Skip (no platform setup)") + fi + fi + + if [[ "${need_platform_choice}" == "true" ]]; then + platform_choice=$(printf '%s\n' "${platform_options[@]}" | gum choose --header "Platform setup:") + # Normalize skip choices + if [[ "${platform_choice}" == Skip* ]]; then + platform_choice="Skip platform setup" + fi + fi + + collect_choices + else + # Set platform_choice based on IDE plugin installation (for preset mode) + if [[ "${INSTALL_IDE_PLUGIN}" == "true" ]]; then + platform_choice="Android" + fi + fi + + # MCP Client Configuration (new feature!) + if [[ "${CONFIGURE_MCP_CLIENTS}" == "true" ]]; then + echo "" + gum style --bold "MCP Client Configuration" + echo "" + + if [[ -n "${PRESET_CLIENT_FILTER}" ]]; then + # User selected a specific AI agent - auto-configure matching clients + detect_mcp_clients + local matching_clients=() + for entry in "${MCP_CLIENT_LIST[@]}"; do + local entry_name + entry_name=$(echo "${entry}" | cut -d'|' -f1) + # Match clients that start with the filter (e.g., "Cursor" matches "Cursor (Global)") + if [[ "${entry_name}" == "${PRESET_CLIENT_FILTER}"* ]]; then + matching_clients+=("${entry_name}") + fi + done + + if [[ ${#matching_clients[@]} -gt 0 ]]; then + SELECTED_MCP_CLIENTS=("${matching_clients[@]}") + log_info "Configuring ${PRESET_CLIENT_FILTER}..." + configure_selected_mcp_clients + else + log_warn "No ${PRESET_CLIENT_FILTER} installation detected." + log_info "Install ${PRESET_CLIENT_FILTER} first, then run this installer again." + fi + elif [[ "${NON_INTERACTIVE}" == "true" ]]; then + # In non-interactive mode, auto-detect and configure Claude Code + detect_mcp_clients + local claude_code_entry + claude_code_entry=$(find_client_entry "Claude Code (Global)" 2>/dev/null || echo "") + if [[ -n "${claude_code_entry}" ]]; then + SELECTED_MCP_CLIENTS=("Claude Code (Global)") + configure_selected_mcp_clients + else + log_info "No MCP clients auto-detected in non-interactive mode" + fi + else + if select_mcp_clients; then + configure_selected_mcp_clients + fi + fi + fi + + # Bun setup + handle_bun_setup + + # npm install (for local-dev preset) + if [[ "${RUN_NPM_INSTALL}" == "true" ]] && [[ "${IS_REPO}" == "true" ]]; then + if ! execute_spinner "Running npm install" bash -c "cd '${PROJECT_ROOT}' && npm install"; then + log_warn "npm install failed" + fi + fi + + # Runtime dependencies (needed for AutoMobile features) + install_runtime_deps + + # Platform-specific setup + case "${platform_choice}" in + Android) + # Only run setup if not already detected as ready + if [[ "${ANDROID_SETUP_OK}" != "true" ]]; then + check_android_sdk + fi + if [[ "${INSTALL_IDE_PLUGIN}" == "true" ]]; then + install_ide_plugin + fi + ;; + iOS) + # Only run setup if not already detected as ready + if [[ "${IOS_SETUP_OK}" != "true" ]]; then + run_ios_setup + fi + ;; + Both) + # Only run setup for platforms not already detected as ready + if [[ "${ANDROID_SETUP_OK}" != "true" ]]; then + check_android_sdk + fi + if [[ "${INSTALL_IDE_PLUGIN}" == "true" ]]; then + install_ide_plugin + fi + if [[ "${IOS_SETUP_OK}" != "true" ]]; then + run_ios_setup + fi + ;; + esac + + # CLI installation + if [[ "${INSTALL_AUTOMOBILE_CLI}" == "true" ]]; then + install_auto_mobile_cli + fi + + # Claude Marketplace plugin installation + if [[ "${INSTALL_CLAUDE_MARKETPLACE}" == "true" ]]; then + install_claude_marketplace + fi + + # Daemon startup + if [[ "${START_DAEMON}" == "true" ]]; then + start_mcp_daemon + fi + + # Write environment state for callers (e.g., hot-reload.sh) + write_env_file + + # Print dry-run summary if applicable + print_dry_run_summary + + echo "" + if [[ "${DRY_RUN}" != "true" ]]; then + if [[ "${CHANGES_MADE}" == "true" ]]; then + log_info "Setup complete. Get started: https://kaeawc.github.io/auto-mobile/using/ux-exploration/" + else + log_info "No changes necessary" + fi + fi +} + +main "$@" diff --git a/scripts/ios/setup-ios-simulator.sh b/scripts/ios/setup-ios-simulator.sh new file mode 100755 index 000000000..ab168424b --- /dev/null +++ b/scripts/ios/setup-ios-simulator.sh @@ -0,0 +1,385 @@ +#!/bin/bash +# +# iOS Simulator Setup Script +# Ensures an iOS Simulator runtime is available for the current Xcode version. +# +# This script is designed for CI environments (GitHub Actions) where: +# - Xcode is pre-installed but simulator runtimes may be missing or mismatched +# - The runner may have different simulator runtimes than what Xcode expects +# - Downloads may be required to get a compatible runtime +# +# Usage: +# ./setup-ios-simulator.sh [options] +# +# Options: +# --dry-run Show what would be done without making changes +# --force-download Force download even if a runtime is available +# --skip-download Don't download missing runtimes, just report status +# --verbose Show detailed output +# --min-ios VERSION Minimum iOS version required (default: from project) +# +# Environment Variables: +# IOS_SIMULATOR_DESTINATION Set to override the auto-detected destination +# + +set -euo pipefail + +# Options +DRY_RUN=false +FORCE_DOWNLOAD=false +SKIP_DOWNLOAD=false +VERBOSE=false +MIN_IOS_VERSION="" + +for arg in "$@"; do + case "$arg" in + --dry-run) + DRY_RUN=true + ;; + --force-download) + FORCE_DOWNLOAD=true + ;; + --skip-download) + SKIP_DOWNLOAD=true + ;; + --verbose) + VERBOSE=true + ;; + --min-ios=*) + MIN_IOS_VERSION="${arg#*=}" + ;; + *) + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}ℹ${NC} $1" >&2 +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" >&2 +} + +log_warn() { + echo -e "${YELLOW}⚠${NC} $1" >&2 +} + +log_error() { + echo -e "${RED}✗${NC} $1" >&2 +} + +log_debug() { + if [ "$VERBOSE" = true ]; then + echo -e "${CYAN}→${NC} $1" >&2 + fi +} + +run_cmd() { + if [ "$DRY_RUN" = true ]; then + echo -e " ${YELLOW}↳${NC} (dry-run) $*" >&2 + return 0 + fi + "$@" +} + +echo -e "${CYAN}========================================${NC}" >&2 +echo -e "${CYAN} iOS Simulator Setup${NC}" >&2 +echo -e "${CYAN}========================================${NC}" >&2 +echo "" >&2 + +# Check required tools +if ! command -v rg &> /dev/null; then + log_error "ripgrep (rg) not found. Install with: brew install ripgrep" + exit 1 +fi + +if ! command -v xcodebuild &> /dev/null; then + log_error "xcodebuild not found. Please install Xcode." + exit 1 +fi + +# Get Xcode version info +XCODE_VERSION_FULL=$(xcodebuild -version) +XCODE_VERSION=$(echo "$XCODE_VERSION_FULL" | head -1 | sed 's/Xcode //') +XCODE_BUILD=$(echo "$XCODE_VERSION_FULL" | tail -1 | sed 's/Build version //') +XCODE_PATH=$(xcode-select -p) + +log_info "Xcode version: ${XCODE_VERSION} (${XCODE_BUILD})" +log_info "Xcode path: ${XCODE_PATH}" + +# Detect the iOS Simulator SDK version bundled with this Xcode. +# This tells us the maximum iOS runtime version this Xcode can use. +# On CI runners with multiple Xcode versions, runtimes from newer Xcode +# installations are visible but unusable by older Xcode versions. +MAX_IOS_SDK_VERSION=$(xcodebuild -showsdks 2>/dev/null | sed -n 's/.*-sdk iphonesimulator\([0-9.]*\)/\1/p' | sort -V | tail -1) +if [ -n "$MAX_IOS_SDK_VERSION" ]; then + log_info "iOS Simulator SDK: ${MAX_IOS_SDK_VERSION} (max runtime version)" +else + log_error "Could not detect iOS Simulator SDK version (no iphonesimulator SDK found)" + log_error "Ensure Xcode is installed with iOS Simulator support" + exit 1 +fi +echo "" >&2 + +# Script directory for finding project files +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +# Detect minimum iOS version from project files if not specified +detect_min_ios_version() { + local min_version="15.0" # Default fallback + + # Use ripgrep to find all iOS deployment targets in one pass + # Pattern matches: iOS: "16.0" or iOS: 16.0 + local versions + versions=$(rg -o --no-filename 'iOS:\s*"?([0-9.]+)"?' -r '$1' "${IOS_DIR}"/*/project.yml 2>/dev/null || echo "") + if [ -n "$versions" ]; then + # Get the highest version found + min_version=$(echo "$versions" | sort -V | tail -1) + log_debug "Found deployment targets via ripgrep, highest: ${min_version}" + fi + + echo "$min_version" +} + +if [ -z "$MIN_IOS_VERSION" ]; then + MIN_IOS_VERSION=$(detect_min_ios_version) +fi +log_info "Minimum iOS deployment target: ${MIN_IOS_VERSION}" + +# List available simulator runtimes +echo "" >&2 +echo -e "${BLUE}Available Simulator Runtimes:${NC}" >&2 +RUNTIMES_OUTPUT=$(xcrun simctl list runtimes 2>/dev/null || echo "") +if [ -z "$RUNTIMES_OUTPUT" ]; then + log_warn "No simulator runtimes found" +else + echo "$RUNTIMES_OUTPUT" | grep -E "iOS|watchOS|tvOS|visionOS" | head -10 | while read -r line; do + echo " $line" >&2 + done +fi + +# Parse available iOS runtimes and find the best one +IOS_RUNTIMES=$(echo "$RUNTIMES_OUTPUT" | grep "^iOS" || echo "") +log_debug "iOS runtimes: $IOS_RUNTIMES" + +# Extract version numbers from runtimes (e.g., "iOS 18.5" -> "18.5") +get_available_ios_versions() { + echo "$IOS_RUNTIMES" | sed -n 's/^iOS \([0-9.]*\).*/\1/p' | sort -V +} + +# Check if a version meets the minimum requirement +version_gte() { + local version=$1 + local min=$2 + [ "$(printf '%s\n' "$min" "$version" | sort -V | head -1)" = "$min" ] +} + +# Find the best available iOS runtime (highest version >= minimum, <= max SDK) +find_best_ios_runtime() { + local min_version=$1 + local max_version=$2 + local best_version="" + + while IFS= read -r version; do + if [ -n "$version" ] && version_gte "$version" "$min_version"; then + # Skip runtimes newer than what this Xcode supports + if ! version_gte "$max_version" "$version"; then + log_debug "Skipping iOS ${version} (exceeds SDK ${max_version})" + continue + fi + best_version="$version" + fi + done < <(get_available_ios_versions) + + echo "$best_version" +} + +BEST_IOS_VERSION=$(find_best_ios_runtime "$MIN_IOS_VERSION" "$MAX_IOS_SDK_VERSION") + +if [ -n "$BEST_IOS_VERSION" ]; then + log_success "Found compatible iOS runtime: iOS ${BEST_IOS_VERSION}" +else + log_warn "No compatible iOS runtime found (need >= ${MIN_IOS_VERSION}, <= ${MAX_IOS_SDK_VERSION})" +fi +echo "" >&2 + +# Find available simulator devices for the best runtime +find_simulator_device() { + local ios_version=$1 + + if [ -z "$ios_version" ]; then + echo "" + return + fi + + # Get devices for this iOS version + local devices + devices=$(xcrun simctl list devices "iOS ${ios_version}" 2>/dev/null | grep -E "iPhone|iPad" | grep -v "unavailable" | head -5 || echo "") + + if [ -z "$devices" ]; then + log_debug "No devices found for iOS ${ios_version}" + echo "" + return + fi + + log_debug "Available devices for iOS ${ios_version}:" + log_debug "$devices" + + # Prefer iPhone 16, then iPhone 15, then any iPhone + local device_name="" + if echo "$devices" | grep -q "iPhone 16[^0-9]"; then + device_name=$(echo "$devices" | grep "iPhone 16[^0-9]" | head -1 | sed 's/^[[:space:]]*//' | cut -d'(' -f1 | sed 's/[[:space:]]*$//') + elif echo "$devices" | grep -q "iPhone 15[^0-9]"; then + device_name=$(echo "$devices" | grep "iPhone 15[^0-9]" | head -1 | sed 's/^[[:space:]]*//' | cut -d'(' -f1 | sed 's/[[:space:]]*$//') + elif echo "$devices" | grep -q "iPhone"; then + device_name=$(echo "$devices" | grep "iPhone" | head -1 | sed 's/^[[:space:]]*//' | cut -d'(' -f1 | sed 's/[[:space:]]*$//') + fi + + echo "$device_name" +} + +SIMULATOR_DEVICE=$(find_simulator_device "$BEST_IOS_VERSION") + +# Build the destination string +build_destination() { + local ios_version=$1 + local device_name=$2 + + if [ -n "$device_name" ] && [ -n "$ios_version" ]; then + echo "platform=iOS Simulator,name=${device_name},OS=${ios_version}" + elif [ -n "$ios_version" ]; then + echo "platform=iOS Simulator,OS=${ios_version}" + else + echo "generic/platform=iOS Simulator" + fi +} + +# Check if we need to download +NEEDS_DOWNLOAD=false + +if [ -z "$BEST_IOS_VERSION" ]; then + NEEDS_DOWNLOAD=true +fi + +if [ "$FORCE_DOWNLOAD" = true ]; then + log_info "Force download flag set" + NEEDS_DOWNLOAD=true +fi + +# Handle download if needed +if [ "$NEEDS_DOWNLOAD" = true ]; then + if [ "$SKIP_DOWNLOAD" = true ]; then + log_warn "Download skipped (--skip-download flag)" + log_error "No compatible iOS Simulator runtime available" + exit 1 + fi + + echo "" >&2 + log_info "Downloading iOS platform for Xcode ${XCODE_VERSION}..." + log_info "This may take several minutes..." + echo "" >&2 + + DOWNLOAD_START=$(date +%s) + + if [ "$DRY_RUN" = true ]; then + run_cmd xcodebuild -downloadPlatform iOS + DOWNLOAD_EXIT_CODE=0 + else + set +e + xcodebuild -downloadPlatform iOS 2>&1 | while IFS= read -r line; do + if [ "$VERBOSE" = true ]; then + echo " $line" >&2 + else + if echo "$line" | grep -qE "Downloading|Installing|Progress|%"; then + printf "\r %s" "$line" >&2 + fi + fi + done + DOWNLOAD_EXIT_CODE=${PIPESTATUS[0]} + set -e + echo "" >&2 + fi + + DOWNLOAD_END=$(date +%s) + DOWNLOAD_DURATION=$((DOWNLOAD_END - DOWNLOAD_START)) + + if [ "$DOWNLOAD_EXIT_CODE" -ne 0 ]; then + log_error "Failed to download iOS platform (exit code: ${DOWNLOAD_EXIT_CODE})" + exit 1 + fi + + log_success "Download completed in ${DOWNLOAD_DURATION}s" + echo "" >&2 + + # Refresh runtime list + sleep 2 + RUNTIMES_OUTPUT=$(xcrun simctl list runtimes 2>/dev/null || echo "") + IOS_RUNTIMES=$(echo "$RUNTIMES_OUTPUT" | grep "^iOS" || echo "") + BEST_IOS_VERSION=$(find_best_ios_runtime "$MIN_IOS_VERSION" "$MAX_IOS_SDK_VERSION") + SIMULATOR_DEVICE=$(find_simulator_device "$BEST_IOS_VERSION") +fi + +# Final destination +DESTINATION=$(build_destination "$BEST_IOS_VERSION" "$SIMULATOR_DEVICE") + +# Export for use by other scripts +export IOS_SIMULATOR_RUNTIME="$BEST_IOS_VERSION" +export IOS_SIMULATOR_DEVICE="$SIMULATOR_DEVICE" +export IOS_SIMULATOR_DESTINATION="$DESTINATION" + +# Write to GitHub Actions output if available +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "ios-runtime=${BEST_IOS_VERSION}" + echo "ios-device=${SIMULATOR_DEVICE}" + echo "ios-destination=${DESTINATION}" + } >> "$GITHUB_OUTPUT" +fi + +# Also write to a file for non-GitHub environments +DEST_FILE="${PROJECT_ROOT}/.ios-simulator-destination" +if [ "$DRY_RUN" = false ]; then + cat > "$DEST_FILE" << EOF +# Auto-generated by setup-ios-simulator.sh +# Source this file or read these values for xcodebuild commands +IOS_SIMULATOR_RUNTIME="${BEST_IOS_VERSION}" +IOS_SIMULATOR_DEVICE="${SIMULATOR_DEVICE}" +IOS_SIMULATOR_DESTINATION="${DESTINATION}" +EOF + log_debug "Wrote destination config to ${DEST_FILE}" +fi + +# Summary +echo "" >&2 +echo -e "${CYAN}========================================${NC}" >&2 +echo -e "${CYAN} Setup Complete${NC}" >&2 +echo -e "${CYAN}========================================${NC}" >&2 +echo "" >&2 +echo " Xcode version: ${XCODE_VERSION}" >&2 +echo " Min iOS target: ${MIN_IOS_VERSION}" >&2 +echo " iOS runtime: ${BEST_IOS_VERSION:-none}" >&2 +echo " Simulator device: ${SIMULATOR_DEVICE:-auto}" >&2 +echo " Destination: ${DESTINATION}" >&2 +echo "" >&2 + +if [ -n "$BEST_IOS_VERSION" ]; then + log_success "iOS Simulator ready" + echo "" >&2 + echo -e "${BLUE}Use this destination in xcodebuild:${NC}" >&2 + echo " -destination '${DESTINATION}'" >&2 + exit 0 +else + log_error "No compatible iOS Simulator runtime available" + exit 1 +fi diff --git a/scripts/ios/setup-macos-signing-keychain.sh b/scripts/ios/setup-macos-signing-keychain.sh new file mode 100755 index 000000000..8341c9006 --- /dev/null +++ b/scripts/ios/setup-macos-signing-keychain.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# Install a macOS Developer ID signing certificate into a temporary keychain for CI builds. +# + +set -euo pipefail + +if [[ -z "${MACOS_DEVELOPER_ID_CERT_BASE64:-}" ]]; then + echo "Error: MACOS_DEVELOPER_ID_CERT_BASE64 is required." >&2 + exit 1 +fi + +if [[ -z "${MACOS_DEVELOPER_ID_CERT_PASSWORD:-}" ]]; then + echo "Error: MACOS_DEVELOPER_ID_CERT_PASSWORD is required." >&2 + exit 1 +fi + +KEYCHAIN_PASSWORD="${MACOS_KEYCHAIN_PASSWORD:-}" +KEYCHAIN_PATH="${MACOS_KEYCHAIN_PATH:-${RUNNER_TEMP:-/tmp}/macos-signing.keychain-db}" + +CERTIFICATE_PATH="${RUNNER_TEMP:-/tmp}/macos-signing-certificate.p12" + +security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" +security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}" +security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + +security list-keychains -d user -s "${KEYCHAIN_PATH}" +security default-keychain -s "${KEYCHAIN_PATH}" + +printf '%s' "${MACOS_DEVELOPER_ID_CERT_BASE64}" | base64 --decode > "${CERTIFICATE_PATH}" +security import "${CERTIFICATE_PATH}" -k "${KEYCHAIN_PATH}" -P "${MACOS_DEVELOPER_ID_CERT_PASSWORD}" -T /usr/bin/codesign + +security set-key-partition-list -S apple-tool:,apple: -s -k "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + +rm -f "${CERTIFICATE_PATH}" + +if [[ -n "${GITHUB_ENV:-}" ]]; then + echo "MACOS_KEYCHAIN_PATH=${KEYCHAIN_PATH}" >> "${GITHUB_ENV}" +fi + +echo "Installed macOS Developer ID certificate into keychain: ${KEYCHAIN_PATH}" diff --git a/scripts/ios/setup-signing-keychain.sh b/scripts/ios/setup-signing-keychain.sh new file mode 100755 index 000000000..fc282b6eb --- /dev/null +++ b/scripts/ios/setup-signing-keychain.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# +# Install an Apple signing certificate into a temporary keychain for CI builds. +# + +set -euo pipefail + +if [[ -z "${IOS_CERTIFICATE_BASE64:-}" ]]; then + echo "Error: IOS_CERTIFICATE_BASE64 is required." >&2 + exit 1 +fi + +if [[ -z "${IOS_CERTIFICATE_PASSWORD:-}" ]]; then + echo "Error: IOS_CERTIFICATE_PASSWORD is required." >&2 + exit 1 +fi + +KEYCHAIN_PASSWORD="${IOS_KEYCHAIN_PASSWORD:-}" # empty password is allowed +KEYCHAIN_PATH="${IOS_KEYCHAIN_PATH:-${RUNNER_TEMP:-/tmp}/ios-signing.keychain-db}" + +CERTIFICATE_PATH="${RUNNER_TEMP:-/tmp}/ios-signing-certificate.p12" + +security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" +security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}" +security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + +# Ensure the new keychain is used for subsequent codesign operations. +security list-keychains -d user -s "${KEYCHAIN_PATH}" +security default-keychain -s "${KEYCHAIN_PATH}" + +printf '%s' "${IOS_CERTIFICATE_BASE64}" | base64 --decode > "${CERTIFICATE_PATH}" +security import "${CERTIFICATE_PATH}" -k "${KEYCHAIN_PATH}" -P "${IOS_CERTIFICATE_PASSWORD}" -T /usr/bin/codesign + +security set-key-partition-list -S apple-tool:,apple: -s -k "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + +rm -f "${CERTIFICATE_PATH}" + +if [[ -n "${GITHUB_ENV:-}" ]]; then + echo "IOS_KEYCHAIN_PATH=${KEYCHAIN_PATH}" >> "${GITHUB_ENV}" +fi + +echo "Installed signing certificate into keychain: ${KEYCHAIN_PATH}" diff --git a/scripts/ios/sign-ios-frameworks.sh b/scripts/ios/sign-ios-frameworks.sh new file mode 100755 index 000000000..96aa0988a --- /dev/null +++ b/scripts/ios/sign-ios-frameworks.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# +# Build and sign iOS Swift package frameworks for release builds. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +if [[ -z "${IOS_SIGNING_IDENTITY:-}" ]]; then + echo "Error: IOS_SIGNING_IDENTITY is required." >&2 + exit 1 +fi + +if [[ -z "${IOS_SIGNING_TEAM_ID:-}" ]]; then + echo "Error: IOS_SIGNING_TEAM_ID is required." >&2 + exit 1 +fi + +KEYCHAIN_PATH="${IOS_KEYCHAIN_PATH:-}" +STRICT_MODE="${IOS_SIGNING_STRICT:-false}" + +PACKAGES=( + "XCTestRunner" + "XCTestService" +) + +for package in "${PACKAGES[@]}"; do + PACKAGE_DIR="${IOS_DIR}/${package}" + if [[ ! -d "${PACKAGE_DIR}" ]]; then + echo "Error: ${package} package not found at ${PACKAGE_DIR}" >&2 + exit 1 + fi + + DERIVED_DATA_PATH="${PROJECT_ROOT}/scratch/ios-signing/${package}" + mkdir -p "${DERIVED_DATA_PATH}" + + XCODEBUILD_ARGS=( + -scheme "${package}" + -destination 'generic/platform=iOS' + -configuration Release + -derivedDataPath "${DERIVED_DATA_PATH}" + CODE_SIGN_STYLE=Manual + CODE_SIGN_IDENTITY="${IOS_SIGNING_IDENTITY}" + DEVELOPMENT_TEAM="${IOS_SIGNING_TEAM_ID}" + CODE_SIGNING_ALLOWED=YES + CODE_SIGNING_REQUIRED=YES + ) + + if [[ -n "${KEYCHAIN_PATH}" ]]; then + XCODEBUILD_ARGS+=(OTHER_CODE_SIGN_FLAGS="--keychain ${KEYCHAIN_PATH}") + fi + + echo "Building ${package} for iOS release signing..." + (cd "${PACKAGE_DIR}" && xcodebuild "${XCODEBUILD_ARGS[@]}") + + FRAMEWORK_PATH=$(find "${DERIVED_DATA_PATH}" -type d -name "${package}.framework" -path "*/Release-iphoneos/*" | head -n 1 || true) + if [[ -z "${FRAMEWORK_PATH}" ]]; then + if [[ "${STRICT_MODE}" == "true" ]]; then + echo "Error: ${package}.framework not found in derived data: ${DERIVED_DATA_PATH}" >&2 + exit 1 + else + echo "Warning: ${package}.framework not found in derived data: ${DERIVED_DATA_PATH}" + continue + fi + fi + + echo "Verifying signed framework: ${FRAMEWORK_PATH}" + codesign --verify --strict --verbose=2 "${FRAMEWORK_PATH}" + codesign -dv --verbose=2 "${FRAMEWORK_PATH}" 2>&1 | sed 's/^/ /' + echo "" +done diff --git a/scripts/ios/sign-macos-products.sh b/scripts/ios/sign-macos-products.sh new file mode 100755 index 000000000..322a133fb --- /dev/null +++ b/scripts/ios/sign-macos-products.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# Build and sign macOS Swift package products with Developer ID. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +if [[ -z "${MACOS_DEVELOPER_ID_SIGNING_IDENTITY:-}" ]]; then + echo "Error: MACOS_DEVELOPER_ID_SIGNING_IDENTITY is required." >&2 + exit 1 +fi + +if [[ -z "${MACOS_DEVELOPER_ID_TEAM_ID:-}" ]]; then + echo "Error: MACOS_DEVELOPER_ID_TEAM_ID is required." >&2 + exit 1 +fi + +KEYCHAIN_PATH="${MACOS_KEYCHAIN_PATH:-}" + +CODESIGN_ARGS=( + --force + --options runtime + --timestamp + --sign "${MACOS_DEVELOPER_ID_SIGNING_IDENTITY}" +) + +if [[ -n "${KEYCHAIN_PATH}" ]]; then + CODESIGN_ARGS+=(--keychain "${KEYCHAIN_PATH}") +fi + +sign_paths=() +STRICT_MODE="${MACOS_SIGNING_STRICT:-false}" + +sign_package() { + local package_name="$1" + local product_name="$2" + local require_signables="$3" + local package_dir="${IOS_DIR}/${package_name}" + + if [[ ! -d "${package_dir}" ]]; then + echo "Error: ${package_name} package not found at ${package_dir}" >&2 + exit 1 + fi + + echo "Building ${package_name} (release) for macOS signing..." + local bin_path + bin_path=$(cd "${package_dir}" && swift build -c release --show-bin-path) + + if [[ ! -d "${bin_path}" ]]; then + echo "Error: build output not found for ${package_name}: ${bin_path}" >&2 + exit 1 + fi + + local found=false + if [[ -n "${product_name}" && -e "${bin_path}/${product_name}" ]]; then + sign_paths+=("${bin_path}/${product_name}") + found=true + fi + + while IFS= read -r -d '' candidate; do + sign_paths+=("${candidate}") + found=true + done < <(find "${bin_path}" -maxdepth 2 \( -name "*.app" -o -name "*.appex" -o -name "*.framework" -o -name "*.dylib" \) -print0) + + if [[ "${found}" != true ]]; then + if [[ "${STRICT_MODE}" == "true" && "${require_signables}" == "true" ]]; then + echo "Error: no signable artifacts found for ${package_name} in ${bin_path}" >&2 + exit 1 + fi + echo "Warning: no signable artifacts found for ${package_name} in ${bin_path}" + fi +} + +sign_package "XcodeCompanion" "AutoMobileCompanion" "true" +sign_package "XcodeExtension" "" "false" + +for artifact in "${sign_paths[@]}"; do + echo "Signing ${artifact}" + codesign "${CODESIGN_ARGS[@]}" "${artifact}" + codesign --verify --strict --verbose=2 "${artifact}" + codesign -dv --verbose=2 "${artifact}" 2>&1 | sed 's/^/ /' + echo "" +done diff --git a/scripts/ios/swift-build.sh b/scripts/ios/swift-build.sh new file mode 100755 index 000000000..0661905e2 --- /dev/null +++ b/scripts/ios/swift-build.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# +# Swift Package Build Script +# Builds all Swift packages in the ios directory +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Swift Package Build${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Track overall status +OVERALL_STATUS=0 +FAILED_PACKAGES=() + +# Helper function to print status +print_status() { + local status=$1 + local message=$2 + if [ "$status" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $message" + else + echo -e " ${RED}✗${NC} $message" + OVERALL_STATUS=1 + fi +} + +print_info() { + echo -e " ${BLUE}ℹ${NC} $1" +} + +# Check if swift is available +if ! command -v swift &> /dev/null; then + echo -e "${RED}Error: swift command not found${NC}" + exit 1 +fi + +SWIFT_VERSION=$(swift --version | head -1) +print_info "Swift version: ${SWIFT_VERSION}" +echo "" + +# macOS packages (can be built and tested on macOS) +MACOS_PACKAGES=( + "AXeAutomation" + "XcodeCompanion" + "XcodeExtension" +) + +# iOS + macOS packages (have both platform support) +IOS_MACOS_PACKAGES=( + "XCTestService" + "XCTestRunner" +) + +# iOS-only packages (need to be built for iOS simulator) +IOS_ONLY_PACKAGES=( + "AccessibilityService" +) + +# Build macOS packages +echo -e "${BLUE}Building macOS packages...${NC}" +for package in "${MACOS_PACKAGES[@]}"; do + PACKAGE_DIR="${IOS_DIR}/${package}" + if [ -f "${PACKAGE_DIR}/Package.swift" ]; then + echo -e " Building ${package}..." + if (cd "${PACKAGE_DIR}" && swift build 2>&1); then + print_status 0 "${package} built successfully" + else + print_status 1 "${package} build failed" + FAILED_PACKAGES+=("${package}") + fi + else + print_info "Skipping ${package} (no Package.swift)" + fi +done +echo "" + +# Optional: sign macOS products with Developer ID when credentials are available. +if [[ "${MACOS_SIGNING_ENABLED:-false}" == "true" ]]; then + echo -e "${BLUE}Signing macOS products for release builds...${NC}" + if "${SCRIPT_DIR}/sign-macos-products.sh"; then + print_status 0 "macOS products signed successfully" + else + print_status 1 "macOS product signing failed" + FAILED_PACKAGES+=("macOS product signing") + fi + echo "" +fi + +# Build iOS + macOS packages (build for macOS platform on CI) +echo -e "${BLUE}Building iOS + macOS packages...${NC}" +for package in "${IOS_MACOS_PACKAGES[@]}"; do + PACKAGE_DIR="${IOS_DIR}/${package}" + if [ -f "${PACKAGE_DIR}/Package.swift" ]; then + echo -e " Building ${package}..." + if (cd "${PACKAGE_DIR}" && swift build 2>&1); then + print_status 0 "${package} built successfully" + else + print_status 1 "${package} build failed" + FAILED_PACKAGES+=("${package}") + fi + else + print_info "Skipping ${package} (no Package.swift)" + fi +done +echo "" + +# Build iOS-only packages for iOS simulator +echo -e "${BLUE}Building iOS-only packages for simulator...${NC}" +for package in "${IOS_ONLY_PACKAGES[@]}"; do + PACKAGE_DIR="${IOS_DIR}/${package}" + if [ -f "${PACKAGE_DIR}/Package.swift" ]; then + echo -e " Building ${package} for iOS simulator..." + # Use xcodebuild to build for iOS simulator since swift build doesn't support cross-compilation + if (cd "${PACKAGE_DIR}" && xcodebuild -scheme "${package}" -destination 'generic/platform=iOS Simulator' -quiet build 2>&1); then + print_status 0 "${package} built successfully for iOS simulator" + else + print_status 1 "${package} build failed for iOS simulator" + FAILED_PACKAGES+=("${package}") + fi + else + print_info "Skipping ${package} (no Package.swift)" + fi +done +echo "" + +# Optional: sign XCTestRunner for iOS releases when credentials are available. +if [[ "${IOS_SIGNING_ENABLED:-false}" == "true" ]]; then + echo -e "${BLUE}Signing iOS frameworks for release builds...${NC}" + if "${SCRIPT_DIR}/sign-ios-frameworks.sh"; then + print_status 0 "iOS frameworks signed successfully" + else + print_status 1 "iOS framework signing failed" + FAILED_PACKAGES+=("iOS framework signing") + fi + echo "" +fi + +# Summary +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Build Summary${NC}" +echo -e "${CYAN}========================================${NC}" +if [ ${#FAILED_PACKAGES[@]} -eq 0 ]; then + echo -e "${GREEN}All packages built successfully!${NC}" +else + echo -e "${RED}Failed packages:${NC}" + for pkg in "${FAILED_PACKAGES[@]}"; do + echo -e " ${RED}✗${NC} ${pkg}" + done +fi + +exit $OVERALL_STATUS diff --git a/scripts/ios/swift-test.sh b/scripts/ios/swift-test.sh new file mode 100755 index 000000000..2a5e90b4a --- /dev/null +++ b/scripts/ios/swift-test.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# +# Swift Package Test Script +# Runs tests for all Swift packages that support macOS +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Swift Package Tests${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Track overall status +OVERALL_STATUS=0 +FAILED_PACKAGES=() +SKIPPED_PACKAGES=() +PASSED_PACKAGES=() + +# Helper function to print status +print_status() { + local status=$1 + local message=$2 + if [ "$status" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $message" + else + echo -e " ${RED}✗${NC} $message" + OVERALL_STATUS=1 + fi +} + +print_warning() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +print_info() { + echo -e " ${BLUE}ℹ${NC} $1" +} + +# Check if swift is available +if ! command -v swift &> /dev/null; then + echo -e "${RED}Error: swift command not found${NC}" + exit 1 +fi + +SWIFT_VERSION=$(swift --version | head -1) +print_info "Swift version: ${SWIFT_VERSION}" +echo "" + +# Packages that can be tested on macOS (either macOS-only or cross-platform) +# Note: iOS-only packages cannot run tests on macOS without a simulator +# XCTestService has unit tests that can run on macOS +TESTABLE_PACKAGES=( + "AXeAutomation" + "XcodeCompanion" + "XcodeExtension" + "XCTestService" +) + +# iOS-only packages (tests require iOS simulator - skip in basic test run) +# XCTestRunner UI tests require iOS simulator +IOS_ONLY_PACKAGES=( + "AccessibilityService" + "XCTestRunner" +) + +# Run tests for macOS-compatible packages +echo -e "${BLUE}Running tests for macOS-compatible packages...${NC}" +for package in "${TESTABLE_PACKAGES[@]}"; do + PACKAGE_DIR="${IOS_DIR}/${package}" + if [ -f "${PACKAGE_DIR}/Package.swift" ]; then + echo -e " Testing ${package}..." + + # Check if the package has test targets + if grep -q "testTarget" "${PACKAGE_DIR}/Package.swift"; then + if (cd "${PACKAGE_DIR}" && swift test 2>&1); then + print_status 0 "${package} tests passed" + PASSED_PACKAGES+=("${package}") + else + print_status 1 "${package} tests failed" + FAILED_PACKAGES+=("${package}") + fi + else + print_warning "${package} has no test targets" + SKIPPED_PACKAGES+=("${package} (no tests)") + fi + else + print_info "Skipping ${package} (no Package.swift)" + SKIPPED_PACKAGES+=("${package} (no Package.swift)") + fi +done +echo "" + +# Note about iOS-only packages +echo -e "${BLUE}iOS-only packages (tests skipped - require simulator):${NC}" +for package in "${IOS_ONLY_PACKAGES[@]}"; do + print_warning "${package} - tests require iOS simulator" + SKIPPED_PACKAGES+=("${package} (iOS-only)") +done +echo "" + +# Summary +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Test Summary${NC}" +echo -e "${CYAN}========================================${NC}" + +if [ ${#PASSED_PACKAGES[@]} -gt 0 ]; then + echo -e "${GREEN}Passed:${NC}" + for pkg in "${PASSED_PACKAGES[@]}"; do + echo -e " ${GREEN}✓${NC} ${pkg}" + done +fi + +if [ ${#SKIPPED_PACKAGES[@]} -gt 0 ]; then + echo -e "${YELLOW}Skipped:${NC}" + for pkg in "${SKIPPED_PACKAGES[@]}"; do + echo -e " ${YELLOW}⚠${NC} ${pkg}" + done +fi + +if [ ${#FAILED_PACKAGES[@]} -gt 0 ]; then + echo -e "${RED}Failed:${NC}" + for pkg in "${FAILED_PACKAGES[@]}"; do + echo -e " ${RED}✗${NC} ${pkg}" + done +fi + +echo "" +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" +else + echo -e "${RED}Some tests failed!${NC}" +fi + +exit $OVERALL_STATUS diff --git a/scripts/ios/xcode-build.sh b/scripts/ios/xcode-build.sh new file mode 100755 index 000000000..845529f44 --- /dev/null +++ b/scripts/ios/xcode-build.sh @@ -0,0 +1,190 @@ +#!/bin/bash +# +# Xcode Project Build Script +# Builds Xcode projects (xcodeproj) for iOS simulator +# + +set -e + +# Options +DRY_RUN=false +for arg in "$@"; do + case "$arg" in + --dry-run) + DRY_RUN=true + ;; + *) + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Xcode Project Build${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Track overall status +OVERALL_STATUS=0 +BUILT_PROJECTS=() +FAILED_PROJECTS=() + +# Helper function to print status +print_status() { + local status=$1 + local message=$2 + if [ "$status" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $message" + else + echo -e " ${RED}✗${NC} $message" + OVERALL_STATUS=1 + fi +} + +print_info() { + echo -e " ${BLUE}ℹ${NC} $1" +} + +run_cmd() { + if [ "$DRY_RUN" = true ]; then + echo -e " ${YELLOW}↳${NC} (dry-run) $*" + return 0 + fi + "$@" +} + +has_simulator_sdk() { + if [ "$DRY_RUN" = true ]; then + echo -e "${YELLOW}(dry-run) Skipping simulator SDK detection.${NC}" + return 0 + fi + xcodebuild -showsdks 2>/dev/null | grep -q "iphonesimulator" +} + +# Check if xcodebuild is available +if ! command -v xcodebuild &> /dev/null; then + echo -e "${RED}Error: xcodebuild not found. Please install Xcode.${NC}" + exit 1 +fi + +XCODE_VERSION=$(xcodebuild -version) +XCODE_VERSION=${XCODE_VERSION%%$'\n'*} +print_info "Xcode version: ${XCODE_VERSION}" + +DESTINATION="generic/platform=iOS Simulator" +print_info "Using iOS destination: ${DESTINATION}" +echo "" + +# Ensure iOS Simulator SDK is installed +if ! has_simulator_sdk; then + echo -e "${YELLOW}No iOS Simulator SDK detected. Attempting to install iOS platform...${NC}" + set +e + run_cmd xcodebuild -downloadPlatform iOS 2>&1 + DOWNLOAD_EXIT_CODE=$? + set -e + + if [ $DOWNLOAD_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Failed to download iOS platform (exit ${DOWNLOAD_EXIT_CODE}).${NC}" + fi + + if ! has_simulator_sdk; then + echo -e "${RED}iOS Simulator SDK still missing after download attempt.${NC}" + echo -e "${YELLOW}Install the iOS platform in Xcode or ensure CI has the simulator SDK preinstalled.${NC}" + exit 1 + fi +fi + +# Find all xcodeproj directories using glob (faster than find) +echo -e "${BLUE}Searching for Xcode projects...${NC}" +shopt -s nullglob +XCODEPROJ_ARRAY=("${IOS_DIR}"/*/*.xcodeproj "${IOS_DIR}"/*.xcodeproj) +shopt -u nullglob + +if [ ${#XCODEPROJ_ARRAY[@]} -eq 0 ]; then + echo -e "${YELLOW}No Xcode projects found in ${IOS_DIR}${NC}" + exit 0 +fi + +# Build each project +for xcodeproj in "${XCODEPROJ_ARRAY[@]}"; do + PROJECT_NAME=$(basename "${xcodeproj}" .xcodeproj) + + echo -e " Building ${PROJECT_NAME}..." + + # Get available schemes + SCHEMES=$(run_cmd xcodebuild -project "${xcodeproj}" -list 2>/dev/null | sed -n '/Schemes:/,/^$/p' | grep -v "Schemes:" | sed 's/^[[:space:]]*//' | grep -v '^$' || true) + + if [ -z "${SCHEMES}" ]; then + print_info "No schemes found for ${PROJECT_NAME}, skipping" + continue + fi + + # Build each scheme for iOS simulator + BUILD_SUCCESS=true + while IFS= read -r scheme; do + if [ -n "${scheme}" ]; then + echo -e " Building scheme: ${scheme}..." + if run_cmd xcodebuild \ + -project "${xcodeproj}" \ + -scheme "${scheme}" \ + -destination "${DESTINATION}" \ + -configuration Debug \ + -quiet \ + build 2>&1; then + echo -e " ${GREEN}✓${NC} ${scheme} built" + else + echo -e " ${RED}✗${NC} ${scheme} failed" + BUILD_SUCCESS=false + fi + fi + done <<< "${SCHEMES}" + + if [ "${BUILD_SUCCESS}" = true ]; then + print_status 0 "${PROJECT_NAME} built successfully" + BUILT_PROJECTS+=("${PROJECT_NAME}") + else + print_status 1 "${PROJECT_NAME} build failed" + FAILED_PROJECTS+=("${PROJECT_NAME}") + fi +done +echo "" + +# Summary +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Build Summary${NC}" +echo -e "${CYAN}========================================${NC}" + +if [ ${#BUILT_PROJECTS[@]} -gt 0 ]; then + echo -e "${GREEN}Built:${NC}" + for proj in "${BUILT_PROJECTS[@]}"; do + echo -e " ${GREEN}✓${NC} ${proj}" + done +fi + +if [ ${#FAILED_PROJECTS[@]} -gt 0 ]; then + echo -e "${RED}Failed:${NC}" + for proj in "${FAILED_PROJECTS[@]}"; do + echo -e " ${RED}✗${NC} ${proj}" + done +fi + +echo "" +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}All Xcode projects built successfully!${NC}" +else + echo -e "${RED}Some projects failed to build!${NC}" +fi + +exit $OVERALL_STATUS diff --git a/scripts/ios/xcode-test.sh b/scripts/ios/xcode-test.sh new file mode 100755 index 000000000..c292dd311 --- /dev/null +++ b/scripts/ios/xcode-test.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# +# Xcode Project Test Script +# Runs tests for Xcode projects (xcodeproj) on iOS simulator +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Xcode Project Tests${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Track overall status +OVERALL_STATUS=0 +TESTED_PROJECTS=() +FAILED_PROJECTS=() +SKIPPED_PROJECTS=() + +# Helper function to print status +print_status() { + local status=$1 + local message=$2 + if [ "$status" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $message" + else + echo -e " ${RED}✗${NC} $message" + OVERALL_STATUS=1 + fi +} + +print_warning() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +print_info() { + echo -e " ${BLUE}ℹ${NC} $1" +} + +# Check if xcodebuild is available +if ! command -v xcodebuild &> /dev/null; then + echo -e "${RED}Error: xcodebuild not found. Please install Xcode.${NC}" + exit 1 +fi + +XCODE_VERSION=$(xcodebuild -version | head -1) +print_info "Xcode version: ${XCODE_VERSION}" + +# Find an available iOS simulator +# First try to find a booted one, then fall back to any available iPhone simulator +find_simulator() { + # Look for a booted iPhone simulator + local booted_sim + booted_sim=$(xcrun simctl list devices booted 2>/dev/null | grep -E "iPhone.*Booted" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/') + if [ -n "${booted_sim}" ]; then + echo "${booted_sim}" + return + fi + + # No booted simulator - look for any available iPhone simulator + local available_sim + available_sim=$(xcrun simctl list devices available 2>/dev/null | grep -E "iPhone 16[^e]" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/') + if [ -n "${available_sim}" ]; then + echo "${available_sim}" + return + fi + + # Fall back to any iPhone + available_sim=$(xcrun simctl list devices available 2>/dev/null | grep -E "iPhone" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/') + echo "${available_sim}" +} + +SIMULATOR_ID=$(find_simulator) +if [ -z "${SIMULATOR_ID}" ]; then + echo -e "${YELLOW}Warning: No iOS simulator available. Xcode tests will be skipped.${NC}" + echo -e "${YELLOW}To run tests, create a simulator: xcrun simctl create 'iPhone 16' 'iPhone 16'${NC}" + echo "" + exit 0 +fi + +# Get simulator name for display +SIMULATOR_NAME=$(xcrun simctl list devices 2>/dev/null | grep "${SIMULATOR_ID}" | sed -E 's/^[[:space:]]+([^(]+).*/\1/' | sed 's/[[:space:]]*$//') +print_info "Using simulator: ${SIMULATOR_NAME} (${SIMULATOR_ID})" +echo "" + +# Build destination string +DESTINATION="platform=iOS Simulator,id=${SIMULATOR_ID}" + +# Find all xcodeproj directories +echo -e "${BLUE}Searching for Xcode projects...${NC}" +XCODEPROJ_DIRS=$(find "${IOS_DIR}" -name "*.xcodeproj" -type d 2>/dev/null || true) + +if [ -z "${XCODEPROJ_DIRS}" ]; then + echo -e "${YELLOW}No Xcode projects found in ${IOS_DIR}${NC}" + exit 0 +fi + +# Test each project +for xcodeproj in ${XCODEPROJ_DIRS}; do + PROJECT_NAME=$(basename "${xcodeproj}" .xcodeproj) + + echo -e " Testing ${PROJECT_NAME}..." + + # Get available schemes + SCHEMES=$(xcodebuild -project "${xcodeproj}" -list 2>/dev/null | sed -n '/Schemes:/,/^$/p' | grep -v "Schemes:" | sed 's/^[[:space:]]*//' | grep -v '^$' || true) + + if [ -z "${SCHEMES}" ]; then + print_warning "${PROJECT_NAME} has no schemes, skipping" + SKIPPED_PROJECTS+=("${PROJECT_NAME} (no schemes)") + continue + fi + + # Run tests for the first scheme (usually the main app scheme) + TEST_SUCCESS=true + TESTS_RAN=false + FIRST_SCHEME=$(echo "${SCHEMES}" | head -1) + + if [ -n "${FIRST_SCHEME}" ]; then + echo -e " Testing scheme: ${FIRST_SCHEME}..." + + # Try to run tests + if xcodebuild \ + -project "${xcodeproj}" \ + -scheme "${FIRST_SCHEME}" \ + -destination "${DESTINATION}" \ + -configuration Debug \ + -quiet \ + test 2>&1; then + echo -e " ${GREEN}✓${NC} ${FIRST_SCHEME} tests passed" + TESTS_RAN=true + else + echo -e " ${RED}✗${NC} ${FIRST_SCHEME} tests failed" + TEST_SUCCESS=false + TESTS_RAN=true + fi + fi + + if [ "${TESTS_RAN}" = false ]; then + print_warning "${PROJECT_NAME} has no test targets" + SKIPPED_PROJECTS+=("${PROJECT_NAME} (no tests)") + elif [ "${TEST_SUCCESS}" = true ]; then + print_status 0 "${PROJECT_NAME} tests passed" + TESTED_PROJECTS+=("${PROJECT_NAME}") + else + print_status 1 "${PROJECT_NAME} tests failed" + FAILED_PROJECTS+=("${PROJECT_NAME}") + fi +done +echo "" + +# Summary +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Test Summary${NC}" +echo -e "${CYAN}========================================${NC}" + +if [ ${#TESTED_PROJECTS[@]} -gt 0 ]; then + echo -e "${GREEN}Passed:${NC}" + for proj in "${TESTED_PROJECTS[@]}"; do + echo -e " ${GREEN}✓${NC} ${proj}" + done +fi + +if [ ${#SKIPPED_PROJECTS[@]} -gt 0 ]; then + echo -e "${YELLOW}Skipped:${NC}" + for proj in "${SKIPPED_PROJECTS[@]}"; do + echo -e " ${YELLOW}⚠${NC} ${proj}" + done +fi + +if [ ${#FAILED_PROJECTS[@]} -gt 0 ]; then + echo -e "${RED}Failed:${NC}" + for proj in "${FAILED_PROJECTS[@]}"; do + echo -e " ${RED}✗${NC} ${proj}" + done +fi + +echo "" +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}All Xcode tests passed!${NC}" +else + echo -e "${RED}Some tests failed!${NC}" +fi + +exit $OVERALL_STATUS diff --git a/scripts/ios/xcodegen-generate.sh b/scripts/ios/xcodegen-generate.sh new file mode 100755 index 000000000..619612857 --- /dev/null +++ b/scripts/ios/xcodegen-generate.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# +# XcodeGen Project Generation Script +# Generates Xcode projects from project.yml files +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +IOS_DIR="${PROJECT_ROOT}/ios" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XcodeGen Project Generation${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Track overall status +OVERALL_STATUS=0 +GENERATED_PROJECTS=() +FAILED_PROJECTS=() + +# Helper function to print status +print_status() { + local status=$1 + local message=$2 + if [ "$status" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $message" + else + echo -e " ${RED}✗${NC} $message" + OVERALL_STATUS=1 + fi +} + +print_info() { + echo -e " ${BLUE}ℹ${NC} $1" +} + +# Check if xcodegen is available +if ! command -v xcodegen &> /dev/null; then + echo -e "${YELLOW}Warning: xcodegen not found, attempting to install via Homebrew...${NC}" + if command -v brew &> /dev/null; then + brew install xcodegen + else + echo -e "${RED}Error: Neither xcodegen nor Homebrew is available${NC}" + echo -e "Install XcodeGen: brew install xcodegen" + exit 1 + fi +fi + +XCODEGEN_VERSION=$(xcodegen --version) +print_info "XcodeGen version: ${XCODEGEN_VERSION}" +echo "" + +# Find all project.yml files +echo -e "${BLUE}Searching for project.yml files...${NC}" +PROJECT_YML_FILES=$(find "${IOS_DIR}" -name "project.yml" -type f 2>/dev/null || true) + +if [ -z "${PROJECT_YML_FILES}" ]; then + echo -e "${YELLOW}No project.yml files found in ${IOS_DIR}${NC}" + exit 0 +fi + +# Generate projects +for project_yml in ${PROJECT_YML_FILES}; do + PROJECT_DIR=$(dirname "${project_yml}") + PROJECT_NAME=$(basename "${PROJECT_DIR}") + + echo -e " Generating ${PROJECT_NAME}..." + + if (cd "${PROJECT_DIR}" && xcodegen generate 2>&1); then + print_status 0 "${PROJECT_NAME} project generated" + GENERATED_PROJECTS+=("${PROJECT_NAME}") + else + print_status 1 "${PROJECT_NAME} generation failed" + FAILED_PROJECTS+=("${PROJECT_NAME}") + fi +done +echo "" + +# Summary +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Generation Summary${NC}" +echo -e "${CYAN}========================================${NC}" + +if [ ${#GENERATED_PROJECTS[@]} -gt 0 ]; then + echo -e "${GREEN}Generated:${NC}" + for proj in "${GENERATED_PROJECTS[@]}"; do + echo -e " ${GREEN}✓${NC} ${proj}" + done +fi + +if [ ${#FAILED_PROJECTS[@]} -gt 0 ]; then + echo -e "${RED}Failed:${NC}" + for proj in "${FAILED_PROJECTS[@]}"; do + echo -e " ${RED}✗${NC} ${proj}" + done +fi + +echo "" +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e "${GREEN}All projects generated successfully!${NC}" +else + echo -e "${RED}Some projects failed to generate!${NC}" +fi + +exit $OVERALL_STATUS diff --git a/scripts/ios/xctestrunner-integration-tests.sh b/scripts/ios/xctestrunner-integration-tests.sh new file mode 100755 index 000000000..a1b516bf5 --- /dev/null +++ b/scripts/ios/xctestrunner-integration-tests.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# XCTestRunner Integration Tests Script +# Runs the XCTestRunner integration tests against a simulator +# +# Usage: +# ./scripts/ios/xctestrunner-integration-tests.sh [test-filter] +# +# Arguments: +# test-filter Optional test filter (default: RemindersLaunchPlanTests) +# +# Environment Variables: +# AUTOMOBILE_TEST_PLAN Test plan file to use (default: Plans/launch-reminders-app.yaml) +# +# Prerequisites: +# - XCTestService artifacts must be built (run xctestservice-build-for-testing.sh) +# - A simulator must be booted +# - Bun must be installed (for the MCP daemon) + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +XCTESTRUNNER_DIR="${PROJECT_ROOT}/ios/XCTestRunner" + +# Test filter +TEST_FILTER="${1:-RemindersLaunchPlanTests}" + +# Test plan +TEST_PLAN="${AUTOMOBILE_TEST_PLAN:-Plans/launch-reminders-app.yaml}" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestRunner Integration Tests${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" +echo -e "${BLUE}Test filter:${NC} ${TEST_FILTER}" +echo -e "${BLUE}Test plan:${NC} ${TEST_PLAN}" +echo "" + +# Check prerequisites +echo -e "${BLUE}Checking prerequisites...${NC}" + +# Check if XCTestService artifacts exist +if ! "${SCRIPT_DIR}/xctestservice-verify-artifacts.sh" >/dev/null 2>&1; then + echo -e " ${RED}✗${NC} XCTestService artifacts not found" + echo -e "${YELLOW}Run ./scripts/ios/xctestservice-build-for-testing.sh first${NC}" + exit 1 +fi +echo -e " ${GREEN}✓${NC} XCTestService artifacts found" + +# Check if simulator is booted +BOOTED_DEVICE=$(xcrun simctl list devices booted -j | grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "\(.*\)"/\1/') +if [ -z "$BOOTED_DEVICE" ]; then + echo -e " ${RED}✗${NC} No booted simulator found" + echo -e "${YELLOW}Boot a simulator first: xcrun simctl boot 'iPhone 15'${NC}" + exit 1 +fi +echo -e " ${GREEN}✓${NC} Simulator booted: ${BOOTED_DEVICE}" + +# Check if bun is available +if ! command -v bun &> /dev/null; then + echo -e " ${RED}✗${NC} Bun not found" + echo -e "${YELLOW}Install bun: curl -fsSL https://bun.sh/install | bash${NC}" + exit 1 +fi +echo -e " ${GREEN}✓${NC} Bun available" + +echo "" + +# Run tests +echo -e "${BLUE}Running integration tests...${NC}" +echo "" + +cd "${XCTESTRUNNER_DIR}" +AUTOMOBILE_TEST_PLAN="${TEST_PLAN}" swift test --filter "${TEST_FILTER}" + +echo "" +echo -e "${GREEN}Integration tests completed${NC}" diff --git a/scripts/ios/xctestservice-build-for-testing.sh b/scripts/ios/xctestservice-build-for-testing.sh new file mode 100755 index 000000000..567edfea4 --- /dev/null +++ b/scripts/ios/xctestservice-build-for-testing.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# +# XCTestService Build-for-Testing Script +# Builds XCTestService for iOS Simulator testing and installs to expected location +# +# Usage: +# ./scripts/ios/xctestservice-build-for-testing.sh [--install] +# +# Options: +# --install Install to /tmp/automobile-xctestservice after build +# +# Environment Variables: +# AUTOMOBILE_XCTESTSERVICE_DERIVED_DATA Override the default derived data path +# (default: /tmp/automobile-xctestservice) + +set -e + +# Options +INSTALL_AFTER_BUILD=false +for arg in "$@"; do + case "$arg" in + --install) + INSTALL_AFTER_BUILD=true + ;; + *) + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +XCTESTSERVICE_DIR="${PROJECT_ROOT}/ios/XCTestService" +XCODEPROJ="${XCTESTSERVICE_DIR}/XCTestService.xcodeproj" + +# Default derived data path (matches XCTestServiceBuilder.ts) +DEFAULT_DERIVED_DATA="/tmp/automobile-xctestservice" +DERIVED_DATA="${AUTOMOBILE_XCTESTSERVICE_DERIVED_DATA:-${DEFAULT_DERIVED_DATA}}" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestService Build for Testing${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check prerequisites +if ! command -v xcodebuild &> /dev/null; then + echo -e "${RED}Error: xcodebuild not found. Please install Xcode.${NC}" + exit 1 +fi + +if ! command -v xcodegen &> /dev/null; then + echo -e "${YELLOW}Warning: xcodegen not found. Attempting to install via brew...${NC}" + if command -v brew &> /dev/null; then + brew install xcodegen + else + echo -e "${RED}Error: xcodegen not found and brew not available.${NC}" + echo -e "${RED}Please install xcodegen: brew install xcodegen${NC}" + exit 1 + fi +fi + +XCODE_VERSION=$(xcodebuild -version) +XCODE_VERSION=${XCODE_VERSION%%$'\n'*} +echo -e "${BLUE}Xcode version:${NC} ${XCODE_VERSION}" +echo -e "${BLUE}Derived data:${NC} ${DERIVED_DATA}" +echo "" + +# Generate Xcode project if needed +if [ ! -d "${XCODEPROJ}" ]; then + echo -e "${BLUE}Generating Xcode project...${NC}" + cd "${XCTESTSERVICE_DIR}" + xcodegen generate + cd "${PROJECT_ROOT}" +fi + +# Build for testing +echo -e "${BLUE}Building XCTestService for testing...${NC}" +echo "" + +BUILD_START=$(date +%s) + +xcodebuild build-for-testing \ + -project "${XCODEPROJ}" \ + -scheme "XCTestServiceApp" \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "${DERIVED_DATA}" \ + -configuration Debug \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty --color 2>/dev/null || true + +BUILD_END=$(date +%s) +BUILD_DURATION=$((BUILD_END - BUILD_START)) + +# Verify build products +PRODUCTS_DIR="${DERIVED_DATA}/Build/Products" +SIM_DIR="${PRODUCTS_DIR}/Debug-iphonesimulator" + +echo "" +echo -e "${BLUE}Verifying build products...${NC}" + +XCTESTRUN_FILE=$(find "${PRODUCTS_DIR}" -name "*.xctestrun" -type f 2>/dev/null | head -1) + +if [ -z "${XCTESTRUN_FILE}" ]; then + echo -e "${RED}Error: No .xctestrun file found in ${PRODUCTS_DIR}${NC}" + exit 1 +fi + +REQUIRED_ARTIFACTS=( + "${SIM_DIR}/XCTestServiceApp.app" + "${SIM_DIR}/XCTestServiceUITests-Runner.app" + "${SIM_DIR}/XCTestServiceTests.xctest" +) + +ALL_FOUND=true +for artifact in "${REQUIRED_ARTIFACTS[@]}"; do + if [ -e "${artifact}" ]; then + echo -e " ${GREEN}✓${NC} $(basename "${artifact}")" + else + echo -e " ${RED}✗${NC} $(basename "${artifact}") - MISSING" + ALL_FOUND=false + fi +done + +echo -e " ${GREEN}✓${NC} $(basename "${XCTESTRUN_FILE}")" + +if [ "${ALL_FOUND}" = false ]; then + echo "" + echo -e "${RED}Error: Some required artifacts are missing${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}Build completed in ${BUILD_DURATION}s${NC}" +echo -e "${GREEN}xctestrun: ${XCTESTRUN_FILE}${NC}" + +# Install to default location if requested and not already there +if [ "${INSTALL_AFTER_BUILD}" = true ] && [ "${DERIVED_DATA}" != "${DEFAULT_DERIVED_DATA}" ]; then + echo "" + echo -e "${BLUE}Installing to ${DEFAULT_DERIVED_DATA}...${NC}" + rm -rf "${DEFAULT_DERIVED_DATA}" + mkdir -p "${DEFAULT_DERIVED_DATA}" + cp -R "${DERIVED_DATA}/Build" "${DEFAULT_DERIVED_DATA}/" + echo -e "${GREEN}Installed successfully${NC}" +fi + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Build Summary${NC}" +echo -e "${CYAN}========================================${NC}" +echo -e " Products: ${SIM_DIR}" +echo -e " xctestrun: ${XCTESTRUN_FILE}" +echo "" +echo -e "To run tests manually:" +echo -e " xcodebuild test-without-building \\" +echo -e " -xctestrun \"${XCTESTRUN_FILE}\" \\" +echo -e " -destination 'platform=iOS Simulator,name=iPhone 15' \\" +echo -e " -only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService" +echo "" diff --git a/scripts/ios/xctestservice-create-ipa.sh b/scripts/ios/xctestservice-create-ipa.sh new file mode 100755 index 000000000..6cab536b3 --- /dev/null +++ b/scripts/ios/xctestservice-create-ipa.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# +# XCTestService Create IPA Script +# Builds XCTestService and packages it as a distributable ZIP (named XCTestService.ipa) +# +# Usage: +# ./scripts/ios/xctestservice-create-ipa.sh [--output ] +# +# Options: +# --output Output path for the IPA file (default: ./XCTestService.ipa) +# +# Environment Variables: +# GITHUB_OUTPUT If set, outputs ipa_path and ipa_sha256 for GitHub Actions +# +# Outputs: +# ipa_path - Path to the generated IPA file +# ipa_sha256 - SHA256 checksum of the IPA file + +set -euo pipefail + +# Parse arguments +OUTPUT_PATH="./XCTestService.ipa" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + OUTPUT_PATH="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" + exit 1 + ;; + esac +done + +# Resolve to absolute path (create parent directory if needed) +OUTPUT_DIR="$(dirname "$OUTPUT_PATH")" +mkdir -p "$OUTPUT_DIR" +OUTPUT_PATH="$(cd "$OUTPUT_DIR" && pwd)/$(basename "$OUTPUT_PATH")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +XCTESTSERVICE_DIR="${PROJECT_ROOT}/ios/XCTestService" +XCODEPROJ="${XCTESTSERVICE_DIR}/XCTestService.xcodeproj" + +# Use a temporary derived data path for clean builds +DERIVED_DATA="$(mktemp -d)/automobile-xctestservice" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestService Create IPA${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check prerequisites +if ! command -v xcodebuild &> /dev/null; then + echo -e "${RED}Error: xcodebuild not found. Please install Xcode.${NC}" + exit 1 +fi + +if ! command -v xcodegen &> /dev/null; then + echo -e "${YELLOW}Warning: xcodegen not found. Attempting to install via brew...${NC}" + if command -v brew &> /dev/null; then + brew install xcodegen + else + echo -e "${RED}Error: xcodegen not found and brew not available.${NC}" + echo -e "${RED}Please install xcodegen: brew install xcodegen${NC}" + exit 1 + fi +fi + +XCODE_VERSION=$(xcodebuild -version) +XCODE_VERSION=${XCODE_VERSION%%$'\n'*} +echo -e "${BLUE}Xcode version:${NC} ${XCODE_VERSION}" +echo -e "${BLUE}Derived data:${NC} ${DERIVED_DATA}" +echo -e "${BLUE}Output path:${NC} ${OUTPUT_PATH}" +echo "" + +# Generate Xcode project if needed +if [ ! -d "${XCODEPROJ}" ]; then + echo -e "${BLUE}Generating Xcode project...${NC}" + cd "${XCTESTSERVICE_DIR}" + xcodegen generate + cd "${PROJECT_ROOT}" +fi + +# Build for testing +echo -e "${BLUE}Building XCTestService for testing...${NC}" +echo "" + +BUILD_START=$(date +%s) + +xcodebuild build-for-testing \ + -project "${XCODEPROJ}" \ + -scheme "XCTestServiceApp" \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "${DERIVED_DATA}" \ + -configuration Debug \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty --color 2>/dev/null || true + +BUILD_END=$(date +%s) +BUILD_DURATION=$((BUILD_END - BUILD_START)) + +# Verify build products +PRODUCTS_DIR="${DERIVED_DATA}/Build/Products" +SIM_DIR="${PRODUCTS_DIR}/Debug-iphonesimulator" + +echo "" +echo -e "${BLUE}Verifying build products...${NC}" + +XCTESTRUN_FILE=$(find "${PRODUCTS_DIR}" -name "*.xctestrun" -type f 2>/dev/null | head -1) + +if [ -z "${XCTESTRUN_FILE}" ]; then + echo -e "${RED}Error: No .xctestrun file found in ${PRODUCTS_DIR}${NC}" + exit 1 +fi + +REQUIRED_ARTIFACTS=( + "${SIM_DIR}/XCTestServiceApp.app" + "${SIM_DIR}/XCTestServiceUITests-Runner.app" + "${SIM_DIR}/XCTestServiceTests.xctest" +) + +ALL_FOUND=true +for artifact in "${REQUIRED_ARTIFACTS[@]}"; do + if [ -e "${artifact}" ]; then + echo -e " ${GREEN}✓${NC} $(basename "${artifact}")" + else + echo -e " ${RED}✗${NC} $(basename "${artifact}") - MISSING" + ALL_FOUND=false + fi +done + +echo -e " ${GREEN}✓${NC} $(basename "${XCTESTRUN_FILE}")" + +if [ "${ALL_FOUND}" = false ]; then + echo "" + echo -e "${RED}Error: Some required artifacts are missing${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}Build completed in ${BUILD_DURATION}s${NC}" + +# Create ZIP (named as .ipa) +echo "" +echo -e "${BLUE}Creating IPA archive...${NC}" + +# Ensure output directory exists +mkdir -p "$(dirname "${OUTPUT_PATH}")" + +# Remove existing output file if present +rm -f "${OUTPUT_PATH}" + +# Create ZIP from the Build/Products directory +cd "${DERIVED_DATA}" +zip -r "${OUTPUT_PATH}" Build/Products/ +cd "${PROJECT_ROOT}" + +# Compute SHA256 of IPA +IPA_SHA256=$(shasum -a 256 "${OUTPUT_PATH}" | cut -d' ' -f1) +IPA_SIZE=$(stat -f%z "${OUTPUT_PATH}" 2>/dev/null || stat -c%s "${OUTPUT_PATH}" 2>/dev/null) + +# Compute SHA256 of runner binary +RUNNER_BINARY="${SIM_DIR}/XCTestServiceUITests-Runner.app/XCTestServiceUITests-Runner" +RUNNER_SHA256=$(shasum -a 256 "${RUNNER_BINARY}" | cut -d' ' -f1) + +echo "" +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} IPA Summary${NC}" +echo -e "${CYAN}========================================${NC}" +echo -e " ${BLUE}Path:${NC} ${OUTPUT_PATH}" +echo -e " ${BLUE}Size:${NC} ${IPA_SIZE} bytes" +echo -e " ${BLUE}SHA256:${NC} ${IPA_SHA256}" +echo -e " ${BLUE}Runner SHA256:${NC} ${RUNNER_SHA256}" +echo "" + +# Output for scripts and CI +echo "ipa_path=${OUTPUT_PATH}" +echo "ipa_sha256=${IPA_SHA256}" +echo "runner_sha256=${RUNNER_SHA256}" + +# Output for GitHub Actions +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "ipa_path=${OUTPUT_PATH}" + echo "ipa_sha256=${IPA_SHA256}" + echo "runner_sha256=${RUNNER_SHA256}" + } >> "${GITHUB_OUTPUT}" +fi + +# Clean up temporary derived data +rm -rf "${DERIVED_DATA}" diff --git a/scripts/ios/xctestservice-run.sh b/scripts/ios/xctestservice-run.sh new file mode 100755 index 000000000..d52d2ee51 --- /dev/null +++ b/scripts/ios/xctestservice-run.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# +# XCTestService Run Script +# Builds (if needed) and runs XCTestService on a booted iOS Simulator +# +# Usage: +# ./scripts/ios/xctestservice-run.sh [--rebuild] +# +# Options: +# --rebuild Force rebuild even if artifacts exist +# +# Environment Variables: +# XCTESTSERVICE_PORT Port for XCTestService (default: 8765) +# XCTESTSERVICE_TIMEOUT Timeout in seconds (default: 3600) + +set -e + +# Options +FORCE_REBUILD=false +for arg in "$@"; do + case "$arg" in + --rebuild) + FORCE_REBUILD=true + ;; + *) + ;; + esac +done + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Default paths and settings +DERIVED_DATA="/tmp/automobile-xctestservice" +PORT="${XCTESTSERVICE_PORT:-8765}" +TIMEOUT="${XCTESTSERVICE_TIMEOUT:-3600}" +RUNNER_BINARY="${DERIVED_DATA}/Build/Products/Debug-iphonesimulator/XCTestServiceUITests-Runner.app/XCTestServiceUITests-Runner" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestService Run${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for booted simulator +SIMULATOR_ID=$(xcrun simctl list devices booted -j 2>/dev/null | grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "//;s/"$//') + +if [ -z "${SIMULATOR_ID}" ]; then + echo -e "${RED}Error: No booted iOS simulator found.${NC}" + echo -e "${YELLOW}Boot a simulator first:${NC}" + echo -e " xcrun simctl boot \"iPhone 16\"" + exit 1 +fi + +SIMULATOR_NAME=$(xcrun simctl list devices booted | grep "${SIMULATOR_ID}" | sed 's/.*(\(.*\)) (.*/\1/' | head -1) +echo -e "${BLUE}Simulator:${NC} ${SIMULATOR_NAME:-${SIMULATOR_ID}}" +echo -e "${BLUE}Port:${NC} ${PORT}" +echo "" + +# Check if build is needed +if [ "${FORCE_REBUILD}" = true ] || [ ! -f "${RUNNER_BINARY}" ]; then + echo -e "${BLUE}Building XCTestService...${NC}" + "${SCRIPT_DIR}/xctestservice-build-for-testing.sh" +fi + +if [ ! -f "${RUNNER_BINARY}" ]; then + echo -e "${RED}Error: Runner binary not found: ${RUNNER_BINARY}${NC}" + exit 1 +fi + +echo -e "${GREEN}Using runner binary:${NC} ${RUNNER_BINARY}" +echo "" + +# Run XCTestService via simctl spawn (lighter than xcodebuild test-without-building) +echo -e "${BLUE}Starting XCTestService...${NC}" +echo -e "${YELLOW}Press Ctrl+C to stop${NC}" +echo "" + +xcrun simctl spawn "${SIMULATOR_ID}" \ + --setenv XCTESTSERVICE_PORT="${PORT}" \ + --setenv XCTESTSERVICE_TIMEOUT="${TIMEOUT}" \ + "${RUNNER_BINARY}" diff --git a/scripts/ios/xctestservice-uninstall.sh b/scripts/ios/xctestservice-uninstall.sh new file mode 100755 index 000000000..80980eb0d --- /dev/null +++ b/scripts/ios/xctestservice-uninstall.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# +# XCTestService Uninstall Script +# Removes XCTestService apps from iOS simulator and cleans build artifacts +# +# Usage: +# ./scripts/ios/xctestservice-uninstall.sh [device-id] +# +# Arguments: +# device-id Optional simulator device ID. If not provided, uses first booted simulator. +# +# Environment Variables: +# AUTOMOBILE_XCTESTSERVICE_DERIVED_DATA Override the default derived data path +# (default: /tmp/automobile-xctestservice) + +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Default derived data path +DEFAULT_DERIVED_DATA="/tmp/automobile-xctestservice" +DERIVED_DATA="${AUTOMOBILE_XCTESTSERVICE_DERIVED_DATA:-${DEFAULT_DERIVED_DATA}}" + +# XCTestService bundle identifiers +XCTESTSERVICE_APP_BUNDLE_ID="dev.jasonpearson.automobile.XCTestServiceApp" +XCTESTSERVICE_UITESTS_BUNDLE_ID="dev.jasonpearson.automobile.XCTestServiceUITests.xctrunner" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestService Uninstall${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Get device ID +DEVICE_ID="$1" +if [ -z "$DEVICE_ID" ]; then + # Find first booted simulator + DEVICE_ID=$(xcrun simctl list devices booted -j | grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "\(.*\)"/\1/') + if [ -z "$DEVICE_ID" ]; then + echo -e "${YELLOW}No booted simulator found. Skipping app uninstall.${NC}" + else + echo -e "${BLUE}Using booted simulator:${NC} ${DEVICE_ID}" + fi +else + echo -e "${BLUE}Using specified device:${NC} ${DEVICE_ID}" +fi + +# Uninstall apps if we have a device +if [ -n "$DEVICE_ID" ]; then + echo "" + echo -e "${BLUE}Uninstalling XCTestService apps...${NC}" + + if xcrun simctl uninstall "$DEVICE_ID" "$XCTESTSERVICE_APP_BUNDLE_ID" 2>/dev/null; then + echo -e " ${GREEN}✓${NC} Uninstalled XCTestServiceApp" + else + echo -e " ${YELLOW}○${NC} XCTestServiceApp not installed" + fi + + if xcrun simctl uninstall "$DEVICE_ID" "$XCTESTSERVICE_UITESTS_BUNDLE_ID" 2>/dev/null; then + echo -e " ${GREEN}✓${NC} Uninstalled XCTestServiceUITests-Runner" + else + echo -e " ${YELLOW}○${NC} XCTestServiceUITests-Runner not installed" + fi +fi + +# Remove build artifacts +echo "" +echo -e "${BLUE}Removing build artifacts...${NC}" +if [ -d "$DERIVED_DATA" ]; then + rm -rf "$DERIVED_DATA" + echo -e " ${GREEN}✓${NC} Removed ${DERIVED_DATA}" +else + echo -e " ${YELLOW}○${NC} ${DERIVED_DATA} does not exist" +fi + +# Kill any running XCTestService processes +echo "" +echo -e "${BLUE}Stopping XCTestService processes...${NC}" +if pkill -f "xcodebuild.*XCTestServiceUITests" 2>/dev/null; then + echo -e " ${GREEN}✓${NC} Stopped xcodebuild test processes" +else + echo -e " ${YELLOW}○${NC} No xcodebuild test processes running" +fi + +echo "" +echo -e "${GREEN}XCTestService uninstall complete${NC}" diff --git a/scripts/ios/xctestservice-verify-artifacts.sh b/scripts/ios/xctestservice-verify-artifacts.sh new file mode 100755 index 000000000..c4e623f41 --- /dev/null +++ b/scripts/ios/xctestservice-verify-artifacts.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# +# XCTestService Verify Artifacts Script +# Verifies that XCTestService build artifacts exist and are valid +# +# Usage: +# ./scripts/ios/xctestservice-verify-artifacts.sh +# +# Environment Variables: +# AUTOMOBILE_XCTESTSERVICE_DERIVED_DATA Override the default derived data path +# (default: /tmp/automobile-xctestservice) +# +# Exit Codes: +# 0 - All artifacts found +# 1 - One or more artifacts missing + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Default derived data path +DEFAULT_DERIVED_DATA="/tmp/automobile-xctestservice" +DERIVED_DATA="${AUTOMOBILE_XCTESTSERVICE_DERIVED_DATA:-${DEFAULT_DERIVED_DATA}}" +PRODUCTS_DIR="${DERIVED_DATA}/Build/Products" +SIM_DIR="${PRODUCTS_DIR}/Debug-iphonesimulator" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestService Artifact Verification${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" +echo -e "${BLUE}Derived data:${NC} ${DERIVED_DATA}" +echo "" + +# Check if products directory exists +if [ ! -d "${PRODUCTS_DIR}" ]; then + echo -e "${RED}Error: Products directory not found: ${PRODUCTS_DIR}${NC}" + echo -e "${YELLOW}Run ./scripts/ios/xctestservice-build-for-testing.sh first${NC}" + exit 1 +fi + +# Find xctestrun file +XCTESTRUN_FILE=$(find "${PRODUCTS_DIR}" -name "*.xctestrun" -type f 2>/dev/null | head -1) + +if [ -z "${XCTESTRUN_FILE}" ]; then + echo -e "${RED}Error: No .xctestrun file found in ${PRODUCTS_DIR}${NC}" + exit 1 +fi + +echo -e "${BLUE}Checking artifacts...${NC}" + +# Required artifacts +REQUIRED_ARTIFACTS=( + "${SIM_DIR}/XCTestServiceApp.app" + "${SIM_DIR}/XCTestServiceUITests-Runner.app" + "${SIM_DIR}/XCTestServiceTests.xctest" +) + +ALL_FOUND=true +for artifact in "${REQUIRED_ARTIFACTS[@]}"; do + if [ -e "${artifact}" ]; then + echo -e " ${GREEN}✓${NC} $(basename "${artifact}")" + else + echo -e " ${RED}✗${NC} $(basename "${artifact}") - MISSING" + ALL_FOUND=false + fi +done + +echo -e " ${GREEN}✓${NC} $(basename "${XCTESTRUN_FILE}")" + +echo "" +if [ "${ALL_FOUND}" = false ]; then + echo -e "${RED}Error: Some required artifacts are missing${NC}" + exit 1 +fi + +echo -e "${GREEN}All artifacts verified${NC}" +echo "" +echo -e "${BLUE}Products directory:${NC}" +ls -la "${PRODUCTS_DIR}/" +echo "" +echo -e "${BLUE}Simulator build directory:${NC}" +ls -la "${SIM_DIR}/" +echo "" +echo -e "${BLUE}xctestrun file:${NC} ${XCTESTRUN_FILE}" diff --git a/scripts/ktfmt/validate_ktfmt.sh b/scripts/ktfmt/validate_ktfmt.sh index d0d563f31..2f225e1e9 100755 --- a/scripts/ktfmt/validate_ktfmt.sh +++ b/scripts/ktfmt/validate_ktfmt.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash INSTALL_KTFMT_WHEN_MISSING=${INSTALL_KTFMT_WHEN_MISSING:-false} -ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-true} +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-false} ONLY_CHANGED_SINCE_SHA=${ONLY_CHANGED_SINCE_SHA:-""} # Colors for output @@ -119,26 +119,49 @@ get_touched_files() { declare -a files_to_process errors="" +supports_mapfile=false +if builtin help mapfile >/dev/null 2>&1; then + supports_mapfile=true +fi + if [[ -n "$ONLY_CHANGED_SINCE_SHA" ]]; then echo -e "${YELLOW}Processing files changed since SHA: $ONLY_CHANGED_SINCE_SHA${NC}" # Get list of changed files since SHA - mapfile -t changed_files < <(get_changed_files_since_sha "$ONLY_CHANGED_SINCE_SHA") - files_to_process=("${changed_files[@]}") + if [[ "$supports_mapfile" == "true" ]]; then + mapfile -t changed_files < <(get_changed_files_since_sha "$ONLY_CHANGED_SINCE_SHA") + files_to_process=("${changed_files[@]}") + else + while IFS= read -r file; do + files_to_process+=("$file") + done < <(get_changed_files_since_sha "$ONLY_CHANGED_SINCE_SHA") + fi elif [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then echo -e "${YELLOW}Processing only touched/staged files${NC}" # Get list of touched files - mapfile -t touched_files < <(get_touched_files) - files_to_process=("${touched_files[@]}") + if [[ "$supports_mapfile" == "true" ]]; then + mapfile -t touched_files < <(get_touched_files) + files_to_process=("${touched_files[@]}") + else + while IFS= read -r file; do + files_to_process+=("$file") + done < <(get_touched_files) + fi else echo -e "${YELLOW}Processing all Kotlin files in the project${NC}" # Get all Kotlin files - mapfile -t all_files < <(find_all_kotlin_files) - files_to_process=("${all_files[@]}") + if [[ "$supports_mapfile" == "true" ]]; then + mapfile -t all_files < <(find_all_kotlin_files) + files_to_process=("${all_files[@]}") + else + while IFS= read -r file; do + files_to_process+=("$file") + done < <(find_all_kotlin_files) + fi fi # Check if we have files to process diff --git a/scripts/launch_app_perf_test.sh b/scripts/launch_app_perf_test.sh new file mode 100755 index 000000000..c38e61e4d --- /dev/null +++ b/scripts/launch_app_perf_test.sh @@ -0,0 +1,461 @@ +#!/bin/bash + +# launch_app_perf_test.sh - Test and debug UI stability criteria during cold boot app launches +# Usage: ./scripts/launch_app_perf_test.sh [num_launches=10] [device_id] [package_name=com.google.android.deskclock] [--options] +# +# Options: +# --p50-threshold p50 percentile threshold (default: 100) +# --p90-threshold p90 percentile threshold (default: 100) +# --p95-threshold p95 percentile threshold (default: 200) +# --allow-vsync-delta Allow missed vsync delta (default: 0) +# --allow-slowui-delta Allow slow UI thread delta (default: 0) +# --allow-deadline-delta Allow frame deadline missed delta (default: 0) +# --stability-duration Stability threshold duration (default: 60) +# --timeout Total stability poll timeout (default: 12000) +# +# This script: +# 1. Repeatedly launches an app with coldBoot=true +# 2. Collects detailed UI stability metrics and debugging data +# 3. Analyzes stability criteria compliance +# 4. Generates a comprehensive report + +set -euo pipefail + +# Configuration - Positional arguments +NUM_LAUNCHES="${1:-10}" +DEVICE_ID="${2:-}" +PACKAGE_NAME="${3:-com.google.android.deskclock}" + +# Stability thresholds - can be overridden via named arguments +P50_THRESHOLD=100 +P90_THRESHOLD=100 +P95_THRESHOLD=200 +ALLOW_VSYNC_DELTA=0 +ALLOW_SLOWUI_DELTA=0 +ALLOW_DEADLINE_DELTA=0 +STABILITY_DURATION=60 +POLL_TIMEOUT=12000 + +# Parse named arguments (starting from arg 4) +for ((i=4; i<=$#; i++)); do + case "${!i}" in + --p50-threshold) + ((i++)) + P50_THRESHOLD="${!i}" + ;; + --p90-threshold) + ((i++)) + P90_THRESHOLD="${!i}" + ;; + --p95-threshold) + ((i++)) + P95_THRESHOLD="${!i}" + ;; + --allow-vsync-delta) + ((i++)) + ALLOW_VSYNC_DELTA="${!i}" + ;; + --allow-slowui-delta) + ((i++)) + ALLOW_SLOWUI_DELTA="${!i}" + ;; + --allow-deadline-delta) + ((i++)) + ALLOW_DEADLINE_DELTA="${!i}" + ;; + --stability-duration) + ((i++)) + STABILITY_DURATION="${!i}" + ;; + --timeout) + ((i++)) + POLL_TIMEOUT="${!i}" + ;; + *) + echo "Unknown option: ${!i}" + exit 1 + ;; + esac +done + +# Derived variables +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +AWAIT_IDLE_SCRIPT="$SCRIPT_DIR/await_idle.sh" +SCRATCH_DIR="$ROOT_DIR/android/scratch" +REPORT_FILE="$SCRATCH_DIR/launch_app_perf_test_$(date +%Y%m%d_%H%M%S).json" +DEBUG_LOG="$SCRATCH_DIR/launch_app_perf_test_debug_$(date +%Y%m%d_%H%M%S).log" + +# Detect if gdate (GNU date) is available for high-precision timing, fallback to python +get_time_ms() { + if command -v gdate &> /dev/null; then + gdate +%s%3N + else + # Use Python for millisecond precision on macOS (where gdate may not be available) + python3 -c 'import time; print(int(time.time() * 1000))' + fi +} + +# ADB command setup - only specify device if not "default" +ADB_CMD="adb" +if [[ -n "$DEVICE_ID" && "$DEVICE_ID" != "default" ]]; then + ADB_CMD="adb -s $DEVICE_ID" +fi + +# Create scratch directory if needed +mkdir -p "$SCRATCH_DIR" + +# Logging functions +log_info() { + local msg + msg="[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" + echo "$msg" | tee -a "$DEBUG_LOG" >&2 +} + +log_debug() { + local msg + msg="[$(date '+%Y-%m-%d %H:%M:%S')] [DEBUG] $*" + echo "$msg" >> "$DEBUG_LOG" >&2 +} + +log_error() { + local msg + msg="[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" + echo "$msg" | tee -a "$DEBUG_LOG" >&2 +} + +# Check stability criteria (using configurable thresholds) +check_stability_criteria() { + local p50="$1" + local p90="$2" + local p95="$3" + local missed_vsync_delta="$4" + local slow_ui_delta="$5" + local deadline_delta="$6" + + # Convert to integers for comparison + local p50_int + p50_int=$(echo "$p50" | cut -d'.' -f1) + local p90_int + p90_int=$(echo "$p90" | cut -d'.' -f1) + local p95_int + p95_int=$(echo "$p95" | cut -d'.' -f1) + + # Default to 0 if empty + p50_int=${p50_int:-0} + p90_int=${p90_int:-0} + p95_int=${p95_int:-0} + missed_vsync_delta=${missed_vsync_delta:-0} + slow_ui_delta=${slow_ui_delta:-0} + deadline_delta=${deadline_delta:-0} + + # Stability criteria (configurable thresholds) + local is_stable=0 + if [[ $missed_vsync_delta -le $ALLOW_VSYNC_DELTA && \ + $slow_ui_delta -le $ALLOW_SLOWUI_DELTA && \ + $deadline_delta -le $ALLOW_DEADLINE_DELTA && \ + $p50_int -lt $P50_THRESHOLD && \ + $p90_int -lt $P90_THRESHOLD && \ + $p95_int -lt $P95_THRESHOLD ]]; then + is_stable=1 + fi + + echo "$is_stable" +} + +# Perform a single app launch with detailed metrics collection +launch_and_collect_metrics() { + local launch_num="$1" + local start_time + start_time=$(get_time_ms) + + log_info "==========================================" + log_info "Launch #$launch_num - Starting cold boot launch" + log_info "==========================================" + + # Note: coldBoot=true in the launch parameters already handles terminating the app + # so we don't need to explicitly force-stop + + # Launch the app using the CLI daemon with coldBoot=true (with fallback to direct) + log_info "Launching app with CLI daemon: $PACKAGE_NAME" + local launch_start + launch_start=$(get_time_ms) + + # Run the launch command and capture output + local launch_output + launch_output=$(bun src/index.ts --cli launchApp --appId "$PACKAGE_NAME" --coldBoot true 2>&1 || echo "LAUNCH_FAILED") + + local launch_end + launch_end=$(get_time_ms) + local launch_duration=$((launch_end - launch_start)) + + log_info "App launch completed in ${launch_duration}ms" + log_debug "Launch output: $launch_output" + + # Now check if app actually launched and is in foreground + log_info "Checking if app is in foreground and stable" + + # Poll for UI stability with detailed metrics collection + local poll_count=0 + local poll_start + poll_start=$(get_time_ms) + local last_non_idle_time=$poll_start + local is_stable=0 + local stability_achieved_at=0 + + local prev_missed_vsync="" + local prev_slow_ui="" + local prev_deadline="" + + local metrics_log + metrics_log="$SCRATCH_DIR/metrics_launch_${launch_num}_$(get_time_ms).log" + + # Headers for metrics log + echo "poll_num,elapsed_ms,p50,p90,p95,p99,missed_vsync,slow_ui,deadline,mv_delta,su_delta,dd_delta,is_stable_criteria,stable_duration_ms" > "$metrics_log" + + while true; do + local current_time + current_time=$(get_time_ms) + local elapsed=$((current_time - poll_start)) + + # Check timeout + if [[ $elapsed -ge $POLL_TIMEOUT ]]; then + log_info "Timeout reached after ${elapsed}ms (limit: ${POLL_TIMEOUT}ms), ending stability poll" + break + fi + + # Get gfxinfo output + local gfx_output + gfx_output=$($ADB_CMD shell dumpsys gfxinfo "$PACKAGE_NAME" 2>&1) + local gfx_exit=$? + + if [[ $gfx_exit -ne 0 ]]; then + log_debug "Poll $poll_count: gfxinfo failed (exit $gfx_exit): $gfx_output" + sleep 0.017 + continue + fi + + if [[ -z "$gfx_output" ]]; then + log_debug "Poll $poll_count: gfxinfo returned empty output" + sleep 0.017 + continue + fi + + # Extract metrics using simpler grep patterns that work on macOS and Linux + local p50 + p50=$(echo "$gfx_output" | grep "^50th percentile:" | awk '{print $3}' | sed 's/ms//' || echo "") + local p90 + p90=$(echo "$gfx_output" | grep "^90th percentile:" | awk '{print $3}' | sed 's/ms//' || echo "") + local p95 + p95=$(echo "$gfx_output" | grep "^95th percentile:" | awk '{print $3}' | sed 's/ms//' || echo "") + local p99 + p99=$(echo "$gfx_output" | grep "^99th percentile:" | awk '{print $3}' | sed 's/ms//' || echo "") + local missed_vsync + missed_vsync=$(echo "$gfx_output" | grep "^Number Missed Vsync:" | awk '{print $4}' || echo "") + local slow_ui + slow_ui=$(echo "$gfx_output" | grep "^Number Slow UI thread:" | awk '{print $5}' || echo "") + local deadline + deadline=$(echo "$gfx_output" | grep "^Number Frame deadline missed:" | head -1 | awk '{print $5}' || echo "") + + # Skip if we couldn't parse metrics + if [[ -z "$p50" || -z "$missed_vsync" || -z "$slow_ui" || -z "$deadline" ]]; then + log_debug "Poll $poll_count: Incomplete gfxinfo data (p50=$p50, mv=$missed_vsync, su=$slow_ui, dd=$deadline)" + sleep 0.017 + continue + fi + + # Calculate deltas + local mv_delta=0 + local su_delta=0 + local dd_delta=0 + + if [[ -n "$prev_missed_vsync" && -n "$missed_vsync" ]]; then + mv_delta=$((missed_vsync - prev_missed_vsync)) + fi + if [[ -n "$prev_slow_ui" && -n "$slow_ui" ]]; then + su_delta=$((slow_ui - prev_slow_ui)) + fi + if [[ -n "$prev_deadline" && -n "$deadline" ]]; then + dd_delta=$((deadline - prev_deadline)) + fi + + # Update previous values + prev_missed_vsync="$missed_vsync" + prev_slow_ui="$slow_ui" + prev_deadline="$deadline" + + # Check stability criteria + local criteria_met + criteria_met=$(check_stability_criteria "$p50" "$p90" "$p95" "$mv_delta" "$su_delta" "$dd_delta") + + # Calculate stable duration + local stable_duration=$((current_time - last_non_idle_time)) + + # Check if stable + if [[ $criteria_met -eq 1 && $stable_duration -ge $STABILITY_DURATION ]]; then + is_stable=1 + stability_achieved_at=$elapsed + log_info "Poll $poll_count: STABLE CRITERIA MET! (elapsed: ${elapsed}ms, stable for: ${stable_duration}ms)" + break + elif [[ $criteria_met -ne 1 ]]; then + # Criteria not met, update last_non_idle_time + last_non_idle_time=$current_time + log_debug "Poll $poll_count: Not stable - deltas(mv=$mv_delta,su=$su_delta,dd=$dd_delta) percentiles(p50=$p50,p90=$p90,p95=$p95)" + else + log_debug "Poll $poll_count: Criteria met but stable duration only $stable_duration ms (need ${STABILITY_DURATION}ms)" + fi + + # Log this poll's metrics + echo "$poll_count,$elapsed,$p50,$p90,$p95,$p99,$missed_vsync,$slow_ui,$deadline,$mv_delta,$su_delta,$dd_delta,$criteria_met,$stable_duration" >> "$metrics_log" + + poll_count=$((poll_count + 1)) + + # Poll interval (17ms like TypeScript) + sleep 0.017 + done + + local total_time + total_time=$(get_time_ms) + local total_duration=$((total_time - start_time)) + + log_info "Launch #$launch_num Summary:" + log_info " Total time: ${total_duration}ms" + log_info " App launch: ${launch_duration}ms" + log_info " Stability poll time: ${stability_achieved_at}ms" + log_info " Poll count: $poll_count" + log_info " Stable: $is_stable" + log_info " Metrics log: $metrics_log" + + # Output as JSON-compatible format for later processing + echo "launch_${launch_num}|${total_duration}|${launch_duration}|${stability_achieved_at}|${poll_count}|${is_stable}" +} + +# Main execution +main() { + log_info "==========================================" + log_info "Launch App Performance Test - UI Stability Debug" + log_info "==========================================" + log_info "Configuration:" + log_info " Number of launches: $NUM_LAUNCHES" + log_info " Device ID: ${DEVICE_ID:-default}" + log_info " Package: $PACKAGE_NAME" + log_info " Report file: $REPORT_FILE" + log_info " Debug log: $DEBUG_LOG" + log_info "" + log_info "Stability Thresholds:" + log_info " p50 < ${P50_THRESHOLD}ms, p90 < ${P90_THRESHOLD}ms, p95 < ${P95_THRESHOLD}ms" + log_info " Missed Vsync delta <= ${ALLOW_VSYNC_DELTA}, Slow UI delta <= ${ALLOW_SLOWUI_DELTA}, Deadline delta <= ${ALLOW_DEADLINE_DELTA}" + log_info " Stability duration: ${STABILITY_DURATION}ms, Timeout: ${POLL_TIMEOUT}ms" + log_info "" + + # Verify await_idle.sh exists + if [[ ! -f "$AWAIT_IDLE_SCRIPT" ]]; then + log_error "await_idle.sh not found at $AWAIT_IDLE_SCRIPT" + exit 1 + fi + + # Source the await_idle functions (optional - we implement inline for now) + # source "$AWAIT_IDLE_SCRIPT" + + # Initialize results + local results_json="{" + results_json="$results_json\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," + results_json="$results_json\"device_id\":\"${DEVICE_ID:-default}\"," + results_json="$results_json\"package\":\"$PACKAGE_NAME\"," + results_json="$results_json\"num_launches\":$NUM_LAUNCHES," + results_json="$results_json\"launches\":[" + + local all_durations=() + local all_stable_counts=0 + local all_poll_counts=() + + # Run launches + for ((i=1; i<=NUM_LAUNCHES; i++)); do + local result + result=$(launch_and_collect_metrics $i) + + # Parse result + local total_time + total_time=$(echo "$result" | cut -d'|' -f2) + local launch_time + launch_time=$(echo "$result" | cut -d'|' -f3) + local stability_time + stability_time=$(echo "$result" | cut -d'|' -f4) + local poll_count + poll_count=$(echo "$result" | cut -d'|' -f5) + local is_stable + is_stable=$(echo "$result" | cut -d'|' -f6) + + all_durations+=("$total_time") + all_poll_counts+=("$poll_count") + [[ $is_stable -eq 1 ]] && all_stable_counts=$((all_stable_counts + 1)) + + # Add to JSON + if [[ $i -gt 1 ]]; then + results_json="$results_json," + fi + + results_json="$results_json{" + results_json="$results_json\"launch_num\":$i," + results_json="$results_json\"total_time_ms\":$total_time," + results_json="$results_json\"app_launch_ms\":$launch_time," + results_json="$results_json\"stability_poll_ms\":$stability_time," + results_json="$results_json\"poll_count\":$poll_count," + results_json="$results_json\"is_stable\":$is_stable" + results_json="$results_json}" + + # Small delay between launches + sleep 1 + done + + # Calculate statistics + local min_time=${all_durations[0]} + local max_time=${all_durations[0]} + local sum_time=0 + + for duration in "${all_durations[@]}"; do + [[ $duration -lt $min_time ]] && min_time=$duration + [[ $duration -gt $max_time ]] && max_time=$duration + sum_time=$((sum_time + duration)) + done + + local avg_time=$((sum_time / NUM_LAUNCHES)) + + # Close JSON + results_json="$results_json]," + results_json="$results_json\"statistics\":{" + results_json="$results_json\"min_ms\":$min_time," + results_json="$results_json\"max_ms\":$max_time," + results_json="$results_json\"avg_ms\":$avg_time," + results_json="$results_json\"total_ms\":$sum_time," + results_json="$results_json\"stable_count\":$all_stable_counts," + results_json="$results_json\"stability_percent\":$((all_stable_counts * 100 / NUM_LAUNCHES))" + results_json="$results_json}" + results_json="$results_json}" + + # Save report + echo "$results_json" | jq '.' > "$REPORT_FILE" 2>/dev/null || echo "$results_json" > "$REPORT_FILE" + + # Print summary + echo "" + log_info "==========================================" + log_info "Test Complete - Summary" + log_info "==========================================" + log_info "Total launches: $NUM_LAUNCHES" + log_info "Launches achieving stability: $all_stable_counts / $NUM_LAUNCHES" + log_info "" + log_info "Duration Statistics:" + log_info " Min: ${min_time}ms" + log_info " Max: ${max_time}ms" + log_info " Avg: ${avg_time}ms" + log_info " Total: ${sum_time}ms" + log_info "" + log_info "Files generated:" + log_info " Report: $REPORT_FILE" + log_info " Debug log: $DEBUG_LOG" + log_info " Individual metrics: $SCRATCH_DIR/metrics_launch_*_*.log" + log_info "" +} + +# Run main +main "$@" diff --git a/scripts/local-dev/hot-reload.sh b/scripts/local-dev/hot-reload.sh new file mode 100755 index 000000000..393385e1c --- /dev/null +++ b/scripts/local-dev/hot-reload.sh @@ -0,0 +1,878 @@ +#!/usr/bin/env bash +# +# AutoMobile hot-reload development workflow. +# +# Builds all components, then launches a background watcher that monitors for +# changes and rebuilds/restarts as needed. The script exits after setup; the +# background watcher keeps running until another invocation replaces it or +# the timeout expires (default 60 minutes). +# +# Components watched (in order): +# 1. IntelliJ/Android Studio IDE plugin +# 2. Android AccessibilityService (with sha updates for TypeScript) +# 3. iOS XCTestService (with sha updates for TypeScript) +# 4. MCP TypeScript daemon +# +# Usage: +# ./scripts/local-dev/hot-reload.sh [options] +# +# Options: +# --ide Pre-select IDE by name (skip prompt) +# --device Target specific ADB device +# --simulator Target specific iOS simulator +# --once Build all components once and exit +# --poll-interval File watch interval (default: 2) +# --timeout Background watcher timeout in minutes (default: 60) +# --no-ide-restart Install IDE plugin without restarting +# --help Show help +# +# Environment: +# ANDROID_SERIAL ADB device id override +# XCTESTSERVICE_PORT Override XCTestService port (default: 8765) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +export PROJECT_ROOT + +# Source library files +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/deps.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/adb.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/apk.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/ide-plugin.sh" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/lib/xctestservice.sh" + +# Path constants +ANDROID_DIR="${PROJECT_ROOT}/android" +SERVICE_DIR="${ANDROID_DIR}/control-proxy" +APK_PATH="${SERVICE_DIR}/build/outputs/apk/debug/control-proxy-debug.apk" +XCTEST_SERVICE_DIR="${PROJECT_ROOT}/ios/XCTestService" +DERIVED_DATA_PATH="/tmp/automobile-xctestservice" +PID_FILE="${PROJECT_ROOT}/.automobile-hot-reload.pid" + +# CLI options with defaults +IDE_NAME="" +DEVICE_ID="" +SIMULATOR_ID="" +RUN_ONCE=false +POLL_INTERVAL=2 +TIMEOUT_MINUTES=60 +NO_IDE_RESTART=false + +# Runtime state +IDE_PLUGINS_DIR="" +IDE_PLUGIN_ENABLED=false +ANDROID_ENABLED=false +IOS_ENABLED=false +HAVE_DEVICE=false + +# Track last hashes for change detection +LAST_IDE_PLUGIN_HASH="" +LAST_APK_HASH="" +LAST_IOS_HASH="" +LAST_TS_HASH="" + +# Track device/simulator state +LAST_ADB_DEVICES="" +LAST_SIMULATOR="" +APK_NEEDS_INSTALL=false +IOS_NEEDS_RESTART=false + +usage() { + cat << EOF +Usage: $0 [options] + +AutoMobile unified hot-reload development workflow. + +Watches all components in a single loop: + 1. IntelliJ/Android Studio IDE plugin + 2. Android AccessibilityService (with sha updates) + 3. iOS XCTestService (with sha updates) + 4. MCP TypeScript daemon + +Options: + --ide Pre-select IDE by name (e.g., "Android Studio") + --device Target specific ADB device + --simulator Target specific iOS simulator + --once Build all components once and exit + --poll-interval File watch interval (default: 2) + --timeout Background watcher timeout in minutes (default: 60) + --no-ide-restart Install IDE plugin without restarting + --help Show this help text + +Environment variables: + ANDROID_SERIAL ADB device id override + XCTESTSERVICE_PORT Override XCTestService port (default: 8765) +EOF +} + +# Kill any previous hot-reload processes +kill_previous() { + if [[ -f "${PID_FILE}" ]]; then + local old_pid + old_pid=$(cat "${PID_FILE}" 2>/dev/null || true) + if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then + log_info "Killing previous hot-reload process (PID ${old_pid})..." + kill "${old_pid}" 2>/dev/null || true + # Wait up to 10 seconds for graceful shutdown (allows XCTestService cleanup) + local count=0 + while kill -0 "${old_pid}" 2>/dev/null && [[ ${count} -lt 10 ]]; do + sleep 1 + count=$((count + 1)) + done + if kill -0 "${old_pid}" 2>/dev/null; then + log_warn "Force killing previous watcher..." + kill -9 "${old_pid}" 2>/dev/null || true + fi + fi + rm -f "${PID_FILE}" + fi + + local pids + pids=$(pgrep -f "hot-reload.sh" 2>/dev/null | grep -v "$$" || true) + if [[ -n "${pids}" ]]; then + log_info "Killing orphaned hot-reload processes..." + echo "${pids}" | xargs kill 2>/dev/null || true + sleep 1 + # Force kill if still running + pids=$(pgrep -f "hot-reload.sh" 2>/dev/null | grep -v "$$" || true) + if [[ -n "${pids}" ]]; then + log_warn "Force killing orphaned hot-reload processes..." + echo "${pids}" | xargs kill -9 2>/dev/null || true + fi + fi + + # Kill any orphaned xcodebuild test processes for XCTestService that may + # have been left behind when a watcher was SIGKILL'd + pids=$(pgrep -f "xcodebuild.*test.*XCTestService" 2>/dev/null || true) + if [[ -n "${pids}" ]]; then + log_info "Killing orphaned XCTestService xcodebuild processes: ${pids}" + echo "${pids}" | xargs kill 2>/dev/null || true + sleep 2 + pids=$(pgrep -f "xcodebuild.*test.*XCTestService" 2>/dev/null || true) + if [[ -n "${pids}" ]]; then + log_warn "Force killing orphaned xcodebuild processes..." + echo "${pids}" | xargs kill -9 2>/dev/null || true + fi + fi +} + +# Reload MCP daemon by restarting the daemon process +reload_mcp_daemon() { + log_info "Restarting MCP daemon..." + if command -v auto-mobile >/dev/null 2>&1; then + # Run daemon restart in background with timeout to prevent hanging + local daemon_log="${PROJECT_ROOT}/scratch/daemon-restart.log" + auto-mobile --daemon restart --debug --debug-perf > "${daemon_log}" 2>&1 & + local daemon_pid=$! + + # Wait up to 30 seconds for daemon restart + local count=0 + while kill -0 "${daemon_pid}" 2>/dev/null && [[ ${count} -lt 30 ]]; do + sleep 1 + count=$((count + 1)) + done + + if kill -0 "${daemon_pid}" 2>/dev/null; then + log_warn "Daemon restart timed out, force killing..." + kill -9 "${daemon_pid}" 2>/dev/null || true + # Also kill any daemon processes + local pids + pids=$(pgrep -f "auto-mobile.*--daemon-mode" 2>/dev/null || true) + if [[ -n "${pids}" ]]; then + echo "${pids}" | xargs kill -9 2>/dev/null || true + fi + else + # Check exit status of completed process + local exit_status=0 + wait "${daemon_pid}" || exit_status=$? + if [[ ${exit_status} -eq 0 ]]; then + log_info "MCP daemon restarted." + else + log_warn "Daemon restart failed (exit ${exit_status}). See ${daemon_log}" + fi + fi + else + local pids + pids=$(pgrep -f "auto-mobile.*--daemon-mode" 2>/dev/null || true) + if [[ -n "${pids}" ]]; then + log_info "Killing daemon processes: ${pids}" + echo "${pids}" | xargs kill 2>/dev/null || true + sleep 1 + # Force kill if still running + pids=$(pgrep -f "auto-mobile.*--daemon-mode" 2>/dev/null || true) + if [[ -n "${pids}" ]]; then + log_warn "Force killing daemon processes..." + echo "${pids}" | xargs kill -9 2>/dev/null || true + fi + fi + fi +} + +# List TypeScript source files to watch +list_ts_files() { + local src_dir="${PROJECT_ROOT}/src" + if [[ -d "${src_dir}" ]]; then + find "${src_dir}" -type f -name "*.ts" 2>/dev/null || true + fi +} + +# Compute hash of TypeScript file timestamps +hash_ts_state() { + list_ts_files | while read -r file; do + if [[ -f "${file}" ]]; then + stat_entry "${file}" 2>/dev/null || true + fi + done | sort | hash_stream +} + +# Build TypeScript +build_typescript() { + log_info "Building TypeScript..." + if (cd "${PROJECT_ROOT}" && bun run build); then + log_info "TypeScript build complete." + return 0 + else + log_warn "TypeScript build failed." + return 1 + fi +} + +# iOS-specific file list (separate from APK file list) +list_ios_watch_files() { + local watch_dirs=( + "${XCTEST_SERVICE_DIR}/Sources" + "${XCTEST_SERVICE_DIR}/Tests" + "${XCTEST_SERVICE_DIR}/XCTestServiceApp" + ) + local extra_files=( + "${XCTEST_SERVICE_DIR}/project.yml" + "${XCTEST_SERVICE_DIR}/XCTestService.xcodeproj/project.pbxproj" + ) + + if command -v rg >/dev/null 2>&1; then + rg --files "${watch_dirs[@]}" -g '!**/build/**' 2>/dev/null || true + else + find "${watch_dirs[@]}" -type f ! -path "*/build/*" 2>/dev/null || true + fi + + for file in "${extra_files[@]}"; do + if [[ -f "${file}" ]]; then + echo "${file}" + fi + done +} + +# iOS-specific hash (separate from APK hash) +hash_ios_watch_state() { + list_ios_watch_files | while read -r file; do + if [[ -f "${file}" ]]; then + stat_entry "${file}" 2>/dev/null || true + fi + done | sort | hash_stream +} + +# APK-specific file list (renamed from hash_watch_state collision) +list_apk_watch_files() { + local watch_dirs=( + "${SERVICE_DIR}" + "${ANDROID_DIR}/auto-mobile-sdk" + ) + local extra_files=( + "${ANDROID_DIR}/build.gradle.kts" + "${ANDROID_DIR}/settings.gradle.kts" + "${ANDROID_DIR}/gradle.properties" + ) + + if command -v rg >/dev/null 2>&1; then + rg --files "${watch_dirs[@]}" -g '!**/build/**' 2>/dev/null || true + else + find "${watch_dirs[@]}" -type f ! -path "*/build/*" 2>/dev/null || true + fi + + for file in "${extra_files[@]}"; do + if [[ -f "${file}" ]]; then + echo "${file}" + fi + done +} + +# APK-specific hash (renamed from hash_watch_state collision) +hash_apk_watch_state() { + list_apk_watch_files | while read -r file; do + if [[ -f "${file}" ]]; then + stat_entry "${file}" 2>/dev/null || true + fi + done | sort | hash_stream +} + +# Restart IDE using full path (not relying on Spotlight) +# Uses direct binary execution instead of 'open -a "..."' +restart_ide_full_path() { + if [[ -z "${SELECTED_IDE_NAME}" || -z "${SELECTED_IDE_PATH}" ]]; then + log_error "No IDE selected. Call select_ide first." + return 1 + fi + + log_info "Restarting ${SELECTED_IDE_NAME}..." + + # Extract app name for osascript + local app_name + app_name=$(basename "${SELECTED_IDE_PATH}" .app) + + # Determine the binary name inside the .app bundle + local binary_name + if [[ "${SELECTED_IDE_TYPE}" == "android-studio" ]]; then + binary_name="studio" + else + binary_name="idea" + fi + + # Find the actual binary path + local binary_path="${SELECTED_IDE_PATH}/Contents/MacOS/${binary_name}" + if [[ ! -x "${binary_path}" ]]; then + log_warn "Binary not found at ${binary_path}, falling back to open command" + binary_path="" + fi + + # Step 1: Graceful quit via osascript + log_info "Sending quit signal..." + osascript -e "tell application \"${app_name}\" to quit" 2>/dev/null || true + + # Step 2: Wait for exit (max 10s) + local count=0 + local process_pattern="${binary_name}" + + while pgrep -f "${process_pattern}" >/dev/null 2>&1 && [[ ${count} -lt 10 ]]; do + sleep 1 + count=$((count + 1)) + done + + # Step 3: Force kill if still running + if pgrep -f "${process_pattern}" >/dev/null 2>&1; then + log_warn "Force killing IDE..." + pkill -9 -f "${process_pattern}" 2>/dev/null || true + sleep 1 + fi + + # Step 4: Relaunch using full binary path (completely bypasses Spotlight) + log_info "Launching ${app_name} from ${SELECTED_IDE_PATH}..." + if [[ -n "${binary_path}" ]]; then + # Launch the binary directly in background + # This completely bypasses Spotlight and Launch Services + nohup "${binary_path}" >/dev/null 2>&1 & + else + # Fallback: use open with full .app path (still avoids Spotlight name lookup) + open "${SELECTED_IDE_PATH}" + fi + + log_info "IDE restarted." + return 0 +} + +# Setup IDE plugin +setup_ide_plugin() { + if [[ "$(uname -s)" != "Darwin" ]]; then + log_warn "IDE plugin hot-reload only supported on macOS." + return 1 + fi + + if ! ensure_gum; then + if [[ -z "${IDE_NAME}" ]]; then + log_warn "gum not available. Use --ide to specify the IDE." + return 1 + fi + fi + + log_info "Detecting installed IDEs..." + if ! select_ide "${IDE_NAME}"; then + log_error "IDE selection failed." + return 1 + fi + + IDE_PLUGINS_DIR=$(get_ide_plugins_dir) + if [[ -z "${IDE_PLUGINS_DIR}" ]]; then + log_error "Could not determine plugins directory." + return 1 + fi + + log_info "Plugins directory: ${IDE_PLUGINS_DIR}" + # shellcheck disable=SC2153 # IDE_PLUGIN_DIR is defined in lib/ide-plugin.sh + log_info "IDE plugin source: ${IDE_PLUGIN_DIR}/src" + + log_info "Building IDE plugin..." + if ! build_ide_plugin; then + log_error "IDE plugin build failed. Fix errors and retry." + return 1 + fi + + if ! install_ide_plugin "${IDE_PLUGINS_DIR}"; then + log_error "IDE plugin install failed." + return 1 + fi + + return 0 +} + +# Setup Android +setup_android() { + resolve_adb + + if resolve_device; then + HAVE_DEVICE=true + fi + + if [[ -n "${DEVICE_ID}" ]]; then + log_info "Target device: ${DEVICE_ID}" + elif [[ "${HAVE_DEVICE}" == "true" ]]; then + log_info "Target: all connected devices" + else + log_info "Target: no devices (will install when available)" + fi + + log_info "APK path: ${APK_PATH}" + log_info "Building AccessibilityService..." + + if ! build_apk; then + log_error "Initial APK build failed. Fix errors and retry." + return 1 + fi + + # Update SHA after successful build (regardless of device availability) + local checksum + checksum="$(compute_checksum)" + if [[ -n "${checksum}" ]]; then + log_info "APK sha256: ${checksum}" + update_checksum "${checksum}" + fi + + if [[ "${HAVE_DEVICE}" == "true" ]]; then + if ! install_apk; then + log_warn "Initial install failed. Will retry when devices connect." + fi + else + log_info "No devices connected. APK built and ready for install." + fi + + return 0 +} + +# Setup iOS +setup_ios() { + if ! command -v xcodebuild >/dev/null 2>&1; then + log_warn "xcodebuild not found. Skipping iOS setup." + return 1 + fi + + if [[ ! -d "${XCTEST_SERVICE_DIR}" ]]; then + log_warn "XCTestService directory not found: ${XCTEST_SERVICE_DIR}" + return 1 + fi + + if [[ -n "${SIMULATOR_ID}" ]]; then + export SIMULATOR_ID_OVERRIDE="${SIMULATOR_ID}" + log_info "Target simulator: ${SIMULATOR_ID} (must be booted)" + else + log_info "Target: booted simulator (will start service when available)" + fi + + log_info "Derived data: ${DERIVED_DATA_PATH}" + log_info "Building XCTestService..." + + if ! build_xctestservice; then + log_warn "Initial XCTestService build failed. Will retry on changes." + return 1 + fi + + return 0 +} + +# Get current booted simulator +get_current_simulator() { + if [[ -n "${SIMULATOR_ID_OVERRIDE:-}" ]]; then + if xcrun simctl list devices booted -j 2>/dev/null | \ + grep -q "\"${SIMULATOR_ID_OVERRIDE}\""; then + echo "${SIMULATOR_ID_OVERRIDE}" + fi + else + xcrun simctl list devices booted -j 2>/dev/null | \ + grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "//;s/"$//' + fi +} + +# Unified watch loop +unified_watch_loop() { + local poll_interval="${1:-2}" + + log_info "Starting unified watch loop (poll interval ${poll_interval}s)..." + log_info "Watching: IDE plugin=$(bool_str ${IDE_PLUGIN_ENABLED}), Android=$(bool_str ${ANDROID_ENABLED}), iOS=$(bool_str ${IOS_ENABLED}), TypeScript=true" + + # Initialize hashes + if [[ "${IDE_PLUGIN_ENABLED}" == "true" ]]; then + LAST_IDE_PLUGIN_HASH="$(hash_ide_plugin_state)" + fi + if [[ "${ANDROID_ENABLED}" == "true" ]]; then + LAST_APK_HASH="$(hash_apk_watch_state)" + fi + if [[ "${IOS_ENABLED}" == "true" ]]; then + LAST_IOS_HASH="$(hash_ios_watch_state)" + fi + LAST_TS_HASH="$(hash_ts_state)" + + # Initialize device state + if [[ "${ANDROID_ENABLED}" == "true" ]]; then + LAST_ADB_DEVICES=$(get_connected_devices | sort | tr '\n' ' ') + fi + if [[ "${IOS_ENABLED}" == "true" ]]; then + LAST_SIMULATOR="$(get_current_simulator)" + fi + + while true; do + sleep "${poll_interval}" + + # Check timeout if running with a deadline + if [[ -n "${WATCHER_START_TIME:-}" ]] && [[ -n "${MAX_DURATION:-}" ]]; then + local elapsed=$(( $(date +%s) - WATCHER_START_TIME )) + if [[ ${elapsed} -ge ${MAX_DURATION} ]]; then + log_info "Hot-reload timeout reached (${TIMEOUT_MINUTES:-60} minutes). Stopping." + return 0 + fi + fi + + # === 1. Check IDE plugin changes === + if [[ "${IDE_PLUGIN_ENABLED}" == "true" ]]; then + local next_ide_hash + next_ide_hash="$(hash_ide_plugin_state)" + if [[ "${next_ide_hash}" != "${LAST_IDE_PLUGIN_HASH}" ]]; then + log_info "[IDE Plugin] Change detected. Rebuilding..." + LAST_IDE_PLUGIN_HASH="${next_ide_hash}" + + if build_ide_plugin; then + if install_ide_plugin "${IDE_PLUGINS_DIR}"; then + if [[ "${NO_IDE_RESTART}" != "true" ]]; then + restart_ide_full_path + else + log_info "[IDE Plugin] Installed. Restart IDE to apply changes." + fi + else + log_warn "[IDE Plugin] Install failed; waiting for next change." + fi + else + log_warn "[IDE Plugin] Build failed; waiting for next change." + fi + + LAST_IDE_PLUGIN_HASH="$(hash_ide_plugin_state)" + fi + fi + + # === 2. Check Android changes === + if [[ "${ANDROID_ENABLED}" == "true" ]]; then + # Check device list changes + local current_devices + current_devices=$(get_connected_devices | sort | tr '\n' ' ') + local devices_changed=false + + if [[ "${current_devices}" != "${LAST_ADB_DEVICES}" ]]; then + devices_changed=true + if [[ -n "${current_devices}" ]] && [[ -z "${LAST_ADB_DEVICES}" ]]; then + log_info "[Android] Device(s) connected: ${current_devices}" + elif [[ -z "${current_devices}" ]] && [[ -n "${LAST_ADB_DEVICES}" ]]; then + log_info "[Android] All devices disconnected." + elif [[ -n "${current_devices}" ]]; then + log_info "[Android] Device list changed: ${current_devices}" + fi + LAST_ADB_DEVICES="${current_devices}" + fi + + # Check file changes + local next_apk_hash + next_apk_hash="$(hash_apk_watch_state)" + local files_changed=false + + if [[ "${next_apk_hash}" != "${LAST_APK_HASH}" ]]; then + files_changed=true + LAST_APK_HASH="${next_apk_hash}" + fi + + # Rebuild if files changed + if [[ "${files_changed}" == "true" ]]; then + log_info "[Android] Change detected. Rebuilding..." + if build_apk; then + APK_NEEDS_INSTALL=true + LAST_APK_HASH="$(hash_apk_watch_state)" + # Update SHA after successful build (regardless of device availability) + local checksum + checksum="$(compute_checksum)" + if [[ -n "${checksum}" ]]; then + log_info "[Android] APK sha256: ${checksum}" + update_checksum "${checksum}" + fi + else + log_warn "[Android] Build failed; waiting for next change." + fi + fi + + # Install if pending and devices available + if [[ "${APK_NEEDS_INSTALL}" == "true" ]] || [[ "${devices_changed}" == "true" ]]; then + if [[ -n "${current_devices}" ]] && [[ -f "${APK_PATH}" ]]; then + if install_apk; then + APK_NEEDS_INSTALL=false + log_info "[Android] APK installed to device(s)." + else + log_warn "[Android] Install failed; will retry." + fi + fi + fi + fi + + # === 3. Check iOS changes === + if [[ "${IOS_ENABLED}" == "true" ]]; then + # Check simulator state + local current_simulator + current_simulator="$(get_current_simulator)" + + if [[ "${current_simulator}" != "${LAST_SIMULATOR}" ]]; then + if [[ -n "${current_simulator}" ]]; then + log_info "[iOS] Booted simulator: ${current_simulator}" + IOS_NEEDS_RESTART=true + else + log_warn "[iOS] No booted simulator detected." + fi + LAST_SIMULATOR="${current_simulator}" + fi + + # Check file changes + local next_ios_hash + next_ios_hash="$(hash_ios_watch_state)" + + if [[ "${next_ios_hash}" != "${LAST_IOS_HASH}" ]]; then + log_info "[iOS] Change detected. Rebuilding..." + if build_xctestservice; then + LAST_IOS_HASH="$(hash_ios_watch_state)" + IOS_NEEDS_RESTART=true + # Update TypeScript checksum after iOS build + local ios_checksum + ios_checksum="$(get_xctestrun_path | hash_stream)" + if [[ -n "${ios_checksum}" ]]; then + log_info "[iOS] Build hash: ${ios_checksum:0:16}..." + fi + else + log_warn "[iOS] Build failed; waiting for next change." + fi + fi + + # Check if XCTestService process died + if [[ -n "${XCODEBUILD_PID}" ]] && ! kill -0 "${XCODEBUILD_PID}" 2>/dev/null; then + log_warn "[iOS] XCTestService process exited." + XCODEBUILD_PID="" + IOS_NEEDS_RESTART=true + fi + + # Restart if needed + if [[ "${IOS_NEEDS_RESTART}" == "true" ]] && [[ -n "${LAST_SIMULATOR}" ]]; then + stop_xctestservice + start_xctestservice "${LAST_SIMULATOR}" + IOS_NEEDS_RESTART=false + fi + fi + + # === 4. Check TypeScript changes === + local next_ts_hash + next_ts_hash="$(hash_ts_state)" + if [[ "${next_ts_hash}" != "${LAST_TS_HASH}" ]]; then + log_info "[TypeScript] Change detected. Rebuilding and reloading MCP daemon..." + if build_typescript; then + reload_mcp_daemon + fi + LAST_TS_HASH="${next_ts_hash}" + fi + done +} + +# Helper for boolean display +bool_str() { + if [[ "$1" == "true" ]]; then + echo "yes" + else + echo "no" + fi +} + +# Cleanup on foreground exit (only removes PID file if we fail before backgrounding) +cleanup() { + rm -f "${PID_FILE}" +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --ide) + if [[ $# -lt 2 ]]; then + log_error "--ide requires a value." + usage + exit 1 + fi + IDE_NAME="$2" + shift 2 + ;; + --device) + if [[ $# -lt 2 ]]; then + log_error "--device requires a value." + usage + exit 1 + fi + DEVICE_ID="$2" + shift 2 + ;; + --simulator) + if [[ $# -lt 2 ]]; then + log_error "--simulator requires a value." + usage + exit 1 + fi + SIMULATOR_ID="$2" + shift 2 + ;; + --once) + RUN_ONCE=true + shift + ;; + --poll-interval) + if [[ $# -lt 2 ]]; then + log_error "--poll-interval requires a value." + usage + exit 1 + fi + POLL_INTERVAL="$2" + shift 2 + ;; + --timeout) + if [[ $# -lt 2 ]]; then + log_error "--timeout requires a value." + usage + exit 1 + fi + TIMEOUT_MINUTES="$2" + shift 2 + ;; + --no-ide-restart) + NO_IDE_RESTART=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + log_error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Set up cleanup trap +trap cleanup EXIT INT TERM + +# Ensure scratch directory exists +mkdir -p "${PROJECT_ROOT}/scratch" + +# Check and install dependencies +if ! ensure_dependencies; then + log_error "Dependency check failed. Exiting." + exit 1 +fi + +log_info "=== AutoMobile Unified Hot-Reload ===" + +# Setup IDE plugin +if setup_ide_plugin; then + IDE_PLUGIN_ENABLED=true + log_info "IDE plugin setup complete." +else + log_warn "IDE plugin setup failed. Continuing without IDE plugin hot-reload." +fi + +# Setup Android +if setup_android; then + ANDROID_ENABLED=true + log_info "Android setup complete." +else + log_warn "Android setup failed. Continuing without Android hot-reload." +fi + +# Setup iOS +if setup_ios; then + IOS_ENABLED=true + log_info "iOS setup complete." +else + log_warn "iOS setup failed. Continuing without iOS hot-reload." +fi + +# Handle --once mode +if [[ "${RUN_ONCE}" == "true" ]]; then + log_info "Run-once mode complete." + if [[ "${IDE_PLUGIN_ENABLED}" == "true" && "${NO_IDE_RESTART}" != "true" ]]; then + restart_ide_full_path + fi + exit 0 +fi + +# Kill previous background watchers +kill_previous + +# Change to project root +cd "${PROJECT_ROOT}" + +# Restart IDE for initial plugin install (foreground, before backgrounding) +if [[ "${IDE_PLUGIN_ENABLED}" == "true" && "${NO_IDE_RESTART}" != "true" ]]; then + restart_ide_full_path +fi + +# Launch background watcher +WATCHER_LOG="${PROJECT_ROOT}/scratch/hot-reload.log" +: > "${WATCHER_LOG}" + +( + # Background watcher cleanup — stops XCTestService and removes PID file + # shellcheck disable=SC2317,SC2329 # invoked indirectly via trap + watcher_cleanup() { + log_info "Watcher stopping..." + stop_xctestservice + rm -f "${PID_FILE}" + } + trap watcher_cleanup EXIT TERM INT HUP + + WATCHER_START_TIME=$(date +%s) + MAX_DURATION=$(( TIMEOUT_MINUTES * 60 )) + + # Start initial iOS XCTestService if simulator available + if [[ "${IOS_ENABLED}" == "true" ]]; then + initial_simulator="$(get_current_simulator)" + if [[ -n "${initial_simulator}" ]]; then + start_xctestservice "${initial_simulator}" + LAST_SIMULATOR="${initial_simulator}" + fi + fi + + unified_watch_loop "${POLL_INTERVAL}" +) >> "${WATCHER_LOG}" 2>&1 & + +WATCHER_PID=$! +echo "${WATCHER_PID}" > "${PID_FILE}" +disown "${WATCHER_PID}" + +# Clear the foreground trap — PID file now belongs to the background watcher +trap - EXIT INT TERM + +log_info "Hot-reload watcher running in background (PID ${WATCHER_PID})." +log_info "Auto-stops after ${TIMEOUT_MINUTES} minutes. Re-run to restart." +log_info "Watch logs: tail -f ${WATCHER_LOG}" diff --git a/scripts/local-dev/lib/adb.sh b/scripts/local-dev/lib/adb.sh new file mode 100755 index 000000000..a31cd4d3b --- /dev/null +++ b/scripts/local-dev/lib/adb.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# ADB utilities for local development scripts. +# +# Variables (set by functions): +# ADB_BIN - Path to adb binary (set by resolve_adb) +# +# Functions: +# resolve_adb() - Find adb binary, sets ADB_BIN +# get_connected_devices() - List device IDs with "device" status +# resolve_device() - Resolve target device from env/auto-detect + +ADB_BIN="" + +# Find adb binary and set ADB_BIN +resolve_adb() { + if command -v adb >/dev/null 2>&1; then + ADB_BIN="adb" + return + fi + + if [[ -n "${ANDROID_HOME:-}" && -x "${ANDROID_HOME}/platform-tools/adb" ]]; then + ADB_BIN="${ANDROID_HOME}/platform-tools/adb" + return + fi + + if [[ -n "${ANDROID_SDK_ROOT:-}" && -x "${ANDROID_SDK_ROOT}/platform-tools/adb" ]]; then + ADB_BIN="${ANDROID_SDK_ROOT}/platform-tools/adb" + return + fi + + log_error "adb not found. Install Android platform-tools or set ANDROID_HOME." + exit 1 +} + +# Get list of connected devices with "device" status +get_connected_devices() { + "${ADB_BIN}" devices | awk 'NR>1 && $2=="device" {print $1}' +} + +# Resolve target device from DEVICE_ID, ANDROID_SERIAL, or auto-detect +# Sets DEVICE_ID if a single device is found +# Returns 0 if devices available, 1 if none +resolve_device() { + if [[ -z "${DEVICE_ID}" && -n "${ANDROID_SERIAL:-}" ]]; then + DEVICE_ID="${ANDROID_SERIAL}" + fi + + # If a specific device is set, use it + if [[ -n "${DEVICE_ID}" ]]; then + return 0 + fi + + # Otherwise, we'll install to all connected devices dynamically + local devices + devices=$(get_connected_devices) + if [[ -z "${devices}" ]]; then + log_warn "No adb devices found. Will install when devices connect." + return 1 + fi + + local count + count=$(echo "${devices}" | wc -l | tr -d ' ') + if [[ "${count}" -eq 1 ]]; then + DEVICE_ID="${devices}" + log_info "Auto-detected device: ${DEVICE_ID}" + else + log_info "Multiple devices found - will install to all connected devices." + fi + return 0 +} diff --git a/scripts/local-dev/lib/apk.sh b/scripts/local-dev/lib/apk.sh new file mode 100755 index 000000000..f218ab123 --- /dev/null +++ b/scripts/local-dev/lib/apk.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# +# APK build, install, and watch utilities for local development. +# +# Required variables (must be set before sourcing): +# PROJECT_ROOT - Path to project root +# ANDROID_DIR - Path to android directory +# SERVICE_DIR - Path to control-proxy directory +# APK_PATH - Path to built APK +# ADB_BIN - Path to adb binary (from lib/adb.sh) +# DEVICE_ID - Target device ID (optional, empty = all devices) +# +# Functions: +# hash_stream() - Compute SHA256 hash from stdin +# stat_entry() - Get file modification time (portable) +# list_watch_files() - List files to watch for changes +# hash_watch_state() - Hash of all watched file timestamps +# build_apk() - Build the accessibility service APK +# install_apk_to_device() - Install APK to specific device +# install_apk() - Install APK to target device(s) +# compute_checksum() - Compute SHA256 of APK +# update_checksum() - Update release.ts with APK checksum +# build_install_cycle() - Full build + install + checksum cycle +# watch_loop() - Watch for changes and rebuild/reinstall + +# Compute SHA256 hash from stdin +hash_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 | awk '{print $2}' + return + fi + + log_error "No SHA256 tool available (sha256sum, shasum, or openssl)." + exit 1 +} + +# Get file modification time (portable between macOS and Linux) +stat_entry() { + local file="$1" + if [[ "$(uname -s)" == "Darwin" ]]; then + stat -f "%m %N" "${file}" + else + stat -c "%Y %n" "${file}" + fi +} + +# List all files to watch for changes +list_watch_files() { + local watch_dirs=( + "${SERVICE_DIR}" + "${ANDROID_DIR}/auto-mobile-sdk" + ) + local extra_files=( + "${ANDROID_DIR}/build.gradle.kts" + "${ANDROID_DIR}/settings.gradle.kts" + "${ANDROID_DIR}/gradle.properties" + ) + + if command -v rg >/dev/null 2>&1; then + rg --files "${watch_dirs[@]}" -g '!**/build/**' + else + find "${watch_dirs[@]}" -type f ! -path "*/build/*" + fi + + for file in "${extra_files[@]}"; do + if [[ -f "${file}" ]]; then + echo "${file}" + fi + done +} + +# Compute hash of all watched file timestamps +hash_watch_state() { + list_watch_files | while read -r file; do + if [[ -f "${file}" ]]; then + stat_entry "${file}" 2>/dev/null || true + fi + done | sort | hash_stream +} + +# Build the accessibility service APK +build_apk() { + log_info "Building AccessibilityService..." + if ! (cd "${ANDROID_DIR}" && ./gradlew :control-proxy:assembleDebug); then + log_error "Gradle build failed." + return 1 + fi + + if [[ ! -f "${APK_PATH}" ]]; then + log_error "APK not found at ${APK_PATH}" + return 1 + fi + + return 0 +} + +# Install APK to a specific device +install_apk_to_device() { + local device="$1" + log_info "Installing APK to ${device}..." + if ! "${ADB_BIN}" -s "${device}" install -r "${APK_PATH}"; then + log_warn "ADB install failed for ${device}." + return 1 + fi + log_info "APK installed to ${device}." + return 0 +} + +# Install APK to target device(s) +install_apk() { + # If a specific device is set, install only to that device + if [[ -n "${DEVICE_ID}" ]]; then + install_apk_to_device "${DEVICE_ID}" + return $? + fi + + # Otherwise, install to all connected devices + local devices + devices=$(get_connected_devices) + if [[ -z "${devices}" ]]; then + log_warn "No devices connected. Skipping install." + return 1 + fi + + local success=0 + local fail=0 + while IFS= read -r device; do + if install_apk_to_device "${device}"; then + success=$((success + 1)) + else + fail=$((fail + 1)) + fi + done <<< "${devices}" + + log_info "Install complete: ${success} succeeded, ${fail} failed." + [[ ${success} -gt 0 ]] +} + +# Compute SHA256 checksum of APK +compute_checksum() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${APK_PATH}" | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${APK_PATH}" | awk '{print $1}' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "${APK_PATH}" | awk '{print $2}' + return + fi + + echo "" +} + +# Update src/constants/release.ts with APK checksum +update_checksum() { + local checksum="$1" + log_info "Updating src/constants/release.ts with checksum ${checksum}" + APK_SHA256_CHECKSUM="${checksum}" bash "${PROJECT_ROOT}/scripts/generate-release-constants.sh" +} + +# Full build + install + checksum cycle +build_install_cycle() { + local update_checksum_flag="${1:-false}" + local started_at + started_at=$(date +%s) + + if ! build_apk; then + return 1 + fi + + if ! install_apk; then + return 1 + fi + + local checksum + checksum="$(compute_checksum)" + if [[ -n "${checksum}" ]]; then + log_info "APK sha256: ${checksum}" + if [[ "${update_checksum_flag}" == "true" ]]; then + update_checksum "${checksum}" + fi + fi + + local finished_at + finished_at=$(date +%s) + log_info "Deploy complete in $((finished_at - started_at))s." + return 0 +} + +# Watch for changes and rebuild/reinstall +# Args: poll_interval update_checksum_flag +watch_loop() { + local poll_interval="${1:-2}" + local update_checksum_flag="${2:-false}" + + log_info "Watching for changes (poll interval ${poll_interval}s)." + local last_hash + last_hash="$(hash_watch_state)" + local last_devices="" + local apk_needs_install=false + + # If we have a built APK but no devices yet, mark for install + if [[ -f "${APK_PATH}" ]] && [[ -z "$(get_connected_devices)" ]]; then + apk_needs_install=true + fi + + while true; do + sleep "${poll_interval}" + + # Check for file changes + local next_hash + next_hash="$(hash_watch_state)" + local files_changed=false + if [[ "${next_hash}" != "${last_hash}" ]]; then + files_changed=true + last_hash="${next_hash}" + fi + + # Check for device changes + local current_devices + current_devices=$(get_connected_devices | sort | tr '\n' ' ') + local devices_changed=false + if [[ "${current_devices}" != "${last_devices}" ]]; then + devices_changed=true + if [[ -n "${current_devices}" ]] && [[ -z "${last_devices}" ]]; then + log_info "Device(s) connected: ${current_devices}" + elif [[ -z "${current_devices}" ]] && [[ -n "${last_devices}" ]]; then + log_info "All devices disconnected." + elif [[ -n "${current_devices}" ]]; then + log_info "Device list changed: ${current_devices}" + fi + last_devices="${current_devices}" + fi + + # Rebuild if files changed + if [[ "${files_changed}" == "true" ]]; then + log_info "Change detected. Rebuilding..." + if build_apk; then + apk_needs_install=true + last_hash="$(hash_watch_state)" + else + log_warn "Build failed; waiting for next change." + continue + fi + fi + + # Install if we have a pending APK and devices are available + if [[ "${apk_needs_install}" == "true" ]] || [[ "${devices_changed}" == "true" ]]; then + if [[ -n "${current_devices}" ]] && [[ -f "${APK_PATH}" ]]; then + if install_apk; then + apk_needs_install=false + + local checksum + checksum="$(compute_checksum)" + if [[ -n "${checksum}" ]]; then + log_info "APK sha256: ${checksum}" + if [[ "${update_checksum_flag}" == "true" ]]; then + update_checksum "${checksum}" + fi + fi + else + log_warn "Install failed; will retry." + fi + fi + fi + done +} diff --git a/scripts/local-dev/lib/common.sh b/scripts/local-dev/lib/common.sh new file mode 100755 index 000000000..fe4d9b92d --- /dev/null +++ b/scripts/local-dev/lib/common.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# Common utilities shared across local development scripts. +# +# Functions: +# timestamp() - Current time in HH:MM:SS format +# log_info() - Log info message +# log_warn() - Log warning message +# log_error() - Log error message + +# Logging functions +timestamp() { + date +"%H:%M:%S" +} + +log_info() { + echo "[$(timestamp)] [INFO] $*" +} + +log_warn() { + echo "[$(timestamp)] [WARN] $*" +} + +log_error() { + echo "[$(timestamp)] [ERROR] $*" +} diff --git a/scripts/local-dev/lib/deps.sh b/scripts/local-dev/lib/deps.sh new file mode 100755 index 000000000..3ce2b5137 --- /dev/null +++ b/scripts/local-dev/lib/deps.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +# +# Dependency detection and installation for local development. +# +# Delegates heavy lifting to scripts/install.sh (--preset local-dev), +# then performs post-install validation and local-only steps (npm link). +# +# Required variables (must be set before sourcing): +# PROJECT_ROOT - Path to project root +# +# Functions: +# parse_required_versions() - Extract required versions from package.json +# version_gte() - Semver comparison (returns 0 if $1 >= $2) +# ensure_gum() - Check gum availability (installed by install.sh) +# ensure_auto_mobile() - Build and npm link auto-mobile CLI +# ensure_dev_tools() - Install brew packages, Java, manual tools if missing +# ensure_dependencies() - Run all dependency checks via install.sh + +# Required versions (populated by parse_required_versions) +REQUIRED_BUN_VERSION="" +REQUIRED_NODE_MAJOR="" + +# Parse required versions from package.json +parse_required_versions() { + local package_json="${PROJECT_ROOT}/package.json" + + if [[ ! -f "${package_json}" ]]; then + log_error "package.json not found at ${package_json}" + return 1 + fi + + # Extract bun version from packageManager field (e.g., "bun@1.3.6") + REQUIRED_BUN_VERSION=$(grep -o '"packageManager":[[:space:]]*"bun@[^"]*"' "${package_json}" | \ + sed 's/.*bun@\([^"]*\).*/\1/' || true) + + if [[ -z "${REQUIRED_BUN_VERSION}" ]]; then + # Fallback to engines.bun field + REQUIRED_BUN_VERSION=$(grep -o '"bun":[[:space:]]*"[^"]*"' "${package_json}" | \ + head -1 | sed 's/.*">=\{0,1\}\([0-9.]*\).*/\1/' || true) + fi + + # Extract node major version from @types/node (e.g., "^24.9.2" -> 24) + REQUIRED_NODE_MAJOR=$(grep -o '"@types/node":[[:space:]]*"[^"]*"' "${package_json}" | \ + sed 's/.*"\^\{0,1\}\([0-9]*\).*/\1/' || true) + + if [[ -z "${REQUIRED_BUN_VERSION}" ]]; then + log_warn "Could not determine required bun version from package.json" + REQUIRED_BUN_VERSION="1.3.6" # fallback + fi + + if [[ -z "${REQUIRED_NODE_MAJOR}" ]]; then + log_warn "Could not determine required node version from package.json" + REQUIRED_NODE_MAJOR="24" # fallback + fi + + log_info "Required versions: bun ${REQUIRED_BUN_VERSION}, node ${REQUIRED_NODE_MAJOR}.x" +} + +# Compare semver versions: returns 0 if $1 >= $2 +version_gte() { + local v1="$1" + local v2="$2" + + # Use sort -V for version comparison + local sorted + sorted=$(printf '%s\n%s\n' "$v1" "$v2" | sort -V | head -n1) + [[ "$sorted" == "$v2" ]] +} + +# Prompt user with gum (or fallback to read) +# In non-interactive mode (no TTY), returns based on default value +prompt_confirm() { + local message="$1" + local default="${2:-yes}" + + # Check if we have a TTY for interactive prompts + if [[ ! -t 0 ]]; then + # Non-interactive mode: use default value + log_info "${message} (auto-accepting: ${default})" + [[ "${default}" == "yes" ]] + return + fi + + if command -v gum >/dev/null 2>&1; then + if [[ "${default}" == "yes" ]]; then + gum confirm "${message}" && return 0 || return 1 + else + gum confirm --default=false "${message}" && return 0 || return 1 + fi + else + # Fallback to read + local response + if [[ "${default}" == "yes" ]]; then + printf "%s [Y/n] " "${message}" + read -r response + [[ -z "${response}" || "${response}" =~ ^[Yy] ]] + else + printf "%s [y/N] " "${message}" + read -r response + [[ "${response}" =~ ^[Yy] ]] + fi + fi +} + +# Show spinner with gum (or fallback to simple message) +run_with_spinner() { + local title="$1" + shift + + if command -v gum >/dev/null 2>&1; then + gum spin --spinner dot --title "${title}" -- "$@" + else + log_info "${title}..." + "$@" + fi +} + +# Check gum availability (install.sh handles actual installation) +ensure_gum() { + command -v gum >/dev/null 2>&1 +} + +# Build and install auto-mobile globally from local project +ensure_auto_mobile() { + # Always build the project to pick up latest changes + log_info "Building TypeScript project..." + if ! (cd "${PROJECT_ROOT}" && bun run build); then + log_error "Failed to build TypeScript project." + return 1 + fi + + # Always reinstall globally to pick up changes + log_info "Installing auto-mobile globally via npm link..." + if (cd "${PROJECT_ROOT}" && npm link 2>/dev/null); then + if command -v auto-mobile >/dev/null 2>&1; then + log_info "auto-mobile CLI installed globally." + return 0 + fi + fi + + log_error "Failed to install auto-mobile globally." + log_error "Try running manually: cd ${PROJECT_ROOT} && npm link" + return 1 +} + +# Install a Homebrew package if the command is not already available. +# Usage: brew_install_if_missing [] +# If brew_package_name is omitted, command_name is used. +brew_install_if_missing() { + local cmd="$1" + local pkg="${2:-$1}" + + if command -v "${cmd}" >/dev/null 2>&1; then + return 0 + fi + + if ! command -v brew >/dev/null 2>&1; then + log_warn "${cmd} not found and Homebrew not available — skipping" + return 1 + fi + + log_info "Installing ${pkg} via Homebrew..." + if brew install "${pkg}"; then + log_info "${pkg} installed" + else + log_warn "Failed to install ${pkg}" + return 1 + fi +} + +# Install a Homebrew cask if the command/directory is not detected. +brew_cask_install_if_missing() { + local check_cmd="$1" + local cask="$2" + + if command -v "${check_cmd}" >/dev/null 2>&1; then + return 0 + fi + + if ! command -v brew >/dev/null 2>&1; then + log_warn "${check_cmd} not found and Homebrew not available — skipping" + return 1 + fi + + log_info "Installing ${cask} via Homebrew cask..." + if brew install --cask "${cask}"; then + log_info "${cask} installed" + else + log_warn "Failed to install ${cask}" + return 1 + fi +} + +# Ensure all development tools that clean-env-uninstall.sh removes are present. +# Installs missing tools via Homebrew, project install scripts, or gem. +ensure_dev_tools() { + log_info "Checking development tools..." + + local missing=0 + + # --- Homebrew packages --------------------------------------------------- + brew_install_if_missing rg ripgrep || ((missing++)) || true + brew_install_if_missing shellcheck shellcheck || ((missing++)) || true + brew_install_if_missing jq jq || ((missing++)) || true + brew_install_if_missing ffmpeg ffmpeg || ((missing++)) || true + brew_install_if_missing xmlstarlet xmlstarlet || ((missing++)) || true + brew_install_if_missing swiftformat swiftformat || ((missing++)) || true + brew_install_if_missing swiftlint swiftlint || ((missing++)) || true + brew_install_if_missing xcodegen xcodegen || ((missing++)) || true + brew_install_if_missing yq yq || ((missing++)) || true + brew_install_if_missing gum gum || ((missing++)) || true + brew_install_if_missing hadolint hadolint || ((missing++)) || true + brew_install_if_missing vips vips || ((missing++)) || true + + # --- Java 21 (needed for Gradle / Android builds) ----------------------- + if ! java -version 2>&1 | grep -q 'version "21'; then + log_info "Java 21 not detected" + brew_cask_install_if_missing java zulu-jdk21 || ((missing++)) || true + fi + + # --- Manual tool installs (use project install scripts) ------------------ + if ! command -v lychee >/dev/null 2>&1; then + log_info "Installing lychee..." + bash "${PROJECT_ROOT}/scripts/lychee/install_lychee.sh" || ((missing++)) || true + fi + + if ! command -v ktfmt >/dev/null 2>&1; then + log_info "Installing ktfmt..." + bash "${PROJECT_ROOT}/scripts/ktfmt/install_ktfmt.sh" || ((missing++)) || true + fi + + if [[ "${missing}" -gt 0 ]]; then + log_warn "${missing} tool(s) could not be installed — some features may be unavailable" + else + log_info "All development tools present." + fi + + return 0 +} + +# Run all dependency checks via install.sh +ensure_dependencies() { + log_info "Checking dependencies..." + + # Delegate to install.sh for gum, node, bun, and npm install + local env_file + env_file=$(mktemp) + + log_info "Running install.sh --preset local-dev..." + if ! bash "${PROJECT_ROOT}/scripts/install.sh" \ + --preset local-dev \ + --non-interactive \ + --env-file "${env_file}"; then + log_error "install.sh failed" + rm -f "${env_file}" + return 1 + fi + + # Source environment changes (PATH, NVM_DIR, ANDROID_HOME) + if [[ -f "${env_file}" ]]; then + # shellcheck disable=SC1090 + source "${env_file}" + rm -f "${env_file}" + fi + + # Post-install validation + parse_required_versions + + # Verify node meets requirements + if command -v node >/dev/null 2>&1; then + local node_major + node_major=$(node --version | sed 's/^v//' | cut -d. -f1) + if [[ "${node_major}" -lt "${REQUIRED_NODE_MAJOR}" ]]; then + log_error "Node.js v${node_major}.x found but v${REQUIRED_NODE_MAJOR}.x required after install" + return 1 + fi + log_info "Node.js $(node --version) verified" + else + log_error "Node.js not found after install" + return 1 + fi + + # Verify bun meets requirements + if command -v bun >/dev/null 2>&1; then + local bun_version + bun_version=$(bun --version 2>/dev/null || true) + if ! version_gte "${bun_version}" "${REQUIRED_BUN_VERSION}"; then + log_error "Bun v${bun_version} found but v${REQUIRED_BUN_VERSION} required after install" + return 1 + fi + log_info "Bun v${bun_version} verified" + else + log_error "Bun not found after install" + return 1 + fi + + # Install development tools (brew packages, Java, manual installs) + ensure_dev_tools + + # Build and npm link auto-mobile (local-dev specific) + if ! ensure_auto_mobile; then + log_error "auto-mobile global installation failed." + return 1 + fi + + log_info "All dependencies satisfied." + return 0 +} diff --git a/scripts/local-dev/lib/ide-plugin.sh b/scripts/local-dev/lib/ide-plugin.sh new file mode 100755 index 000000000..e2ddac359 --- /dev/null +++ b/scripts/local-dev/lib/ide-plugin.sh @@ -0,0 +1,596 @@ +#!/usr/bin/env bash +# +# IDE plugin build, install, and watch utilities for local development. +# +# Required variables (must be set before sourcing): +# PROJECT_ROOT - Path to project root +# +# Functions: +# detect_toolbox_ides() - Parse JetBrains Toolbox state.json +# detect_spotlight_ides() - Fallback using mdfind for bundle identifiers +# get_all_ides() - Combined detection, sorted by access time +# select_ide() - Interactive gum selection (IDE type -> version) +# get_ide_plugins_dir() - Resolve plugins directory for selected IDE +# build_ide_plugin() - Run gradlew buildPlugin +# install_ide_plugin() - Unzip plugin to IDE's plugins directory +# restart_ide() - Graceful quit via osascript, fallback to pkill +# list_ide_plugin_watch_files() - Files to watch for changes +# hash_ide_plugin_state() - Hash of watched file timestamps + +# IDE plugin paths +IDE_PLUGIN_DIR="${PROJECT_ROOT}/android/ide-plugin" +PLUGIN_NAME="auto-mobile-ide-plugin" + +# Selected IDE state (set by select_ide) +SELECTED_IDE_NAME="" +SELECTED_IDE_PATH="" +SELECTED_IDE_TYPE="" # "android-studio" or "intellij" + +# Detect IDEs from JetBrains Toolbox state.json (primary method) +# Outputs: Lines of "type|name|path|launch_command" +detect_toolbox_ides() { + local toolbox_state="${HOME}/Library/Application Support/JetBrains/Toolbox/state.json" + + if [[ ! -f "${toolbox_state}" ]]; then + return 0 + fi + + # Parse the state.json using jq if available, otherwise use grep/sed + if command -v jq >/dev/null 2>&1; then + jq -r '.tools[]? | select(.productCode == "AI" or .productCode == "IU" or .productCode == "IC") | + "\(.productCode)|\(.displayName)|\(.installLocation)|\(.launchCommand // "")"' "${toolbox_state}" 2>/dev/null || true + else + # Fallback: basic grep for install locations + grep -o '"installLocation"[[:space:]]*:[[:space:]]*"[^"]*"' "${toolbox_state}" 2>/dev/null | \ + sed 's/.*"installLocation"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | \ + while read -r path; do + if [[ -d "${path}" ]]; then + local name + name=$(basename "${path}" .app) + local type="unknown" + if [[ "${name}" == *"Android Studio"* ]]; then + type="AI" + elif [[ "${name}" == *"IntelliJ"* ]]; then + type="IU" + fi + echo "${type}|${name}|${path}|" + fi + done + fi +} + +# Detect IDEs using Spotlight mdfind (fallback method) +# Outputs: Lines of "type|name|path|" +detect_spotlight_ides() { + local apps=() + + # Search for Android Studio + while IFS= read -r path; do + if [[ -n "${path}" && -d "${path}" ]]; then + local name + name=$(basename "${path}" .app) + apps+=("AI|${name}|${path}|") + fi + done < <(mdfind "kMDItemCFBundleIdentifier == 'com.google.android.studio'" 2>/dev/null || true) + + # Search for Android Studio Preview + while IFS= read -r path; do + if [[ -n "${path}" && -d "${path}" ]]; then + local name + name=$(basename "${path}" .app) + apps+=("AI|${name}|${path}|") + fi + done < <(mdfind "kMDItemCFBundleIdentifier == 'com.google.android.studio-EAP'" 2>/dev/null || true) + + # Search for IntelliJ IDEA + while IFS= read -r path; do + if [[ -n "${path}" && -d "${path}" ]]; then + local name + name=$(basename "${path}" .app) + local type="IU" + if [[ "${name}" == *"Community"* ]]; then + type="IC" + fi + apps+=("${type}|${name}|${path}|") + fi + done < <(mdfind "kMDItemCFBundleIdentifier == 'com.jetbrains.intellij*'" 2>/dev/null || true) + + # Only print if array is non-empty (avoids unbound variable error with set -u) + if [[ ${#apps[@]} -gt 0 ]]; then + printf '%s\n' "${apps[@]}" + fi +} + +# Get access time for a path (for sorting by recency) +get_access_time() { + local path="$1" + if [[ "$(uname -s)" == "Darwin" ]]; then + stat -f "%a" "${path}" 2>/dev/null || echo "0" + else + stat -c "%X" "${path}" 2>/dev/null || echo "0" + fi +} + +# Get all detected IDEs, sorted by access time (most recent first) +# Outputs: Lines of "type|name|path|launch_command" +get_all_ides() { + local ides=() + local seen_paths=() + + log_info "Scanning for JetBrains IDEs..." + + # Collect from Toolbox first + log_info "Checking JetBrains Toolbox..." + while IFS= read -r line; do + if [[ -n "${line}" ]]; then + local path + path=$(echo "${line}" | cut -d'|' -f3) + log_info " Toolbox candidate: ${path}" + if [[ -d "${path}" ]]; then + ides+=("${line}") + seen_paths+=("${path}") + else + log_info " (path does not exist, skipping)" + fi + fi + done < <(detect_toolbox_ides) + + # Add Spotlight results if not already seen + log_info "Checking Spotlight index..." + while IFS= read -r line; do + if [[ -n "${line}" ]]; then + local path + path=$(echo "${line}" | cut -d'|' -f3) + log_info " Spotlight candidate: ${path}" + local already_seen=false + if [[ ${#seen_paths[@]} -gt 0 ]]; then + for seen in "${seen_paths[@]}"; do + if [[ "${seen}" == "${path}" ]]; then + already_seen=true + break + fi + done + fi + if [[ "${already_seen}" == "true" ]]; then + log_info " (already found via Toolbox, skipping)" + elif [[ ! -d "${path}" ]]; then + log_info " (path does not exist, skipping)" + else + ides+=("${line}") + fi + fi + done < <(detect_spotlight_ides) + + log_info "Found ${#ides[@]} IDE(s)" + + # Sort by access time (most recent first) + if [[ ${#ides[@]} -gt 0 ]]; then + for ide in "${ides[@]}"; do + local path + path=$(echo "${ide}" | cut -d'|' -f3) + local atime + atime=$(get_access_time "${path}") + echo "${atime}|${ide}" + done | sort -t'|' -k1 -rn | cut -d'|' -f2- + fi +} + +# Interactive IDE selection using gum +# Sets: SELECTED_IDE_NAME, SELECTED_IDE_PATH, SELECTED_IDE_TYPE, SELECTED_IDE_VERSION +select_ide() { + local preselect="${1:-}" + local ides=() + local ide_types=() + + # Collect all IDEs + while IFS= read -r line; do + if [[ -n "${line}" ]]; then + ides+=("${line}") + local type + type=$(echo "${line}" | cut -d'|' -f1) + # Track unique types + local found=false + if [[ ${#ide_types[@]} -gt 0 ]]; then + for t in "${ide_types[@]}"; do + if [[ "${t}" == "${type}" ]]; then + found=true + break + fi + done + fi + if [[ "${found}" == "false" ]]; then + ide_types+=("${type}") + fi + fi + done < <(get_all_ides) + + if [[ ${#ides[@]} -eq 0 ]]; then + log_error "No JetBrains IDEs detected." + log_error "Install Android Studio or IntelliJ IDEA and run them at least once." + return 1 + fi + + # If preselect specified, try to match + if [[ -n "${preselect}" ]]; then + for ide in "${ides[@]}"; do + local name + name=$(echo "${ide}" | cut -d'|' -f2) + if [[ "${name}" == *"${preselect}"* ]]; then + SELECTED_IDE_NAME="${name}" + SELECTED_IDE_PATH=$(echo "${ide}" | cut -d'|' -f3) + local type + type=$(echo "${ide}" | cut -d'|' -f1) + if [[ "${type}" == "AI" ]]; then + SELECTED_IDE_TYPE="android-studio" + else + SELECTED_IDE_TYPE="intellij" + fi + log_info "Selected IDE: ${SELECTED_IDE_NAME}" + return 0 + fi + done + log_warn "No IDE matching '${preselect}' found. Prompting for selection." + fi + + # If only one IDE, select it automatically + if [[ ${#ides[@]} -eq 1 ]]; then + local ide="${ides[0]}" + SELECTED_IDE_NAME=$(echo "${ide}" | cut -d'|' -f2) + SELECTED_IDE_PATH=$(echo "${ide}" | cut -d'|' -f3) + local type + type=$(echo "${ide}" | cut -d'|' -f1) + if [[ "${type}" == "AI" ]]; then + SELECTED_IDE_TYPE="android-studio" + else + SELECTED_IDE_TYPE="intellij" + fi + log_info "Auto-selected IDE: ${SELECTED_IDE_NAME}" + return 0 + fi + + # Check if gum is available + if ! command -v gum >/dev/null 2>&1; then + log_error "Multiple IDEs detected but gum not available for selection." + log_error "Install gum or use --ide to specify the IDE." + return 1 + fi + + local selected_type="" + + # Stage 1: IDE type selection (if multiple types) + local has_android_studio=false + local has_intellij=false + for type in "${ide_types[@]}"; do + if [[ "${type}" == "AI" ]]; then + has_android_studio=true + elif [[ "${type}" == "IU" || "${type}" == "IC" ]]; then + has_intellij=true + fi + done + + if [[ "${has_android_studio}" == "true" && "${has_intellij}" == "true" ]]; then + selected_type=$(gum choose --header "Which IDE?" "Android Studio" "IntelliJ IDEA") + if [[ "${selected_type}" == "Android Studio" ]]; then + selected_type="AI" + else + selected_type="IU" # Will match both IU and IC + fi + elif [[ "${has_android_studio}" == "true" ]]; then + selected_type="AI" + else + selected_type="IU" + fi + + # Filter IDEs by selected type + local filtered_ides=() + for ide in "${ides[@]}"; do + local type + type=$(echo "${ide}" | cut -d'|' -f1) + if [[ "${selected_type}" == "AI" && "${type}" == "AI" ]]; then + filtered_ides+=("${ide}") + elif [[ "${selected_type}" == "IU" && ("${type}" == "IU" || "${type}" == "IC") ]]; then + filtered_ides+=("${ide}") + fi + done + + # Stage 2: Version selection (if multiple versions) + if [[ ${#filtered_ides[@]} -eq 1 ]]; then + local ide="${filtered_ides[0]}" + SELECTED_IDE_NAME=$(echo "${ide}" | cut -d'|' -f2) + SELECTED_IDE_PATH=$(echo "${ide}" | cut -d'|' -f3) + else + # Build choice list with "(most recent)" marker on first + local choices=() + local first=true + for ide in "${filtered_ides[@]}"; do + local name + name=$(echo "${ide}" | cut -d'|' -f2) + if [[ "${first}" == "true" ]]; then + choices+=("${name} (most recent)") + first=false + else + choices+=("${name}") + fi + done + + local selection + selection=$(printf '%s\n' "${choices[@]}" | gum choose --header "Which version?") + + # Remove "(most recent)" suffix if present + selection="${selection% (most recent)}" + + # Find matching IDE + for ide in "${filtered_ides[@]}"; do + local name + name=$(echo "${ide}" | cut -d'|' -f2) + if [[ "${name}" == "${selection}" ]]; then + SELECTED_IDE_NAME="${name}" + SELECTED_IDE_PATH=$(echo "${ide}" | cut -d'|' -f3) + break + fi + done + fi + + if [[ "${selected_type}" == "AI" ]]; then + SELECTED_IDE_TYPE="android-studio" + else + SELECTED_IDE_TYPE="intellij" + fi + + log_info "Selected IDE: ${SELECTED_IDE_NAME}" + return 0 +} + +# Resolve plugins directory for the selected IDE +# Outputs: Path to plugins directory +get_ide_plugins_dir() { + local plugins_dir="" + + if [[ -z "${SELECTED_IDE_NAME}" ]]; then + log_error "No IDE selected. Call select_ide first." + return 1 + fi + + # Extract version from IDE name (e.g., "Android Studio Panda | 2025.3.1" -> "2025.3") + local version="" + if [[ "${SELECTED_IDE_NAME}" =~ ([0-9]{4}\.[0-9]+) ]]; then + version="${BASH_REMATCH[1]}" + fi + + # If no version in name, try to read from app bundle Info.plist + if [[ -z "${version}" && -n "${SELECTED_IDE_PATH}" ]]; then + local bundle_version + bundle_version=$(defaults read "${SELECTED_IDE_PATH}/Contents/Info" CFBundleShortVersionString 2>/dev/null || true) + if [[ "${bundle_version}" =~ ([0-9]{4}\.[0-9]+) ]]; then + version="${BASH_REMATCH[1]}" + fi + fi + + if [[ "${SELECTED_IDE_TYPE}" == "android-studio" ]]; then + # Android Studio: ~/Library/Application Support/Google/AndroidStudio/plugins + local config_base="${HOME}/Library/Application Support/Google" + if [[ -n "${version}" ]]; then + # Try exact match first, then glob for patch versions (e.g., 2025.3 -> AndroidStudio2025.3.1) + if [[ -d "${config_base}/AndroidStudio${version}" ]]; then + plugins_dir="${config_base}/AndroidStudio${version}/plugins" + else + # Find directory matching version prefix (e.g., AndroidStudio2025.3*) + plugins_dir=$(find "${config_base}" -maxdepth 1 -type d -name "AndroidStudio${version}*" ! -name "*Preview*" 2>/dev/null | sort -V | tail -n 1 || true) + if [[ -n "${plugins_dir}" ]]; then + plugins_dir="${plugins_dir}/plugins" + fi + fi + fi + # Fallback: find most recent non-Preview AndroidStudio directory by modification time + if [[ -z "${plugins_dir}" || ! -d "$(dirname "${plugins_dir}")" ]]; then + plugins_dir=$(find "${config_base}" -maxdepth 1 -type d -name "AndroidStudio[0-9]*" ! -name "*Preview*" -exec stat -f "%m %N" {} \; 2>/dev/null | sort -rn | head -n 1 | cut -d' ' -f2- || true) + if [[ -n "${plugins_dir}" ]]; then + plugins_dir="${plugins_dir}/plugins" + fi + fi + else + # IntelliJ IDEA: ~/Library/Application Support/JetBrains/IntelliJIdea/plugins + local config_base="${HOME}/Library/Application Support/JetBrains" + if [[ -n "${version}" ]]; then + if [[ -d "${config_base}/IntelliJIdea${version}" ]]; then + plugins_dir="${config_base}/IntelliJIdea${version}/plugins" + else + plugins_dir=$(find "${config_base}" -maxdepth 1 -type d -name "IntelliJIdea${version}*" 2>/dev/null | sort -V | tail -n 1 || true) + if [[ -n "${plugins_dir}" ]]; then + plugins_dir="${plugins_dir}/plugins" + fi + fi + fi + # Fallback: find most recent IntelliJIdea directory by modification time + if [[ -z "${plugins_dir}" || ! -d "$(dirname "${plugins_dir}")" ]]; then + plugins_dir=$(find "${config_base}" -maxdepth 1 -type d -name "IntelliJIdea*" -exec stat -f "%m %N" {} \; 2>/dev/null | sort -rn | head -n 1 | cut -d' ' -f2- || true) + if [[ -n "${plugins_dir}" ]]; then + plugins_dir="${plugins_dir}/plugins" + fi + fi + fi + + if [[ -z "${plugins_dir}" ]]; then + log_error "Could not determine plugins directory for ${SELECTED_IDE_NAME}" + return 1 + fi + + # Create plugins directory if it doesn't exist + if [[ ! -d "${plugins_dir}" ]]; then + log_info "Creating plugins directory: ${plugins_dir}" + mkdir -p "${plugins_dir}" + fi + + echo "${plugins_dir}" +} + +# Build the IDE plugin +build_ide_plugin() { + log_info "Building IDE plugin..." + + if ! (cd "${IDE_PLUGIN_DIR}" && ./gradlew buildPlugin); then + log_error "Gradle build failed." + return 1 + fi + + # Verify plugin zip exists + local plugin_zip + plugin_zip=$(find "${IDE_PLUGIN_DIR}/build/distributions" -maxdepth 1 -name '*.zip' -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -n 1 || true) + + if [[ -z "${plugin_zip}" || ! -f "${plugin_zip}" ]]; then + log_error "Plugin zip not found in ${IDE_PLUGIN_DIR}/build/distributions" + return 1 + fi + + log_info "Plugin built: $(basename "${plugin_zip}")" + return 0 +} + +# Install the IDE plugin to the selected IDE's plugins directory +install_ide_plugin() { + local plugins_dir="$1" + + if [[ -z "${plugins_dir}" ]]; then + log_error "Plugins directory not specified." + return 1 + fi + + local plugin_zip + plugin_zip=$(find "${IDE_PLUGIN_DIR}/build/distributions" -maxdepth 1 -name '*.zip' -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -n 1 || true) + + if [[ -z "${plugin_zip}" || ! -f "${plugin_zip}" ]]; then + log_error "Plugin zip not found. Run build_ide_plugin first." + return 1 + fi + + log_info "Installing plugin to ${plugins_dir}..." + + # Remove old version + rm -rf "${plugins_dir:?}/${PLUGIN_NAME:?}" + + # Extract new version (overwrite without prompting) + unzip -o -q "${plugin_zip}" -d "${plugins_dir}" + + log_info "Plugin installed: ${PLUGIN_NAME}" + return 0 +} + +# Restart the IDE (graceful quit, wait, force kill, relaunch) +restart_ide() { + if [[ -z "${SELECTED_IDE_NAME}" || -z "${SELECTED_IDE_PATH}" ]]; then + log_error "No IDE selected. Call select_ide first." + return 1 + fi + + log_info "Restarting ${SELECTED_IDE_NAME}..." + + # Extract app name for osascript (e.g., "Android Studio" from path) + local app_name + app_name=$(basename "${SELECTED_IDE_PATH}" .app) + + # Step 1: Graceful quit via osascript + log_info "Sending quit signal..." + osascript -e "tell application \"${app_name}\" to quit" 2>/dev/null || true + + # Step 2: Wait for exit (max 10s) + local count=0 + local process_pattern + if [[ "${SELECTED_IDE_TYPE}" == "android-studio" ]]; then + process_pattern="studio" + else + process_pattern="idea" + fi + + while pgrep -f "${process_pattern}" >/dev/null 2>&1 && [[ ${count} -lt 10 ]]; do + sleep 1 + count=$((count + 1)) + done + + # Step 3: Force kill if still running + if pgrep -f "${process_pattern}" >/dev/null 2>&1; then + log_warn "Force killing IDE..." + pkill -9 -f "${process_pattern}" 2>/dev/null || true + sleep 1 + fi + + # Step 4: Relaunch + log_info "Launching ${app_name}..." + open -a "${SELECTED_IDE_PATH}" + + log_info "IDE restarted." + return 0 +} + +# List all files to watch for changes +list_ide_plugin_watch_files() { + local watch_dirs=( + "${IDE_PLUGIN_DIR}/src" + ) + local extra_files=( + "${IDE_PLUGIN_DIR}/build.gradle.kts" + ) + + # Use ripgrep if available, otherwise find + if command -v rg >/dev/null 2>&1; then + rg --files "${watch_dirs[@]}" -g '!**/build/**' 2>/dev/null || true + else + find "${watch_dirs[@]}" -type f ! -path "*/build/*" 2>/dev/null || true + fi + + for file in "${extra_files[@]}"; do + if [[ -f "${file}" ]]; then + echo "${file}" + fi + done +} + +# Compute hash of all watched file timestamps +hash_ide_plugin_state() { + list_ide_plugin_watch_files | while read -r file; do + if [[ -f "${file}" ]]; then + stat_entry "${file}" 2>/dev/null || true + fi + done | sort | hash_stream +} + +# Watch for changes and rebuild/reinstall +# Args: poll_interval plugins_dir no_restart_flag +ide_plugin_watch_loop() { + local poll_interval="${1:-2}" + local plugins_dir="$2" + local no_restart="${3:-false}" + + log_info "Watching for changes (poll interval ${poll_interval}s)..." + log_info "Press Ctrl+C to stop." + local last_hash + last_hash="$(hash_ide_plugin_state)" + + while true; do + sleep "${poll_interval}" + + local next_hash + next_hash="$(hash_ide_plugin_state)" + + if [[ "${next_hash}" != "${last_hash}" ]]; then + log_info "Change detected. Rebuilding..." + last_hash="${next_hash}" + + if build_ide_plugin; then + if install_ide_plugin "${plugins_dir}"; then + if [[ "${no_restart}" != "true" ]]; then + restart_ide + else + log_info "Plugin installed. Restart IDE to apply changes." + fi + else + log_warn "Install failed; waiting for next change." + fi + else + log_warn "Build failed; waiting for next change." + fi + + # Update hash after build in case build generated new files + last_hash="$(hash_ide_plugin_state)" + fi + done +} diff --git a/scripts/local-dev/lib/xctestservice.sh b/scripts/local-dev/lib/xctestservice.sh new file mode 100755 index 000000000..481e9f370 --- /dev/null +++ b/scripts/local-dev/lib/xctestservice.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash +# +# XCTestService build, run, and watch utilities for local development. +# +# Required variables (must be set before sourcing): +# PROJECT_ROOT - Path to project root +# XCTEST_SERVICE_DIR - Path to ios/XCTestService directory +# DERIVED_DATA_PATH - Path to derived data for XCTestService +# +# Functions: +# hash_stream() - Compute SHA256 hash from stdin +# stat_entry() - Get file modification time (portable) +# list_watch_files() - List XCTestService source files to watch +# hash_watch_state() - Hash of all watched file timestamps +# needs_project_generation() - Check if xcodegen should run +# run_xcodegen() - Generate Xcode project with xcodegen +# build_xctestservice() - Build XCTestService for testing +# get_xctestrun_path() - Find the .xctestrun file in derived data +# start_xctestservice() - Start XCTestService on a simulator +# stop_xctestservice() - Stop the running XCTestService process +# watch_loop() - Watch for changes and rebuild/restart + +# Runtime state +XCODEBUILD_PID="" +XCODEBUILD_LOG="" + +# Compute SHA256 hash from stdin +hash_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 | awk '{print $2}' + return + fi + + log_error "No SHA256 tool available (sha256sum, shasum, or openssl)." + exit 1 +} + +# Get file modification time (portable between macOS and Linux) +stat_entry() { + local file="$1" + if [[ "$(uname -s)" == "Darwin" ]]; then + stat -f "%m %N" "${file}" + else + stat -c "%Y %n" "${file}" + fi +} + +# List all files to watch for changes +list_watch_files() { + local watch_dirs=( + "${XCTEST_SERVICE_DIR}/Sources" + "${XCTEST_SERVICE_DIR}/Tests" + "${XCTEST_SERVICE_DIR}/XCTestServiceApp" + ) + local extra_files=( + "${XCTEST_SERVICE_DIR}/project.yml" + "${XCTEST_SERVICE_DIR}/XCTestService.xcodeproj/project.pbxproj" + ) + + if command -v rg >/dev/null 2>&1; then + rg --files "${watch_dirs[@]}" -g '!**/build/**' + else + find "${watch_dirs[@]}" -type f ! -path "*/build/*" + fi + + for file in "${extra_files[@]}"; do + if [[ -f "${file}" ]]; then + echo "${file}" + fi + done +} + +# Compute hash of all watched file timestamps +hash_watch_state() { + list_watch_files | while read -r file; do + if [[ -f "${file}" ]]; then + stat_entry "${file}" 2>/dev/null || true + fi + done | sort | hash_stream +} + +# Check if project.yml is newer than xcodeproj +needs_project_generation() { + local project_yml="${XCTEST_SERVICE_DIR}/project.yml" + local xcodeproj="${XCTEST_SERVICE_DIR}/XCTestService.xcodeproj/project.pbxproj" + + if [[ ! -f "${project_yml}" ]]; then + return 1 + fi + + if [[ ! -f "${xcodeproj}" ]]; then + return 0 + fi + + local yml_mtime + local proj_mtime + yml_mtime=$(stat_entry "${project_yml}" | awk '{print $1}' || echo 0) + proj_mtime=$(stat_entry "${xcodeproj}" | awk '{print $1}' || echo 0) + + [[ "${yml_mtime}" -gt "${proj_mtime}" ]] +} + +# Run xcodegen to generate the Xcode project +run_xcodegen() { + if ! command -v xcodegen >/dev/null 2>&1; then + log_warn "xcodegen not available; skipping project generation." + return 1 + fi + + log_info "Running xcodegen..." + if (cd "${XCTEST_SERVICE_DIR}" && xcodegen generate); then + log_info "xcodegen completed." + return 0 + fi + + log_warn "xcodegen failed." + return 1 +} + +# Build XCTestService using xcodebuild build-for-testing +build_xctestservice() { + if ! command -v xcodebuild >/dev/null 2>&1; then + log_error "xcodebuild not found. Install Xcode." + return 1 + fi + + if needs_project_generation; then + run_xcodegen || true + fi + + log_info "Building XCTestService (build-for-testing)..." + if ! (cd "${XCTEST_SERVICE_DIR}" && xcodebuild build-for-testing \ + -scheme XCTestServiceApp \ + -destination "generic/platform=iOS Simulator" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -quiet); then + log_error "xcodebuild build-for-testing failed." + return 1 + fi + + return 0 +} + +# Find the .xctestrun file in derived data +get_xctestrun_path() { + local products_dir="${DERIVED_DATA_PATH}/Build/Products" + if [[ ! -d "${products_dir}" ]]; then + echo "" + return + fi + + local xctestrun_file + xctestrun_file=$(find "${products_dir}" -maxdepth 1 -name "*.xctestrun" 2>/dev/null | head -1 || true) + if [[ -n "${xctestrun_file}" ]]; then + echo "${xctestrun_file}" + return + fi + + echo "" +} + +# Start XCTestService on a simulator +start_xctestservice() { + local simulator_id="$1" + local port="${XCTESTSERVICE_PORT:-8765}" + local xctestrun_path + xctestrun_path="$(get_xctestrun_path)" + + if [[ -z "${simulator_id}" ]]; then + log_warn "No booted simulator available; cannot start XCTestService." + return 1 + fi + + XCODEBUILD_LOG="${PROJECT_ROOT}/scratch/ios-xctestservice.log" + local cmd=() + + if [[ -n "${xctestrun_path}" ]]; then + log_info "Starting XCTestService (test-without-building)..." + cmd=( + xcodebuild + test-without-building + -xctestrun "${xctestrun_path}" + -destination "id=${simulator_id}" + -only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService + "XCTESTSERVICE_PORT=${port}" + ) + else + log_info "Starting XCTestService (xcodebuild test)..." + cmd=( + xcodebuild + test + -scheme XCTestServiceApp + -destination "id=${simulator_id}" + -only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService + "XCTESTSERVICE_PORT=${port}" + ) + fi + + if [[ -n "${XCTESTSERVICE_BUNDLE_ID:-}" ]]; then + cmd+=("XCTESTSERVICE_BUNDLE_ID=${XCTESTSERVICE_BUNDLE_ID}") + fi + if [[ -n "${XCTESTSERVICE_TIMEOUT:-}" ]]; then + cmd+=("XCTESTSERVICE_TIMEOUT=${XCTESTSERVICE_TIMEOUT}") + fi + + # Kill any orphaned xcodebuild test processes for XCTestService (e.g. from a + # previous watcher that was SIGKILL'd before it could clean up its children). + local existing_pids + existing_pids=$(pgrep -f "xcodebuild.*test.*XCTestService" 2>/dev/null || true) + if [[ -n "${existing_pids}" ]]; then + log_info "Killing orphaned XCTestService xcodebuild processes: ${existing_pids}" + echo "${existing_pids}" | xargs kill 2>/dev/null || true + sleep 2 + # Force kill if still running + existing_pids=$(pgrep -f "xcodebuild.*test.*XCTestService" 2>/dev/null || true) + if [[ -n "${existing_pids}" ]]; then + log_warn "Force killing orphaned xcodebuild processes..." + echo "${existing_pids}" | xargs kill -9 2>/dev/null || true + sleep 1 + fi + fi + + echo "" >> "${XCODEBUILD_LOG}" + echo "=== XCTestService starting at $(date) ===" >> "${XCODEBUILD_LOG}" + (cd "${XCTEST_SERVICE_DIR}" && "${cmd[@]}") >> "${XCODEBUILD_LOG}" 2>&1 & + XCODEBUILD_PID=$! + log_info "XCTestService started (PID ${XCODEBUILD_PID})" + log_info "Logs: ${XCODEBUILD_LOG}" + return 0 +} + +# Stop XCTestService process +stop_xctestservice() { + if [[ -n "${XCODEBUILD_PID}" ]] && kill -0 "${XCODEBUILD_PID}" 2>/dev/null; then + log_info "Stopping XCTestService (PID ${XCODEBUILD_PID})..." + kill "${XCODEBUILD_PID}" 2>/dev/null || true + + # Wait up to 5 seconds for graceful exit + local count=0 + while kill -0 "${XCODEBUILD_PID}" 2>/dev/null && [[ ${count} -lt 5 ]]; do + sleep 1 + count=$((count + 1)) + done + + # Force kill if still running + if kill -0 "${XCODEBUILD_PID}" 2>/dev/null; then + log_warn "Force killing XCTestService..." + kill -9 "${XCODEBUILD_PID}" 2>/dev/null || true + fi + fi + XCODEBUILD_PID="" +} + +# Watch for changes and rebuild/restart +# Args: poll_interval +watch_loop() { + local poll_interval="${1:-2}" + local last_hash + local last_simulator="" + local needs_restart=false + + log_info "Watching for changes (poll interval ${poll_interval}s)." + last_hash="$(hash_watch_state)" + + # Initial build + if ! build_xctestservice; then + log_warn "Initial build failed; waiting for changes." + fi + + while true; do + sleep "${poll_interval}" + + local current_simulator + if [[ -n "${SIMULATOR_ID_OVERRIDE:-}" ]]; then + if xcrun simctl list devices booted -j 2>/dev/null | \ + grep -q "\"${SIMULATOR_ID_OVERRIDE}\""; then + current_simulator="${SIMULATOR_ID_OVERRIDE}" + else + current_simulator="" + fi + else + current_simulator=$(xcrun simctl list devices booted -j 2>/dev/null | \ + grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "//;s/"$//') + fi + + if [[ "${current_simulator}" != "${last_simulator}" ]]; then + if [[ -n "${current_simulator}" ]]; then + log_info "Booted simulator: ${current_simulator}" + needs_restart=true + else + log_warn "No booted simulator detected." + fi + last_simulator="${current_simulator}" + fi + + local next_hash + next_hash="$(hash_watch_state)" + if [[ "${next_hash}" != "${last_hash}" ]]; then + log_info "Change detected. Rebuilding..." + if build_xctestservice; then + last_hash="$(hash_watch_state)" + needs_restart=true + else + log_warn "Build failed; waiting for next change." + fi + fi + + if [[ -n "${XCODEBUILD_PID}" ]] && ! kill -0 "${XCODEBUILD_PID}" 2>/dev/null; then + log_warn "XCTestService process exited." + XCODEBUILD_PID="" + needs_restart=true + fi + + if [[ "${needs_restart}" == "true" ]] && [[ -n "${last_simulator}" ]]; then + stop_xctestservice + start_xctestservice "${last_simulator}" + needs_restart=false + fi + done +} diff --git a/scripts/local/build-ios-component.sh b/scripts/local/build-ios-component.sh new file mode 100755 index 000000000..c18c42023 --- /dev/null +++ b/scripts/local/build-ios-component.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Helper script to build a specific iOS component +# Usage: ./build-ios-component.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 " + echo "" + echo "Available components:" + echo " AccessibilityService" + echo " AXeAutomation" + echo " SimctlIntegration" + echo " XCTestRunner" + echo " XcodeCompanion" + echo " XcodeExtension" + exit 1 +fi + +COMPONENT="$1" +COMPONENT_PATH="${PROJECT_ROOT}/ios/${COMPONENT}" + +if [[ ! -d "${COMPONENT_PATH}" ]]; then + echo "❌ Error: Component '${COMPONENT}' not found at ${COMPONENT_PATH}" + exit 1 +fi + +echo "Building ${COMPONENT}..." +echo "---" + +# Check if it's a TypeScript component +if [[ -f "${COMPONENT_PATH}/package.json" ]]; then + echo "Building TypeScript component..." + (cd "${COMPONENT_PATH}" && bun install && bun run build) +else + # Assume it's a Swift package + echo "Building Swift component..." + (cd "${COMPONENT_PATH}" && swift build) +fi + +echo "" +echo "✓ ${COMPONENT} build successful" diff --git a/scripts/local/test-ios-component.sh b/scripts/local/test-ios-component.sh new file mode 100755 index 000000000..12c1a6e04 --- /dev/null +++ b/scripts/local/test-ios-component.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Helper script to test a specific iOS component +# Usage: ./test-ios-component.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 " + echo "" + echo "Available components:" + echo " AccessibilityService" + echo " AXeAutomation" + echo " SimctlIntegration" + echo " XCTestRunner" + echo " XcodeCompanion" + echo " XcodeExtension" + exit 1 +fi + +COMPONENT="$1" +COMPONENT_PATH="${PROJECT_ROOT}/ios/${COMPONENT}" + +if [[ ! -d "${COMPONENT_PATH}" ]]; then + echo "❌ Error: Component '${COMPONENT}' not found at ${COMPONENT_PATH}" + exit 1 +fi + +echo "Testing ${COMPONENT}..." +echo "---" + +# Check if it's a TypeScript component +if [[ -f "${COMPONENT_PATH}/package.json" ]]; then + echo "Running TypeScript tests..." + (cd "${COMPONENT_PATH}" && bun test) +else + # Assume it's a Swift package + echo "Running Swift tests..." + (cd "${COMPONENT_PATH}" && swift test) +fi + +echo "" +echo "✓ ${COMPONENT} tests passed" diff --git a/scripts/local/validate-ios.sh b/scripts/local/validate-ios.sh new file mode 100755 index 000000000..7e9f541f5 --- /dev/null +++ b/scripts/local/validate-ios.sh @@ -0,0 +1,196 @@ +#!/bin/bash + +# Local validation script for iOS components +# Includes additional checks and detailed output for local development + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +echo "=========================================" +echo "iOS Components Validation (Local)" +echo "=========================================" +echo "" + +# Check platform +if [[ "$(uname)" != "Darwin" ]]; then + echo "❌ Error: iOS development requires macOS" + echo " Current platform: $(uname)" + exit 1 +fi + +echo "✓ Running on macOS $(sw_vers -productVersion)" +echo "" + +# Check Xcode +if ! command -v xcodebuild &>/dev/null; then + echo "❌ Error: Xcode not found" + echo " Please install Xcode from the App Store" + exit 1 +fi + +XCODE_VERSION=$(xcodebuild -version | head -n 1) +echo "✓ ${XCODE_VERSION}" +echo "" + +# Check Swift +if ! command -v swift &>/dev/null; then + echo "❌ Error: Swift not found" + exit 1 +fi + +echo "✓ Swift version: $(swift --version | head -n 1)" +echo "" + +# Check Bun +if ! command -v bun &>/dev/null; then + echo "❌ Error: Bun not found" + echo " Please install bun: https://bun.sh" + exit 1 +fi + +echo "✓ Bun version: $(bun --version)" +echo "" + +# Check for simctl +if ! command -v xcrun simctl &>/dev/null; then + echo "⚠️ Warning: simctl not found" + echo " Some functionality may not work" +else + echo "✓ simctl available" +fi + +echo "" +echo "=========================================" +echo "Building Swift Components" +echo "=========================================" +echo "" + +SWIFT_COMPONENTS=( + "ios/AccessibilityService" + "ios/AXeAutomation" + "ios/XCTestRunner" + "ios/XcodeCompanion" + "ios/XcodeExtension" +) + +FAILED_BUILDS=() +PASSED_BUILDS=() + +for component in "${SWIFT_COMPONENTS[@]}"; do + component_path="${PROJECT_ROOT}/${component}" + + if [[ ! -d "${component_path}" ]]; then + echo "⚠️ ${component} not found, skipping" + continue + fi + + echo "Building ${component}..." + + if (cd "${component_path}" && swift build 2>&1 | grep -E "(error:|warning:|Compiling|Linking|Build complete)"); then + if (cd "${component_path}" && swift build >/dev/null 2>&1); then + echo "✓ ${component} build successful" + PASSED_BUILDS+=("${component}") + else + echo "❌ ${component} build failed" + FAILED_BUILDS+=("${component}") + fi + fi + + echo "" +done + +echo "=========================================" +echo "Building TypeScript Components" +echo "=========================================" +echo "" + +SIMCTL_PATH="${PROJECT_ROOT}/ios/SimctlIntegration" + +if [[ -d "${SIMCTL_PATH}" ]]; then + echo "Building ios/SimctlIntegration..." + + (cd "${SIMCTL_PATH}" && bun install) + + if (cd "${SIMCTL_PATH}" && bun run build); then + echo "✓ SimctlIntegration build successful" + PASSED_BUILDS+=("ios/SimctlIntegration") + else + echo "❌ SimctlIntegration build failed" + FAILED_BUILDS+=("ios/SimctlIntegration") + fi +else + echo "⚠️ SimctlIntegration not found, skipping" +fi + +echo "" + +echo "=========================================" +echo "Running Tests" +echo "=========================================" +echo "" + +# Run Swift tests +for component in "${SWIFT_COMPONENTS[@]}"; do + component_path="${PROJECT_ROOT}/${component}" + + if [[ ! -d "${component_path}" ]]; then + continue + fi + + echo "Testing ${component}..." + + if (cd "${component_path}" && swift test 2>&1); then + echo "✓ ${component} tests passed" + else + echo "⚠️ ${component} tests failed or unavailable" + fi + + echo "" +done + +# Run TypeScript tests +if [[ -d "${SIMCTL_PATH}" ]]; then + echo "Testing ios/SimctlIntegration..." + + if (cd "${SIMCTL_PATH}" && bun test); then + echo "✓ SimctlIntegration tests passed" + else + echo "⚠️ SimctlIntegration tests failed" + fi + + echo "" +fi + +echo "=========================================" +echo "Validation Summary" +echo "=========================================" +echo "" + +echo "Build Results:" +echo " Passed: ${#PASSED_BUILDS[@]}" +for component in "${PASSED_BUILDS[@]}"; do + echo " ✓ ${component}" +done +echo "" + +if [[ ${#FAILED_BUILDS[@]} -gt 0 ]]; then + echo " Failed: ${#FAILED_BUILDS[@]}" + for component in "${FAILED_BUILDS[@]}"; do + echo " ❌ ${component}" + done + echo "" + echo "❌ Validation failed" + exit 1 +fi + +echo "✓ All iOS components validated successfully" +echo "" +echo "Next steps:" +echo " - Run individual component tests with 'swift test' in each directory" +echo " - Build for iOS Simulator with xcodebuild" +echo " - Test integration with MCP server" +echo "" + +exit 0 diff --git a/scripts/lychee/apply_lychee.sh b/scripts/lychee/apply_lychee.sh new file mode 100755 index 000000000..fcdbc044f --- /dev/null +++ b/scripts/lychee/apply_lychee.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# +# apply_lychee.sh +# +# automobile: links in documentation by applying suggestions +# from lychee link checker. Uses sed to replace broken links with corrected paths. +# +# This script focuses on fixing relative path issues by calculating the correct +# relative path from each source file to the target file. +# +# Exit codes: +# 0 - All fixes applied successfully or no fixes needed +# 1 - Error running lychee or applying fixes +# +# Usage: +# ./scripts/lychee/apply_lychee.sh [--dry-run] +# +# Options: +# --dry-run Show what would be fixed without making changes +# +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Parse arguments +DRY_RUN=false +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--dry-run]" + exit 1 + ;; + esac +done + +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_fix() { + echo -e "${BLUE}[FIX]${NC} $1" +} + +# Function to calculate relative path from source to target +relpath() { + python3 -c "import os.path; print(os.path.relpath('$1', '${2:-.}'))" 2>/dev/null +} + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if [[ "$DRY_RUN" == true ]]; then + print_status "Running in DRY RUN mode - no files will be modified" +else + print_status "Applying lychee link fixes..." +fi +print_status "" + +cd "$PROJECT_ROOT" + +# Check if lychee is installed +if ! command -v lychee >/dev/null 2>&1; then + print_error "lychee is not installed" + exit 1 +fi + +# Run validation script to get suggestions +VALIDATION_OUTPUT=$(mktemp) +./scripts/lychee/validate_lychee.sh > "$VALIDATION_OUTPUT" 2>&1 || true + +# Parse validation output to extract broken links and suggestions +FIXES_APPLIED=0 +CURRENT_BROKEN_PATH="" +CURRENT_SOURCE_FILE="" +IN_GIT_HISTORY_SECTION=false + +while IFS= read -r line; do + # Detect source file from error messages like: [docs/install/ai-agents/cursor.md]: + if [[ "$line" =~ ^\[docs/([^\]]+)\]: ]]; then + CURRENT_SOURCE_FILE="docs/${BASH_REMATCH[1]}" + fi + + # Detect broken path from suggestions like: ✗ /Users/.../docs/install/img/cursor-mcp-server-success.png + if [[ "$line" =~ ✗\ (.+/docs/.+) ]]; then + CURRENT_BROKEN_PATH="${BASH_REMATCH[1]}" + # Strip the PROJECT_ROOT prefix + CURRENT_BROKEN_PATH="${CURRENT_BROKEN_PATH#"$PROJECT_ROOT"/}" + IN_GIT_HISTORY_SECTION=false + fi + + # Check if we're entering git history section (prioritize these suggestions) + if [[ "$line" =~ File\ was\ moved\ \(from\ git\ history\): ]]; then + IN_GIT_HISTORY_SECTION=true + continue + fi + + # Check if we're leaving git history section + if [[ "$IN_GIT_HISTORY_SECTION" == true ]] && [[ "$line" =~ Possible\ matches: || "$line" =~ Other\ possible\ matches: ]]; then + IN_GIT_HISTORY_SECTION=false + fi + + # Detect suggestion like: - docs/img/cursor-mcp-server-success.png + if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+(docs/.+) ]]; then + SUGGESTION="${BASH_REMATCH[1]}" + + if [[ -n "$CURRENT_BROKEN_PATH" && -n "$CURRENT_SOURCE_FILE" && -f "$SUGGESTION" ]]; then + # Calculate the correct relative path + SOURCE_DIR=$(dirname "$CURRENT_SOURCE_FILE") + NEW_RELATIVE_PATH=$(relpath "$SUGGESTION" "$SOURCE_DIR") + + # Find the basename to search for in the source file + BASENAME=$(basename "$CURRENT_BROKEN_PATH") + + # Find the line containing this basename in the source file + if grep -q "$BASENAME" "$CURRENT_SOURCE_FILE" 2>/dev/null; then + # Extract the old relative path from the source file + OLD_RELATIVE_PATH=$(grep -o "[^(\"']*${BASENAME}[^)\"']*" "$CURRENT_SOURCE_FILE" | head -1) + + if [[ -n "$OLD_RELATIVE_PATH" && "$OLD_RELATIVE_PATH" != "$NEW_RELATIVE_PATH" ]]; then + # Indicate if this is from git history + if [[ "$IN_GIT_HISTORY_SECTION" == true ]]; then + print_fix "$CURRENT_SOURCE_FILE (git history)" + else + print_fix "$CURRENT_SOURCE_FILE" + fi + echo " Old: $OLD_RELATIVE_PATH" + echo " New: $NEW_RELATIVE_PATH" + echo "" + + if [[ "$DRY_RUN" == false ]]; then + # Use sed to replace the old path with the new one + # Escape special characters for sed + OLD_ESCAPED=$(printf '%s\n' "$OLD_RELATIVE_PATH" | sed 's:[][\/.^$*]:\\&:g') + NEW_ESCAPED=$(printf '%s\n' "$NEW_RELATIVE_PATH" | sed 's:[\/&]:\\&:g') + + sed -i.bak "s|$OLD_ESCAPED|$NEW_ESCAPED|g" "$CURRENT_SOURCE_FILE" + rm -f "$CURRENT_SOURCE_FILE.bak" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + + # If we found a git history match, skip other suggestions for this broken path + if [[ "$IN_GIT_HISTORY_SECTION" == true ]]; then + CURRENT_BROKEN_PATH="" + fi + fi + fi + fi + fi +done < "$VALIDATION_OUTPUT" + +rm -f "$VALIDATION_OUTPUT" + +# Summary +echo "" +if [[ "$DRY_RUN" == true ]]; then + print_status "Dry run complete - no changes were made" +else + if [[ $FIXES_APPLIED -eq 0 ]]; then + print_status "No fixes were applied (either no broken links or couldn't find matching paths)" + else + print_status "Applied $FIXES_APPLIED fix(es)" + echo "" + print_status "Run './scripts/lychee/validate_lychee.sh' to verify the fixes" + fi +fi + +exit 0 diff --git a/scripts/lychee/install_lychee.sh b/scripts/lychee/install_lychee.sh old mode 100644 new mode 100755 index 747a65aab..380a0bcd3 --- a/scripts/lychee/install_lychee.sh +++ b/scripts/lychee/install_lychee.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -LYCHEE_VERSION="0.19.1" # Change this to the desired version +LYCHEE_VERSION="0.22.0" # Change this to the desired version # Colors for output RED='\033[0;31m' @@ -55,13 +55,26 @@ install_macos() { if command_exists brew; then echo -e "${GREEN}Using Homebrew to install lychee${NC}" - brew install lychee + if brew install lychee; then + return 0 + else + echo -e "${YELLOW}Homebrew installation failed. Using manual installation...${NC}" + install_manual + return $? + fi elif command_exists port; then echo -e "${GREEN}Using MacPorts to install lychee${NC}" - sudo port install lychee + if sudo port install lychee; then + return 0 + else + echo -e "${YELLOW}MacPorts installation failed. Using manual installation...${NC}" + install_manual + return $? + fi else echo -e "${YELLOW}No package manager found. Using manual installation...${NC}" install_manual + return $? fi } @@ -69,38 +82,46 @@ install_macos() { install_linux() { echo -e "${YELLOW}Installing lychee on Linux...${NC}" - # Check for package managers in order of preference + # For GitHub Actions and other Debian/Ubuntu systems, go straight to manual installation + # as lychee is not in default apt repositories + if command_exists apt-get || command_exists yum || command_exists dnf; then + echo -e "${YELLOW}Debian/Ubuntu/RHEL detected. Using manual installation for latest version...${NC}" + install_manual + return $? + fi + + # Check for package managers that have lychee in repositories if command_exists pacman; then echo -e "${GREEN}Using pacman to install lychee${NC}" - sudo pacman -S lychee + if sudo pacman -S --noconfirm lychee; then + return 0 + fi elif command_exists zypper; then echo -e "${GREEN}Using zypper to install lychee${NC}" - sudo zypper in lychee + if sudo zypper in -y lychee; then + return 0 + fi elif command_exists apk; then echo -e "${GREEN}Using apk to install lychee${NC}" - sudo apk add lychee + if sudo apk add lychee; then + return 0 + fi elif command_exists pkg; then echo -e "${GREEN}Using pkg to install lychee${NC}" - sudo pkg install lychee + if sudo pkg install -y lychee; then + return 0 + fi elif command_exists nix-env; then echo -e "${GREEN}Using nix to install lychee${NC}" - nix-env -iA nixos.lychee - elif command_exists apt-get; then - echo -e "${YELLOW}APT package manager detected, but lychee may not be available in default repositories.${NC}" - echo -e "${YELLOW}Falling back to manual installation...${NC}" - install_manual - elif command_exists yum; then - echo -e "${YELLOW}YUM package manager detected, but lychee may not be available in default repositories.${NC}" - echo -e "${YELLOW}Falling back to manual installation...${NC}" - install_manual - elif command_exists dnf; then - echo -e "${YELLOW}DNF package manager detected, but lychee may not be available in default repositories.${NC}" - echo -e "${YELLOW}Falling back to manual installation...${NC}" - install_manual - else - echo -e "${YELLOW}No supported package manager found. Using manual installation...${NC}" - install_manual + if nix-env -iA nixos.lychee; then + return 0 + fi fi + + # Fallback to manual if package manager installation failed or not available + echo -e "${YELLOW}Package manager installation not available or failed. Using manual installation...${NC}" + install_manual + return $? } # Install lychee on Windows @@ -133,14 +154,17 @@ install_manual() { arch=$(detect_arch) # Map OS and architecture to GitHub release naming + # Note: GitHub release tag is "lychee-v${VERSION}" but asset names are "lychee-${ARCH}..." case "$os" in macos) case "$arch" in x86_64) - binary_name="lychee-lychee-v${LYCHEE_VERSION}-x86_64-apple-darwin.tar.gz" + # No x86_64 macOS binary, use arm64 (Rosetta can run it) + echo -e "${YELLOW}No native x86_64 macOS binary. Using arm64 (Rosetta 2 required)${NC}" + binary_name="lychee-arm64-macos.tar.gz" ;; aarch64) - binary_name="lychee-lychee-v${LYCHEE_VERSION}-aarch64-apple-darwin.tar.gz" + binary_name="lychee-arm64-macos.tar.gz" ;; *) echo -e "${RED}Unsupported architecture: $arch${NC}" @@ -151,13 +175,13 @@ install_manual() { linux) case "$arch" in x86_64) - binary_name="lychee-lychee-v${LYCHEE_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + binary_name="lychee-x86_64-unknown-linux-gnu.tar.gz" ;; aarch64) - binary_name="lychee-lychee-v${LYCHEE_VERSION}-aarch64-unknown-linux-gnu.tar.gz" + binary_name="lychee-aarch64-unknown-linux-gnu.tar.gz" ;; armv7) - binary_name="lychee-lychee-v${LYCHEE_VERSION}-armv7-unknown-linux-gnueabihf.tar.gz" + binary_name="lychee-armv7-unknown-linux-gnueabihf.tar.gz" ;; *) echo -e "${RED}Unsupported architecture: $arch${NC}" @@ -168,7 +192,7 @@ install_manual() { windows) case "$arch" in x86_64) - binary_name="lychee-lychee-v${LYCHEE_VERSION}-x86_64-pc-windows-msvc.zip" + binary_name="lychee-x86_64-windows.exe" ;; *) echo -e "${RED}Unsupported architecture: $arch${NC}" @@ -186,7 +210,7 @@ install_manual() { install_dir="$HOME/.local/bin" mkdir -p "$install_dir" - # Download URL + # Download URL - tag is "lychee-v${VERSION}" but asset names don't include version download_url="https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/${binary_name}" echo -e "${GREEN}Downloading lychee binary from GitHub releases...${NC}" @@ -213,43 +237,72 @@ install_manual() { return 1 fi - # Extract the archive + # Extract the archive or handle direct executable cd "$temp_dir" || exit case "$binary_name" in *.tar.gz) - tar -xzf "$binary_name" + if ! tar -xzf "$binary_name"; then + echo -e "${RED}Failed to extract archive${NC}" + return 1 + fi ;; *.zip) if command_exists unzip; then - unzip "$binary_name" + if ! unzip "$binary_name"; then + echo -e "${RED}Failed to extract archive${NC}" + return 1 + fi else echo -e "${RED}unzip command not found. Please install unzip or extract manually.${NC}" return 1 fi ;; + *.exe) + # Windows executable - no extraction needed, just rename + mv "$binary_name" lychee.exe + ;; *) - echo -e "${RED}Unsupported archive format${NC}" + echo -e "${RED}Unsupported archive format: $binary_name${NC}" return 1 ;; esac # Find the lychee binary and move it to install directory - lychee_binary=$(find . -name "lychee" -type f | head -1) + if [[ "$binary_name" == *.exe ]]; then + lychee_binary="./lychee.exe" + else + lychee_binary=$(find . -name "lychee" -o -name "lychee.exe" | grep -v ".tar.gz" | head -1) + fi + if [[ -z "$lychee_binary" ]]; then echo -e "${RED}Could not find lychee binary in extracted archive${NC}" + echo -e "${YELLOW}Contents of temp directory:${NC}" + ls -la return 1 fi # Make executable and move to install directory chmod +x "$lychee_binary" - mv "$lychee_binary" "$install_dir/lychee" + if [[ "$binary_name" == *.exe ]]; then + mv "$lychee_binary" "$install_dir/lychee.exe" + else + mv "$lychee_binary" "$install_dir/lychee" + fi echo -e "${GREEN}lychee installed successfully to $install_dir${NC}" - echo -e "${YELLOW}Make sure $install_dir is in your PATH environment variable.${NC}" + + # Add to PATH for current session + export PATH="$install_dir:$PATH" + + # Add to GitHub Actions PATH if running in GitHub Actions + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$install_dir" >> "$GITHUB_PATH" + echo -e "${GREEN}Added $install_dir to GITHUB_PATH${NC}" + fi # Check if directory is in PATH if [[ ":$PATH:" != *":$install_dir:"* ]]; then - echo -e "${YELLOW}To add $install_dir to your PATH, add this line to your shell configuration:${NC}" + echo -e "${YELLOW}To add $install_dir to your PATH permanently, add this line to your shell configuration:${NC}" echo "export PATH=\"\$PATH:$install_dir\"" fi @@ -275,6 +328,13 @@ main() { echo -e "${GREEN}Lychee Installation Script${NC}" echo -e "${GREEN}==========================${NC}" + # Check if lychee is already installed + if command_exists lychee; then + echo -e "${GREEN}lychee is already installed${NC}" + lychee --version + return 0 + fi + local os os=$(detect_os) local arch @@ -282,24 +342,29 @@ main() { echo -e "${YELLOW}Detected OS: $os${NC}" echo -e "${YELLOW}Detected Architecture: $arch${NC}" + local install_result=1 case $os in macos) install_macos + install_result=$? ;; linux) install_linux + install_result=$? ;; windows) install_windows + install_result=$? ;; *) echo -e "${RED}Unsupported operating system: $os${NC}" echo -e "${YELLOW}Falling back to manual installation...${NC}" install_manual + install_result=$? ;; esac - if install_manual; then + if [ $install_result -eq 0 ]; then echo -e "${GREEN}Installation completed successfully!${NC}" verify_installation else diff --git a/scripts/lychee/validate_lychee.sh b/scripts/lychee/validate_lychee.sh old mode 100644 new mode 100755 index e16356e1d..d3b0985da --- a/scripts/lychee/validate_lychee.sh +++ b/scripts/lychee/validate_lychee.sh @@ -1,13 +1,228 @@ #!/usr/bin/env bash +# +# validate_lychee.sh +# +# Validates all links in documentation files using lychee link checker. +# Checks both internal and external links for broken references. +# +# Exit codes: +# 0 - All links are valid +# 1 - lychee not installed or configuration error +# 2 - Broken links found +# +# Usage: +# ./scripts/lychee/validate_lychee.sh [--verbose] +# +# Options: +# --verbose Show detailed output including excluded and unsupported links +# +set -euo pipefail -# Validate lychee configuration and run link checking -echo "Validating lychee configuration..." +# Parse arguments +VERBOSE_FLAG="" +while [[ $# -gt 0 ]]; do + case $1 in + --verbose|-v) + VERBOSE_FLAG="-vv" + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--verbose]" + exit 1 + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +LYCHEE_CONFIG="$PROJECT_ROOT/.lycherc.toml" + +print_status "Validating documentation links with lychee..." # Check if lychee is installed if ! command -v lychee >/dev/null 2>&1; then - echo "Error: lychee is not installed" + print_error "lychee is not installed" + echo "" + echo "To install lychee, run:" + echo " ./scripts/lychee/install_lychee.sh" + echo "" + echo "Or install manually:" + echo " - macOS: brew install lychee" + echo " - Linux: cargo install lychee" + echo " - Other: See https://github.com/lycheeverse/lychee" exit 1 fi -echo "lychee is available" -exit 0 +# Show lychee version +LYCHEE_VERSION=$(lychee --version | head -1) +print_status "Using $LYCHEE_VERSION" + +# Check if config file exists +if [[ ! -f "$LYCHEE_CONFIG" ]]; then + print_warning "Lychee config not found at: $LYCHEE_CONFIG" + print_warning "Running with default configuration..." +fi + +# Run lychee on docs directory +print_status "Checking links in docs/ directory..." +print_status "" + +cd "$PROJECT_ROOT" + +# Function to suggest similar files for broken file:// links +suggest_similar_files() { + local broken_path="$1" + local basename_file + basename_file=$(basename "$broken_path") + + local found_suggestions=false + + # First, check git history for renamed/moved files + if git rev-parse --git-dir > /dev/null 2>&1; then + # Look for files that were moved or renamed + local git_suggestions + git_suggestions=$(git log --follow --all --diff-filter=R --find-renames --name-status --pretty="" -- "*${basename_file}" 2>/dev/null | \ + grep -E "^R" | \ + awk '{print $3}' | \ + head -3) + + if [[ -n "$git_suggestions" ]]; then + echo " File was moved (from git history):" + echo "$git_suggestions" | while IFS= read -r match; do + if [[ -f "$match" ]]; then + echo " - $match" + found_suggestions=true + fi + done + fi + fi + + # Search for files with similar names in current tree + local suggestions + suggestions=$(find docs/ -type f -name "*${basename_file}*" 2>/dev/null | head -5) + + if [[ -n "$suggestions" ]]; then + if [[ "$found_suggestions" == false ]]; then + echo " Possible matches:" + else + echo " Other possible matches:" + fi + echo "$suggestions" | while IFS= read -r match; do + echo " - $match" + done + found_suggestions=true + fi + + # If still no suggestions, check if file was deleted + if [[ "$found_suggestions" == false ]] && git rev-parse --git-dir > /dev/null 2>&1; then + local deleted_info + deleted_info=$(git log --all --diff-filter=D --summary -- "*${basename_file}" 2>/dev/null | grep "delete mode" | head -1) + + if [[ -n "$deleted_info" ]]; then + echo " File was deleted in git history (no current replacement found)" + fi + fi +} + +# Run lychee and capture output +LYCHEE_OUTPUT=$(mktemp) +LYCHEE_ERRORS=$(mktemp) + +# Run lychee with config (if exists) or default settings +if [[ -f "$LYCHEE_CONFIG" ]]; then + if lychee --config "$LYCHEE_CONFIG" $VERBOSE_FLAG "docs/" 2>&1 | tee "$LYCHEE_OUTPUT"; then + rm -f "$LYCHEE_OUTPUT" "$LYCHEE_ERRORS" + print_status "✓ All links are valid" + exit 0 + else + EXIT_CODE=$? + if [[ $EXIT_CODE -eq 2 ]]; then + # Extract broken file:// links and provide suggestions + grep -E "\[ERROR\].*file://" "$LYCHEE_OUTPUT" | grep "Cannot find file" > "$LYCHEE_ERRORS" || true + + if [[ -s "$LYCHEE_ERRORS" ]]; then + echo "" + print_warning "Suggestions for broken file:// links:" + echo "" + while IFS= read -r error_line; do + # Extract the file path from the error + if [[ "$error_line" =~ file://([^[:space:]|]+) ]]; then + broken_path="${BASH_REMATCH[1]}" + echo " ✗ $broken_path" + suggest_similar_files "$broken_path" + echo "" + fi + done < "$LYCHEE_ERRORS" + fi + + rm -f "$LYCHEE_OUTPUT" "$LYCHEE_ERRORS" + print_error "✗ Broken links found" + echo "" + echo "To fix broken links:" + echo " 1. Update the links to point to valid URLs" + echo " 2. Remove dead links from documentation" + echo " 3. Add exclusions to .lycherc.toml if links are intentionally unreachable" + exit 2 + else + rm -f "$LYCHEE_OUTPUT" "$LYCHEE_ERRORS" + print_error "✗ Lychee failed with exit code $EXIT_CODE" + exit 1 + fi + fi +else + # Run with minimal default options + if lychee --max-concurrency 10 --timeout 20 $VERBOSE_FLAG "docs/" 2>&1 | tee "$LYCHEE_OUTPUT"; then + rm -f "$LYCHEE_OUTPUT" "$LYCHEE_ERRORS" + print_status "✓ All links are valid" + exit 0 + else + EXIT_CODE=$? + if [[ $EXIT_CODE -eq 2 ]]; then + # Extract broken file:// links and provide suggestions + grep -E "\[ERROR\].*file://" "$LYCHEE_OUTPUT" | grep "Cannot find file" > "$LYCHEE_ERRORS" || true + + if [[ -s "$LYCHEE_ERRORS" ]]; then + echo "" + print_warning "Suggestions for broken file:// links:" + echo "" + while IFS= read -r error_line; do + # Extract the file path from the error + if [[ "$error_line" =~ file://([^[:space:]|]+) ]]; then + broken_path="${BASH_REMATCH[1]}" + echo " ✗ $broken_path" + suggest_similar_files "$broken_path" + echo "" + fi + done < "$LYCHEE_ERRORS" + fi + + rm -f "$LYCHEE_OUTPUT" "$LYCHEE_ERRORS" + print_error "✗ Broken links found" + exit 2 + else + rm -f "$LYCHEE_OUTPUT" "$LYCHEE_ERRORS" + print_error "✗ Lychee failed with exit code $EXIT_CODE" + exit 1 + fi + fi +fi diff --git a/scripts/memory/stress-harness.ts b/scripts/memory/stress-harness.ts new file mode 100644 index 000000000..42316cd45 --- /dev/null +++ b/scripts/memory/stress-harness.ts @@ -0,0 +1,401 @@ +import fs from "node:fs"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { defaultTimer } from "../../src/utils/SystemTimer"; +import { RealObserveScreen } from "../../src/features/observe/ObserveScreen"; +import type { ObserveScreen } from "../../src/features/observe/interfaces/ObserveScreen"; +import { TapOnElement } from "../../src/features/action/TapOnElement"; +import { SwipeOn } from "../../src/features/action/swipeon"; +import { InputText } from "../../src/features/action/InputText"; +import { ViewHierarchy } from "../../src/features/observe/ViewHierarchy"; +import { CtrlProxyClient } from "../../src/features/observe/android"; +import { SharpImageUtils } from "../../src/utils/image-utils"; +import type { BootedDevice } from "../../src/models"; +import type { AdbClientFactory } from "../../src/utils/android-cmdline-tools/AdbClientFactory"; +import { FakeAdbExecutor } from "../../test/fakes/FakeAdbExecutor"; +import { FakeAccessibilityDetector } from "../../test/fakes/FakeAccessibilityDetector"; + +export type StressOperation = "observe" | "tapOn" | "swipeOn" | "inputText"; + +export interface StressRunConfig { + iterations: number; + opsPerSecond: number; + operations: StressOperation[]; + gcEvery: number; +} + +export interface StressRunResult { + iterations: number; + durationMs: number; + operationCounts: Record; +} + +export interface StressHarnessResources { + device: BootedDevice; + viewHierarchy: ViewHierarchy; + observeScreen: ObserveScreen; + fixtureImagePaths: string[]; +} + +export interface StressHarness { + operations: Record Promise>; + cleanup: () => Promise; + resources: StressHarnessResources; +} + +const DUMPSYS_WINDOW_OUTPUT = [ + "mRotation=0", + "statusBars=Window{123 u0 StatusBar} frame=[0,0][1080,74]", + "navigationBars=Window{456 u0 NavigationBar} frame=[0,2337][1080,2400]", + "systemGestures=InsetsSource sideHint=LEFT frame=[0,0][78,2400]", + "systemGestures=InsetsSource sideHint=RIGHT frame=[1002,0][1080,2400]" +].join("\n"); + +const DUMPSYS_WINDOW_WINDOWS_OUTPUT = [ + "WINDOW MANAGER WINDOWS (dumpsys window windows)", + " imeControlTarget in display# 0 Window{abc u0 com.example.app/com.example.app.MainActivity}" +].join("\n"); + +const DUMPSYS_ACTIVITY_OUTPUT = [ + "Task id #123", + " affinity=com.example.app", + " * Hist #0: ActivityRecord{111 u0 com.example.app/.MainActivity t123}", + "mResumedActivity: ActivityRecord{111 u0 com.example.app/.MainActivity t123}" +].join("\n"); + +const DEFAULT_OPERATIONS: StressOperation[] = ["observe", "tapOn", "swipeOn", "inputText"]; +const DEFAULT_ITERATIONS = 1000; +const DEFAULT_OPS_PER_SECOND = 10; +const DEFAULT_GC_EVERY = 100; +const DEFAULT_WARMUP_ITERATIONS = 100; + +export interface StressCliOptions { + iterations?: number; + durationMs?: number; + opsPerSecond?: number; + operations?: StressOperation[]; + gcEvery?: number; + warmupIterations?: number; +} + +export interface ResolvedStressConfig { + runConfig: StressRunConfig; + warmupIterations: number; +} + +function resolveFixtureImages(): string[] { + const fixtureDir = path.join(process.cwd(), "test", "fixtures", "screenshots"); + const candidates = [ + "black-on-white.png", + "white-on-black.png", + "blue-on-yellow.png" + ]; + return candidates + .map(file => path.join(fixtureDir, file)) + .filter(filePath => fs.existsSync(filePath)); +} + +function createMockDevice(): BootedDevice { + return { + name: "memory-stress-device", + platform: "android", + deviceId: "memory-stress-001", + source: "local" + }; +} + +function parseDurationMs(value: string): number { + const match = value.trim().match(/^(\d+)(ms|s|m|h)?$/i); + if (!match) { + throw new Error(`Invalid duration format: ${value}`); + } + const amount = Number.parseInt(match[1], 10); + const unit = (match[2] || "ms").toLowerCase(); + const multipliers: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000 + }; + return amount * (multipliers[unit] ?? 1); +} + +export function parseStressArgs(argv: string[]): StressCliOptions { + const options: StressCliOptions = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = argv[i + 1]; + + if (arg === "--iterations" || arg === "-i") { + options.iterations = Number.parseInt(next, 10); + i++; + continue; + } + + if (arg === "--duration") { + options.durationMs = parseDurationMs(next); + i++; + continue; + } + + if (arg === "--ops-per-second" || arg === "--ops") { + options.opsPerSecond = Number.parseFloat(next); + i++; + continue; + } + + if (arg === "--operations") { + const parsed = next.split(",").map(item => item.trim()).filter(Boolean) as StressOperation[]; + options.operations = parsed; + i++; + continue; + } + + if (arg === "--gc-every") { + options.gcEvery = Number.parseInt(next, 10); + i++; + continue; + } + + if (arg === "--warmup") { + options.warmupIterations = Number.parseInt(next, 10); + i++; + } + } + return options; +} + +export function resolveStressConfig(options: StressCliOptions = {}): ResolvedStressConfig { + const opsPerSecond = options.opsPerSecond ?? DEFAULT_OPS_PER_SECOND; + const operations = options.operations ?? DEFAULT_OPERATIONS; + const gcEvery = options.gcEvery ?? DEFAULT_GC_EVERY; + const warmupIterations = options.warmupIterations ?? DEFAULT_WARMUP_ITERATIONS; + + let iterations = options.iterations ?? DEFAULT_ITERATIONS; + if (!options.iterations && options.durationMs && opsPerSecond > 0) { + iterations = Math.max(1, Math.floor((options.durationMs / 1000) * opsPerSecond)); + } + + return { + runConfig: { + iterations, + opsPerSecond, + operations, + gcEvery + }, + warmupIterations + }; +} + +function createFakeAccessibilityClient() { + const staticHierarchy = { + hierarchy: { + node: { + $: { + "text": "Mock Button", + "resource-id": "com.example:id/mock", + "clickable": "true", + "bounds": "[0,0][100,100]" + } + } + }, + packageName: "com.example.app", + updatedAt: Date.now() + }; + + return { + async getAccessibilityHierarchy() { + return staticHierarchy; + }, + async requestAction() { + return { success: true, totalTimeMs: 1 }; + }, + async requestSwipe() { + return { success: true, totalTimeMs: 1 }; + }, + async requestSetText() { + return { success: true, totalTimeMs: 1 }; + }, + async requestImeAction(action: string) { + return { success: true, action, totalTimeMs: 1 }; + }, + async requestClearText() { + return { success: true, totalTimeMs: 1 }; + }, + async requestSelectAll() { + return { success: true, totalTimeMs: 1 }; + }, + async setRecompositionTrackingEnabled() { + return; + }, + invalidateCache() { + return; + }, + hasCachedHierarchy() { + return true; + }, + isConnected() { + return true; + } + }; +} + +function createFakeAdb(screenshotBase64: string): FakeAdbExecutor { + const fakeAdb = new FakeAdbExecutor(); + fakeAdb.setCommandResponse("wm size", { stdout: "Physical size: 1080x2400", stderr: "" }); + fakeAdb.setCommandResponse("dumpsys window windows", { stdout: DUMPSYS_WINDOW_WINDOWS_OUTPUT, stderr: "" }); + fakeAdb.setCommandResponse("dumpsys window", { stdout: DUMPSYS_WINDOW_OUTPUT, stderr: "" }); + fakeAdb.setCommandResponse("dumpsys activity activities", { stdout: DUMPSYS_ACTIVITY_OUTPUT, stderr: "" }); + fakeAdb.setCommandResponse("screencap -p", { stdout: screenshotBase64, stderr: "" }); + return fakeAdb; +} + +export async function createStressHarness(): Promise { + const fixtureImages = resolveFixtureImages(); + const fallbackImage = fixtureImages[0] ?? path.join(process.cwd(), "test", "fixtures", "screenshots", "black-on-white.png"); + const imageBuffers = fixtureImages.length > 0 + ? fixtureImages.map(filePath => fs.readFileSync(filePath)) + : [fs.readFileSync(fallbackImage)]; + const screenshotBase64 = imageBuffers[0].toString("base64"); + + const device = createMockDevice(); + const fakeAdb = createFakeAdb(screenshotBase64); + const fakeAccessibilityDetector = new FakeAccessibilityDetector(); + fakeAccessibilityDetector.setTalkBackEnabled(false); + + // Create a factory that returns the fake ADB executor + const fakeAdbFactory: AdbClientFactory = { + create: () => fakeAdb, + }; + + const fakeA11yClient = createFakeAccessibilityClient(); + const originalGetInstance = CtrlProxyClient.getInstance; + (CtrlProxyClient as unknown as { getInstance: () => unknown }).getInstance = () => fakeA11yClient; + + const viewHierarchy = new ViewHierarchy( + device, + fakeAdbFactory, + fakeA11yClient as unknown as any + ); + + const observeScreen = new RealObserveScreen(device, fakeAdbFactory); + (observeScreen as unknown as { viewHierarchy: ViewHierarchy }).viewHierarchy = viewHierarchy; + + const tapOnElement = new TapOnElement( + device, + fakeAdb as unknown as any, + undefined, // visionConfig + undefined, // selectionStateTracker + fakeAccessibilityDetector + ); + + const swipeOn = new SwipeOn( + device, + fakeAdb as unknown as any, + { + executeGesture: { + async swipe() { + return { success: true, totalTimeMs: 1 }; + } + }, + accessibilityDetector: fakeAccessibilityDetector + } + ); + + const inputText = new InputText(device, fakeAdb as unknown as any); + const imageUtils = new SharpImageUtils(); + + let imageIndex = 0; + + const operations: Record Promise> = { + observe: async () => { + await observeScreen.execute(); + const buffer = imageBuffers[imageIndex++ % imageBuffers.length]; + await imageUtils.resize(buffer, 120); + }, + tapOn: async () => { + const element = { + "bounds": { left: 0, top: 0, right: 100, bottom: 50 }, + "resource-id": "com.example:id/button" + }; + await (tapOnElement as unknown as any).executeAndroidTap( + "tap", + 10, + 10, + 50, + element + ); + }, + swipeOn: async () => { + await (swipeOn as unknown as any).talkBackExecutor.executeSwipeGesture( + 10, + 10, + 200, + 10, + "right", + null + ); + }, + inputText: async () => { + await (inputText as unknown as any).executeAndroidTextInput("memory-test"); + } + }; + + const cleanup = async () => { + (CtrlProxyClient as unknown as { getInstance: unknown }).getInstance = originalGetInstance; + CtrlProxyClient.resetInstances(); + RealObserveScreen.clearCache(); + }; + + return { + operations, + cleanup, + resources: { + device, + viewHierarchy, + observeScreen, + fixtureImagePaths: fixtureImages.length > 0 ? fixtureImages : [fallbackImage] + } + }; +} + +export async function runStressOperations( + harness: StressHarness, + config: StressRunConfig +): Promise { + const operations = config.operations.length > 0 ? config.operations : DEFAULT_OPERATIONS; + const operationCounts = operations.reduce((acc, op) => { + acc[op] = 0; + return acc; + }, {} as Record); + + const delayMs = config.opsPerSecond > 0 ? 1000 / config.opsPerSecond : 0; + const startTime = performance.now(); + + for (let i = 0; i < config.iterations; i++) { + const operation = operations[i % operations.length]; + const opStart = performance.now(); + await harness.operations[operation](); + operationCounts[operation] += 1; + + if (config.gcEvery > 0 && typeof global.gc === "function" && i > 0 && i % config.gcEvery === 0) { + global.gc(); + } + + if (delayMs > 0) { + const elapsed = performance.now() - opStart; + const remaining = delayMs - elapsed; + if (remaining > 0) { + await defaultTimer.sleep(remaining); + } + } + } + + const durationMs = performance.now() - startTime; + + return { + iterations: config.iterations, + durationMs, + operationCounts + }; +} diff --git a/scripts/npm-unpacked-size-thresholds.json b/scripts/npm-unpacked-size-thresholds.json new file mode 100644 index 000000000..47668c9a9 --- /dev/null +++ b/scripts/npm-unpacked-size-thresholds.json @@ -0,0 +1,10 @@ +{ + "version": "1.0.0", + "thresholds": { + "unpackedBytes": 31457280 + }, + "metadata": { + "generatedAt": "2025-01-01", + "description": "NPM unpacked size cap for the root package" + } +} diff --git a/scripts/npm/transform-readme.js b/scripts/npm/transform-readme.js index 8f39ebbc6..f6b167845 100755 --- a/scripts/npm/transform-readme.js +++ b/scripts/npm/transform-readme.js @@ -3,8 +3,8 @@ const fs = require('fs'); const path = require('path'); -const GITHUB_REPO = 'https://github.com/zillow/auto-mobile/blob/main'; -const GITHUB_RAW = 'https://github.com/zillow/auto-mobile/raw/main'; +const GITHUB_REPO = 'https://github.com/kaeawc/auto-mobile/blob/main'; +const GITHUB_RAW = 'https://github.com/kaeawc/auto-mobile/raw/main'; function transformReadme() { const readmePath = path.join(__dirname, '../..', 'README.md'); diff --git a/scripts/release/publish-mcp-registry.sh b/scripts/release/publish-mcp-registry.sh new file mode 100755 index 000000000..795f93a1e --- /dev/null +++ b/scripts/release/publish-mcp-registry.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Publish AutoMobile to the MCP Registry using DNS verification. +# +# Prerequisites: +# - brew install modelcontextprotocol/tap/mcp-publisher +# - mcp-key.pem in repo root (Ed25519 private key) +# - TXT record on auto-mobile.jasonpearson.dev with matching public key +# +# Usage: +# ./scripts/release/publish-mcp-registry.sh +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +KEY_FILE="$REPO_ROOT/mcp-key.pem" +DOMAIN="jasonpearson.dev" + +if ! command -v mcp-publisher >/dev/null 2>&1; then + echo "Error: mcp-publisher not found. Install with:" >&2 + echo " brew install modelcontextprotocol/tap/mcp-publisher" >&2 + exit 1 +fi + +if [[ ! -f "$KEY_FILE" ]]; then + echo "Error: $KEY_FILE not found." >&2 + echo "Generate with: openssl genpkey -algorithm Ed25519 -out mcp-key.pem" >&2 + exit 1 +fi + +if [[ ! -f "$REPO_ROOT/server.json" ]]; then + echo "Error: server.json not found in repo root." >&2 + exit 1 +fi + +echo "Building and publishing npm package..." +cd "$REPO_ROOT" +bun run build +npm publish --access public + +PRIVATE_KEY="$(openssl pkey -in "$KEY_FILE" -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')" + +echo "Authenticating with MCP Registry via DNS ($DOMAIN)..." +mcp-publisher login dns --domain "$DOMAIN" --private-key "$PRIVATE_KEY" + +echo "Publishing server.json to MCP Registry..." +cd "$REPO_ROOT" +mcp-publisher publish + +echo "Done! Verify at:" +echo " curl 'https://registry.modelcontextprotocol.io/v0.1/servers?search=dev.jasonpearson/auto-mobile'" diff --git a/scripts/release/release.sh b/scripts/release/release.sh new file mode 100755 index 000000000..04eb08066 --- /dev/null +++ b/scripts/release/release.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# Release script for AutoMobile Android libraries +# +# This script handles the full release lifecycle: +# 1. Updates VERSION_NAME to release version +# 2. Commits and tags the release +# 3. Publishes to Maven Central +# 4. Bumps to next SNAPSHOT version +# 5. Commits and pushes everything +# +# Usage: +# ./scripts/release/release.sh 0.0.10 +# ./scripts/release/release.sh --dry-run 0.0.10 +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +GRADLE_PROPERTIES="$REPO_ROOT/android/gradle.properties" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +dry_run=false +version="" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + dry_run=true + shift + ;; + --help|-h) + echo "Usage: $0 [--dry-run] " + echo "" + echo "Options:" + echo " --dry-run Show what would happen without making changes" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 0.0.10 # Release version 0.0.10" + echo " $0 --dry-run 0.0.10 # Dry run for version 0.0.10" + exit 0 + ;; + *) + if [[ -z "$version" ]]; then + version="$1" + else + echo -e "${RED}Error: Unexpected argument: $1${NC}" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$version" ]]; then + echo -e "${RED}Error: Version argument required${NC}" >&2 + echo "Usage: $0 [--dry-run] " + exit 1 +fi + +# Validate version format (semver without -SNAPSHOT) +if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}Error: Invalid version format: $version${NC}" >&2 + echo "Expected format: X.Y.Z (e.g., 0.0.10)" + exit 1 +fi + +# Calculate next snapshot version (bump minor, reset patch) +IFS='.' read -r major minor _patch <<< "$version" +next_minor=$((minor + 1)) +next_snapshot="${major}.${next_minor}.0-SNAPSHOT" + +echo -e "${GREEN}Release Configuration:${NC}" +echo " Release version: $version" +echo " Next snapshot: $next_snapshot" +echo " Dry run: $dry_run" +echo "" + +# Function to update VERSION_NAME in gradle.properties +update_gradle_version() { + local new_version="$1" + local file="$GRADLE_PROPERTIES" + + if [[ "$dry_run" == true ]]; then + echo -e "${YELLOW}[DRY RUN]${NC} Would update VERSION_NAME to $new_version in $file" + return 0 + fi + + # Use sed to replace VERSION_NAME line + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=$new_version/" "$file" + else + sed -i "s/^VERSION_NAME=.*/VERSION_NAME=$new_version/" "$file" + fi + + echo -e "${GREEN}Updated${NC} VERSION_NAME to $new_version" +} + +# Function to run git commands +run_git() { + if [[ "$dry_run" == true ]]; then + echo -e "${YELLOW}[DRY RUN]${NC} git $*" + return 0 + fi + git "$@" +} + +# Function to run gradle commands +run_gradle() { + if [[ "$dry_run" == true ]]; then + echo -e "${YELLOW}[DRY RUN]${NC} ./gradlew $*" + return 0 + fi + (cd "$REPO_ROOT/android" && ./gradlew "$@") +} + +# Ensure we're on main branch and up to date +current_branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD) +if [[ "$current_branch" != "main" && "$dry_run" == false ]]; then + echo -e "${YELLOW}Warning: Not on main branch (currently on $current_branch)${NC}" + read -p "Continue anyway? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Check for uncommitted changes +if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" && "$dry_run" == false ]]; then + echo -e "${RED}Error: Working directory has uncommitted changes${NC}" >&2 + echo "Please commit or stash your changes before releasing." + exit 1 +fi + +echo "" +echo -e "${GREEN}Step 1: Update to release version ($version)${NC}" +update_gradle_version "$version" + +echo "" +echo -e "${GREEN}Step 2: Commit release version${NC}" +run_git -C "$REPO_ROOT" add android/gradle.properties +run_git -C "$REPO_ROOT" commit -m "chore: prepare release $version" + +echo "" +echo -e "${GREEN}Step 3: Create release tag (v$version)${NC}" +run_git -C "$REPO_ROOT" tag -a "v$version" -m "Release v$version" + +echo "" +echo -e "${GREEN}Step 4: Publish to Maven Central${NC}" +run_gradle :protocol:publishAndReleaseToMavenCentral :test-plan-validation:publishAndReleaseToMavenCentral --no-configuration-cache +run_gradle :junit-runner:publishAndReleaseToMavenCentral :auto-mobile-sdk:publishAndReleaseToMavenCentral --no-configuration-cache + +echo "" +echo -e "${GREEN}Step 5: Update to next snapshot version ($next_snapshot)${NC}" +update_gradle_version "$next_snapshot" + +echo "" +echo -e "${GREEN}Step 6: Commit snapshot version${NC}" +run_git -C "$REPO_ROOT" add android/gradle.properties +run_git -C "$REPO_ROOT" commit -m "chore: prepare next development version" + +echo "" +echo -e "${GREEN}Step 7: Push commits and tags${NC}" +run_git -C "$REPO_ROOT" push origin HEAD +run_git -C "$REPO_ROOT" push origin "v$version" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Release complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "Published artifacts:" +echo " - dev.jasonpearson.auto-mobile:auto-mobile-protocol:$version" +echo " - dev.jasonpearson.auto-mobile:auto-mobile-test-plan-validation:$version" +echo " - dev.jasonpearson.auto-mobile:auto-mobile-junit-runner:$version" +echo " - dev.jasonpearson.auto-mobile:auto-mobile-sdk:$version" +echo "" +echo "Next steps:" +echo " - The tag push will trigger the release.yml workflow" +echo " - This will publish npm, Docker, and create GitHub Release" +echo " - Maven Central artifacts should be available shortly" +echo "" +echo "Maven Central URLs (may take a few minutes to appear):" +echo " https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-protocol/$version" +echo " https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-test-plan-validation/$version" +echo " https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-junit-runner/$version" +echo " https://central.sonatype.com/artifact/dev.jasonpearson.auto-mobile/auto-mobile-sdk/$version" diff --git a/scripts/shellcheck/apply_shfmt.sh b/scripts/shellcheck/apply_shfmt.sh new file mode 100755 index 000000000..abf300e78 --- /dev/null +++ b/scripts/shellcheck/apply_shfmt.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash + +INSTALL_SHFMT_WHEN_MISSING=${INSTALL_SHFMT_WHEN_MISSING:-false} +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-true} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +PROJECT_ROOT="$(pwd)" + +echo "PROJECT_ROOT: $PROJECT_ROOT" + +# Check for required commands and install missing commands if allowed +echo -e "${YELLOW}Checking for required commands...${NC}" + +# Check if shfmt is installed +if ! command_exists shfmt; then + echo -e "${RED}shfmt is not installed${NC}" + if [[ "${INSTALL_SHFMT_WHEN_MISSING}" == "true" ]]; then + echo -e "${YELLOW}Installing shfmt...${NC}" + if [[ -f "$PROJECT_ROOT/scripts/shellcheck/install_shfmt.sh" ]]; then + if ! bash "$PROJECT_ROOT/scripts/shellcheck/install_shfmt.sh"; then + echo -e "${RED}Failed to install shfmt${NC}" + exit 1 + fi + else + echo -e "${RED}shfmt installation script not found${NC}" + exit 1 + fi + else + echo -e "${RED}shfmt is required. Set INSTALL_SHFMT_WHEN_MISSING=true to auto-install or install manually${NC}" + exit 1 + fi +fi + +# Verify shfmt is available +if ! command_exists shfmt; then + echo -e "${RED}shfmt is still not available after installation attempt${NC}" + exit 1 +fi + +echo -e "${GREEN}shfmt is available${NC}" + +# Check for other required commands +for cmd in find xargs git; do + if ! command_exists "$cmd"; then + echo -e "${RED}Required command '$cmd' is not available${NC}" + exit 1 + fi +done + +# Start the timer +if [[ -f "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + start_time=$(bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh") +else + start_time=$(date +%s)000 # Fallback to seconds * 1000 +fi + +echo -e "${YELLOW}Starting shfmt formatting...${NC}" + +# Function to find all shell script files +find_all_shell_files() { + git ls-files --cached --others --exclude-standard -z \ + | grep -z '\.sh$' \ + | xargs -0 -I {} echo "$PROJECT_ROOT/{}" \ + | sort \ + | uniq +} + +# Function to get touched/staged files +get_touched_files() { + { + # Get staged files + git diff --cached --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^.*\.sh$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + + # Get modified but not staged files + git diff --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^.*\.sh$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + } | sort | uniq +} + +# Determine which files to process +declare -a files_to_process +errors="" + +if [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then + echo -e "${YELLOW}Processing only touched/staged files${NC}" + + # Get list of changed files + touched_files=$(get_touched_files) + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done <<< "$touched_files" + +else + echo -e "${YELLOW}Processing all shell script files in the project${NC}" + + # Get all shell script files + all_files=$(find_all_shell_files) + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done <<< "$all_files" +fi + +# Check if we have files to process +if [[ ${#files_to_process[@]} -eq 0 ]]; then + echo -e "${GREEN}No shell script files to process${NC}" + if [[ -f "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + end_time=$(bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh") + total_elapsed=$((end_time - start_time)) + else + end_time=$(date +%s)000 + total_elapsed=$((end_time - start_time)) + fi + echo "Total time elapsed: $total_elapsed ms." + exit 0 +fi + +echo -e "${YELLOW}Found ${#files_to_process[@]} shell script file(s) to process${NC}" + +# Create temporary file for storing file list +temp_file=$(mktemp) +trap 'rm -f "$temp_file"' EXIT + +# Write files to temporary file for xargs processing +printf '%s\n' "${files_to_process[@]}" > "$temp_file" + +# Apply shfmt formatting with Google style +# -i 2: indent with 2 spaces +# -bn: binary ops like && and | may start a line +# -ci: switch cases will be indented +# -sr: redirect operators will be followed by a space +echo -e "${YELLOW}Applying shfmt formatting...${NC}" + +if [[ -s "$temp_file" ]]; then + # Apply shfmt formatting and capture output + shfmt_output=$(xargs shfmt -i 2 -bn -ci -sr -w 2>&1 < "$temp_file") + + # Check if the output contains actual errors + if echo "$shfmt_output" | grep -E "(error|Error|ERROR|failed|Failed|FAILED)" > /dev/null 2>&1; then + errors="$shfmt_output" + fi +fi + +# Count how many files were actually processed +processed_count=0 +while IFS= read -r file; do + if [[ -f "$file" ]]; then + ((processed_count++)) + fi +done < "$temp_file" + +echo -e "${GREEN}Processed $processed_count file(s)${NC}" + +# Calculate total elapsed time +if [[ -f "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + end_time=$(bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh") +else + end_time=$(date +%s)000 +fi +total_elapsed=$((end_time - start_time)) + +# Check and report errors +if [[ -n "$errors" ]]; then + echo -e "${RED}Errors encountered during formatting:${NC}" + echo -e "$errors" + echo -e "${RED}Total time elapsed: $total_elapsed ms.${NC}" + exit 1 +fi + +# Stage the formatted files +if [[ ${#files_to_process[@]} -gt 0 ]]; then + echo -e "${YELLOW}Staging formatted files...${NC}" + printf '%s\n' "${files_to_process[@]}" | xargs git add +fi + +echo -e "${GREEN}Shell scripts have been formatted successfully.${NC}" +echo "Total time elapsed: $total_elapsed ms." +exit 0 diff --git a/scripts/shellcheck/install_shfmt.sh b/scripts/shellcheck/install_shfmt.sh new file mode 100755 index 000000000..d5785583e --- /dev/null +++ b/scripts/shellcheck/install_shfmt.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash + +SHFMT_VERSION="3.10.0" # Change this to the desired version + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Detect operating system +detect_os() { + case "$(uname -s)" in + Darwin*) + echo "macos" + ;; + Linux*) + echo "linux" + ;; + CYGWIN* | MINGW* | MSYS*) + echo "windows" + ;; + *) + echo "unknown" + ;; + esac +} + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + x86_64 | amd64) + echo "amd64" + ;; + arm64 | aarch64) + echo "arm64" + ;; + armv7l) + echo "arm" + ;; + i386 | i686) + echo "386" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if command exists +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +# Install shfmt on macOS +install_macos() { + echo -e "${YELLOW}Installing shfmt on macOS...${NC}" + + if command_exists brew; then + echo -e "${GREEN}Using Homebrew to install shfmt${NC}" + brew install shfmt + return $? + else + echo -e "${YELLOW}Homebrew not found. Falling back to binary installation...${NC}" + install_binary "darwin" + return $? + fi +} + +# Install shfmt on Linux +install_linux() { + echo -e "${YELLOW}Installing shfmt on Linux...${NC}" + install_binary "linux" + return $? +} + +# Install shfmt on Windows +install_windows() { + echo -e "${YELLOW}Installing shfmt on Windows...${NC}" + + if command_exists scoop; then + echo -e "${GREEN}Using Scoop to install shfmt${NC}" + scoop install shfmt + return $? + elif command_exists choco; then + echo -e "${GREEN}Using Chocolatey to install shfmt${NC}" + choco install shfmt + return $? + else + echo -e "${YELLOW}No supported package manager found. Using binary installation...${NC}" + install_binary "windows" + return $? + fi +} + +# Install shfmt by downloading binary +install_binary() { + local os="$1" + local arch + arch=$(detect_arch) + + echo -e "${YELLOW}Installing shfmt binary for $os ($arch)...${NC}" + + if [[ "$arch" == "unknown" ]]; then + echo -e "${RED}Unsupported architecture: $(uname -m)${NC}" + return 1 + fi + + # Create installation directory + install_dir="$HOME/.local/bin" + mkdir -p "$install_dir" + + # Construct download URL + # Format: https://github.com/mvdan/sh/releases/download/v3.10.0/shfmt_v3.10.0_darwin_amd64 + local binary_name="shfmt_v${SHFMT_VERSION}_${os}_${arch}" + if [[ "$os" == "windows" ]]; then + binary_name="${binary_name}.exe" + fi + + local download_url="https://github.com/mvdan/sh/releases/download/v${SHFMT_VERSION}/${binary_name}" + + echo -e "${GREEN}Downloading shfmt from GitHub releases...${NC}" + echo -e "${YELLOW}URL: $download_url${NC}" + + local target_file="$install_dir/shfmt" + if [[ "$os" == "windows" ]]; then + target_file="${target_file}.exe" + fi + + if command_exists curl; then + curl -L -o "$target_file" "$download_url" + elif command_exists wget; then + wget -O "$target_file" "$download_url" + else + echo -e "${RED}Neither curl nor wget found. Please install one of them or download manually from:${NC}" + echo "$download_url" + return 1 + fi + + # Verify the binary was downloaded correctly + if [[ ! -f "$target_file" ]]; then + echo -e "${RED}Failed to download shfmt binary${NC}" + return 1 + fi + + # Make it executable + chmod +x "$target_file" + + echo -e "${GREEN}shfmt binary installed successfully to $install_dir${NC}" + + # Check if directory is in PATH + if [[ ":$PATH:" != *":$install_dir:"* ]]; then + echo -e "${YELLOW}Make sure $install_dir is in your PATH environment variable.${NC}" + echo -e "${YELLOW}To add $install_dir to your PATH, add this line to your shell configuration:${NC}" + echo "export PATH=\"\$PATH:$install_dir\"" + fi + + return 0 +} + +# Verify installation +verify_installation() { + echo -e "${YELLOW}Verifying shfmt installation...${NC}" + + if command_exists shfmt; then + echo -e "${GREEN}shfmt is installed and available in PATH${NC}" + shfmt --version 2> /dev/null || echo -e "${GREEN}shfmt is ready to use${NC}" + return 0 + else + echo -e "${RED}shfmt is not available in PATH${NC}" + return 1 + fi +} + +# Main installation function +main() { + echo -e "${GREEN}shfmt Installation Script${NC}" + echo -e "${GREEN}========================${NC}" + + os=$(detect_os) + echo -e "${YELLOW}Detected OS: $os${NC}" + + case $os in + macos) + install_macos + ;; + linux) + install_linux + ;; + windows) + install_windows + ;; + *) + echo -e "${RED}Unsupported operating system: $os${NC}" + exit 1 + ;; + esac + + install_result=$? + + if [[ $install_result -eq 0 ]]; then + echo -e "${GREEN}Installation completed successfully!${NC}" + verify_installation + else + echo -e "${RED}Installation failed!${NC}" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/scripts/stress-test.ts b/scripts/stress-test.ts new file mode 100644 index 000000000..db15ba241 --- /dev/null +++ b/scripts/stress-test.ts @@ -0,0 +1,33 @@ +import { + createStressHarness, + parseStressArgs, + resolveStressConfig, + runStressOperations +} from "./memory/stress-harness"; + +async function main(): Promise { + const argv = process.argv.slice(2); + const stressArgs = parseStressArgs(argv); + const { runConfig, warmupIterations } = resolveStressConfig(stressArgs); + + const harness = await createStressHarness(); + + try { + if (warmupIterations > 0) { + await runStressOperations(harness, { + ...runConfig, + iterations: warmupIterations, + gcEvery: 0 + }); + } + + const result = await runStressOperations(harness, runConfig); + console.log("[stress-test] Completed stress run."); + console.log(`[stress-test] Duration: ${(result.durationMs / 1000).toFixed(2)}s`); + console.log(`[stress-test] Iterations: ${result.iterations}`); + } finally { + await harness.cleanup(); + } +} + +void main(); diff --git a/scripts/swiftformat/apply_swiftformat.sh b/scripts/swiftformat/apply_swiftformat.sh new file mode 100755 index 000000000..89dafaf74 --- /dev/null +++ b/scripts/swiftformat/apply_swiftformat.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash + +INSTALL_SWIFTFORMAT_WHEN_MISSING=${INSTALL_SWIFTFORMAT_WHEN_MISSING:-false} +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-true} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +PROJECT_ROOT="$(pwd)" + +echo "PROJECT_ROOT: $PROJECT_ROOT" + +# Check for required commands and install missing commands if allowed +echo -e "${YELLOW}Checking for required commands...${NC}" + +# Check if swiftformat is installed +if ! command_exists swiftformat; then + echo -e "${RED}swiftformat is not installed${NC}" + if [[ "${INSTALL_SWIFTFORMAT_WHEN_MISSING}" == "true" ]]; then + echo -e "${YELLOW}Installing swiftformat...${NC}" + if [[ -f "$PROJECT_ROOT/scripts/swiftformat/install_swiftformat.sh" ]]; then + if ! bash "$PROJECT_ROOT/scripts/swiftformat/install_swiftformat.sh"; then + echo -e "${RED}Failed to install swiftformat${NC}" + exit 1 + fi + else + echo -e "${RED}swiftformat installation script not found${NC}" + exit 1 + fi + else + echo -e "${RED}swiftformat is required. Set INSTALL_SWIFTFORMAT_WHEN_MISSING=true to auto-install or install manually${NC}" + exit 1 + fi +fi + +# Verify swiftformat is available +if ! command_exists swiftformat; then + echo -e "${RED}swiftformat is still not available after installation attempt${NC}" + exit 1 +fi + +echo -e "${GREEN}swiftformat is available ($(swiftformat --version))${NC}" + +# Check for other required commands +for cmd in find git; do + if ! command_exists "$cmd"; then + echo -e "${RED}Required command '$cmd' is not available${NC}" + exit 1 + fi +done + +# Start the timer +start_time=$(date +%s) + +echo -e "${YELLOW}Starting SwiftFormat formatting...${NC}" + +# Function to find all Swift files in ios directory +find_all_swift_files() { + find "$PROJECT_ROOT/ios" -type f -name "*.swift" \ + -not -path "*/build/*" \ + -not -path "*/.build/*" \ + -not -path "*/DerivedData/*" \ + -not -path "*/Pods/*" \ + -not -path "*/Carthage/*" \ + -not -path "*/.swiftpm/*" \ + -not -path "*/xcuserdata/*" \ + 2>/dev/null | sort | uniq +} + +# Function to get touched/staged files +get_touched_files() { + { + # Get staged files + git diff --cached --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + + # Get modified but not staged files + git diff --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + } | sort | uniq +} + +# Determine which files to process +declare -a files_to_process + +if [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then + echo -e "${YELLOW}Processing only touched/staged files${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(get_touched_files) + +else + echo -e "${YELLOW}Processing all Swift files in ios/ directory${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(find_all_swift_files) +fi + +# Check if we have files to process +if [[ ${#files_to_process[@]} -eq 0 ]]; then + echo -e "${GREEN}No Swift files to process${NC}" + end_time=$(date +%s) + total_elapsed=$((end_time - start_time)) + echo "Total time elapsed: ${total_elapsed}s" + exit 0 +fi + +echo -e "${YELLOW}Found ${#files_to_process[@]} Swift file(s) to format${NC}" + +# Apply swiftformat +echo -e "${YELLOW}Applying swiftformat...${NC}" + +formatted_count=0 +error_count=0 + +for file in "${files_to_process[@]}"; do + if [[ -f "$file" ]]; then + if swiftformat "$file" 2>/dev/null; then + ((formatted_count++)) + else + echo -e "${RED}Error formatting: $file${NC}" + ((error_count++)) + fi + fi +done + +echo -e "${GREEN}Formatted $formatted_count file(s)${NC}" + +# Calculate total elapsed time +end_time=$(date +%s) +total_elapsed=$((end_time - start_time)) + +# Check and report errors +if [[ $error_count -gt 0 ]]; then + echo -e "${RED}Errors encountered while formatting $error_count file(s)${NC}" + echo -e "${RED}Total time elapsed: ${total_elapsed}s${NC}" + exit 1 +fi + +echo -e "${GREEN}Swift source files have been formatted successfully.${NC}" +echo "Total time elapsed: ${total_elapsed}s" +exit 0 diff --git a/scripts/swiftformat/install_swiftformat.sh b/scripts/swiftformat/install_swiftformat.sh new file mode 100755 index 000000000..77a6306f3 --- /dev/null +++ b/scripts/swiftformat/install_swiftformat.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +SWIFTFORMAT_VERSION="0.54.6" # Change this to the desired version + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Detect operating system +detect_os() { + case "$(uname -s)" in + Darwin*) + echo "macos" + ;; + Linux*) + echo "linux" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Install SwiftFormat on macOS +install_macos() { + echo -e "${YELLOW}Installing SwiftFormat on macOS...${NC}" + + if command_exists brew; then + echo -e "${GREEN}Using Homebrew to install SwiftFormat${NC}" + brew install swiftformat + return $? + else + echo -e "${YELLOW}Homebrew not found. Attempting manual installation...${NC}" + install_manual + return $? + fi +} + +# Install SwiftFormat on Linux +install_linux() { + echo -e "${YELLOW}Installing SwiftFormat on Linux...${NC}" + echo -e "${YELLOW}SwiftFormat requires manual installation on Linux...${NC}" + install_manual + return $? +} + +# Manual installation by downloading binary +install_manual() { + echo -e "${YELLOW}Installing SwiftFormat manually...${NC}" + + # Create installation directory + install_dir="$HOME/.local/bin" + mkdir -p "$install_dir" + + os=$(detect_os) + + if [[ "$os" == "macos" ]]; then + # Download from GitHub releases + download_url="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_macos.zip" + temp_dir=$(mktemp -d) + trap 'rm -rf "$temp_dir"' EXIT + + echo -e "${GREEN}Downloading SwiftFormat from GitHub...${NC}" + + if command_exists curl; then + curl -L -o "$temp_dir/swiftformat.zip" "$download_url" + elif command_exists wget; then + wget -O "$temp_dir/swiftformat.zip" "$download_url" + else + echo -e "${RED}Neither curl nor wget found. Please install one of them.${NC}" + return 1 + fi + + # Extract and install + unzip -o "$temp_dir/swiftformat.zip" -d "$temp_dir" + mv "$temp_dir/swiftformat" "$install_dir/swiftformat" + chmod +x "$install_dir/swiftformat" + + echo -e "${GREEN}SwiftFormat installed successfully to $install_dir${NC}" + else + # For Linux, need to build from source or use Swift Package Manager + echo -e "${YELLOW}On Linux, SwiftFormat needs to be built from source.${NC}" + echo -e "${YELLOW}Please install Swift first, then run:${NC}" + echo "git clone https://github.com/nicklockwood/SwiftFormat.git" + echo "cd SwiftFormat" + echo "swift build -c release" + echo "cp .build/release/swiftformat $install_dir/" + return 1 + fi + + # Check if directory is in PATH + if [[ ":$PATH:" != *":$install_dir:"* ]]; then + echo -e "${YELLOW}To add $install_dir to your PATH, add this line to your shell configuration:${NC}" + echo "export PATH=\"\$PATH:$install_dir\"" + fi + + return 0 +} + +# Verify installation +verify_installation() { + echo -e "${YELLOW}Verifying SwiftFormat installation...${NC}" + + if command_exists swiftformat; then + echo -e "${GREEN}SwiftFormat is installed and available in PATH${NC}" + swiftformat --version 2>/dev/null || echo -e "${GREEN}SwiftFormat is ready to use${NC}" + return 0 + else + echo -e "${RED}SwiftFormat is not available in PATH${NC}" + return 1 + fi +} + +# Main installation function +main() { + echo -e "${GREEN}SwiftFormat Installation Script${NC}" + echo -e "${GREEN}================================${NC}" + + os=$(detect_os) + echo -e "${YELLOW}Detected OS: $os${NC}" + + case $os in + macos) + install_macos + ;; + linux) + install_linux + ;; + *) + echo -e "${RED}Unsupported operating system: $os${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + ;; + esac + + install_result=$? + + if [[ $install_result -eq 0 ]]; then + echo -e "${GREEN}Installation completed successfully!${NC}" + verify_installation + else + echo -e "${RED}Installation failed!${NC}" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/scripts/swiftformat/validate_swiftformat.sh b/scripts/swiftformat/validate_swiftformat.sh new file mode 100755 index 000000000..6a28c2b00 --- /dev/null +++ b/scripts/swiftformat/validate_swiftformat.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +INSTALL_SWIFTFORMAT_WHEN_MISSING=${INSTALL_SWIFTFORMAT_WHEN_MISSING:-false} +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-false} +ONLY_CHANGED_SINCE_SHA=${ONLY_CHANGED_SINCE_SHA:-""} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +PROJECT_ROOT="$(pwd)" + +# Check for required commands and install missing commands if allowed +echo -e "${YELLOW}Checking for required commands...${NC}" + +# Check if swiftformat is installed +if ! command_exists swiftformat; then + echo -e "${RED}swiftformat is not installed${NC}" + if [[ "${INSTALL_SWIFTFORMAT_WHEN_MISSING}" == "true" ]]; then + echo -e "${YELLOW}Installing swiftformat...${NC}" + if [[ -f "$PROJECT_ROOT/scripts/swiftformat/install_swiftformat.sh" ]]; then + if ! bash "$PROJECT_ROOT/scripts/swiftformat/install_swiftformat.sh"; then + echo -e "${RED}Failed to install swiftformat${NC}" + exit 1 + fi + else + echo -e "${RED}swiftformat installation script not found${NC}" + exit 1 + fi + else + echo -e "${RED}swiftformat is required. Set INSTALL_SWIFTFORMAT_WHEN_MISSING=true to auto-install or install manually${NC}" + exit 1 + fi +fi + +# Verify swiftformat is available +if ! command_exists swiftformat; then + echo -e "${RED}swiftformat is still not available after installation attempt${NC}" + exit 1 +fi + +echo -e "${GREEN}swiftformat is available ($(swiftformat --version))${NC}" + +# Check for other required commands +for cmd in find git; do + if ! command_exists "$cmd"; then + echo -e "${RED}Required command '$cmd' is not available${NC}" + exit 1 + fi +done + +# Start the timer +start_time=$(date +%s) + +echo -e "${YELLOW}Starting SwiftFormat validation...${NC}" + +# Function to find all Swift files in ios directory +find_all_swift_files() { + find "$PROJECT_ROOT/ios" -type f -name "*.swift" \ + -not -path "*/build/*" \ + -not -path "*/.build/*" \ + -not -path "*/DerivedData/*" \ + -not -path "*/Pods/*" \ + -not -path "*/Carthage/*" \ + -not -path "*/.swiftpm/*" \ + -not -path "*/xcuserdata/*" \ + 2>/dev/null | sort | uniq +} + +# Function to get changed files since SHA +get_changed_files_since_sha() { + local sha="$1" + + # Verify SHA exists + if ! git rev-parse --verify "$sha" >/dev/null 2>&1; then + echo -e "${RED}SHA '$sha' does not exist in the repository${NC}" >&2 + exit 1 + fi + + # Get list of changed files since SHA + git diff --name-only "$sha"...HEAD | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done | sort | uniq +} + +# Function to get touched/staged files +get_touched_files() { + { + # Get staged files + git diff --cached --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + + # Get modified but not staged files + git diff --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + } | sort | uniq +} + +# Determine which files to process +declare -a files_to_process + +if [[ -n "$ONLY_CHANGED_SINCE_SHA" ]]; then + echo -e "${YELLOW}Processing files changed since SHA: $ONLY_CHANGED_SINCE_SHA${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(get_changed_files_since_sha "$ONLY_CHANGED_SINCE_SHA") + +elif [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then + echo -e "${YELLOW}Processing only touched/staged files${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(get_touched_files) + +else + echo -e "${YELLOW}Processing all Swift files in ios/ directory${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(find_all_swift_files) +fi + +# Check if we have files to process +if [[ ${#files_to_process[@]} -eq 0 ]]; then + echo -e "${GREEN}No Swift files to process${NC}" + end_time=$(date +%s) + total_elapsed=$((end_time - start_time)) + echo "Total time elapsed: ${total_elapsed}s" + exit 0 +fi + +echo -e "${YELLOW}Found ${#files_to_process[@]} Swift file(s) to check${NC}" + +# Run swiftformat in lint mode (check without modifying) +echo -e "${YELLOW}Running swiftformat --lint...${NC}" + +# Create temporary file for storing file list +temp_file=$(mktemp) +trap 'rm -f "$temp_file"' EXIT + +# Write files to temporary file +printf '%s\n' "${files_to_process[@]}" > "$temp_file" + +# Run swiftformat in lint mode +lint_output="" +lint_exit_code=0 + +while IFS= read -r file; do + if [[ -f "$file" ]]; then + # Run swiftformat --lint on each file + file_output=$(swiftformat --lint "$file" 2>&1) + file_exit_code=$? + + if [[ $file_exit_code -ne 0 ]]; then + lint_exit_code=1 + lint_output="${lint_output}${file_output}\n" + fi + fi +done < "$temp_file" + +# Calculate total elapsed time +end_time=$(date +%s) +total_elapsed=$((end_time - start_time)) + +# Check and report errors +if [[ $lint_exit_code -ne 0 ]]; then + echo -e "${RED}Formatting issues found:${NC}" + echo -e "$lint_output" + echo "" + echo -e "${YELLOW}To fix these issues, run:${NC}" + echo " ./scripts/swiftformat/apply_swiftformat.sh" + echo "" + echo -e "${RED}Total time elapsed: ${total_elapsed}s${NC}" + exit 1 +fi + +echo -e "${GREEN}All Swift source files are properly formatted.${NC}" +echo "Total time elapsed: ${total_elapsed}s" +exit 0 diff --git a/scripts/swiftlint/apply_swiftlint.sh b/scripts/swiftlint/apply_swiftlint.sh new file mode 100755 index 000000000..410755009 --- /dev/null +++ b/scripts/swiftlint/apply_swiftlint.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash + +INSTALL_SWIFTLINT_WHEN_MISSING=${INSTALL_SWIFTLINT_WHEN_MISSING:-false} +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-true} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +PROJECT_ROOT="$(pwd)" + +echo "PROJECT_ROOT: $PROJECT_ROOT" + +# Check for required commands and install missing commands if allowed +echo -e "${YELLOW}Checking for required commands...${NC}" + +# Check if swiftlint is installed +if ! command_exists swiftlint; then + echo -e "${RED}swiftlint is not installed${NC}" + if [[ "${INSTALL_SWIFTLINT_WHEN_MISSING}" == "true" ]]; then + echo -e "${YELLOW}Installing swiftlint...${NC}" + if [[ -f "$PROJECT_ROOT/scripts/swiftlint/install_swiftlint.sh" ]]; then + if ! bash "$PROJECT_ROOT/scripts/swiftlint/install_swiftlint.sh"; then + echo -e "${RED}Failed to install swiftlint${NC}" + exit 1 + fi + else + echo -e "${RED}swiftlint installation script not found${NC}" + exit 1 + fi + else + echo -e "${RED}swiftlint is required. Set INSTALL_SWIFTLINT_WHEN_MISSING=true to auto-install or install manually${NC}" + exit 1 + fi +fi + +# Verify swiftlint is available +if ! command_exists swiftlint; then + echo -e "${RED}swiftlint is still not available after installation attempt${NC}" + exit 1 +fi + +echo -e "${GREEN}swiftlint is available ($(swiftlint version))${NC}" + +# Check for other required commands +for cmd in find git; do + if ! command_exists "$cmd"; then + echo -e "${RED}Required command '$cmd' is not available${NC}" + exit 1 + fi +done + +# Start the timer +start_time=$(date +%s) + +echo -e "${YELLOW}Starting SwiftLint auto-correction...${NC}" + +# Function to find all Swift files in ios directory +find_all_swift_files() { + find "$PROJECT_ROOT/ios" -type f -name "*.swift" \ + -not -path "*/build/*" \ + -not -path "*/.build/*" \ + -not -path "*/DerivedData/*" \ + -not -path "*/Pods/*" \ + -not -path "*/Carthage/*" \ + -not -path "*/.swiftpm/*" \ + -not -path "*/xcuserdata/*" \ + 2>/dev/null | sort | uniq +} + +# Function to get touched/staged files +get_touched_files() { + { + # Get staged files + git diff --cached --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + + # Get modified but not staged files + git diff --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + } | sort | uniq +} + +# Determine which files to process +declare -a files_to_process + +if [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then + echo -e "${YELLOW}Processing only touched/staged files${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(get_touched_files) + +else + echo -e "${YELLOW}Processing all Swift files in ios/ directory${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(find_all_swift_files) +fi + +# Check if we have files to process +if [[ ${#files_to_process[@]} -eq 0 ]]; then + echo -e "${GREEN}No Swift files to process${NC}" + end_time=$(date +%s) + total_elapsed=$((end_time - start_time)) + echo "Total time elapsed: ${total_elapsed}s" + exit 0 +fi + +echo -e "${YELLOW}Found ${#files_to_process[@]} Swift file(s) to auto-correct${NC}" + +# Build swiftlint command +swiftlint_cmd="swiftlint lint --fix" + +# Check if config file exists +if [[ -f "$PROJECT_ROOT/.swiftlint.yml" ]]; then + swiftlint_cmd="$swiftlint_cmd --config $PROJECT_ROOT/.swiftlint.yml" +fi + +# Apply swiftlint auto-corrections +echo -e "${YELLOW}Applying swiftlint auto-corrections...${NC}" + +corrected_count=0 +error_count=0 + +for file in "${files_to_process[@]}"; do + if [[ -f "$file" ]]; then + if $swiftlint_cmd "$file" 2>/dev/null; then + ((corrected_count++)) + else + echo -e "${RED}Error processing: $file${NC}" + ((error_count++)) + fi + fi +done + +echo -e "${GREEN}Processed $corrected_count file(s)${NC}" + +# Calculate total elapsed time +end_time=$(date +%s) +total_elapsed=$((end_time - start_time)) + +# Check and report errors +if [[ $error_count -gt 0 ]]; then + echo -e "${RED}Errors encountered while processing $error_count file(s)${NC}" + echo -e "${RED}Total time elapsed: ${total_elapsed}s${NC}" + exit 1 +fi + +echo -e "${GREEN}SwiftLint auto-corrections applied successfully.${NC}" +echo -e "${YELLOW}Note: Not all issues can be auto-fixed. Run validate to check remaining issues.${NC}" +echo "Total time elapsed: ${total_elapsed}s" +exit 0 diff --git a/scripts/swiftlint/install_swiftlint.sh b/scripts/swiftlint/install_swiftlint.sh new file mode 100755 index 000000000..7dd44a007 --- /dev/null +++ b/scripts/swiftlint/install_swiftlint.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +SWIFTLINT_VERSION="0.57.0" # Change this to the desired version + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Detect operating system +detect_os() { + case "$(uname -s)" in + Darwin*) + echo "macos" + ;; + Linux*) + echo "linux" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Install SwiftLint on macOS +install_macos() { + echo -e "${YELLOW}Installing SwiftLint on macOS...${NC}" + + if command_exists brew; then + echo -e "${GREEN}Using Homebrew to install SwiftLint${NC}" + brew install swiftlint + return $? + else + echo -e "${YELLOW}Homebrew not found. Attempting manual installation...${NC}" + install_manual + return $? + fi +} + +# Install SwiftLint on Linux +install_linux() { + echo -e "${YELLOW}Installing SwiftLint on Linux...${NC}" + echo -e "${YELLOW}SwiftLint requires manual installation on Linux...${NC}" + install_manual + return $? +} + +# Manual installation by downloading binary +install_manual() { + echo -e "${YELLOW}Installing SwiftLint manually...${NC}" + + # Create installation directory + install_dir="$HOME/.local/bin" + mkdir -p "$install_dir" + + os=$(detect_os) + + if [[ "$os" == "macos" ]]; then + # Download from GitHub releases + download_url="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + temp_dir=$(mktemp -d) + trap 'rm -rf "$temp_dir"' EXIT + + echo -e "${GREEN}Downloading SwiftLint from GitHub...${NC}" + + if command_exists curl; then + curl -L -o "$temp_dir/swiftlint.zip" "$download_url" + elif command_exists wget; then + wget -O "$temp_dir/swiftlint.zip" "$download_url" + else + echo -e "${RED}Neither curl nor wget found. Please install one of them.${NC}" + return 1 + fi + + # Extract and install + unzip -o "$temp_dir/swiftlint.zip" -d "$temp_dir" + mv "$temp_dir/swiftlint" "$install_dir/swiftlint" + chmod +x "$install_dir/swiftlint" + + echo -e "${GREEN}SwiftLint installed successfully to $install_dir${NC}" + else + # For Linux, need to build from source + echo -e "${YELLOW}On Linux, SwiftLint needs to be built from source.${NC}" + echo -e "${YELLOW}Please install Swift first, then run:${NC}" + echo "git clone https://github.com/realm/SwiftLint.git" + echo "cd SwiftLint" + echo "swift build -c release" + echo "cp .build/release/swiftlint $install_dir/" + return 1 + fi + + # Check if directory is in PATH + if [[ ":$PATH:" != *":$install_dir:"* ]]; then + echo -e "${YELLOW}To add $install_dir to your PATH, add this line to your shell configuration:${NC}" + echo "export PATH=\"\$PATH:$install_dir\"" + fi + + return 0 +} + +# Verify installation +verify_installation() { + echo -e "${YELLOW}Verifying SwiftLint installation...${NC}" + + if command_exists swiftlint; then + echo -e "${GREEN}SwiftLint is installed and available in PATH${NC}" + swiftlint version 2>/dev/null || echo -e "${GREEN}SwiftLint is ready to use${NC}" + return 0 + else + echo -e "${RED}SwiftLint is not available in PATH${NC}" + return 1 + fi +} + +# Main installation function +main() { + echo -e "${GREEN}SwiftLint Installation Script${NC}" + echo -e "${GREEN}==============================${NC}" + + os=$(detect_os) + echo -e "${YELLOW}Detected OS: $os${NC}" + + case $os in + macos) + install_macos + ;; + linux) + install_linux + ;; + *) + echo -e "${RED}Unsupported operating system: $os${NC}" + echo -e "${YELLOW}Falling back to manual installation...${NC}" + install_manual + ;; + esac + + install_result=$? + + if [[ $install_result -eq 0 ]]; then + echo -e "${GREEN}Installation completed successfully!${NC}" + verify_installation + else + echo -e "${RED}Installation failed!${NC}" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/scripts/swiftlint/validate_swiftlint.sh b/scripts/swiftlint/validate_swiftlint.sh new file mode 100755 index 000000000..648204975 --- /dev/null +++ b/scripts/swiftlint/validate_swiftlint.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash + +INSTALL_SWIFTLINT_WHEN_MISSING=${INSTALL_SWIFTLINT_WHEN_MISSING:-false} +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-false} +STRICT_MODE=${STRICT_MODE:-false} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +PROJECT_ROOT="$(pwd)" + +# Check for required commands and install missing commands if allowed +echo -e "${YELLOW}Checking for required commands...${NC}" + +# Check if swiftlint is installed +if ! command_exists swiftlint; then + echo -e "${RED}swiftlint is not installed${NC}" + if [[ "${INSTALL_SWIFTLINT_WHEN_MISSING}" == "true" ]]; then + echo -e "${YELLOW}Installing swiftlint...${NC}" + if [[ -f "$PROJECT_ROOT/scripts/swiftlint/install_swiftlint.sh" ]]; then + if ! bash "$PROJECT_ROOT/scripts/swiftlint/install_swiftlint.sh"; then + echo -e "${RED}Failed to install swiftlint${NC}" + exit 1 + fi + else + echo -e "${RED}swiftlint installation script not found${NC}" + exit 1 + fi + else + echo -e "${RED}swiftlint is required. Set INSTALL_SWIFTLINT_WHEN_MISSING=true to auto-install or install manually${NC}" + exit 1 + fi +fi + +# Verify swiftlint is available +if ! command_exists swiftlint; then + echo -e "${RED}swiftlint is still not available after installation attempt${NC}" + exit 1 +fi + +echo -e "${GREEN}swiftlint is available ($(swiftlint version))${NC}" + +# Check for other required commands +for cmd in find git; do + if ! command_exists "$cmd"; then + echo -e "${RED}Required command '$cmd' is not available${NC}" + exit 1 + fi +done + +# Start the timer +start_time=$(date +%s) + +echo -e "${YELLOW}Starting SwiftLint validation...${NC}" + +# Function to find all Swift files in ios directory +find_all_swift_files() { + find "$PROJECT_ROOT/ios" -type f -name "*.swift" \ + -not -path "*/build/*" \ + -not -path "*/.build/*" \ + -not -path "*/DerivedData/*" \ + -not -path "*/Pods/*" \ + -not -path "*/Carthage/*" \ + -not -path "*/.swiftpm/*" \ + -not -path "*/xcuserdata/*" \ + 2>/dev/null | sort | uniq +} + +# Function to get touched/staged files +get_touched_files() { + { + # Get staged files + git diff --cached --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + + # Get modified but not staged files + git diff --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^ios/.*\.swift$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + } | sort | uniq +} + +# Determine which files to process +declare -a files_to_process + +if [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then + echo -e "${YELLOW}Processing only touched/staged files${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(get_touched_files) + +else + echo -e "${YELLOW}Processing all Swift files in ios/ directory${NC}" + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done < <(find_all_swift_files) +fi + +# Check if we have files to process +if [[ ${#files_to_process[@]} -eq 0 ]]; then + echo -e "${GREEN}No Swift files to process${NC}" + end_time=$(date +%s) + total_elapsed=$((end_time - start_time)) + echo "Total time elapsed: ${total_elapsed}s" + exit 0 +fi + +echo -e "${YELLOW}Found ${#files_to_process[@]} Swift file(s) to lint${NC}" + +# Build swiftlint command +swiftlint_cmd="swiftlint lint" + +if [[ "${STRICT_MODE}" == "true" ]]; then + swiftlint_cmd="$swiftlint_cmd --strict" +fi + +# Check if config file exists +if [[ -f "$PROJECT_ROOT/.swiftlint.yml" ]]; then + swiftlint_cmd="$swiftlint_cmd --config $PROJECT_ROOT/.swiftlint.yml" +fi + +# Run swiftlint +echo -e "${YELLOW}Running swiftlint...${NC}" + +# Create temporary file for storing file list +temp_file=$(mktemp) +trap 'rm -f "$temp_file"' EXIT + +# Write files to temporary file +printf '%s\n' "${files_to_process[@]}" > "$temp_file" + +# Run swiftlint on each file and collect output +lint_output="" +warning_count=0 +error_count=0 + +while IFS= read -r file; do + if [[ -f "$file" ]]; then + file_output=$($swiftlint_cmd "$file" 2>&1) + + if [[ -n "$file_output" ]]; then + lint_output="${lint_output}${file_output}\n" + + # Count warnings and errors + file_warnings=$(echo "$file_output" | grep -c "warning:" || true) + file_errors=$(echo "$file_output" | grep -c "error:" || true) + ((warning_count += file_warnings)) + ((error_count += file_errors)) + fi + + fi +done < "$temp_file" + +# Calculate total elapsed time +end_time=$(date +%s) +total_elapsed=$((end_time - start_time)) + +# Report results +echo "" +echo -e "${YELLOW}SwiftLint Results:${NC}" +echo -e " Warnings: $warning_count" +echo -e " Errors: $error_count" +echo "" + +if [[ -n "$lint_output" ]]; then + echo -e "${YELLOW}Details:${NC}" + echo -e "$lint_output" +fi + +# Check and report errors +if [[ $error_count -gt 0 ]]; then + echo -e "${RED}SwiftLint found $error_count error(s).${NC}" + echo -e "${YELLOW}To auto-fix some issues, run:${NC}" + echo " ./scripts/swiftlint/apply_swiftlint.sh" + echo "" + echo -e "${RED}Total time elapsed: ${total_elapsed}s${NC}" + exit 1 +fi + +if [[ "${STRICT_MODE}" == "true" ]] && [[ $warning_count -gt 0 ]]; then + echo -e "${RED}SwiftLint found $warning_count warning(s) in strict mode.${NC}" + echo -e "${RED}Total time elapsed: ${total_elapsed}s${NC}" + exit 1 +fi + +echo -e "${GREEN}SwiftLint validation passed.${NC}" +echo "Total time elapsed: ${total_elapsed}s" +exit 0 diff --git a/scripts/test-xctestservice.sh b/scripts/test-xctestservice.sh new file mode 100755 index 000000000..9f76b0cdf --- /dev/null +++ b/scripts/test-xctestservice.sh @@ -0,0 +1,567 @@ +#!/bin/bash +# +# XCTestService Diagnostic Script +# Tests installation, running status, WebSocket connection, and view hierarchy retrieval +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Configuration +PORT=${XCTESTSERVICE_PORT:-8765} +HOST="localhost" +HEALTH_URL="http://${HOST}:${PORT}/health" +WS_URL="ws://${HOST}:${PORT}/ws" +TIMEOUT=5 + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +XCTEST_SERVICE_DIR="${PROJECT_ROOT}/ios/XCTestService" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} XCTestService Diagnostic Script${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" +echo -e "Port: ${PORT}" +echo -e "Health URL: ${HEALTH_URL}" +echo -e "WebSocket URL: ${WS_URL}" +echo "" + +# Track overall status +OVERALL_STATUS=0 + +# Helper function to print status +print_status() { + local status=$1 + local message=$2 + if [ "$status" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $message" + else + echo -e " ${RED}✗${NC} $message" + OVERALL_STATUS=1 + fi +} + +print_warning() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +print_info() { + echo -e " ${BLUE}ℹ${NC} $1" +} + +# ============================================================================= +# Test 1: Check if XCTestService project exists and is buildable +# ============================================================================= +echo -e "${BLUE}[1/5] Checking XCTestService Installation${NC}" +echo "" + +# Check Xcode project exists +if [ -d "${XCTEST_SERVICE_DIR}/XCTestService.xcodeproj" ]; then + print_status 0 "Xcode project found: ios/XCTestService/XCTestService.xcodeproj" +else + print_status 1 "Xcode project not found at ios/XCTestService/XCTestService.xcodeproj" +fi + +# Check Swift sources exist +if [ -f "${XCTEST_SERVICE_DIR}/Sources/XCTestService/XCTestService.swift" ]; then + print_status 0 "XCTestService.swift source found" +else + print_status 1 "XCTestService.swift source not found" +fi + +# Check UI Tests exist +if [ -f "${XCTEST_SERVICE_DIR}/Tests/XCTestServiceUITests/XCTestServiceUITests.swift" ]; then + print_status 0 "XCTestServiceUITests.swift found" +else + print_status 1 "XCTestServiceUITests.swift not found" +fi + +# Check if xcodebuild is available +if command -v xcodebuild &> /dev/null; then + print_status 0 "xcodebuild is available" + XCODE_VERSION=$(xcodebuild -version | head -1) + print_info "Version: ${XCODE_VERSION}" +else + print_status 1 "xcodebuild not found - Xcode required" +fi + +# Check available schemes +echo "" +echo -e " ${BLUE}Available schemes:${NC}" +if [ -d "${XCTEST_SERVICE_DIR}/XCTestService.xcodeproj" ]; then + cd "${XCTEST_SERVICE_DIR}" + xcodebuild -list -json 2>/dev/null | grep -A 20 '"schemes"' | grep '"' | sed 's/[",]//g' | while read -r scheme; do + echo " - $scheme" + done + cd - > /dev/null +fi + +echo "" + +# ============================================================================= +# Test 2: Check if XCTestService is running (health endpoint + process check) +# ============================================================================= +echo -e "${BLUE}[2/5] Checking if XCTestService is Running${NC}" +echo "" + +# First check if xcodebuild test process is running for XCTestService +XCTEST_PROCESS_PID=$(pgrep -f 'xcodebuild.*XCTestService' 2>/dev/null || echo "") +if [ -n "$XCTEST_PROCESS_PID" ]; then + print_status 0 "XCTestService xcodebuild process found (PID: ${XCTEST_PROCESS_PID})" + PROCESS_RUNNING=true +else + print_warning "No XCTestService xcodebuild process found" + PROCESS_RUNNING=false +fi + +HEALTH_RESPONSE=$(curl -s --max-time ${TIMEOUT} "${HEALTH_URL}" 2>/dev/null || echo "FAILED") + +if [ "$HEALTH_RESPONSE" == "FAILED" ]; then + print_status 1 "Health endpoint not responding at ${HEALTH_URL}" + SERVICE_RUNNING=false + + # Check if we should auto-start the service + if [ "$PROCESS_RUNNING" = false ]; then + echo "" + print_info "XCTestService is not running. Attempting to start it..." + + # Find a booted simulator + BOOTED_SIMULATOR=$(xcrun simctl list devices booted -j 2>/dev/null | grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "//;s/"$//') + + if [ -z "$BOOTED_SIMULATOR" ]; then + print_warning "No booted simulator found. Trying to find and boot one..." + # Try to find an available iPhone simulator + AVAILABLE_SIMULATOR=$(xcrun simctl list devices available -j 2>/dev/null | grep -B2 '"isAvailable" : true' | grep -o '"udid" : "[^"]*"' | head -1 | sed 's/"udid" : "//;s/"$//') + + if [ -n "$AVAILABLE_SIMULATOR" ]; then + print_info "Booting simulator ${AVAILABLE_SIMULATOR}..." + xcrun simctl boot "$AVAILABLE_SIMULATOR" 2>/dev/null || true + sleep 3 + BOOTED_SIMULATOR="$AVAILABLE_SIMULATOR" + else + print_status 1 "No available iOS simulators found" + print_info "Please install iOS Simulators via Xcode" + fi + fi + + if [ -n "$BOOTED_SIMULATOR" ]; then + print_info "Using simulator: ${BOOTED_SIMULATOR}" + + # Create log file for xcodebuild output + LOG_FILE="/tmp/xctestservice-$(date +%Y%m%d-%H%M%S).log" + print_info "Log file: ${LOG_FILE}" + + # Start xcodebuild test in background + echo "" + print_info "Starting XCTestService in background..." + + cd "${XCTEST_SERVICE_DIR}" + nohup xcodebuild test \ + -scheme XCTestServiceApp \ + -destination "id=${BOOTED_SIMULATOR}" \ + -only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService \ + > "$LOG_FILE" 2>&1 & + + XCODEBUILD_PID=$! + cd - > /dev/null + + print_info "Started xcodebuild with PID: ${XCODEBUILD_PID}" + + # Wait for service to come up + echo "" + print_info "Waiting for XCTestService to start (up to 60 seconds)..." + + MAX_WAIT=60 + WAITED=0 + while [ $WAITED -lt $MAX_WAIT ]; do + HEALTH_CHECK=$(curl -s --max-time 2 "${HEALTH_URL}" 2>/dev/null || echo "") + if [[ "$HEALTH_CHECK" == *"ok"* ]] || [[ "$HEALTH_CHECK" == *"status"* ]]; then + echo "" + print_status 0 "XCTestService is now running!" + SERVICE_RUNNING=true + break + fi + + # Check if process is still alive + if ! kill -0 $XCODEBUILD_PID 2>/dev/null; then + echo "" + print_status 1 "xcodebuild process exited unexpectedly" + print_info "Check log file for errors: ${LOG_FILE}" + # Show last few lines of log + if [ -f "$LOG_FILE" ]; then + echo "" + echo -e " ${YELLOW}Last 10 lines of log:${NC}" + tail -10 "$LOG_FILE" | sed 's/^/ /' + fi + break + fi + + printf "." + sleep 2 + WAITED=$((WAITED + 2)) + done + + if [ "$SERVICE_RUNNING" = false ] && [ $WAITED -ge $MAX_WAIT ]; then + echo "" + print_status 1 "Timeout waiting for XCTestService to start" + print_info "xcodebuild may still be building. Check: ${LOG_FILE}" + # Show last few lines of log + if [ -f "$LOG_FILE" ]; then + echo "" + echo -e " ${YELLOW}Last 10 lines of log:${NC}" + tail -10 "$LOG_FILE" | sed 's/^/ /' + fi + fi + fi + else + print_info "Process is running but health endpoint not responding yet" + print_info "The service may still be starting up..." + fi +else + print_status 0 "Health endpoint responding" + print_info "Response: ${HEALTH_RESPONSE}" + SERVICE_RUNNING=true +fi + +# Check if port is in use +if command -v lsof &> /dev/null; then + PORT_PROCESS=$(lsof -i :"${PORT}" -t 2>/dev/null || echo "") + if [ -n "$PORT_PROCESS" ]; then + print_info "Port ${PORT} is in use by PID: ${PORT_PROCESS}" + else + if [ "$SERVICE_RUNNING" = false ]; then + print_warning "Port ${PORT} is not in use" + fi + fi +fi + +echo "" + +# ============================================================================= +# Test 3: Test WebSocket Connection +# ============================================================================= +echo -e "${BLUE}[3/5] Testing WebSocket Connection${NC}" +echo "" + +# Check if we have a WebSocket client available +WS_CLIENT="" +if command -v websocat &> /dev/null; then + WS_CLIENT="websocat" + print_status 0 "websocat available for WebSocket testing" +elif command -v wscat &> /dev/null; then + WS_CLIENT="wscat" + print_status 0 "wscat available for WebSocket testing" +else + print_warning "No WebSocket CLI client found (websocat or wscat)" + print_info "Install with: brew install websocat" + print_info "Or: npm install -g wscat" +fi + +if [ "$SERVICE_RUNNING" = true ] && [ -n "$WS_CLIENT" ]; then + echo "" + echo -e " ${BLUE}Testing WebSocket handshake...${NC}" + + # Test basic WebSocket connection with timeout + if [ "$WS_CLIENT" = "websocat" ]; then + WS_TEST=$(timeout 3 websocat -1 "${WS_URL}" 2>&1 || echo "TIMEOUT_OR_ERROR") + else + WS_TEST=$(timeout 3 wscat -c "${WS_URL}" -x '{}' 2>&1 || echo "TIMEOUT_OR_ERROR") + fi + + if [[ "$WS_TEST" == *"connected"* ]] || [[ "$WS_TEST" == *"type"* ]]; then + print_status 0 "WebSocket connection successful" + print_info "Received: ${WS_TEST:0:100}..." + else + print_status 1 "WebSocket connection failed or timed out" + print_info "Response: ${WS_TEST:0:200}" + fi +elif [ "$SERVICE_RUNNING" = false ]; then + print_warning "Skipping WebSocket test - service not running" +fi + +echo "" + +# ============================================================================= +# Test 4: List Supported WebSocket Endpoints/Commands +# ============================================================================= +echo -e "${BLUE}[4/5] Supported WebSocket Commands${NC}" +echo "" + +echo -e " ${CYAN}Request Types (from Models.swift):${NC}" +echo " View Hierarchy:" +echo " - request_hierarchy : Get full view hierarchy" +echo " - request_hierarchy_if_stale : Get hierarchy only if cached is stale" +echo " - request_screenshot : Get screenshot as base64 PNG" +echo "" +echo " Gestures:" +echo " - request_tap_coordinates : Tap at x, y coordinates" +echo " - request_swipe : Swipe from (x1,y1) to (x2,y2)" +echo " - request_two_finger_swipe : Two-finger swipe (not implemented)" +echo " - request_drag : Drag with press/drag/hold durations" +echo " - request_pinch : Pinch gesture with scale" +echo "" +echo " Text Input:" +echo " - request_set_text : Set text in focused field" +echo " - request_ime_action : Perform IME action (done, next, etc)" +echo " - request_select_all : Select all text" +echo "" +echo " Actions:" +echo " - request_action : Perform action on element" +echo " - request_clipboard : Clipboard operations (not implemented)" +echo "" +echo " Accessibility:" +echo " - get_current_focus : Get focused element (not implemented)" +echo " - get_traversal_order : Get traversal order (not implemented)" +echo " - add_highlight : Add highlight overlay (not implemented)" +echo "" + +echo -e " ${CYAN}Response Types:${NC}" +echo " - connected : Connection established (push)" +echo " - hierarchy_update : View hierarchy data" +echo " - screenshot : Screenshot data" +echo " - tap_coordinates_result: Tap result" +echo " - swipe_result : Swipe result" +echo " - drag_result : Drag result" +echo " - pinch_result : Pinch result" +echo " - set_text_result : Text input result" +echo " - ime_action_result : IME action result" +echo " - select_all_result : Select all result" +echo " - action_result : Action result" +echo "" + +# ============================================================================= +# Test 5: Attempt View Hierarchy Request +# ============================================================================= +echo -e "${BLUE}[5/5] Testing View Hierarchy Request${NC}" +echo "" + +if [ "$SERVICE_RUNNING" = true ]; then + # Check if Bun is available (preferred - has native WebSocket support) + if command -v bun &> /dev/null; then + print_status 0 "Bun available for WebSocket testing (native WebSocket)" + + # Create Bun-compatible WebSocket test script + BUN_WS_SCRIPT="/tmp/ws_test_$$.ts" + rm -f "$BUN_WS_SCRIPT" + cat > "$BUN_WS_SCRIPT" << 'BUN_SCRIPT' +const WS_URL = process.argv[2] || 'ws://localhost:8765/ws'; +const TIMEOUT_MS = parseInt(process.argv[3] || '10000'); + +console.log(`Connecting to ${WS_URL}...`); + +const ws = new WebSocket(WS_URL); +let hierarchyReceived = false; + +const timeout = setTimeout(() => { + console.log('\n❌ Timeout waiting for response'); + ws.close(); + process.exit(1); +}, TIMEOUT_MS); + +ws.onopen = () => { + console.log('✓ WebSocket connected'); + + // Send hierarchy request + const request = { + type: 'request_hierarchy', + requestId: 'test-' + Date.now() + }; + + console.log(`\nSending request: ${JSON.stringify(request)}`); + ws.send(JSON.stringify(request)); +}; + +ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + console.log(`\nReceived message type: ${message.type}`); + + if (message.type === 'connected') { + console.log(` Connection ID: ${message.id}`); + } else if (message.type === 'hierarchy_update') { + hierarchyReceived = true; + console.log('✓ Hierarchy response received'); + + if (message.data) { + console.log(`\n Package: ${message.data.packageName || 'N/A'}`); + console.log(` Updated: ${message.data.updatedAt}`); + + if (message.data.hierarchy) { + console.log(` Root class: ${message.data.hierarchy.className || 'N/A'}`); + const childCount = message.data.hierarchy.node?.length || 0; + console.log(` Child nodes: ${childCount}`); + + // Print first few elements + if (childCount > 0) { + console.log('\n Sample elements:'); + const printNode = (node: any, depth = 0) => { + if (depth > 2) return; + const indent = ' '.repeat(depth + 1); + const text = node.text || node['content-desc'] || node.className || 'Unknown'; + const id = node['resource-id'] || ''; + console.log(`${indent}- ${text}${id ? ` (${id})` : ''}`); + if (node.node && Array.isArray(node.node)) { + node.node.slice(0, 3).forEach((child: any) => printNode(child, depth + 1)); + } + }; + printNode(message.data.hierarchy); + } + } else if (message.data.error) { + console.log(` Error: ${message.data.error}`); + } + } else if (message.error) { + console.log(` Error: ${message.error}`); + } + + if (message.perfTiming) { + console.log(`\n Performance timing:`); + const printTiming = (timing: any, depth = 0) => { + const indent = ' '.repeat(depth + 1); + console.log(`${indent}${timing.name}: ${timing.durationMs}ms`); + if (timing.children) { + timing.children.forEach((child: any) => printTiming(child, depth + 1)); + } + }; + printTiming(message.perfTiming); + } + + clearTimeout(timeout); + ws.close(); + process.exit(0); + } else if (message.error) { + console.log(` Error: ${message.error}`); + } + } catch (e) { + console.log(` Raw data: ${String(event.data).substring(0, 500)}`); + } +}; + +ws.onerror = (error) => { + console.log(`\n❌ WebSocket error: ${error}`); + clearTimeout(timeout); + process.exit(1); +}; + +ws.onclose = () => { + console.log('\nWebSocket closed'); + clearTimeout(timeout); + if (!hierarchyReceived) { + process.exit(1); + } +}; +BUN_SCRIPT + + print_info "Running WebSocket hierarchy test..." + echo "" + + # Run the test with Bun + # Note: Hierarchy extraction can be slow (10-30s) because XCUIElement + # property accesses communicate with iOS accessibility service + bun run "$BUN_WS_SCRIPT" "${WS_URL}" 60000 + TEST_RESULT=$? + + rm -f "$BUN_WS_SCRIPT" + + echo "" + if [ $TEST_RESULT -eq 0 ]; then + print_status 0 "View hierarchy request successful" + else + print_status 1 "View hierarchy request failed" + fi + + elif command -v node &> /dev/null; then + print_warning "Node.js available but 'ws' module may not be installed" + + # Fallback: Try with curl for basic HTTP test + echo "" + print_info "Attempting raw HTTP WebSocket upgrade..." + + # Generate WebSocket key + WS_KEY=$(openssl rand -base64 16) + + UPGRADE_RESPONSE=$(curl -s -i --max-time 5 \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: ${WS_KEY}" \ + -H "Sec-WebSocket-Version: 13" \ + "http://${HOST}:${PORT}/ws" 2>/dev/null || echo "FAILED") + + if [[ "$UPGRADE_RESPONSE" == *"101"* ]]; then + print_status 0 "WebSocket upgrade handshake successful" + print_info "Server responded with 101 Switching Protocols" + else + print_status 1 "WebSocket upgrade failed" + print_info "Response: ${UPGRADE_RESPONSE:0:200}" + fi + else + print_warning "Neither Bun nor Node.js available for WebSocket testing" + print_info "Install Bun or Node.js for full WebSocket testing capabilities" + fi +else + print_warning "Skipping hierarchy test - service not running" + echo "" + print_info "To start the XCTestService:" + echo "" + echo " # List available simulators" + echo " xcrun simctl list devices available" + echo "" + echo " # Boot a simulator (if not already running)" + echo " xcrun simctl boot 'iPhone 16 Pro'" + echo "" + echo " # Get the booted simulator ID" + echo " DEVICE_ID=\$(xcrun simctl list devices booted -j | grep -o '\"udid\" : \"[^\"]*\"' | head -1 | sed 's/\"udid\" : \"//;s/\"\$//')" + echo "" + echo " # Run the XCTestService" + echo " cd ${XCTEST_SERVICE_DIR}" + echo " xcodebuild test \\" + echo " -scheme XCTestServiceApp \\" + echo " -destination \"id=\$DEVICE_ID\" \\" + echo " -only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService" + echo "" +fi + +echo "" + +# ============================================================================= +# Summary +# ============================================================================= +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} Summary${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +if [ $OVERALL_STATUS -eq 0 ]; then + echo -e " ${GREEN}All checks passed!${NC}" +else + echo -e " ${RED}Some checks failed${NC}" +fi + +echo "" +echo -e " Service Status: $([ "$SERVICE_RUNNING" = true ] && echo -e "${GREEN}Running${NC}" || echo -e "${RED}Not Running${NC}")" +echo -e " Port: ${PORT}" +echo "" + +if [ "$SERVICE_RUNNING" = false ]; then + echo -e "${YELLOW}Quick Start:${NC}" + echo "" + echo " # Get booted simulator ID (or boot one first with: xcrun simctl boot 'iPhone 16 Pro')" + echo " DEVICE_ID=\$(xcrun simctl list devices booted -j | grep -o '\"udid\" : \"[^\"]*\"' | head -1 | sed 's/\"udid\" : \"//;s/\"\$//')" + echo "" + echo " # Start XCTestService on simulator" + echo " cd ios/XCTestService" + echo " xcodebuild test -scheme XCTestServiceApp \\" + echo " -destination \"id=\$DEVICE_ID\" \\" + echo " -only-testing:XCTestServiceUITests/XCTestServiceUITests/testRunService" + echo "" +fi + +exit $OVERALL_STATUS diff --git a/scripts/tool-thresholds.json b/scripts/tool-thresholds.json new file mode 100644 index 000000000..51bbb001c --- /dev/null +++ b/scripts/tool-thresholds.json @@ -0,0 +1,61 @@ +{ + "version": "1.2.0", + "metadata": { + "generatedAt": "2026-02-02", + "description": "MCP tool call throughput thresholds with 20% regression tolerance", + "baseline": { + "description": "Baseline measurements from actual tool handler execution (MCP overhead without device I/O)", + "sampleSize": 50, + "generatedBy": "benchmark-mcp-tools.ts", + "note": "Measures tool registry lookup, schema validation, device resolution, and handler wrapper overhead. Updated after ViewHierarchy caching removal (commit 724ae74d)." + }, + "regressionTolerance": "20%" + }, + "thresholds": { + "listDevices": { + "p50": 0.5, + "p95": 1.0, + "mean": 0.5 + }, + "getForegroundApp": { + "p50": 0.5, + "p95": 1.0, + "mean": 0.5 + }, + "pressButton": { + "p50": 220.0, + "p95": 250.0, + "mean": 225.0 + }, + "observe": { + "p50": 150.0, + "p95": 250.0, + "mean": 180.0 + }, + "tapOn": { + "p50": 0.5, + "p95": 1.0, + "mean": 0.5 + }, + "inputText": { + "p50": 350.0, + "p95": 400.0, + "mean": 360.0 + }, + "swipe": { + "p50": 0.5, + "p95": 1.0, + "mean": 0.5 + }, + "launchApp": { + "p50": 140.0, + "p95": 145.0, + "mean": 140.0 + }, + "installApp": { + "p50": 0.5, + "p95": 1.0, + "mean": 0.5 + } + } +} diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 000000000..c346740d0 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,801 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Handle Ctrl-C (SIGINT) - exit immediately +trap 'echo ""; echo "Uninstall cancelled."; exit 130' INT + +# Handle piped execution (SCRIPT_DIR used for potential future expansion) +# shellcheck disable=SC2034 +if [[ -n "${BASH_SOURCE[0]:-}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +else + SCRIPT_DIR="$(pwd)" +fi + +# Detect project root (for project-level configs) +PROJECT_ROOT="$(pwd)" + +# ============================================================================ +# Global State +# ============================================================================ +ALL=false +DRY_RUN=false +FORCE=false +RECORD_MODE=false +CHANGES_MADE=false + +# Components to uninstall (set by interactive selection or --all) +UNINSTALL_MCP_CONFIGS=false +UNINSTALL_MARKETPLACE=false +UNINSTALL_CLI=false +UNINSTALL_DAEMON=false +UNINSTALL_DATA=false + +# Detected components +MCP_CONFIGS_FOUND=() +MARKETPLACE_INSTALLED=false +MARKETPLACE_NAME="" +CLI_INSTALLED=false +DAEMON_RUNNING=false +DATA_DIR_EXISTS=false + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +# ============================================================================ +# Utility Functions +# ============================================================================ +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +log_info() { + echo -e "${GREEN}INFO${RESET} $*" +} + +log_warn() { + echo -e "${YELLOW}WARN${RESET} $*" +} + +log_error() { + echo -e "${RED}ERROR${RESET} $*" +} + +detect_os() { + case "$(uname -s)" in + Darwin*) echo "macos" ;; + Linux*) echo "linux" ;; + MINGW*|MSYS*|CYGWIN*) echo "linux" ;; + *) echo "unknown" ;; + esac +} + +# ============================================================================ +# CLI Argument Parsing +# ============================================================================ +show_help() { + cat << 'EOF' +AutoMobile Uninstaller + +Usage: ./scripts/uninstall.sh [OPTIONS] + +Options: + --all Remove all AutoMobile components (non-interactive) + --dry-run Show what would be removed without making changes + --record-mode Auto-select all and run (for demo recording) + --force Skip confirmation prompts + -h, --help Show this help message + +Components that can be removed: + - MCP configurations from AI agents (Claude Desktop, Cursor, VS Code, etc.) + - Claude Marketplace plugin + - AutoMobile CLI (auto-mobile command) + - MCP daemon process + - AutoMobile data directory (~/.automobile) + +Examples: + ./scripts/uninstall.sh # Interactive mode + ./scripts/uninstall.sh --all # Remove everything + ./scripts/uninstall.sh --all --dry-run # Show what would be removed + ./scripts/uninstall.sh --record-mode # For demo recording + +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all|-a) + ALL=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --record-mode) + RECORD_MODE=true + shift + ;; + --force|-f) + FORCE=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + log_error "Unknown option: $1" + show_help + exit 1 + ;; + esac + done +} + +# ============================================================================ +# Gum Setup (reuse from interactive installer if available) +# ============================================================================ +GUM_INSTALL_DIR="${HOME}/.automobile/bin" +GUM_BINARY="${GUM_INSTALL_DIR}/gum" + +ensure_gum() { + # Check if gum is available + if command_exists gum; then + return 0 + fi + + # Check bundled gum + if [[ -x "${GUM_BINARY}" ]]; then + export PATH="${GUM_INSTALL_DIR}:${PATH}" + return 0 + fi + + # Fall back to basic prompts if gum not available + return 1 +} + +# ============================================================================ +# Detection Functions +# ============================================================================ +detect_mcp_configs() { + local os + os=$(detect_os) + MCP_CONFIGS_FOUND=() + + # Define all possible config locations + local configs=() + + # Claude Code + configs+=("Claude Code (Global)|${HOME}/.claude.json|json") + + # Claude Desktop + if [[ "${os}" == "macos" ]]; then + configs+=("Claude Desktop|${HOME}/Library/Application Support/Claude/claude_desktop_config.json|json") + else + configs+=("Claude Desktop|${HOME}/.config/Claude/claude_desktop_config.json|json") + fi + + # Cursor + configs+=("Cursor (Global)|${HOME}/.cursor/mcp.json|json") + + # Windsurf + configs+=("Windsurf|${HOME}/.codeium/windsurf/mcp_config.json|json") + + # Codex + configs+=("Codex|${HOME}/.codex/config.toml|toml") + + # Firebender + configs+=("Firebender (Global)|${HOME}/.firebender/firebender.json|json") + configs+=("Firebender (Project)|${PROJECT_ROOT}/firebender.json|json") + + # Goose + configs+=("Goose|${HOME}/.config/goose/config.yaml|yaml") + + # Check each config for auto-mobile entries + for entry in "${configs[@]}"; do + local name path format + name=$(echo "${entry}" | cut -d'|' -f1) + path=$(echo "${entry}" | cut -d'|' -f2) + format=$(echo "${entry}" | cut -d'|' -f3) + + if [[ -f "${path}" ]]; then + if config_has_automobile "${path}" "${format}"; then + MCP_CONFIGS_FOUND+=("${entry}") + fi + fi + done +} + +config_has_automobile() { + local path="$1" + local format="$2" + + # Look for actual MCP server entries, not project paths or comments + case "${format}" in + json) + # Look for "auto-mobile" as a key followed by { (MCP server object) + # This matches: "auto-mobile": { or "auto-mobile" : { + # But not: "/path/to/auto-mobile/project": { + grep -qE '"auto-mobile"\s*:\s*\{' "${path}" 2>/dev/null + ;; + toml) + # Look for [mcp_servers.auto-mobile] section headers + grep -qiE '^\[.*mcp.*auto-?mobile.*\]' "${path}" 2>/dev/null + ;; + yaml) + # Look for auto-mobile: as a YAML key at root or under mcpServers + grep -qE '^[[:space:]]*auto-mobile\s*:' "${path}" 2>/dev/null + ;; + esac +} + +detect_marketplace() { + if command_exists claude; then + # Get list of marketplaces and find auto-mobile related ones + local marketplace_output + marketplace_output=$(claude plugin marketplace list 2>/dev/null || true) + + # Extract marketplace name (line starting with ❯ followed by the name) + local name + name=$(echo "${marketplace_output}" | grep -i 'auto-mobile' 2>/dev/null | grep '❯' 2>/dev/null | awk '{print $2}' | head -1 || true) + + if [[ -n "${name}" ]]; then + MARKETPLACE_INSTALLED=true + MARKETPLACE_NAME="${name}" + return 0 + fi + fi + MARKETPLACE_INSTALLED=false + MARKETPLACE_NAME="" + return 0 +} + +detect_cli() { + if command_exists auto-mobile; then + CLI_INSTALLED=true + else + CLI_INSTALLED=false + fi + return 0 +} + +detect_daemon() { + local socket_path + socket_path="/tmp/auto-mobile-daemon-$(id -u).sock" + if [[ -S "${socket_path}" ]]; then + DAEMON_RUNNING=true + return 0 + fi + # Also check for running process + if pgrep -f "auto-mobile.*daemon" >/dev/null 2>&1; then + DAEMON_RUNNING=true + return 0 + fi + DAEMON_RUNNING=false + return 0 +} + +detect_data_dir() { + if [[ -d "${HOME}/.automobile" ]]; then + DATA_DIR_EXISTS=true + else + DATA_DIR_EXISTS=false + fi + return 0 +} + +# ============================================================================ +# Removal Functions +# ============================================================================ +remove_from_json_config() { + local path="$1" + local tmp_file="${path}.tmp" + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would remove auto-mobile entries from ${path}" + return 0 + fi + + # Use jq if available for clean JSON manipulation + if command_exists jq; then + # Remove any key matching auto-mobile or automobile (case insensitive) + jq 'walk(if type == "object" then with_entries(select(.key | test("auto-mobile|automobile"; "i") | not)) else . end)' "${path}" > "${tmp_file}" 2>/dev/null + if [[ $? -eq 0 && -s "${tmp_file}" ]]; then + mv "${tmp_file}" "${path}" + return 0 + fi + rm -f "${tmp_file}" + fi + + # Fallback: use sed to remove lines containing auto-mobile + # This is less precise but works without jq + local backup="${path}.bak" + cp "${path}" "${backup}" + + # Remove lines containing auto-mobile (case insensitive) + # and clean up any resulting empty objects or trailing commas + sed -i.tmp -E '/"[^"]*[aA]uto-?[mM]obile[^"]*"/d' "${path}" 2>/dev/null || \ + sed -E '/"[^"]*[aA]uto-?[mM]obile[^"]*"/d' "${path}" > "${tmp_file}" && mv "${tmp_file}" "${path}" + + rm -f "${path}.tmp" 2>/dev/null || true + return 0 +} + +remove_from_toml_config() { + local path="$1" + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would remove auto-mobile entries from ${path}" + return 0 + fi + + local backup="${path}.bak" + cp "${path}" "${backup}" + + # Remove TOML sections containing auto-mobile + # This removes from [section.auto-mobile] to the next section or end of file + local tmp_file="${path}.tmp" + awk ' + /^\[.*[aA]uto-?[mM]obile.*\]/ { skip=1; next } + /^\[/ { skip=0 } + !skip { print } + ' "${path}" > "${tmp_file}" + mv "${tmp_file}" "${path}" + return 0 +} + +remove_from_yaml_config() { + local path="$1" + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would remove auto-mobile entries from ${path}" + return 0 + fi + + # Use yq if available + if command_exists yq; then + local tmp_file="${path}.tmp" + # Remove keys matching auto-mobile pattern + yq 'del(.. | select(key | test("auto-mobile|automobile"; "i")))' "${path}" > "${tmp_file}" 2>/dev/null + if [[ $? -eq 0 && -s "${tmp_file}" ]]; then + mv "${tmp_file}" "${path}" + return 0 + fi + rm -f "${tmp_file}" + fi + + # Fallback: use awk to remove YAML blocks + local backup="${path}.bak" + cp "${path}" "${backup}" + + local tmp_file="${path}.tmp" + awk ' + /^[[:space:]]*[aA]uto-?[mM]obile:/ { skip=1; indent=match($0, /[^[:space:]]/)-1; next } + skip && /^[[:space:]]*[^[:space:]]/ { + current_indent=match($0, /[^[:space:]]/)-1 + if (current_indent <= indent) { skip=0 } + } + !skip { print } + ' "${path}" > "${tmp_file}" + mv "${tmp_file}" "${path}" + return 0 +} + +remove_mcp_configs() { + if [[ ${#MCP_CONFIGS_FOUND[@]} -eq 0 ]]; then + log_info "No MCP configurations found to remove" + return 0 + fi + + for entry in "${MCP_CONFIGS_FOUND[@]}"; do + local name path format + name=$(echo "${entry}" | cut -d'|' -f1) + path=$(echo "${entry}" | cut -d'|' -f2) + format=$(echo "${entry}" | cut -d'|' -f3) + + log_info "Removing auto-mobile from ${name}..." + + case "${format}" in + json) + remove_from_json_config "${path}" + ;; + toml) + remove_from_toml_config "${path}" + ;; + yaml) + remove_from_yaml_config "${path}" + ;; + esac + + if [[ "${DRY_RUN}" != "true" ]]; then + CHANGES_MADE=true + fi + done +} + +remove_marketplace() { + if [[ "${MARKETPLACE_INSTALLED}" != "true" ]]; then + log_info "Claude Marketplace not configured" + return 0 + fi + + if [[ -z "${MARKETPLACE_NAME}" ]]; then + log_warn "Could not determine marketplace name" + return 1 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would run: claude plugin marketplace remove ${MARKETPLACE_NAME}" + return 0 + fi + + log_info "Removing Claude Marketplace: ${MARKETPLACE_NAME}..." + if claude plugin marketplace remove "${MARKETPLACE_NAME}" 2>/dev/null; then + log_info "Claude Marketplace removed" + CHANGES_MADE=true + else + log_warn "Failed to remove Claude Marketplace" + fi +} + +remove_cli() { + if [[ "${CLI_INSTALLED}" != "true" ]]; then + log_info "AutoMobile CLI not installed" + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would remove AutoMobile CLI" + if command_exists bun; then + log_info "[DRY-RUN] - bun remove -g @kaeawc/auto-mobile" + fi + if command_exists npm; then + log_info "[DRY-RUN] - npm uninstall -g @kaeawc/auto-mobile" + fi + return 0 + fi + + log_info "Removing AutoMobile CLI..." + + # Try both bun and npm - the CLI might be installed via either or both + if command_exists bun; then + bun remove -g @kaeawc/auto-mobile 2>/dev/null || true + fi + + if command_exists npm; then + npm uninstall -g @kaeawc/auto-mobile 2>/dev/null || true + fi + + # Verify removal by checking if command still exists + # Need to clear bash's command cache first + hash -r 2>/dev/null || true + + if ! command -v auto-mobile >/dev/null 2>&1; then + log_info "AutoMobile CLI removed" + CHANGES_MADE=true + else + log_warn "AutoMobile CLI may still be installed at: $(command -v auto-mobile)" + fi +} + +stop_daemon() { + if [[ "${DAEMON_RUNNING}" != "true" ]]; then + log_info "MCP daemon not running" + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would stop MCP daemon" + return 0 + fi + + log_info "Stopping MCP daemon..." + + # Try graceful shutdown first + local socket_path + socket_path="/tmp/auto-mobile-daemon-$(id -u).sock" + if [[ -S "${socket_path}" ]]; then + rm -f "${socket_path}" + fi + + # Kill any running daemon processes + pkill -f "auto-mobile.*daemon" 2>/dev/null || true + + log_info "MCP daemon stopped" + CHANGES_MADE=true +} + +remove_data_dir() { + if [[ "${DATA_DIR_EXISTS}" != "true" ]]; then + log_info "AutoMobile data directory not found" + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would remove ${HOME}/.automobile" + return 0 + fi + + log_info "Removing AutoMobile data directory..." + rm -rf "${HOME}/.automobile" + log_info "AutoMobile data directory removed" + CHANGES_MADE=true +} + +# ============================================================================ +# Interactive Selection +# ============================================================================ +select_components() { + if ! ensure_gum; then + log_error "gum is required for interactive mode. Use --all for non-interactive uninstall." + exit 1 + fi + + local options=() + + if [[ ${#MCP_CONFIGS_FOUND[@]} -gt 0 ]]; then + local config_list="" + for entry in "${MCP_CONFIGS_FOUND[@]}"; do + local name + name=$(echo "${entry}" | cut -d'|' -f1) + config_list="${config_list}${name}, " + done + config_list="${config_list%, }" + options+=("MCP Configurations (${config_list})") + fi + + if [[ "${MARKETPLACE_INSTALLED}" == "true" ]]; then + options+=("Claude Marketplace Plugin") + fi + + if [[ "${CLI_INSTALLED}" == "true" ]]; then + options+=("AutoMobile CLI") + fi + + if [[ "${DAEMON_RUNNING}" == "true" ]]; then + options+=("MCP Daemon (running)") + fi + + if [[ "${DATA_DIR_EXISTS}" == "true" ]]; then + options+=("AutoMobile Data (~/.automobile)") + fi + + # Add "Everything" option at the bottom if there are multiple components + if [[ ${#options[@]} -gt 1 ]]; then + options+=("Everything") + fi + + if [[ ${#options[@]} -eq 0 ]]; then + log_info "No AutoMobile components found to uninstall" + exit 0 + fi + + echo "" + gum style --bold "Select components to uninstall:" + echo "" + + local selected + selected=$(printf '%s\n' "${options[@]}" | gum filter --no-limit --placeholder "Type to filter, SPACE to select...") || true + + if [[ -z "${selected}" ]]; then + log_info "No components selected" + exit 0 + fi + + # Parse selection + while IFS= read -r item; do + case "${item}" in + "MCP Configurations"*) + UNINSTALL_MCP_CONFIGS=true + ;; + "Claude Marketplace Plugin") + UNINSTALL_MARKETPLACE=true + ;; + "AutoMobile CLI") + UNINSTALL_CLI=true + ;; + "MCP Daemon"*) + UNINSTALL_DAEMON=true + ;; + "AutoMobile Data"*) + UNINSTALL_DATA=true + ;; + "Everything") + UNINSTALL_MCP_CONFIGS=true + UNINSTALL_MARKETPLACE=true + UNINSTALL_CLI=true + UNINSTALL_DAEMON=true + UNINSTALL_DATA=true + ;; + esac + done <<< "${selected}" +} + +# ============================================================================ +# Confirmation +# ============================================================================ +confirm_uninstall() { + if [[ "${FORCE}" == "true" || "${RECORD_MODE}" == "true" ]]; then + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + return 0 + fi + + echo "" + gum style --foreground 214 --bold "The following will be removed:" + echo "" + + if [[ "${UNINSTALL_MCP_CONFIGS}" == "true" ]]; then + for entry in "${MCP_CONFIGS_FOUND[@]}"; do + local name path + name=$(echo "${entry}" | cut -d'|' -f1) + path=$(echo "${entry}" | cut -d'|' -f2) + echo " - ${name}: ${path}" + done + fi + + if [[ "${UNINSTALL_MARKETPLACE}" == "true" ]]; then + echo " - Claude Marketplace Plugin" + fi + + if [[ "${UNINSTALL_CLI}" == "true" ]]; then + echo " - AutoMobile CLI" + fi + + if [[ "${UNINSTALL_DAEMON}" == "true" ]]; then + echo " - MCP Daemon" + fi + + if [[ "${UNINSTALL_DATA}" == "true" ]]; then + echo " - AutoMobile Data (~/.automobile)" + fi + + echo "" + + if ! gum confirm "Proceed with uninstall?"; then + log_info "Uninstall cancelled" + exit 0 + fi +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + parse_args "$@" + + echo "" + if ensure_gum; then + gum style --bold "AutoMobile Uninstaller" + else + echo -e "${BOLD}AutoMobile Uninstaller${RESET}" + fi + echo "" + + if [[ "${DRY_RUN}" == "true" ]]; then + if ensure_gum; then + gum style --foreground 214 --bold "DRY-RUN MODE: No changes will be made" + else + echo -e "${YELLOW}${BOLD}DRY-RUN MODE: No changes will be made${RESET}" + fi + echo "" + elif [[ "${RECORD_MODE}" == "true" ]]; then + if ensure_gum; then + gum style --foreground 212 --bold "RECORD MODE: Auto-selecting all components" + else + echo -e "${YELLOW}${BOLD}RECORD MODE: Auto-selecting all components${RESET}" + fi + echo "" + fi + + # Detect installed components + log_info "Detecting installed components..." + detect_mcp_configs + detect_marketplace + detect_cli + detect_daemon + detect_data_dir + + # Show what was found + echo "" + if [[ ${#MCP_CONFIGS_FOUND[@]} -gt 0 ]]; then + log_info "Found ${#MCP_CONFIGS_FOUND[@]} MCP configuration(s) with auto-mobile" + fi + if [[ "${MARKETPLACE_INSTALLED}" == "true" ]]; then + log_info "Found Claude Marketplace plugin" + fi + if [[ "${CLI_INSTALLED}" == "true" ]]; then + log_info "Found AutoMobile CLI" + fi + if [[ "${DAEMON_RUNNING}" == "true" ]]; then + log_info "Found running MCP daemon" + fi + if [[ "${DATA_DIR_EXISTS}" == "true" ]]; then + log_info "Found AutoMobile data directory" + fi + + # Check if anything was found + local found_something=false + if [[ ${#MCP_CONFIGS_FOUND[@]} -gt 0 ]] || \ + [[ "${MARKETPLACE_INSTALLED}" == "true" ]] || \ + [[ "${CLI_INSTALLED}" == "true" ]] || \ + [[ "${DAEMON_RUNNING}" == "true" ]] || \ + [[ "${DATA_DIR_EXISTS}" == "true" ]]; then + found_something=true + fi + + if [[ "${found_something}" != "true" ]]; then + echo "" + log_info "No AutoMobile components found to uninstall" + exit 0 + fi + + # Select components + if [[ "${ALL}" == "true" || "${RECORD_MODE}" == "true" ]]; then + UNINSTALL_MCP_CONFIGS=true + UNINSTALL_MARKETPLACE=true + UNINSTALL_CLI=true + UNINSTALL_DAEMON=true + UNINSTALL_DATA=true + else + select_components + fi + + # Confirm + if ensure_gum; then + confirm_uninstall + elif [[ "${FORCE}" != "true" && "${RECORD_MODE}" != "true" && "${DRY_RUN}" != "true" ]]; then + echo "" + echo -e "${YELLOW}${BOLD}Warning: About to remove AutoMobile components${RESET}" + echo "Use --force to skip this prompt or --dry-run to preview changes" + read -p "Continue? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Uninstall cancelled" + exit 0 + fi + fi + + # Perform uninstall + echo "" + if [[ "${UNINSTALL_DAEMON}" == "true" ]]; then + stop_daemon + fi + + if [[ "${UNINSTALL_MCP_CONFIGS}" == "true" ]]; then + remove_mcp_configs + fi + + if [[ "${UNINSTALL_MARKETPLACE}" == "true" ]]; then + remove_marketplace + fi + + if [[ "${UNINSTALL_CLI}" == "true" ]]; then + remove_cli + fi + + if [[ "${UNINSTALL_DATA}" == "true" ]]; then + remove_data_dir + fi + + # Summary + echo "" + if [[ "${DRY_RUN}" == "true" ]]; then + log_info "Dry-run complete. No changes were made." + elif [[ "${CHANGES_MADE}" == "true" ]]; then + log_info "Uninstall complete" + else + log_info "No changes were necessary" + fi +} + +main "$@" diff --git a/scripts/update-readme-badges.sh b/scripts/update-readme-badges.sh new file mode 100755 index 000000000..cd4e11f07 --- /dev/null +++ b/scripts/update-readme-badges.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readme_path="${repo_root}/README.md" + +# --- Count tests --- + +ts_count=$(grep -rE '^\s*(it|test)\(' "${repo_root}/test/" --include='*.test.ts' \ + | grep -vc '\.skip(') + +kotlin_count=$(grep -rE '^\s*@Test' "${repo_root}/android/" --include='*.kt' \ + | grep -cE 'src/(test|androidTest)/') + +swift_count=$(grep -rcE '^\s*func test' "${repo_root}/ios/" --include='*.swift' \ + | awk -F: '{s+=$NF} END {print s+0}') + +# --- Format numbers with commas --- + +format_number() { + local n="$1" + local formatted="" + local i=0 + while [ "$n" -gt 0 ]; do + digit=$((n % 10)) + n=$((n / 10)) + if [ "$i" -gt 0 ] && [ $((i % 3)) -eq 0 ]; then + formatted=",${formatted}" + fi + formatted="${digit}${formatted}" + i=$((i + 1)) + done + [ -z "$formatted" ] && formatted="0" + echo "$formatted" +} + +ts_formatted=$(format_number "$ts_count") +kotlin_formatted=$(format_number "$kotlin_count") +swift_formatted=$(format_number "$swift_count") + +# URL-encode commas for shields.io badge URLs +ts_url="${ts_formatted//,/%2C}" +kotlin_url="${kotlin_formatted//,/%2C}" +swift_url="${swift_formatted//,/%2C}" + +echo "Test counts: TypeScript=${ts_formatted} Kotlin=${kotlin_formatted} Swift=${swift_formatted}" + +# --- Validate badge lines exist --- + +for label in "TypeScript_tests" "Kotlin_tests" "Swift_tests"; do + if ! grep -q "img.shields.io/badge/${label}-" "$readme_path"; then + echo "ERROR: Badge line for ${label} not found in ${readme_path}" >&2 + exit 1 + fi +done + +# --- Update badge lines via sed on temp file --- + +tmp_file="$(mktemp)" +trap 'rm -f "$tmp_file"' EXIT + +cp "$readme_path" "$tmp_file" + +sed -E -i "" \ + -e "s|img.shields.io/badge/TypeScript_tests-[^)]*-3178C6\)|img.shields.io/badge/TypeScript_tests-${ts_url}-3178C6)|" \ + -e "s|img.shields.io/badge/Kotlin_tests-[^)]*-7F52FF\)|img.shields.io/badge/Kotlin_tests-${kotlin_url}-7F52FF)|" \ + -e "s|img.shields.io/badge/Swift_tests-[^)]*-F05138\)|img.shields.io/badge/Swift_tests-${swift_url}-F05138)|" \ + -e "s|\!\[TypeScript tests: [0-9,]*\]|![TypeScript tests: ${ts_formatted}]|" \ + -e "s|\!\[Kotlin tests: [0-9,]*\]|![Kotlin tests: ${kotlin_formatted}]|" \ + -e "s|\!\[Swift tests: [0-9,]*\]|![Swift tests: ${swift_formatted}]|" \ + "$tmp_file" 2>/dev/null \ +|| sed -E -i \ + -e "s|img.shields.io/badge/TypeScript_tests-[^)]*-3178C6\)|img.shields.io/badge/TypeScript_tests-${ts_url}-3178C6)|" \ + -e "s|img.shields.io/badge/Kotlin_tests-[^)]*-7F52FF\)|img.shields.io/badge/Kotlin_tests-${kotlin_url}-7F52FF)|" \ + -e "s|img.shields.io/badge/Swift_tests-[^)]*-F05138\)|img.shields.io/badge/Swift_tests-${swift_url}-F05138)|" \ + -e "s|\!\[TypeScript tests: [0-9,]*\]|![TypeScript tests: ${ts_formatted}]|" \ + -e "s|\!\[Kotlin tests: [0-9,]*\]|![Kotlin tests: ${kotlin_formatted}]|" \ + -e "s|\!\[Swift tests: [0-9,]*\]|![Swift tests: ${swift_formatted}]|" \ + "$tmp_file" + +# --- Write only if changed --- + +if cmp -s "$readme_path" "$tmp_file"; then + echo "INFO: README badges already up to date" + exit 0 +fi + +mv "$tmp_file" "$readme_path" +trap - EXIT + +echo "Updated README badges:" +echo " TypeScript tests: ${ts_formatted}" +echo " Kotlin tests: ${kotlin_formatted}" +echo " Swift tests: ${swift_formatted}" +echo " File: ${readme_path}" diff --git a/scripts/update-tool-definitions.sh b/scripts/update-tool-definitions.sh new file mode 100755 index 000000000..beeec055c --- /dev/null +++ b/scripts/update-tool-definitions.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# +# Regenerate MCP tool definitions for IDE completion. +# +# Usage: +# ./scripts/update-tool-definitions.sh +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if ! command -v bun >/dev/null 2>&1; then + echo "bun is required to generate tool definitions." >&2 + exit 1 +fi + +echo "Generating tool definitions..." +(cd "${PROJECT_ROOT}" && bun scripts/generate-tool-definitions.ts) + +if git -C "${PROJECT_ROOT}" diff --quiet -- schemas/tool-definitions.json; then + echo "schemas/tool-definitions.json is up to date." + exit 0 +fi + +git -C "${PROJECT_ROOT}" add schemas/tool-definitions.json +echo "Updated and staged schemas/tool-definitions.json." diff --git a/scripts/validate-bun-test-timings.sh b/scripts/validate-bun-test-timings.sh new file mode 100644 index 000000000..2f3505da1 --- /dev/null +++ b/scripts/validate-bun-test-timings.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +report_path="${1:-scratch/bun-test-report.xml}" +max_ms="${BUN_TEST_MAX_MS:-100}" + +mkdir -p "$(dirname "$report_path")" + +bun test --reporter junit --reporter-outfile "$report_path" + +awk -v limit_ms="$max_ms" ' +function attr(rec, key, pattern, start, len) { + pattern = key "=\"[^\"]*\"" + if (match(rec, pattern)) { + start = RSTART + length(key) + 2 + len = RLENGTH - length(key) - 2 + return substr(rec, start, len) + } + return "" +} +BEGIN { + fail = 0 + count = 0 +} +{ + if ($0 ~ / limit_ms) { + if (class != "" && name != "") { + label = class "." name + } else if (name != "") { + label = name + } else { + label = "(unknown)" + } + printf "Test exceeded %dms: %s (%.2fms)\n", limit_ms, label, time_ms > "/dev/stderr" + fail = 1 + } + } +} +END { + if (count == 0) { + print "No testcases found in junit report." > "/dev/stderr" + exit 1 + } + exit fail +} +' "$report_path" diff --git a/scripts/validate-yaml.ts b/scripts/validate-yaml.ts new file mode 100755 index 000000000..0268059d7 --- /dev/null +++ b/scripts/validate-yaml.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun + +/** + * Script to validate all test plan YAML files in the repository + * Usage: bun scripts/validate-yaml.ts [path] + * If no path is provided, validates all test plans in the repository + */ + +import { glob } from "glob"; +import path from "path"; +import { PlanSchemaValidator } from "../src/utils/plan/PlanSchemaValidator"; + +interface ValidationReport { + totalFiles: number; + validFiles: number; + invalidFiles: number; + results: Array<{ + file: string; + valid: boolean; + errors?: Array<{ + field: string; + message: string; + line?: number; + column?: number; + }>; + }>; +} + +async function validateTestPlans(searchPath?: string): Promise { + const validator = new PlanSchemaValidator(); + await validator.loadSchema(); + + // Find all test plan YAML files + const pattern = searchPath || "**/test-plans/**/*.yaml"; + const files = await glob(pattern, { + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + absolute: true + }); + + if (files.length === 0) { + console.error(`No YAML files found matching pattern: ${pattern}`); + process.exit(1); + } + + console.log(`Found ${files.length} test plan file(s) to validate\n`); + + const report: ValidationReport = { + totalFiles: files.length, + validFiles: 0, + invalidFiles: 0, + results: [] + }; + + // Validate each file + for (const file of files) { + const relativePath = path.relative(process.cwd(), file); + const result = await validator.validateFile(file); + + if (result.valid) { + report.validFiles++; + console.log(`✓ ${relativePath}`); + } else { + report.invalidFiles++; + console.log(`✗ ${relativePath}`); + + if (result.errors) { + for (const error of result.errors) { + const location = error.line !== undefined + ? `:${error.line}:${error.column}` + : ""; + console.log(` ${error.field}${location}: ${error.message}`); + } + } + console.log(""); + } + + report.results.push({ + file: relativePath, + valid: result.valid, + errors: result.errors + }); + } + + return report; +} + +async function main() { + const searchPath = process.argv[2]; + + console.log("AutoMobile Test Plan YAML Validation"); + console.log("====================================\n"); + + try { + const report = await validateTestPlans(searchPath); + + console.log("\nValidation Summary"); + console.log("=================="); + console.log(`Total files: ${report.totalFiles}`); + console.log(`Valid files: ${report.validFiles}`); + console.log(`Invalid files: ${report.invalidFiles}`); + + if (report.invalidFiles > 0) { + console.log("\n❌ Validation failed - see errors above"); + process.exit(1); + } else { + console.log("\n✅ All test plans are valid!"); + process.exit(0); + } + } catch (error) { + console.error("\n❌ Validation script failed:"); + console.error(error); + process.exit(1); + } +} + +main(); diff --git a/scripts/validate_dependabot.sh b/scripts/validate_dependabot.sh new file mode 100755 index 000000000..2e3e9420e --- /dev/null +++ b/scripts/validate_dependabot.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# validate_dependabot.sh +# +# Validate that the Dependabot config parses as YAML. +# +# Usage: +# ./scripts/validate_dependabot.sh [path] +# +# Exit codes: +# 0 - YAML parsed successfully +# 1 - Missing file, missing dependency, or parse error +# +set -euo pipefail + +DEPENDABOT_PATH="${1:-.github/dependabot.yml}" + +if ! command -v ruby >/dev/null 2>&1; then + echo "[ERROR] ruby is required to validate YAML." >&2 + exit 1 +fi + +if [[ ! -f "$DEPENDABOT_PATH" ]]; then + echo "[ERROR] Dependabot config not found at: $DEPENDABOT_PATH" >&2 + exit 1 +fi + +ruby - "$DEPENDABOT_PATH" <<'RUBY' +path = ARGV[0] +require "yaml" + +begin + YAML.load_file(path) +rescue StandardError => e + warn "[ERROR] Failed to parse #{path}: #{e.class}: #{e.message}" + exit 1 +end +RUBY + +echo "[INFO] Dependabot config is valid YAML: $DEPENDABOT_PATH" diff --git a/scripts/validate_mkdocs_nav.sh b/scripts/validate_mkdocs_nav.sh new file mode 100755 index 000000000..f2cfdcb3b --- /dev/null +++ b/scripts/validate_mkdocs_nav.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# +# validate_mkdocs_nav.sh +# +# Validates that all documentation files in docs/ are properly linked in mkdocs.yml +# and that all files referenced in mkdocs.yml actually exist. +# +# This script accounts for files that are copied during deployment by +# scripts/github/deploy_pages.py (CHANGELOG.md and .github/CONTRIBUTING.md). +# +# Exit codes: +# 0 - All documentation files are properly linked +# 1 - Found orphaned files (exist but not linked) or missing files (linked but don't exist) +# +# Usage: +# ./scripts/validate_mkdocs_nav.sh +# +# This script can be run in CI to ensure published documentation stays in sync. +# +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +MKDOCS_YML="$PROJECT_ROOT/mkdocs.yml" +DOCS_DIR="$PROJECT_ROOT/docs" + +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if required files exist +if [[ ! -f "$MKDOCS_YML" ]]; then + print_error "mkdocs.yml not found at: $MKDOCS_YML" + exit 1 +fi + +if [[ ! -d "$DOCS_DIR" ]]; then + print_error "docs directory not found at: $DOCS_DIR" + exit 1 +fi + +print_status "Validating MkDocs navigation configuration..." +print_status "Project root: $PROJECT_ROOT" +print_status "" + +# Extract all .md file references from mkdocs.yml +# This regex matches patterns like: 'file.md' or "file.md" after colons in the nav section +extract_nav_files() { + # Find the nav section and extract all .md references + # We look for lines with .md after a colon, handling both single and double quotes + awk '/^nav:$/,0' "$MKDOCS_YML" | \ + grep -oE "['\"]?[a-zA-Z0-9/_-]+\.md['\"]?" | \ + tr -d "'" | \ + tr -d '"' | \ + sort | \ + uniq +} + +# List all .md files in docs/ directory relative to docs/ +list_actual_files() { + find "$DOCS_DIR" -type f -name "*.md" -not -path "*/\.*" | \ + sed "s|^$DOCS_DIR/||" | \ + sort +} + +# Files that are copied by copy_required_files() in deploy_pages.py +# These exist in mkdocs.yml but are copied from other locations at build time +get_copied_files() { + cat < "$REFERENCED_FILES" +REFERENCED_COUNT=$(wc -l < "$REFERENCED_FILES" | tr -d ' ') +print_status "Found $REFERENCED_COUNT files referenced in mkdocs.yml" + +# List actual files in docs/ +print_status "Listing actual files in docs/ directory..." +ACTUAL_FILES=$(mktemp) +list_actual_files > "$ACTUAL_FILES" +ACTUAL_COUNT=$(wc -l < "$ACTUAL_FILES" | tr -d ' ') +print_status "Found $ACTUAL_COUNT markdown files in docs/" + +# Get copied and excluded files +COPIED_FILES=$(mktemp) +get_copied_files > "$COPIED_FILES" + +EXCLUDED_FILES=$(mktemp) +get_excluded_files > "$EXCLUDED_FILES" + +# Check for missing files (referenced in mkdocs.yml but don't exist in docs/) +# Exclude files that are copied at build time +print_status "" +print_status "Checking for missing files..." +MISSING_FILES=$(mktemp) +while IFS= read -r file; do + # Skip if this file is copied at build time + if grep -Fxq "$file" "$COPIED_FILES"; then + continue + fi + + # Check if file exists + if [[ ! -f "$DOCS_DIR/$file" ]]; then + echo "$file" >> "$MISSING_FILES" + fi +done < "$REFERENCED_FILES" + +# Check for orphaned files (exist in docs/ but not referenced in mkdocs.yml) +# Exclude copied and excluded files +print_status "Checking for orphaned files..." +ORPHANED_FILES=$(mktemp) +while IFS= read -r file; do + # Skip if this file is in the excluded list + if grep -Fxq "$file" "$EXCLUDED_FILES"; then + continue + fi + + # Skip if this file is copied (these shouldn't be in git) + if grep -Fxq "$file" "$COPIED_FILES"; then + continue + fi + + # Check if file is referenced in mkdocs.yml + if ! grep -Fxq "$file" "$REFERENCED_FILES"; then + echo "$file" >> "$ORPHANED_FILES" + fi +done < "$ACTUAL_FILES" + +# Report results +HAS_ERRORS=0 + +if [[ -s "$MISSING_FILES" ]]; then + print_error "Files referenced in mkdocs.yml but missing from docs/:" + while IFS= read -r file; do + echo " - $file" + done < "$MISSING_FILES" + echo "" + HAS_ERRORS=1 +fi + +if [[ -s "$ORPHANED_FILES" ]]; then + print_warning "Files in docs/ but not referenced in mkdocs.yml:" + while IFS= read -r file; do + echo " - $file" + done < "$ORPHANED_FILES" + echo "" + HAS_ERRORS=1 +fi + +# Check for duplicate entries in mkdocs.yml +print_status "Checking for duplicate entries in mkdocs.yml..." +DUPLICATES=$(mktemp) +extract_nav_files | sort | uniq -d > "$DUPLICATES" +if [[ -s "$DUPLICATES" ]]; then + print_error "Duplicate entries found in mkdocs.yml:" + while IFS= read -r file; do + echo " - $file" + done < "$DUPLICATES" + echo "" + HAS_ERRORS=1 +fi + +# Check for files containing TODO markers +print_status "Checking for TODO markers in documentation..." +TODO_FILES=$(mktemp) +TODO_IGNORE_FILES=$(mktemp) +get_todo_ignore_files > "$TODO_IGNORE_FILES" +while IFS= read -r file; do + # Skip excluded files + if grep -Fxq "$file" "$EXCLUDED_FILES"; then + continue + fi + + # Skip files where TODO appears in prose (not action items) + if grep -Fxq "$file" "$TODO_IGNORE_FILES"; then + continue + fi + + # Check if file contains TODO (case insensitive, but exclude lychee.toml exclude patterns) + if [[ -f "$DOCS_DIR/$file" ]] && grep -i "TODO" "$DOCS_DIR/$file" >/dev/null 2>&1; then + echo "$file" >> "$TODO_FILES" + fi +done < "$ACTUAL_FILES" + +if [[ -s "$TODO_FILES" ]]; then + print_warning "Files containing TODO markers:" + while IFS= read -r file; do + # Show the TODO lines + echo " - $file" + grep -in "TODO" "$DOCS_DIR/$file" | head -3 | sed 's/^/ /' + done < "$TODO_FILES" + echo "" + HAS_ERRORS=1 +fi + +# Check for empty or whitespace-only files +print_status "Checking for empty or whitespace-only files..." +EMPTY_FILES=$(mktemp) +while IFS= read -r file; do + # Skip excluded files + if grep -Fxq "$file" "$EXCLUDED_FILES"; then + continue + fi + + # Check if file exists and is empty or contains only whitespace + if [[ -f "$DOCS_DIR/$file" ]]; then + # Remove all whitespace and check if anything remains + if [[ -z $(tr -d '[:space:]' < "$DOCS_DIR/$file") ]]; then + echo "$file" >> "$EMPTY_FILES" + fi + fi +done < "$ACTUAL_FILES" + +if [[ -s "$EMPTY_FILES" ]]; then + print_warning "Empty or whitespace-only files found:" + while IFS= read -r file; do + echo " - $file" + done < "$EMPTY_FILES" + echo "" + HAS_ERRORS=1 +fi + +# Clean up temp files +rm -f "$REFERENCED_FILES" "$ACTUAL_FILES" "$COPIED_FILES" "$EXCLUDED_FILES" \ + "$MISSING_FILES" "$ORPHANED_FILES" "$DUPLICATES" "$TODO_FILES" "$TODO_IGNORE_FILES" "$EMPTY_FILES" + +# Final status +if [[ $HAS_ERRORS -eq 0 ]]; then + print_status "✓ All documentation files are properly linked in mkdocs.yml" + exit 0 +else + print_error "✗ Documentation validation failed" + echo "" + echo "To fix orphaned files:" + echo " 1. Add them to mkdocs.yml navigation" + echo " 2. Move them to the excluded list if they're internal docs" + echo " 3. Delete them if they're no longer needed" + echo "" + echo "To fix missing files:" + echo " 1. Create the missing files" + echo " 2. Remove the references from mkdocs.yml if no longer needed" + echo "" + echo "To fix duplicate entries:" + echo " 1. Remove duplicate references from mkdocs.yml" + echo " 2. Each file should only appear once in the navigation" + echo "" + echo "To fix TODO markers:" + echo " 1. Complete the TODO items and remove the markers" + echo " 2. Move files with TODOs to the excluded list if they're internal docs" + echo "" + echo "To fix empty files:" + echo " 1. Add content to the files" + echo " 2. Delete the files if they're no longer needed" + exit 1 +fi diff --git a/scripts/versioning/bump-versions.sh b/scripts/versioning/bump-versions.sh new file mode 100755 index 000000000..8aa0fe1a1 --- /dev/null +++ b/scripts/versioning/bump-versions.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +set -euo pipefail + +new_version="" +dry_run=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --new-version) + new_version="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$new_version" ]]; then + echo "Missing --new-version argument." >&2 + exit 1 +fi + +snapshot_version="${new_version}-SNAPSHOT" + +update_json_version() { + local path="$1" + local version="$2" + local dry="$3" + if [[ "$dry" == true ]]; then + return 0 + fi + python3 - "$path" "$version" <<'PY' +import json +import sys + +path = sys.argv[1] +version = sys.argv[2] + +with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + +data["version"] = version + +with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") +PY +} + +replace_optional_single_match() { + local path="$1" + local pattern="$2" + local replacement="$3" + local dry="$4" + python3 - "$path" "$pattern" "$replacement" "$dry" <<'PY' +import re +import sys + +path = sys.argv[1] +pattern = sys.argv[2] +replacement = sys.argv[3] +dry = sys.argv[4].lower() == "true" + +with open(path, "r", encoding="utf-8") as handle: + data = handle.read() + +matches = list(re.finditer(pattern, data, flags=re.MULTILINE)) +if len(matches) > 1: + raise SystemExit( + f"Expected at most one match for {pattern!r} in {path}, found {len(matches)}" + ) + +if len(matches) == 0: + sys.exit(0) + +updated = re.sub(pattern, replacement, data, flags=re.MULTILINE) + +if not dry: + with open(path, "w", encoding="utf-8") as handle: + handle.write(updated) +PY +} + +update_json_version "package.json" "$new_version" "$dry_run" +update_json_version ".claude-plugin/plugin.json" "$new_version" "$dry_run" + +# Update server.json top-level version and packages[0].version for MCP registry +update_server_json_version() { + local path="$1" + local version="$2" + local dry="$3" + if [[ "$dry" == true ]]; then + return 0 + fi + python3 - "$path" "$version" <<'PY' +import json +import sys + +path = sys.argv[1] +version = sys.argv[2] + +with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + +data["version"] = version + +if "packages" in data: + for pkg in data["packages"]: + pkg["version"] = version + +with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") +PY +} +update_server_json_version "server.json" "$new_version" "$dry_run" + +# Update VERSION_NAME in android/gradle.properties (single source of truth for Android libraries) +update_gradle_properties_version() { + local path="$1" + local version="$2" + local dry="$3" + if [[ "$dry" == true ]]; then + echo "Would update VERSION_NAME to $version in $path" + return 0 + fi + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=$version/" "$path" + else + sed -i "s/^VERSION_NAME=.*/VERSION_NAME=$version/" "$path" + fi +} +update_gradle_properties_version "android/gradle.properties" "${snapshot_version}" "$dry_run" + +# Update marketplace.json plugin version (nested in plugins[0].version) +update_marketplace_plugin_version() { + local path="$1" + local version="$2" + local dry="$3" + if [[ "$dry" == true ]]; then + return 0 + fi + python3 - "$path" "$version" <<'PY' +import json +import sys + +path = sys.argv[1] +version = sys.argv[2] + +with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + +if "plugins" in data and len(data["plugins"]) > 0: + data["plugins"][0]["version"] = version + +with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") +PY +} +update_marketplace_plugin_version ".claude-plugin/marketplace.json" "$new_version" "$dry_run" + +if ! command -v rg >/dev/null 2>&1; then + echo "ripgrep (rg) is required for fast Gradle scanning." >&2 + exit 1 +fi + +while IFS= read -r -d '' gradle_file; do + replace_optional_single_match \ + "$gradle_file" \ + '^version\s*=\s*"[^"]*"' \ + "version = \"${snapshot_version}\"" \ + "$dry_run" + + replace_optional_single_match \ + "$gradle_file" \ + 'versionName\s*=\s*"[^"]*"' \ + "versionName = \"${snapshot_version}\"" \ + "$dry_run" +done < <(rg -l --null -g 'build.gradle.kts' -e 'versionName\s*=' -e '^version\s*=' android) + +if [[ "$dry_run" == true ]]; then + echo "Dry run complete. package.json -> ${new_version}" + echo "Gradle version -> ${snapshot_version}" +fi diff --git a/scripts/xml/format_xml.sh b/scripts/xml/format_xml.sh new file mode 100755 index 000000000..4ff67f966 --- /dev/null +++ b/scripts/xml/format_xml.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash + +ONLY_TOUCHED_FILES=${ONLY_TOUCHED_FILES:-true} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +PROJECT_ROOT="$(pwd)" + +echo "PROJECT_ROOT: $PROJECT_ROOT" + +# Cross-platform XML formatting using xmlstarlet or xml command +format_xml() { + local file="$1" + if [[ "$OSTYPE" == "darwin"* ]]; then + xml fo -s 2 "$file" > "$file.formatted" 2>&1 + else + xmlstarlet fo -s 2 "$file" > "$file.formatted" 2>&1 + fi + + local exit_code=$? + if [[ $exit_code -eq 0 && -f "$file.formatted" ]]; then + mv "$file.formatted" "$file" + return 0 + else + rm -f "$file.formatted" + return 1 + fi +} + +# Check for required XML tools +echo -e "${YELLOW}Checking for required commands...${NC}" + +if [[ "$OSTYPE" == "darwin"* ]]; then + if ! command_exists xml; then + echo -e "${RED}xmlstarlet (xml command) is not installed${NC}" + echo "Try 'brew install xmlstarlet'" + exit 1 + fi +else + if ! command_exists xmlstarlet; then + echo -e "${RED}xmlstarlet is not installed${NC}" + echo "Consult your OS package manager" + exit 1 + fi +fi + +echo -e "${GREEN}XML tools are available${NC}" + +# Check for other required commands +for cmd in find xargs git; do + if ! command_exists "$cmd"; then + echo -e "${RED}Required command '$cmd' is not available${NC}" + exit 1 + fi +done + +# Start the timer +if [[ -f "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + start_time=$(bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh") +else + start_time=$(date +%s)000 # Fallback to seconds * 1000 +fi + +echo -e "${YELLOW}Starting XML formatting...${NC}" + +# Function to find all XML files +find_all_xml_files() { + git ls-files --cached --others --exclude-standard -z | + grep -z '\.xml$' | + xargs -0 -I {} echo "$PROJECT_ROOT/{}" +} + +# Function to get touched/staged files +get_touched_files() { + { + # Get staged files + git diff --cached --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^.*\.xml$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + + # Get modified but not staged files + git diff --name-only --diff-filter=ACMR | while read -r file; do + if [[ "$file" =~ ^.*\.xml$ ]] && [[ -f "$PROJECT_ROOT/$file" ]]; then + echo "$PROJECT_ROOT/$file" + fi + done + } | sort | uniq +} + +# Determine which files to process +declare -a files_to_process + +if [[ "${ONLY_TOUCHED_FILES}" == "true" ]]; then + echo -e "${YELLOW}Processing only touched/staged files${NC}" + + # Get list of changed files + touched_files=$(get_touched_files) + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done <<< "$touched_files" + +else + echo -e "${YELLOW}Processing all XML files in the project${NC}" + + # Get all XML files + all_files=$(find_all_xml_files) + while IFS= read -r file; do + [[ -n "$file" ]] && files_to_process+=("$file") + done <<< "$all_files" +fi + +# Check if we have files to process +if [[ ${#files_to_process[@]} -eq 0 ]]; then + echo -e "${GREEN}No XML files to process${NC}" + if [[ -f "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + end_time=$(bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh") + total_elapsed=$((end_time - start_time)) + else + end_time=$(date +%s)000 + total_elapsed=$((end_time - start_time)) + fi + echo "Total time elapsed: $total_elapsed ms." + exit 0 +fi + +echo -e "${YELLOW}Found ${#files_to_process[@]} XML file(s) to process${NC}" + +# Export function for xargs +export -f format_xml +export OSTYPE + +# Apply XML formatting +echo -e "${YELLOW}Applying XML formatting...${NC}" + +declare -a failed_files +processed_count=0 + +for file in "${files_to_process[@]}"; do + if [[ -f "$file" ]]; then + if format_xml "$file"; then + ((processed_count++)) + echo -e "${GREEN}✓${NC} $file" + else + failed_files+=("$file") + echo -e "${RED}✗${NC} $file" + fi + fi +done + +echo -e "${GREEN}Processed $processed_count file(s)${NC}" + +# Calculate total elapsed time +if [[ -f "$PROJECT_ROOT/scripts/utils/get_timestamp.sh" ]]; then + end_time=$(bash "$PROJECT_ROOT/scripts/utils/get_timestamp.sh") +else + end_time=$(date +%s)000 +fi +total_elapsed=$((end_time - start_time)) + +# Check and report errors +if [[ ${#failed_files[@]} -gt 0 ]]; then + echo -e "${RED}Failed to format ${#failed_files[@]} file(s):${NC}" + printf '%s\n' "${failed_files[@]}" + echo -e "${RED}Total time elapsed: $total_elapsed ms.${NC}" + exit 1 +fi + +# Stage the formatted files +if [[ ${#files_to_process[@]} -gt 0 ]]; then + echo -e "${YELLOW}Staging formatted files...${NC}" + printf '%s\n' "${files_to_process[@]}" | xargs git add +fi + +echo -e "${GREEN}XML files have been formatted successfully.${NC}" +echo "Total time elapsed: $total_elapsed ms." +exit 0 diff --git a/server.json b/server.json new file mode 100644 index 000000000..587b5b2da --- /dev/null +++ b/server.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "dev.jasonpearson/auto-mobile", + "title": "AutoMobile", + "description": "Mobile device interaction automation via MCP", + "version": "0.0.13", + "websiteUrl": "https://kaeawc.github.io/auto-mobile/", + "repository": { + "url": "https://github.com/kaeawc/auto-mobile", + "source": "github" + }, + "packages": [ + { + "registryType": "npm", + "identifier": "@kaeawc/auto-mobile", + "version": "0.0.13", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/skills/android-gradlew/SKILL.md b/skills/android-gradlew/SKILL.md new file mode 100644 index 000000000..3d75b442c --- /dev/null +++ b/skills/android-gradlew/SKILL.md @@ -0,0 +1,12 @@ +--- +name: android-gradlew +description: Use this skill for Android project tasks in the android/ subdirectory, including build, test, lint, and verification commands that must run via the Gradle wrapper. +--- + +# Android Gradle Wrapper + +Run Android tasks from the `android/` directory using the Gradle wrapper. + +- Use `(cd android && ./gradlew )` for Android-only builds, tests, and lint. +- Avoid running Gradle tasks from the repo root. +- Prefer explicit tasks (e.g., `assemble`, `test`, `lint`) based on the requested verification. diff --git a/skills/bun-tasks/SKILL.md b/skills/bun-tasks/SKILL.md new file mode 100644 index 000000000..f847aff75 --- /dev/null +++ b/skills/bun-tasks/SKILL.md @@ -0,0 +1,12 @@ +--- +name: bun-tasks +description: Use this skill when running or documenting repository tasks; package.json is the source of truth for Bun scripts and commands. +--- + +# Bun Tasks + +Use `package.json` scripts via Bun. + +- Check `package.json` for available scripts. +- Run tasks with `bun run