This is page 11 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .axe-version ├── .claude │ └── agents │ └── xcodebuild-mcp-qa-tester.md ├── .cursor │ ├── BUGBOT.md │ └── environment.json ├── .cursorrules ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude-dispatch.yml │ ├── claude.yml │ ├── droid-code-review.yml │ ├── README.md │ ├── release.yml │ └── sentry.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── mcp.json │ ├── settings.json │ └── tasks.json ├── AGENTS.md ├── banner.png ├── build-plugins │ ├── plugin-discovery.js │ ├── plugin-discovery.ts │ └── tsconfig.json ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── docs │ ├── ARCHITECTURE.md │ ├── CODE_QUALITY.md │ ├── CONTRIBUTING.md │ ├── ESLINT_TYPE_SAFETY.md │ ├── MANUAL_TESTING.md │ ├── NODEJS_2025.md │ ├── PLUGIN_DEVELOPMENT.md │ ├── RELEASE_PROCESS.md │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md │ ├── RELOADEROO.md │ ├── session_management_plan.md │ ├── session-aware-migration-todo.md │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md │ ├── TESTING.md │ └── TOOLS.md ├── eslint.config.js ├── example_projects │ ├── .vscode │ │ └── launch.json │ ├── iOS │ │ ├── .cursor │ │ │ └── rules │ │ │ └── errors.mdc │ │ ├── .vscode │ │ │ └── settings.json │ │ ├── Makefile │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ ├── MCPTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── MCPTest.xcscheme │ │ └── MCPTestUITests │ │ └── MCPTestUITests.swift │ ├── iOS_Calculator │ │ ├── CalculatorApp │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── CalculatorApp.swift │ │ │ └── CalculatorApp.xctestplan │ │ ├── CalculatorApp.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── CalculatorApp.xcscheme │ │ ├── CalculatorApp.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── CalculatorAppPackage │ │ │ ├── .gitignore │ │ │ ├── Package.swift │ │ │ ├── Sources │ │ │ │ └── CalculatorAppFeature │ │ │ │ ├── BackgroundEffect.swift │ │ │ │ ├── CalculatorButton.swift │ │ │ │ ├── CalculatorDisplay.swift │ │ │ │ ├── CalculatorInputHandler.swift │ │ │ │ ├── CalculatorService.swift │ │ │ │ └── ContentView.swift │ │ │ └── Tests │ │ │ └── CalculatorAppFeatureTests │ │ │ └── CalculatorServiceTests.swift │ │ ├── CalculatorAppTests │ │ │ └── CalculatorAppTests.swift │ │ └── Config │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ ├── Shared.xcconfig │ │ └── Tests.xcconfig │ ├── macOS │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTest.entitlements │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── MCPTest.xcodeproj │ │ ├── project.pbxproj │ │ └── xcshareddata │ │ └── xcschemes │ │ └── MCPTest.xcscheme │ └── spm │ ├── .gitignore │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ │ ├── long-server │ │ │ └── main.swift │ │ ├── quick-task │ │ │ └── main.swift │ │ ├── spm │ │ │ └── main.swift │ │ └── TestLib │ │ └── TaskManager.swift │ └── Tests │ └── TestLibTests │ └── SimpleTests.swift ├── LICENSE ├── mcp-install-dark.png ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── analysis │ │ └── tools-analysis.ts │ ├── bundle-axe.sh │ ├── check-code-patterns.js │ ├── release.sh │ ├── tools-cli.ts │ └── update-tools-docs.ts ├── server.json ├── smithery.yaml ├── src │ ├── core │ │ ├── __tests__ │ │ │ └── resources.test.ts │ │ ├── dynamic-tools.ts │ │ ├── plugin-registry.ts │ │ ├── plugin-types.ts │ │ └── resources.ts │ ├── doctor-cli.ts │ ├── index.ts │ ├── mcp │ │ ├── resources │ │ │ ├── __tests__ │ │ │ │ ├── devices.test.ts │ │ │ │ ├── doctor.test.ts │ │ │ │ └── simulators.test.ts │ │ │ ├── devices.ts │ │ │ ├── doctor.ts │ │ │ └── simulators.ts │ │ └── tools │ │ ├── device │ │ │ ├── __tests__ │ │ │ │ ├── build_device.test.ts │ │ │ │ ├── get_device_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_device.test.ts │ │ │ │ ├── launch_app_device.test.ts │ │ │ │ ├── list_devices.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_app_device.test.ts │ │ │ │ └── test_device.test.ts │ │ │ ├── build_device.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_device_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_device.ts │ │ │ ├── launch_app_device.ts │ │ │ ├── list_devices.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── stop_app_device.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── test_device.ts │ │ ├── discovery │ │ │ ├── __tests__ │ │ │ │ └── discover_tools.test.ts │ │ │ ├── discover_tools.ts │ │ │ └── index.ts │ │ ├── doctor │ │ │ ├── __tests__ │ │ │ │ ├── doctor.test.ts │ │ │ │ └── index.test.ts │ │ │ ├── doctor.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ └── doctor.deps.ts │ │ ├── logging │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── start_device_log_cap.test.ts │ │ │ │ ├── start_sim_log_cap.test.ts │ │ │ │ ├── stop_device_log_cap.test.ts │ │ │ │ └── stop_sim_log_cap.test.ts │ │ │ ├── index.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── start_sim_log_cap.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── stop_sim_log_cap.ts │ │ ├── macos │ │ │ ├── __tests__ │ │ │ │ ├── build_macos.test.ts │ │ │ │ ├── build_run_macos.test.ts │ │ │ │ ├── get_mac_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── launch_mac_app.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_mac_app.test.ts │ │ │ │ └── test_macos.test.ts │ │ │ ├── build_macos.ts │ │ │ ├── build_run_macos.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_mac_app_path.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── launch_mac_app.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_mac_app.ts │ │ │ └── test_macos.ts │ │ ├── project-discovery │ │ │ ├── __tests__ │ │ │ │ ├── discover_projs.test.ts │ │ │ │ ├── get_app_bundle_id.test.ts │ │ │ │ ├── get_mac_bundle_id.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── list_schemes.test.ts │ │ │ │ └── show_build_settings.test.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── list_schemes.ts │ │ │ └── show_build_settings.ts │ │ ├── project-scaffolding │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── scaffold_ios_project.test.ts │ │ │ │ └── scaffold_macos_project.test.ts │ │ │ ├── index.ts │ │ │ ├── scaffold_ios_project.ts │ │ │ └── scaffold_macos_project.ts │ │ ├── session-management │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── session_clear_defaults.test.ts │ │ │ │ ├── session_set_defaults.test.ts │ │ │ │ └── session_show_defaults.test.ts │ │ │ ├── index.ts │ │ │ ├── session_clear_defaults.ts │ │ │ ├── session_set_defaults.ts │ │ │ └── session_show_defaults.ts │ │ ├── simulator │ │ │ ├── __tests__ │ │ │ │ ├── boot_sim.test.ts │ │ │ │ ├── build_run_sim.test.ts │ │ │ │ ├── build_sim.test.ts │ │ │ │ ├── get_sim_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_sim.test.ts │ │ │ │ ├── launch_app_logs_sim.test.ts │ │ │ │ ├── launch_app_sim.test.ts │ │ │ │ ├── list_sims.test.ts │ │ │ │ ├── open_sim.test.ts │ │ │ │ ├── record_sim_video.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── stop_app_sim.test.ts │ │ │ │ └── test_sim.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── build_run_sim.ts │ │ │ ├── build_sim.ts │ │ │ ├── clean.ts │ │ │ ├── describe_ui.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_sim_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_sim.ts │ │ │ ├── launch_app_logs_sim.ts │ │ │ ├── launch_app_sim.ts │ │ │ ├── list_schemes.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── record_sim_video.ts │ │ │ ├── screenshot.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_app_sim.ts │ │ │ └── test_sim.ts │ │ ├── simulator-management │ │ │ ├── __tests__ │ │ │ │ ├── erase_sims.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── reset_sim_location.test.ts │ │ │ │ ├── set_sim_appearance.test.ts │ │ │ │ ├── set_sim_location.test.ts │ │ │ │ └── sim_statusbar.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── erase_sims.ts │ │ │ ├── index.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── reset_sim_location.ts │ │ │ ├── set_sim_appearance.ts │ │ │ ├── set_sim_location.ts │ │ │ └── sim_statusbar.ts │ │ ├── swift-package │ │ │ ├── __tests__ │ │ │ │ ├── active-processes.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── swift_package_build.test.ts │ │ │ │ ├── swift_package_clean.test.ts │ │ │ │ ├── swift_package_list.test.ts │ │ │ │ ├── swift_package_run.test.ts │ │ │ │ ├── swift_package_stop.test.ts │ │ │ │ └── swift_package_test.test.ts │ │ │ ├── active-processes.ts │ │ │ ├── index.ts │ │ │ ├── swift_package_build.ts │ │ │ ├── swift_package_clean.ts │ │ │ ├── swift_package_list.ts │ │ │ ├── swift_package_run.ts │ │ │ ├── swift_package_stop.ts │ │ │ └── swift_package_test.ts │ │ ├── ui-testing │ │ │ ├── __tests__ │ │ │ │ ├── button.test.ts │ │ │ │ ├── describe_ui.test.ts │ │ │ │ ├── gesture.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── key_press.test.ts │ │ │ │ ├── key_sequence.test.ts │ │ │ │ ├── long_press.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── swipe.test.ts │ │ │ │ ├── tap.test.ts │ │ │ │ ├── touch.test.ts │ │ │ │ └── type_text.test.ts │ │ │ ├── button.ts │ │ │ ├── describe_ui.ts │ │ │ ├── gesture.ts │ │ │ ├── index.ts │ │ │ ├── key_press.ts │ │ │ ├── key_sequence.ts │ │ │ ├── long_press.ts │ │ │ ├── screenshot.ts │ │ │ ├── swipe.ts │ │ │ ├── tap.ts │ │ │ ├── touch.ts │ │ │ └── type_text.ts │ │ └── utilities │ │ ├── __tests__ │ │ │ ├── clean.test.ts │ │ │ └── index.test.ts │ │ ├── clean.ts │ │ └── index.ts │ ├── server │ │ └── server.ts │ ├── test-utils │ │ └── mock-executors.ts │ ├── types │ │ └── common.ts │ └── utils │ ├── __tests__ │ │ ├── build-utils.test.ts │ │ ├── environment.test.ts │ │ ├── session-aware-tool-factory.test.ts │ │ ├── session-store.test.ts │ │ ├── simulator-utils.test.ts │ │ ├── test-runner-env-integration.test.ts │ │ └── typed-tool-factory.test.ts │ ├── axe │ │ └── index.ts │ ├── axe-helpers.ts │ ├── build │ │ └── index.ts │ ├── build-utils.ts │ ├── capabilities.ts │ ├── command.ts │ ├── CommandExecutor.ts │ ├── environment.ts │ ├── errors.ts │ ├── execution │ │ └── index.ts │ ├── FileSystemExecutor.ts │ ├── log_capture.ts │ ├── log-capture │ │ └── index.ts │ ├── logger.ts │ ├── logging │ │ └── index.ts │ ├── plugin-registry │ │ └── index.ts │ ├── responses │ │ └── index.ts │ ├── schema-helpers.ts │ ├── sentry.ts │ ├── session-store.ts │ ├── simulator-utils.ts │ ├── template │ │ └── index.ts │ ├── template-manager.ts │ ├── test │ │ └── index.ts │ ├── test-common.ts │ ├── tool-registry.ts │ ├── typed-tool-factory.ts │ ├── validation │ │ └── index.ts │ ├── validation.ts │ ├── version │ │ └── index.ts │ ├── video_capture.ts │ ├── video-capture │ │ └── index.ts │ ├── xcode.ts │ ├── xcodemake │ │ └── index.ts │ └── xcodemake.ts ├── tsconfig.json ├── tsconfig.test.json ├── tsup.config.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/swipe.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for swipe tool plugin 3 | */ 4 | 5 | import { describe, it, expect } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; 8 | import { SystemError, DependencyError } from '../../../../utils/responses/index.ts'; 9 | 10 | // Import the plugin module to test 11 | import swipePlugin, { AxeHelpers, swipeLogic, SwipeParams } from '../swipe.ts'; 12 | 13 | // Helper function to create mock axe helpers 14 | function createMockAxeHelpers(): AxeHelpers { 15 | return { 16 | getAxePath: () => '/mocked/axe/path', 17 | getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), 18 | createAxeNotAvailableResponse: () => ({ 19 | content: [ 20 | { 21 | type: 'text', 22 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 23 | }, 24 | ], 25 | isError: true, 26 | }), 27 | }; 28 | } 29 | 30 | // Helper function to create mock axe helpers with null path (for dependency error tests) 31 | function createMockAxeHelpersWithNullPath(): AxeHelpers { 32 | return { 33 | getAxePath: () => null, 34 | getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), 35 | createAxeNotAvailableResponse: () => ({ 36 | content: [ 37 | { 38 | type: 'text', 39 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 40 | }, 41 | ], 42 | isError: true, 43 | }), 44 | }; 45 | } 46 | 47 | describe('Swipe Plugin', () => { 48 | describe('Export Field Validation (Literal)', () => { 49 | it('should have correct name', () => { 50 | expect(swipePlugin.name).toBe('swipe'); 51 | }); 52 | 53 | it('should have correct description', () => { 54 | expect(swipePlugin.description).toBe( 55 | "Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.", 56 | ); 57 | }); 58 | 59 | it('should have handler function', () => { 60 | expect(typeof swipePlugin.handler).toBe('function'); 61 | }); 62 | 63 | it('should validate schema fields with safeParse', () => { 64 | const schema = z.object(swipePlugin.schema); 65 | 66 | // Valid case 67 | expect( 68 | schema.safeParse({ 69 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 70 | x1: 100, 71 | y1: 200, 72 | x2: 300, 73 | y2: 400, 74 | }).success, 75 | ).toBe(true); 76 | 77 | // Invalid simulatorUuid 78 | expect( 79 | schema.safeParse({ 80 | simulatorUuid: 'invalid-uuid', 81 | x1: 100, 82 | y1: 200, 83 | x2: 300, 84 | y2: 400, 85 | }).success, 86 | ).toBe(false); 87 | 88 | // Invalid x1 (not integer) 89 | expect( 90 | schema.safeParse({ 91 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 92 | x1: 100.5, 93 | y1: 200, 94 | x2: 300, 95 | y2: 400, 96 | }).success, 97 | ).toBe(false); 98 | 99 | // Valid with optional parameters 100 | expect( 101 | schema.safeParse({ 102 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 103 | x1: 100, 104 | y1: 200, 105 | x2: 300, 106 | y2: 400, 107 | duration: 1.5, 108 | delta: 10, 109 | preDelay: 0.5, 110 | postDelay: 0.2, 111 | }).success, 112 | ).toBe(true); 113 | 114 | // Invalid duration (negative) 115 | expect( 116 | schema.safeParse({ 117 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 118 | x1: 100, 119 | y1: 200, 120 | x2: 300, 121 | y2: 400, 122 | duration: -1, 123 | }).success, 124 | ).toBe(false); 125 | }); 126 | }); 127 | 128 | describe('Command Generation', () => { 129 | it('should generate correct axe command for basic swipe', async () => { 130 | let capturedCommand: string[] = []; 131 | const trackingExecutor = async (command: string[]) => { 132 | capturedCommand = command; 133 | return { 134 | success: true, 135 | output: 'swipe completed', 136 | error: undefined, 137 | process: { pid: 12345 }, 138 | }; 139 | }; 140 | 141 | const mockAxeHelpers = createMockAxeHelpers(); 142 | 143 | await swipeLogic( 144 | { 145 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 146 | x1: 100, 147 | y1: 200, 148 | x2: 300, 149 | y2: 400, 150 | }, 151 | trackingExecutor, 152 | mockAxeHelpers, 153 | ); 154 | 155 | expect(capturedCommand).toEqual([ 156 | '/mocked/axe/path', 157 | 'swipe', 158 | '--start-x', 159 | '100', 160 | '--start-y', 161 | '200', 162 | '--end-x', 163 | '300', 164 | '--end-y', 165 | '400', 166 | '--udid', 167 | '12345678-1234-1234-1234-123456789012', 168 | ]); 169 | }); 170 | 171 | it('should generate correct axe command for swipe with duration', async () => { 172 | let capturedCommand: string[] = []; 173 | const trackingExecutor = async (command: string[]) => { 174 | capturedCommand = command; 175 | return { 176 | success: true, 177 | output: 'swipe completed', 178 | error: undefined, 179 | process: { pid: 12345 }, 180 | }; 181 | }; 182 | 183 | const mockAxeHelpers = createMockAxeHelpers(); 184 | 185 | await swipeLogic( 186 | { 187 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 188 | x1: 50, 189 | y1: 75, 190 | x2: 250, 191 | y2: 350, 192 | duration: 1.5, 193 | }, 194 | trackingExecutor, 195 | mockAxeHelpers, 196 | ); 197 | 198 | expect(capturedCommand).toEqual([ 199 | '/mocked/axe/path', 200 | 'swipe', 201 | '--start-x', 202 | '50', 203 | '--start-y', 204 | '75', 205 | '--end-x', 206 | '250', 207 | '--end-y', 208 | '350', 209 | '--duration', 210 | '1.5', 211 | '--udid', 212 | '12345678-1234-1234-1234-123456789012', 213 | ]); 214 | }); 215 | 216 | it('should generate correct axe command for swipe with all optional parameters', async () => { 217 | let capturedCommand: string[] = []; 218 | const trackingExecutor = async (command: string[]) => { 219 | capturedCommand = command; 220 | return { 221 | success: true, 222 | output: 'swipe completed', 223 | error: undefined, 224 | process: { pid: 12345 }, 225 | }; 226 | }; 227 | 228 | const mockAxeHelpers = createMockAxeHelpers(); 229 | 230 | await swipeLogic( 231 | { 232 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 233 | x1: 0, 234 | y1: 0, 235 | x2: 500, 236 | y2: 800, 237 | duration: 2.0, 238 | delta: 10, 239 | preDelay: 0.5, 240 | postDelay: 0.3, 241 | }, 242 | trackingExecutor, 243 | mockAxeHelpers, 244 | ); 245 | 246 | expect(capturedCommand).toEqual([ 247 | '/mocked/axe/path', 248 | 'swipe', 249 | '--start-x', 250 | '0', 251 | '--start-y', 252 | '0', 253 | '--end-x', 254 | '500', 255 | '--end-y', 256 | '800', 257 | '--duration', 258 | '2', 259 | '--delta', 260 | '10', 261 | '--pre-delay', 262 | '0.5', 263 | '--post-delay', 264 | '0.3', 265 | '--udid', 266 | '12345678-1234-1234-1234-123456789012', 267 | ]); 268 | }); 269 | 270 | it('should generate correct axe command with bundled axe path', async () => { 271 | let capturedCommand: string[] = []; 272 | const trackingExecutor = async (command: string[]) => { 273 | capturedCommand = command; 274 | return { 275 | success: true, 276 | output: 'swipe completed', 277 | error: undefined, 278 | process: { pid: 12345 }, 279 | }; 280 | }; 281 | 282 | const mockAxeHelpers = { 283 | getAxePath: () => '/path/to/bundled/axe', 284 | getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), 285 | createAxeNotAvailableResponse: () => ({ 286 | content: [{ type: 'text', text: 'AXe tools not available' }], 287 | isError: true, 288 | }), 289 | }; 290 | 291 | await swipeLogic( 292 | { 293 | simulatorUuid: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 294 | x1: 150, 295 | y1: 250, 296 | x2: 400, 297 | y2: 600, 298 | delta: 5, 299 | }, 300 | trackingExecutor, 301 | mockAxeHelpers, 302 | ); 303 | 304 | expect(capturedCommand).toEqual([ 305 | '/path/to/bundled/axe', 306 | 'swipe', 307 | '--start-x', 308 | '150', 309 | '--start-y', 310 | '250', 311 | '--end-x', 312 | '400', 313 | '--end-y', 314 | '600', 315 | '--delta', 316 | '5', 317 | '--udid', 318 | 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 319 | ]); 320 | }); 321 | }); 322 | 323 | describe('Handler Behavior (Complete Literal Returns)', () => { 324 | it('should return error for missing simulatorUuid via handler', async () => { 325 | const result = await swipePlugin.handler({ x1: 100, y1: 200, x2: 300, y2: 400 }); 326 | 327 | expect(result.isError).toBe(true); 328 | expect(result.content[0].type).toBe('text'); 329 | expect(result.content[0].text).toContain('Parameter validation failed'); 330 | expect(result.content[0].text).toContain('simulatorUuid'); 331 | }); 332 | 333 | it('should return error for missing x1 via handler', async () => { 334 | const result = await swipePlugin.handler({ 335 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 336 | y1: 200, 337 | x2: 300, 338 | y2: 400, 339 | }); 340 | 341 | expect(result.isError).toBe(true); 342 | expect(result.content[0].type).toBe('text'); 343 | expect(result.content[0].text).toContain('Parameter validation failed'); 344 | expect(result.content[0].text).toContain('x1'); 345 | }); 346 | 347 | it('should return success for valid swipe execution', async () => { 348 | const mockExecutor = createMockExecutor({ 349 | success: true, 350 | output: 'swipe completed', 351 | error: '', 352 | }); 353 | 354 | const mockAxeHelpers = createMockAxeHelpers(); 355 | 356 | const result = await swipeLogic( 357 | { 358 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 359 | x1: 100, 360 | y1: 200, 361 | x2: 300, 362 | y2: 400, 363 | }, 364 | mockExecutor, 365 | mockAxeHelpers, 366 | ); 367 | 368 | expect(result).toEqual({ 369 | content: [ 370 | { 371 | type: 'text', 372 | text: 'Swipe from (100, 200) to (300, 400) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 373 | }, 374 | ], 375 | isError: false, 376 | }); 377 | }); 378 | 379 | it('should return success for swipe with duration', async () => { 380 | const mockExecutor = createMockExecutor({ 381 | success: true, 382 | output: 'swipe completed', 383 | error: '', 384 | }); 385 | 386 | const mockAxeHelpers = createMockAxeHelpers(); 387 | 388 | const result = await swipeLogic( 389 | { 390 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 391 | x1: 100, 392 | y1: 200, 393 | x2: 300, 394 | y2: 400, 395 | duration: 1.5, 396 | }, 397 | mockExecutor, 398 | mockAxeHelpers, 399 | ); 400 | 401 | expect(result).toEqual({ 402 | content: [ 403 | { 404 | type: 'text', 405 | text: 'Swipe from (100, 200) to (300, 400) duration=1.5s simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 406 | }, 407 | ], 408 | isError: false, 409 | }); 410 | }); 411 | 412 | it('should handle DependencyError when axe is not available', async () => { 413 | const mockExecutor = createMockExecutor({ 414 | success: true, 415 | output: 'swipe completed', 416 | error: '', 417 | }); 418 | 419 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 420 | 421 | const result = await swipeLogic( 422 | { 423 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 424 | x1: 100, 425 | y1: 200, 426 | x2: 300, 427 | y2: 400, 428 | }, 429 | mockExecutor, 430 | mockAxeHelpers, 431 | ); 432 | 433 | expect(result).toEqual({ 434 | content: [ 435 | { 436 | type: 'text', 437 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 438 | }, 439 | ], 440 | isError: true, 441 | }); 442 | }); 443 | 444 | it('should handle AxeError from failed command execution', async () => { 445 | const mockExecutor = createMockExecutor({ 446 | success: false, 447 | output: '', 448 | error: 'axe command failed', 449 | }); 450 | 451 | const mockAxeHelpers = createMockAxeHelpers(); 452 | 453 | const result = await swipeLogic( 454 | { 455 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 456 | x1: 100, 457 | y1: 200, 458 | x2: 300, 459 | y2: 400, 460 | }, 461 | mockExecutor, 462 | mockAxeHelpers, 463 | ); 464 | 465 | expect(result).toEqual({ 466 | content: [ 467 | { 468 | type: 'text', 469 | text: "Error: Failed to simulate swipe: axe command 'swipe' failed.\nDetails: axe command failed", 470 | }, 471 | ], 472 | isError: true, 473 | }); 474 | }); 475 | 476 | it('should handle SystemError from command execution', async () => { 477 | // Override the executor to throw SystemError for this test 478 | const systemErrorExecutor = async () => { 479 | throw new SystemError('System error occurred'); 480 | }; 481 | 482 | const mockAxeHelpers = createMockAxeHelpers(); 483 | 484 | const result = await swipeLogic( 485 | { 486 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 487 | x1: 100, 488 | y1: 200, 489 | x2: 300, 490 | y2: 400, 491 | }, 492 | systemErrorExecutor, 493 | mockAxeHelpers, 494 | ); 495 | 496 | expect(result.isError).toBe(true); 497 | expect(result.content[0].type).toBe('text'); 498 | expect(result.content[0].text).toContain( 499 | 'Error: System error executing axe: Failed to execute axe command: System error occurred', 500 | ); 501 | expect(result.content[0].text).toContain('Details: SystemError: System error occurred'); 502 | }); 503 | 504 | it('should handle unexpected Error objects', async () => { 505 | // Override the executor to throw an unexpected Error for this test 506 | const unexpectedErrorExecutor = async () => { 507 | throw new Error('Unexpected error'); 508 | }; 509 | 510 | const mockAxeHelpers = createMockAxeHelpers(); 511 | 512 | const result = await swipeLogic( 513 | { 514 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 515 | x1: 100, 516 | y1: 200, 517 | x2: 300, 518 | y2: 400, 519 | }, 520 | unexpectedErrorExecutor, 521 | mockAxeHelpers, 522 | ); 523 | 524 | expect(result.isError).toBe(true); 525 | expect(result.content[0].type).toBe('text'); 526 | expect(result.content[0].text).toContain( 527 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error', 528 | ); 529 | expect(result.content[0].text).toContain('Details: Error: Unexpected error'); 530 | }); 531 | 532 | it('should handle unexpected string errors', async () => { 533 | // Override the executor to throw a string error for this test 534 | const stringErrorExecutor = async () => { 535 | throw 'String error'; 536 | }; 537 | 538 | const mockAxeHelpers = createMockAxeHelpers(); 539 | 540 | const result = await swipeLogic( 541 | { 542 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 543 | x1: 100, 544 | y1: 200, 545 | x2: 300, 546 | y2: 400, 547 | }, 548 | stringErrorExecutor, 549 | mockAxeHelpers, 550 | ); 551 | 552 | expect(result).toEqual({ 553 | content: [ 554 | { 555 | type: 'text', 556 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 557 | }, 558 | ], 559 | isError: true, 560 | }); 561 | }); 562 | }); 563 | }); 564 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/button.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for button tool plugin 3 | */ 4 | 5 | import { describe, it, expect, beforeEach } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; 8 | import buttonPlugin, { buttonLogic } from '../button.ts'; 9 | 10 | describe('Button Plugin', () => { 11 | describe('Export Field Validation (Literal)', () => { 12 | it('should have correct name', () => { 13 | expect(buttonPlugin.name).toBe('button'); 14 | }); 15 | 16 | it('should have correct description', () => { 17 | expect(buttonPlugin.description).toBe( 18 | 'Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri', 19 | ); 20 | }); 21 | 22 | it('should have handler function', () => { 23 | expect(typeof buttonPlugin.handler).toBe('function'); 24 | }); 25 | 26 | it('should validate schema fields with safeParse', () => { 27 | const schema = z.object(buttonPlugin.schema); 28 | 29 | // Valid case 30 | expect( 31 | schema.safeParse({ 32 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 33 | buttonType: 'home', 34 | }).success, 35 | ).toBe(true); 36 | 37 | // Invalid simulatorUuid 38 | expect( 39 | schema.safeParse({ 40 | simulatorUuid: 'invalid-uuid', 41 | buttonType: 'home', 42 | }).success, 43 | ).toBe(false); 44 | 45 | // Invalid buttonType 46 | expect( 47 | schema.safeParse({ 48 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 49 | buttonType: 'invalid-button', 50 | }).success, 51 | ).toBe(false); 52 | 53 | // Valid with duration 54 | expect( 55 | schema.safeParse({ 56 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 57 | buttonType: 'home', 58 | duration: 2.5, 59 | }).success, 60 | ).toBe(true); 61 | 62 | // Invalid duration (negative) 63 | expect( 64 | schema.safeParse({ 65 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 66 | buttonType: 'home', 67 | duration: -1, 68 | }).success, 69 | ).toBe(false); 70 | 71 | // Test all valid button types 72 | const validButtons = ['apple-pay', 'home', 'lock', 'side-button', 'siri']; 73 | validButtons.forEach((buttonType) => { 74 | expect( 75 | schema.safeParse({ 76 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 77 | buttonType, 78 | }).success, 79 | ).toBe(true); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('Command Generation', () => { 85 | it('should generate correct axe command for basic button press', async () => { 86 | let capturedCommand: string[] = []; 87 | const trackingExecutor = async (command: string[]) => { 88 | capturedCommand = command; 89 | return { 90 | success: true, 91 | output: 'button press completed', 92 | error: undefined, 93 | process: { pid: 12345 }, 94 | }; 95 | }; 96 | 97 | const mockAxeHelpers = { 98 | getAxePath: () => '/usr/local/bin/axe', 99 | getBundledAxeEnvironment: () => ({}), 100 | createAxeNotAvailableResponse: () => ({ 101 | content: [{ type: 'text', text: 'axe not available' }], 102 | isError: true, 103 | }), 104 | }; 105 | 106 | await buttonLogic( 107 | { 108 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 109 | buttonType: 'home', 110 | }, 111 | trackingExecutor, 112 | mockAxeHelpers, 113 | ); 114 | 115 | expect(capturedCommand).toEqual([ 116 | '/usr/local/bin/axe', 117 | 'button', 118 | 'home', 119 | '--udid', 120 | '12345678-1234-1234-1234-123456789012', 121 | ]); 122 | }); 123 | 124 | it('should generate correct axe command for button press with duration', async () => { 125 | let capturedCommand: string[] = []; 126 | const trackingExecutor = async (command: string[]) => { 127 | capturedCommand = command; 128 | return { 129 | success: true, 130 | output: 'button press completed', 131 | error: undefined, 132 | process: { pid: 12345 }, 133 | }; 134 | }; 135 | 136 | const mockAxeHelpers = { 137 | getAxePath: () => '/usr/local/bin/axe', 138 | getBundledAxeEnvironment: () => ({}), 139 | createAxeNotAvailableResponse: () => ({ 140 | content: [{ type: 'text', text: 'axe not available' }], 141 | isError: true, 142 | }), 143 | }; 144 | 145 | await buttonLogic( 146 | { 147 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 148 | buttonType: 'side-button', 149 | duration: 2.5, 150 | }, 151 | trackingExecutor, 152 | mockAxeHelpers, 153 | ); 154 | 155 | expect(capturedCommand).toEqual([ 156 | '/usr/local/bin/axe', 157 | 'button', 158 | 'side-button', 159 | '--duration', 160 | '2.5', 161 | '--udid', 162 | '12345678-1234-1234-1234-123456789012', 163 | ]); 164 | }); 165 | 166 | it('should generate correct axe command for different button types', async () => { 167 | let capturedCommand: string[] = []; 168 | const trackingExecutor = async (command: string[]) => { 169 | capturedCommand = command; 170 | return { 171 | success: true, 172 | output: 'button press completed', 173 | error: undefined, 174 | process: { pid: 12345 }, 175 | }; 176 | }; 177 | 178 | const mockAxeHelpers = { 179 | getAxePath: () => '/usr/local/bin/axe', 180 | getBundledAxeEnvironment: () => ({}), 181 | createAxeNotAvailableResponse: () => ({ 182 | content: [{ type: 'text', text: 'axe not available' }], 183 | isError: true, 184 | }), 185 | }; 186 | 187 | await buttonLogic( 188 | { 189 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 190 | buttonType: 'apple-pay', 191 | }, 192 | trackingExecutor, 193 | mockAxeHelpers, 194 | ); 195 | 196 | expect(capturedCommand).toEqual([ 197 | '/usr/local/bin/axe', 198 | 'button', 199 | 'apple-pay', 200 | '--udid', 201 | '12345678-1234-1234-1234-123456789012', 202 | ]); 203 | }); 204 | 205 | it('should generate correct axe command with bundled axe path', async () => { 206 | let capturedCommand: string[] = []; 207 | const trackingExecutor = async (command: string[]) => { 208 | capturedCommand = command; 209 | return { 210 | success: true, 211 | output: 'button press completed', 212 | error: undefined, 213 | process: { pid: 12345 }, 214 | }; 215 | }; 216 | 217 | const mockAxeHelpers = { 218 | getAxePath: () => '/path/to/bundled/axe', 219 | getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), 220 | }; 221 | 222 | await buttonLogic( 223 | { 224 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 225 | buttonType: 'siri', 226 | }, 227 | trackingExecutor, 228 | mockAxeHelpers, 229 | ); 230 | 231 | expect(capturedCommand).toEqual([ 232 | '/path/to/bundled/axe', 233 | 'button', 234 | 'siri', 235 | '--udid', 236 | '12345678-1234-1234-1234-123456789012', 237 | ]); 238 | }); 239 | }); 240 | 241 | describe('Handler Behavior (Complete Literal Returns)', () => { 242 | it('should return error for missing simulatorUuid', async () => { 243 | const result = await buttonPlugin.handler({ buttonType: 'home' }); 244 | 245 | expect(result.isError).toBe(true); 246 | expect(result.content[0].text).toContain('Parameter validation failed'); 247 | expect(result.content[0].text).toContain('simulatorUuid: Required'); 248 | }); 249 | 250 | it('should return error for missing buttonType', async () => { 251 | const result = await buttonPlugin.handler({ 252 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 253 | }); 254 | 255 | expect(result.isError).toBe(true); 256 | expect(result.content[0].text).toContain('Parameter validation failed'); 257 | expect(result.content[0].text).toContain('buttonType: Required'); 258 | }); 259 | 260 | it('should return error for invalid simulatorUuid format', async () => { 261 | const result = await buttonPlugin.handler({ 262 | simulatorUuid: 'invalid-uuid-format', 263 | buttonType: 'home', 264 | }); 265 | 266 | expect(result.isError).toBe(true); 267 | expect(result.content[0].text).toContain('Parameter validation failed'); 268 | expect(result.content[0].text).toContain('Invalid Simulator UUID format'); 269 | }); 270 | 271 | it('should return error for invalid buttonType', async () => { 272 | const result = await buttonPlugin.handler({ 273 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 274 | buttonType: 'invalid-button', 275 | }); 276 | 277 | expect(result.isError).toBe(true); 278 | expect(result.content[0].text).toContain('Parameter validation failed'); 279 | }); 280 | 281 | it('should return error for negative duration', async () => { 282 | const result = await buttonPlugin.handler({ 283 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 284 | buttonType: 'home', 285 | duration: -1, 286 | }); 287 | 288 | expect(result.isError).toBe(true); 289 | expect(result.content[0].text).toContain('Parameter validation failed'); 290 | expect(result.content[0].text).toContain('Duration must be non-negative'); 291 | }); 292 | 293 | it('should return success for valid button press', async () => { 294 | const mockExecutor = createMockExecutor({ 295 | success: true, 296 | output: 'button press completed', 297 | error: undefined, 298 | process: { pid: 12345 }, 299 | }); 300 | 301 | const mockAxeHelpers = { 302 | getAxePath: () => '/usr/local/bin/axe', 303 | getBundledAxeEnvironment: () => ({}), 304 | createAxeNotAvailableResponse: () => ({ 305 | content: [{ type: 'text', text: 'axe not available' }], 306 | isError: true, 307 | }), 308 | }; 309 | 310 | const result = await buttonLogic( 311 | { 312 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 313 | buttonType: 'home', 314 | }, 315 | mockExecutor, 316 | mockAxeHelpers, 317 | ); 318 | 319 | expect(result).toEqual({ 320 | content: [{ type: 'text', text: "Hardware button 'home' pressed successfully." }], 321 | isError: false, 322 | }); 323 | }); 324 | 325 | it('should return success for button press with duration', async () => { 326 | const mockExecutor = createMockExecutor({ 327 | success: true, 328 | output: 'button press completed', 329 | error: undefined, 330 | process: { pid: 12345 }, 331 | }); 332 | 333 | const mockAxeHelpers = { 334 | getAxePath: () => '/usr/local/bin/axe', 335 | getBundledAxeEnvironment: () => ({}), 336 | createAxeNotAvailableResponse: () => ({ 337 | content: [{ type: 'text', text: 'axe not available' }], 338 | isError: true, 339 | }), 340 | }; 341 | 342 | const result = await buttonLogic( 343 | { 344 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 345 | buttonType: 'side-button', 346 | duration: 2.5, 347 | }, 348 | mockExecutor, 349 | mockAxeHelpers, 350 | ); 351 | 352 | expect(result).toEqual({ 353 | content: [{ type: 'text', text: "Hardware button 'side-button' pressed successfully." }], 354 | isError: false, 355 | }); 356 | }); 357 | 358 | it('should handle DependencyError when axe is not available', async () => { 359 | const mockAxeHelpers = { 360 | getAxePath: () => null, 361 | getBundledAxeEnvironment: () => ({}), 362 | createAxeNotAvailableResponse: () => ({ 363 | content: [ 364 | { 365 | type: 'text', 366 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 367 | }, 368 | ], 369 | isError: true, 370 | }), 371 | }; 372 | 373 | const result = await buttonLogic( 374 | { 375 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 376 | buttonType: 'home', 377 | }, 378 | createNoopExecutor(), 379 | mockAxeHelpers, 380 | ); 381 | 382 | expect(result).toEqual({ 383 | content: [ 384 | { 385 | type: 'text', 386 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 387 | }, 388 | ], 389 | isError: true, 390 | }); 391 | }); 392 | 393 | it('should handle AxeError from failed command execution', async () => { 394 | const mockExecutor = createMockExecutor({ 395 | success: false, 396 | output: '', 397 | error: 'axe command failed', 398 | process: { pid: 12345 }, 399 | }); 400 | 401 | const mockAxeHelpers = { 402 | getAxePath: () => '/usr/local/bin/axe', 403 | getBundledAxeEnvironment: () => ({}), 404 | createAxeNotAvailableResponse: () => ({ 405 | content: [{ type: 'text', text: 'axe not available' }], 406 | isError: true, 407 | }), 408 | }; 409 | 410 | const result = await buttonLogic( 411 | { 412 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 413 | buttonType: 'home', 414 | }, 415 | mockExecutor, 416 | mockAxeHelpers, 417 | ); 418 | 419 | expect(result).toEqual({ 420 | content: [ 421 | { 422 | type: 'text', 423 | text: "Error: Failed to press button 'home': axe command 'button' failed.\nDetails: axe command failed", 424 | }, 425 | ], 426 | isError: true, 427 | }); 428 | }); 429 | 430 | it('should handle SystemError from command execution', async () => { 431 | const mockExecutor = async () => { 432 | throw new Error('ENOENT: no such file or directory'); 433 | }; 434 | 435 | const mockAxeHelpers = { 436 | getAxePath: () => '/usr/local/bin/axe', 437 | getBundledAxeEnvironment: () => ({}), 438 | createAxeNotAvailableResponse: () => ({ 439 | content: [{ type: 'text', text: 'axe not available' }], 440 | isError: true, 441 | }), 442 | }; 443 | 444 | const result = await buttonLogic( 445 | { 446 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 447 | buttonType: 'home', 448 | }, 449 | mockExecutor, 450 | mockAxeHelpers, 451 | ); 452 | 453 | expect(result.content[0].text).toMatch( 454 | /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, 455 | ); 456 | expect(result.isError).toBe(true); 457 | }); 458 | 459 | it('should handle unexpected Error objects', async () => { 460 | const mockExecutor = async () => { 461 | throw new Error('Unexpected error'); 462 | }; 463 | 464 | const mockAxeHelpers = { 465 | getAxePath: () => '/usr/local/bin/axe', 466 | getBundledAxeEnvironment: () => ({}), 467 | createAxeNotAvailableResponse: () => ({ 468 | content: [{ type: 'text', text: 'axe not available' }], 469 | isError: true, 470 | }), 471 | }; 472 | 473 | const result = await buttonLogic( 474 | { 475 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 476 | buttonType: 'home', 477 | }, 478 | mockExecutor, 479 | mockAxeHelpers, 480 | ); 481 | 482 | expect(result.content[0].text).toMatch( 483 | /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, 484 | ); 485 | expect(result.isError).toBe(true); 486 | }); 487 | 488 | it('should handle unexpected string errors', async () => { 489 | const mockExecutor = async () => { 490 | throw 'String error'; 491 | }; 492 | 493 | const mockAxeHelpers = { 494 | getAxePath: () => '/usr/local/bin/axe', 495 | getBundledAxeEnvironment: () => ({}), 496 | createAxeNotAvailableResponse: () => ({ 497 | content: [{ type: 'text', text: 'axe not available' }], 498 | isError: true, 499 | }), 500 | }; 501 | 502 | const result = await buttonLogic( 503 | { 504 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 505 | buttonType: 'home', 506 | }, 507 | mockExecutor, 508 | mockAxeHelpers, 509 | ); 510 | 511 | expect(result).toEqual({ 512 | content: [ 513 | { 514 | type: 'text', 515 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 516 | }, 517 | ], 518 | isError: true, 519 | }); 520 | }); 521 | }); 522 | }); 523 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/key_press.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for key_press tool plugin 3 | */ 4 | 5 | import { describe, it, expect } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { 8 | createMockExecutor, 9 | createMockFileSystemExecutor, 10 | createNoopExecutor, 11 | } from '../../../../test-utils/mock-executors.ts'; 12 | import keyPressPlugin, { key_pressLogic } from '../key_press.ts'; 13 | 14 | describe('Key Press Plugin', () => { 15 | describe('Export Field Validation (Literal)', () => { 16 | it('should have correct name', () => { 17 | expect(keyPressPlugin.name).toBe('key_press'); 18 | }); 19 | 20 | it('should have correct description', () => { 21 | expect(keyPressPlugin.description).toBe( 22 | 'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.', 23 | ); 24 | }); 25 | 26 | it('should have handler function', () => { 27 | expect(typeof keyPressPlugin.handler).toBe('function'); 28 | }); 29 | 30 | it('should validate schema fields with safeParse', () => { 31 | const schema = z.object(keyPressPlugin.schema); 32 | 33 | // Valid case 34 | expect( 35 | schema.safeParse({ 36 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 37 | keyCode: 40, 38 | }).success, 39 | ).toBe(true); 40 | 41 | // Invalid simulatorUuid 42 | expect( 43 | schema.safeParse({ 44 | simulatorUuid: 'invalid-uuid', 45 | keyCode: 40, 46 | }).success, 47 | ).toBe(false); 48 | 49 | // Invalid keyCode (string) 50 | expect( 51 | schema.safeParse({ 52 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 53 | keyCode: 'invalid', 54 | }).success, 55 | ).toBe(false); 56 | 57 | // Invalid keyCode (below range) 58 | expect( 59 | schema.safeParse({ 60 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 61 | keyCode: -1, 62 | }).success, 63 | ).toBe(false); 64 | 65 | // Invalid keyCode (above range) 66 | expect( 67 | schema.safeParse({ 68 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 69 | keyCode: 256, 70 | }).success, 71 | ).toBe(false); 72 | 73 | // Valid with duration 74 | expect( 75 | schema.safeParse({ 76 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 77 | keyCode: 40, 78 | duration: 1.5, 79 | }).success, 80 | ).toBe(true); 81 | 82 | // Invalid duration (negative) 83 | expect( 84 | schema.safeParse({ 85 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 86 | keyCode: 40, 87 | duration: -1, 88 | }).success, 89 | ).toBe(false); 90 | }); 91 | }); 92 | 93 | describe('Command Generation', () => { 94 | it('should generate correct axe command for basic key press', async () => { 95 | let capturedCommand: string[] = []; 96 | const trackingExecutor = async (command: string[]) => { 97 | capturedCommand = command; 98 | return { 99 | success: true, 100 | output: 'key press completed', 101 | error: undefined, 102 | process: { pid: 12345 }, 103 | }; 104 | }; 105 | 106 | const mockAxeHelpers = { 107 | getAxePath: () => '/usr/local/bin/axe', 108 | getBundledAxeEnvironment: () => ({}), 109 | createAxeNotAvailableResponse: () => ({ 110 | content: [ 111 | { 112 | type: 'text', 113 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 114 | }, 115 | ], 116 | isError: true, 117 | }), 118 | }; 119 | 120 | await key_pressLogic( 121 | { 122 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 123 | keyCode: 40, 124 | }, 125 | trackingExecutor, 126 | mockAxeHelpers, 127 | ); 128 | 129 | expect(capturedCommand).toEqual([ 130 | '/usr/local/bin/axe', 131 | 'key', 132 | '40', 133 | '--udid', 134 | '12345678-1234-1234-1234-123456789012', 135 | ]); 136 | }); 137 | 138 | it('should generate correct axe command for key press with duration', async () => { 139 | let capturedCommand: string[] = []; 140 | const trackingExecutor = async (command: string[]) => { 141 | capturedCommand = command; 142 | return { 143 | success: true, 144 | output: 'key press completed', 145 | error: undefined, 146 | process: { pid: 12345 }, 147 | }; 148 | }; 149 | 150 | const mockAxeHelpers = { 151 | getAxePath: () => '/usr/local/bin/axe', 152 | getBundledAxeEnvironment: () => ({}), 153 | createAxeNotAvailableResponse: () => ({ 154 | content: [ 155 | { 156 | type: 'text', 157 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 158 | }, 159 | ], 160 | isError: true, 161 | }), 162 | }; 163 | 164 | await key_pressLogic( 165 | { 166 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 167 | keyCode: 42, 168 | duration: 1.5, 169 | }, 170 | trackingExecutor, 171 | mockAxeHelpers, 172 | ); 173 | 174 | expect(capturedCommand).toEqual([ 175 | '/usr/local/bin/axe', 176 | 'key', 177 | '42', 178 | '--duration', 179 | '1.5', 180 | '--udid', 181 | '12345678-1234-1234-1234-123456789012', 182 | ]); 183 | }); 184 | 185 | it('should generate correct axe command for different key codes', async () => { 186 | let capturedCommand: string[] = []; 187 | const trackingExecutor = async (command: string[]) => { 188 | capturedCommand = command; 189 | return { 190 | success: true, 191 | output: 'key press completed', 192 | error: undefined, 193 | process: { pid: 12345 }, 194 | }; 195 | }; 196 | 197 | const mockAxeHelpers = { 198 | getAxePath: () => '/usr/local/bin/axe', 199 | getBundledAxeEnvironment: () => ({}), 200 | createAxeNotAvailableResponse: () => ({ 201 | content: [ 202 | { 203 | type: 'text', 204 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 205 | }, 206 | ], 207 | isError: true, 208 | }), 209 | }; 210 | 211 | await key_pressLogic( 212 | { 213 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 214 | keyCode: 255, 215 | }, 216 | trackingExecutor, 217 | mockAxeHelpers, 218 | ); 219 | 220 | expect(capturedCommand).toEqual([ 221 | '/usr/local/bin/axe', 222 | 'key', 223 | '255', 224 | '--udid', 225 | '12345678-1234-1234-1234-123456789012', 226 | ]); 227 | }); 228 | 229 | it('should generate correct axe command with bundled axe path', async () => { 230 | let capturedCommand: string[] = []; 231 | const trackingExecutor = async (command: string[]) => { 232 | capturedCommand = command; 233 | return { 234 | success: true, 235 | output: 'key press completed', 236 | error: undefined, 237 | process: { pid: 12345 }, 238 | }; 239 | }; 240 | 241 | const mockAxeHelpers = { 242 | getAxePath: () => '/path/to/bundled/axe', 243 | getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), 244 | createAxeNotAvailableResponse: () => ({ 245 | content: [ 246 | { 247 | type: 'text', 248 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 249 | }, 250 | ], 251 | isError: true, 252 | }), 253 | }; 254 | 255 | await key_pressLogic( 256 | { 257 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 258 | keyCode: 44, 259 | }, 260 | trackingExecutor, 261 | mockAxeHelpers, 262 | ); 263 | 264 | expect(capturedCommand).toEqual([ 265 | '/path/to/bundled/axe', 266 | 'key', 267 | '44', 268 | '--udid', 269 | '12345678-1234-1234-1234-123456789012', 270 | ]); 271 | }); 272 | }); 273 | 274 | describe('Handler Behavior (Complete Literal Returns)', () => { 275 | // Note: Parameter validation is now handled by Zod schema validation in createTypedTool wrapper. 276 | // The key_pressLogic function expects valid parameters and focuses on business logic testing. 277 | 278 | it('should return success for valid key press execution', async () => { 279 | const mockExecutor = createMockExecutor({ 280 | success: true, 281 | output: 'key press completed', 282 | error: '', 283 | }); 284 | 285 | const mockAxeHelpers = { 286 | getAxePath: () => '/usr/local/bin/axe', 287 | getBundledAxeEnvironment: () => ({}), 288 | createAxeNotAvailableResponse: () => ({ 289 | content: [ 290 | { 291 | type: 'text', 292 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 293 | }, 294 | ], 295 | isError: true, 296 | }), 297 | }; 298 | 299 | const result = await key_pressLogic( 300 | { 301 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 302 | keyCode: 40, 303 | }, 304 | mockExecutor, 305 | mockAxeHelpers, 306 | ); 307 | 308 | expect(result).toEqual({ 309 | content: [{ type: 'text', text: 'Key press (code: 40) simulated successfully.' }], 310 | isError: false, 311 | }); 312 | }); 313 | 314 | it('should return success for key press with duration', async () => { 315 | const mockExecutor = createMockExecutor({ 316 | success: true, 317 | output: 'key press completed', 318 | error: '', 319 | }); 320 | 321 | const mockAxeHelpers = { 322 | getAxePath: () => '/usr/local/bin/axe', 323 | getBundledAxeEnvironment: () => ({}), 324 | createAxeNotAvailableResponse: () => ({ 325 | content: [ 326 | { 327 | type: 'text', 328 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 329 | }, 330 | ], 331 | isError: true, 332 | }), 333 | }; 334 | 335 | const result = await key_pressLogic( 336 | { 337 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 338 | keyCode: 42, 339 | duration: 1.5, 340 | }, 341 | mockExecutor, 342 | mockAxeHelpers, 343 | ); 344 | 345 | expect(result).toEqual({ 346 | content: [{ type: 'text', text: 'Key press (code: 42) simulated successfully.' }], 347 | isError: false, 348 | }); 349 | }); 350 | 351 | it('should handle DependencyError when axe is not available', async () => { 352 | const mockAxeHelpers = { 353 | getAxePath: () => null, 354 | getBundledAxeEnvironment: () => ({}), 355 | createAxeNotAvailableResponse: () => ({ 356 | content: [ 357 | { 358 | type: 'text', 359 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 360 | }, 361 | ], 362 | isError: true, 363 | }), 364 | }; 365 | 366 | const result = await key_pressLogic( 367 | { 368 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 369 | keyCode: 40, 370 | }, 371 | createNoopExecutor(), 372 | mockAxeHelpers, 373 | ); 374 | 375 | expect(result).toEqual({ 376 | content: [ 377 | { 378 | type: 'text', 379 | text: 380 | 'Bundled axe tool not found. UI automation features are not available.\n\n' + 381 | 'This is likely an installation issue with the npm package.\n' + 382 | 'Please reinstall xcodebuildmcp or report this issue.', 383 | }, 384 | ], 385 | isError: true, 386 | }); 387 | }); 388 | 389 | it('should handle AxeError from failed command execution', async () => { 390 | const mockExecutor = createMockExecutor({ 391 | success: false, 392 | output: '', 393 | error: 'axe command failed', 394 | }); 395 | 396 | const mockAxeHelpers = { 397 | getAxePath: () => '/usr/local/bin/axe', 398 | getBundledAxeEnvironment: () => ({}), 399 | createAxeNotAvailableResponse: () => ({ 400 | content: [ 401 | { 402 | type: 'text', 403 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 404 | }, 405 | ], 406 | isError: true, 407 | }), 408 | }; 409 | 410 | const result = await key_pressLogic( 411 | { 412 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 413 | keyCode: 40, 414 | }, 415 | mockExecutor, 416 | mockAxeHelpers, 417 | ); 418 | 419 | expect(result).toEqual({ 420 | content: [ 421 | { 422 | type: 'text', 423 | text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed", 424 | }, 425 | ], 426 | isError: true, 427 | }); 428 | }); 429 | 430 | it('should handle SystemError from command execution', async () => { 431 | const mockExecutor = () => { 432 | throw new Error('System error occurred'); 433 | }; 434 | 435 | const mockAxeHelpers = { 436 | getAxePath: () => '/usr/local/bin/axe', 437 | getBundledAxeEnvironment: () => ({}), 438 | createAxeNotAvailableResponse: () => ({ 439 | content: [ 440 | { 441 | type: 'text', 442 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 443 | }, 444 | ], 445 | isError: true, 446 | }), 447 | }; 448 | 449 | const result = await key_pressLogic( 450 | { 451 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 452 | keyCode: 40, 453 | }, 454 | mockExecutor, 455 | mockAxeHelpers, 456 | ); 457 | 458 | expect(result.isError).toBe(true); 459 | expect(result.content[0].text).toContain( 460 | 'Error: System error executing axe: Failed to execute axe command: System error occurred', 461 | ); 462 | }); 463 | 464 | it('should handle unexpected Error objects', async () => { 465 | const mockExecutor = () => { 466 | throw new Error('Unexpected error'); 467 | }; 468 | 469 | const mockAxeHelpers = { 470 | getAxePath: () => '/usr/local/bin/axe', 471 | getBundledAxeEnvironment: () => ({}), 472 | createAxeNotAvailableResponse: () => ({ 473 | content: [ 474 | { 475 | type: 'text', 476 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 477 | }, 478 | ], 479 | isError: true, 480 | }), 481 | }; 482 | 483 | const result = await key_pressLogic( 484 | { 485 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 486 | keyCode: 40, 487 | }, 488 | mockExecutor, 489 | mockAxeHelpers, 490 | ); 491 | 492 | expect(result.isError).toBe(true); 493 | expect(result.content[0].text).toContain( 494 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error', 495 | ); 496 | }); 497 | 498 | it('should handle unexpected string errors', async () => { 499 | const mockExecutor = () => { 500 | throw 'String error'; 501 | }; 502 | 503 | const mockAxeHelpers = { 504 | getAxePath: () => '/usr/local/bin/axe', 505 | getBundledAxeEnvironment: () => ({}), 506 | createAxeNotAvailableResponse: () => ({ 507 | content: [ 508 | { 509 | type: 'text', 510 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 511 | }, 512 | ], 513 | isError: true, 514 | }), 515 | }; 516 | 517 | const result = await key_pressLogic( 518 | { 519 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 520 | keyCode: 40, 521 | }, 522 | mockExecutor, 523 | mockAxeHelpers, 524 | ); 525 | 526 | expect(result).toEqual({ 527 | content: [ 528 | { 529 | type: 'text', 530 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 531 | }, 532 | ], 533 | isError: true, 534 | }); 535 | }); 536 | }); 537 | }); 538 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/screenshot.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for screenshot plugin 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using pure dependency injection for deterministic testing 5 | */ 6 | 7 | import { describe, it, expect, beforeEach } from 'vitest'; 8 | import { z } from 'zod'; 9 | import { 10 | createMockExecutor, 11 | createMockFileSystemExecutor, 12 | createCommandMatchingMockExecutor, 13 | } from '../../../../test-utils/mock-executors.ts'; 14 | import { SystemError } from '../../../../utils/responses/index.ts'; 15 | import screenshotPlugin, { screenshotLogic } from '../../ui-testing/screenshot.ts'; 16 | 17 | describe('screenshot plugin', () => { 18 | // No mocks to clear since we use pure dependency injection 19 | 20 | describe('Export Field Validation (Literal)', () => { 21 | it('should have correct name field', () => { 22 | expect(screenshotPlugin.name).toBe('screenshot'); 23 | }); 24 | 25 | it('should have correct description field', () => { 26 | expect(screenshotPlugin.description).toBe( 27 | "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", 28 | ); 29 | }); 30 | 31 | it('should have handler function', () => { 32 | expect(typeof screenshotPlugin.handler).toBe('function'); 33 | }); 34 | 35 | it('should have correct schema validation', () => { 36 | const schema = z.object(screenshotPlugin.schema); 37 | 38 | expect( 39 | schema.safeParse({ 40 | simulatorUuid: '550e8400-e29b-41d4-a716-446655440000', 41 | }).success, 42 | ).toBe(true); 43 | 44 | expect( 45 | schema.safeParse({ 46 | simulatorUuid: 123, 47 | }).success, 48 | ).toBe(false); 49 | 50 | expect(schema.safeParse({}).success).toBe(false); 51 | }); 52 | }); 53 | 54 | describe('Command Generation', () => { 55 | it('should generate correct simctl and sips commands', async () => { 56 | const capturedCommands: string[][] = []; 57 | 58 | const mockExecutor = createCommandMatchingMockExecutor({ 59 | 'xcrun simctl': { success: true, output: 'Screenshot saved' }, 60 | sips: { success: true, output: 'Image optimized' }, 61 | }); 62 | 63 | // Wrap to capture both commands 64 | const capturingExecutor = async (command: string[], ...args: any[]) => { 65 | capturedCommands.push(command); 66 | return mockExecutor(command, ...args); 67 | }; 68 | 69 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 70 | readFile: async () => 'fake-image-data', 71 | }); 72 | 73 | const mockPathDeps = { 74 | tmpdir: () => '/tmp', 75 | join: (...paths: string[]) => paths.join('/'), 76 | }; 77 | 78 | const mockUuidDeps = { 79 | v4: () => 'mock-uuid-123', 80 | }; 81 | 82 | await screenshotLogic( 83 | { 84 | simulatorUuid: 'test-uuid', 85 | }, 86 | capturingExecutor, 87 | mockFileSystemExecutor, 88 | mockPathDeps, 89 | mockUuidDeps, 90 | ); 91 | 92 | // Should execute both commands in sequence 93 | expect(capturedCommands).toHaveLength(2); 94 | 95 | // First command: xcrun simctl screenshot 96 | expect(capturedCommands[0]).toEqual([ 97 | 'xcrun', 98 | 'simctl', 99 | 'io', 100 | 'test-uuid', 101 | 'screenshot', 102 | '/tmp/screenshot_mock-uuid-123.png', 103 | ]); 104 | 105 | // Second command: sips optimization 106 | expect(capturedCommands[1]).toEqual([ 107 | 'sips', 108 | '-Z', 109 | '800', 110 | '-s', 111 | 'format', 112 | 'jpeg', 113 | '-s', 114 | 'formatOptions', 115 | '75', 116 | '/tmp/screenshot_mock-uuid-123.png', 117 | '--out', 118 | '/tmp/screenshot_optimized_mock-uuid-123.jpg', 119 | ]); 120 | }); 121 | 122 | it('should generate correct path with different uuid', async () => { 123 | const capturedCommands: string[][] = []; 124 | 125 | const mockExecutor = createCommandMatchingMockExecutor({ 126 | 'xcrun simctl': { success: true, output: 'Screenshot saved' }, 127 | sips: { success: true, output: 'Image optimized' }, 128 | }); 129 | 130 | // Wrap to capture both commands 131 | const capturingExecutor = async (command: string[], ...args: any[]) => { 132 | capturedCommands.push(command); 133 | return mockExecutor(command, ...args); 134 | }; 135 | 136 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 137 | readFile: async () => 'fake-image-data', 138 | }); 139 | 140 | const mockPathDeps = { 141 | tmpdir: () => '/tmp', 142 | join: (...paths: string[]) => paths.join('/'), 143 | }; 144 | 145 | const mockUuidDeps = { 146 | v4: () => 'different-uuid-456', 147 | }; 148 | 149 | await screenshotLogic( 150 | { 151 | simulatorUuid: 'another-uuid', 152 | }, 153 | capturingExecutor, 154 | mockFileSystemExecutor, 155 | mockPathDeps, 156 | mockUuidDeps, 157 | ); 158 | 159 | // Should execute both commands in sequence 160 | expect(capturedCommands).toHaveLength(2); 161 | 162 | // First command: xcrun simctl screenshot 163 | expect(capturedCommands[0]).toEqual([ 164 | 'xcrun', 165 | 'simctl', 166 | 'io', 167 | 'another-uuid', 168 | 'screenshot', 169 | '/tmp/screenshot_different-uuid-456.png', 170 | ]); 171 | 172 | // Second command: sips optimization 173 | expect(capturedCommands[1]).toEqual([ 174 | 'sips', 175 | '-Z', 176 | '800', 177 | '-s', 178 | 'format', 179 | 'jpeg', 180 | '-s', 181 | 'formatOptions', 182 | '75', 183 | '/tmp/screenshot_different-uuid-456.png', 184 | '--out', 185 | '/tmp/screenshot_optimized_different-uuid-456.jpg', 186 | ]); 187 | }); 188 | 189 | it('should use default dependencies when not provided', async () => { 190 | const capturedCommands: string[][] = []; 191 | 192 | const mockExecutor = createCommandMatchingMockExecutor({ 193 | 'xcrun simctl': { success: true, output: 'Screenshot saved' }, 194 | sips: { success: true, output: 'Image optimized' }, 195 | }); 196 | 197 | // Wrap to capture both commands 198 | const capturingExecutor = async (command: string[], ...args: any[]) => { 199 | capturedCommands.push(command); 200 | return mockExecutor(command, ...args); 201 | }; 202 | 203 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 204 | readFile: async () => 'fake-image-data', 205 | }); 206 | 207 | await screenshotLogic( 208 | { 209 | simulatorUuid: 'test-uuid', 210 | }, 211 | capturingExecutor, 212 | mockFileSystemExecutor, 213 | ); 214 | 215 | // Should execute both commands in sequence 216 | expect(capturedCommands).toHaveLength(2); 217 | 218 | // First command should be generated with real os.tmpdir, path.join, and uuidv4 219 | const firstCommand = capturedCommands[0]; 220 | expect(firstCommand).toHaveLength(6); 221 | expect(firstCommand[0]).toBe('xcrun'); 222 | expect(firstCommand[1]).toBe('simctl'); 223 | expect(firstCommand[2]).toBe('io'); 224 | expect(firstCommand[3]).toBe('test-uuid'); 225 | expect(firstCommand[4]).toBe('screenshot'); 226 | expect(firstCommand[5]).toMatch(/\/.*\/screenshot_.*\.png/); 227 | 228 | // Second command should be sips optimization 229 | const secondCommand = capturedCommands[1]; 230 | expect(secondCommand[0]).toBe('sips'); 231 | expect(secondCommand[1]).toBe('-Z'); 232 | expect(secondCommand[2]).toBe('800'); 233 | // Should have proper PNG input and JPG output paths 234 | expect(secondCommand[secondCommand.length - 3]).toMatch(/\/.*\/screenshot_.*\.png/); 235 | expect(secondCommand[secondCommand.length - 1]).toMatch(/\/.*\/screenshot_optimized_.*\.jpg/); 236 | }); 237 | }); 238 | 239 | describe('Response Processing', () => { 240 | it('should capture screenshot successfully', async () => { 241 | const mockImageBuffer = Buffer.from('fake-image-data'); 242 | 243 | // Mock both commands: screenshot + optimization 244 | const mockExecutor = createCommandMatchingMockExecutor({ 245 | 'xcrun simctl': { success: true, output: 'Screenshot saved' }, 246 | sips: { success: true, output: 'Image optimized' }, 247 | }); 248 | 249 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 250 | readFile: async () => mockImageBuffer.toString('base64'), // Return base64 directly 251 | }); 252 | 253 | const mockPathDeps = { 254 | tmpdir: () => '/tmp', 255 | join: (...paths: string[]) => paths.join('/'), 256 | }; 257 | 258 | const mockUuidDeps = { 259 | v4: () => 'mock-uuid-123', 260 | }; 261 | 262 | const result = await screenshotLogic( 263 | { 264 | simulatorUuid: 'test-uuid', 265 | }, 266 | mockExecutor, 267 | mockFileSystemExecutor, 268 | mockPathDeps, 269 | mockUuidDeps, 270 | ); 271 | 272 | expect(result).toEqual({ 273 | content: [ 274 | { 275 | type: 'image', 276 | data: mockImageBuffer.toString('base64'), 277 | mimeType: 'image/jpeg', // Now JPEG after optimization 278 | }, 279 | ], 280 | isError: false, 281 | }); 282 | }); 283 | 284 | it('should handle missing simulatorUuid via handler', async () => { 285 | // Test Zod validation by calling the handler with invalid params 286 | const result = await screenshotPlugin.handler({}); 287 | 288 | expect(result).toEqual({ 289 | content: [ 290 | { 291 | type: 'text', 292 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', 293 | }, 294 | ], 295 | isError: true, 296 | }); 297 | }); 298 | 299 | it('should handle command failure', async () => { 300 | const mockExecutor = createMockExecutor({ 301 | success: false, 302 | output: '', 303 | error: 'Command failed', 304 | }); 305 | 306 | const mockPathDeps = { 307 | tmpdir: () => '/tmp', 308 | join: (...paths: string[]) => paths.join('/'), 309 | }; 310 | 311 | const mockUuidDeps = { 312 | v4: () => 'mock-uuid-123', 313 | }; 314 | 315 | const result = await screenshotLogic( 316 | { 317 | simulatorUuid: 'test-uuid', 318 | }, 319 | mockExecutor, 320 | createMockFileSystemExecutor(), 321 | mockPathDeps, 322 | mockUuidDeps, 323 | ); 324 | 325 | expect(result).toEqual({ 326 | content: [ 327 | { 328 | type: 'text', 329 | text: 'Error: System error executing screenshot: Failed to capture screenshot: Command failed', 330 | }, 331 | ], 332 | isError: true, 333 | }); 334 | }); 335 | 336 | it('should handle file read failure', async () => { 337 | const mockExecutor = createMockExecutor({ 338 | success: true, 339 | output: '', 340 | error: undefined, 341 | }); 342 | 343 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 344 | readFile: async () => { 345 | throw new Error('File not found'); 346 | }, 347 | }); 348 | 349 | const mockPathDeps = { 350 | tmpdir: () => '/tmp', 351 | join: (...paths: string[]) => paths.join('/'), 352 | }; 353 | 354 | const mockUuidDeps = { 355 | v4: () => 'mock-uuid-123', 356 | }; 357 | 358 | const result = await screenshotLogic( 359 | { 360 | simulatorUuid: 'test-uuid', 361 | }, 362 | mockExecutor, 363 | mockFileSystemExecutor, 364 | mockPathDeps, 365 | mockUuidDeps, 366 | ); 367 | 368 | expect(result).toEqual({ 369 | content: [ 370 | { 371 | type: 'text', 372 | text: 'Error: Screenshot captured but failed to process image file: File not found', 373 | }, 374 | ], 375 | isError: true, 376 | }); 377 | }); 378 | 379 | it('should call correct command with direct execution', async () => { 380 | const capturedArgs: any[][] = []; 381 | 382 | const mockExecutor = createCommandMatchingMockExecutor({ 383 | 'xcrun simctl': { success: true, output: 'Screenshot saved' }, 384 | sips: { success: true, output: 'Image optimized' }, 385 | }); 386 | 387 | // Wrap to capture both command executions 388 | const capturingExecutor = async (...args: any[]) => { 389 | capturedArgs.push(args); 390 | return mockExecutor(...args); 391 | }; 392 | 393 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 394 | readFile: async () => 'fake-image-data', 395 | }); 396 | 397 | const mockPathDeps = { 398 | tmpdir: () => '/tmp', 399 | join: (...paths: string[]) => paths.join('/'), 400 | }; 401 | 402 | const mockUuidDeps = { 403 | v4: () => 'mock-uuid-123', 404 | }; 405 | 406 | await screenshotLogic( 407 | { 408 | simulatorUuid: 'test-uuid', 409 | }, 410 | capturingExecutor, 411 | mockFileSystemExecutor, 412 | mockPathDeps, 413 | mockUuidDeps, 414 | ); 415 | 416 | // Should capture both command executions 417 | expect(capturedArgs).toHaveLength(2); 418 | 419 | // First call: xcrun simctl screenshot (3 args: command, logPrefix, useShell) 420 | expect(capturedArgs[0]).toEqual([ 421 | ['xcrun', 'simctl', 'io', 'test-uuid', 'screenshot', '/tmp/screenshot_mock-uuid-123.png'], 422 | '[Screenshot]: screenshot', 423 | false, 424 | ]); 425 | 426 | // Second call: sips optimization (3 args: command, logPrefix, useShell) 427 | expect(capturedArgs[1]).toEqual([ 428 | [ 429 | 'sips', 430 | '-Z', 431 | '800', 432 | '-s', 433 | 'format', 434 | 'jpeg', 435 | '-s', 436 | 'formatOptions', 437 | '75', 438 | '/tmp/screenshot_mock-uuid-123.png', 439 | '--out', 440 | '/tmp/screenshot_optimized_mock-uuid-123.jpg', 441 | ], 442 | '[Screenshot]: optimize image', 443 | false, 444 | ]); 445 | }); 446 | 447 | it('should handle SystemError exceptions', async () => { 448 | const mockExecutor = createMockExecutor(new SystemError('System error occurred')); 449 | 450 | const mockPathDeps = { 451 | tmpdir: () => '/tmp', 452 | join: (...paths: string[]) => paths.join('/'), 453 | }; 454 | 455 | const mockUuidDeps = { 456 | v4: () => 'mock-uuid-123', 457 | }; 458 | 459 | const result = await screenshotLogic( 460 | { 461 | simulatorUuid: 'test-uuid', 462 | }, 463 | mockExecutor, 464 | createMockFileSystemExecutor(), 465 | mockPathDeps, 466 | mockUuidDeps, 467 | ); 468 | 469 | expect(result).toEqual({ 470 | content: [ 471 | { 472 | type: 'text', 473 | text: 'Error: System error executing screenshot: System error occurred', 474 | }, 475 | ], 476 | isError: true, 477 | }); 478 | }); 479 | 480 | it('should handle unexpected Error objects', async () => { 481 | const mockExecutor = createMockExecutor(new Error('Unexpected error')); 482 | 483 | const mockPathDeps = { 484 | tmpdir: () => '/tmp', 485 | join: (...paths: string[]) => paths.join('/'), 486 | }; 487 | 488 | const mockUuidDeps = { 489 | v4: () => 'mock-uuid-123', 490 | }; 491 | 492 | const result = await screenshotLogic( 493 | { 494 | simulatorUuid: 'test-uuid', 495 | }, 496 | mockExecutor, 497 | createMockFileSystemExecutor(), 498 | mockPathDeps, 499 | mockUuidDeps, 500 | ); 501 | 502 | expect(result).toEqual({ 503 | content: [ 504 | { 505 | type: 'text', 506 | text: 'Error: An unexpected error occurred: Unexpected error', 507 | }, 508 | ], 509 | isError: true, 510 | }); 511 | }); 512 | 513 | it('should handle unexpected string errors', async () => { 514 | const mockExecutor = createMockExecutor('String error'); 515 | 516 | const mockPathDeps = { 517 | tmpdir: () => '/tmp', 518 | join: (...paths: string[]) => paths.join('/'), 519 | }; 520 | 521 | const mockUuidDeps = { 522 | v4: () => 'mock-uuid-123', 523 | }; 524 | 525 | const result = await screenshotLogic( 526 | { 527 | simulatorUuid: 'test-uuid', 528 | }, 529 | mockExecutor, 530 | createMockFileSystemExecutor(), 531 | mockPathDeps, 532 | mockUuidDeps, 533 | ); 534 | 535 | expect(result).toEqual({ 536 | content: [ 537 | { 538 | type: 'text', 539 | text: 'Error: An unexpected error occurred: String error', 540 | }, 541 | ], 542 | isError: true, 543 | }); 544 | }); 545 | 546 | it('should handle file read error with fileSystemExecutor', async () => { 547 | const mockExecutor = createMockExecutor({ 548 | success: true, 549 | output: '', 550 | error: undefined, 551 | }); 552 | 553 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 554 | readFile: async () => { 555 | throw 'File system error'; 556 | }, 557 | }); 558 | 559 | const mockPathDeps = { 560 | tmpdir: () => '/tmp', 561 | join: (...paths: string[]) => paths.join('/'), 562 | }; 563 | 564 | const mockUuidDeps = { 565 | v4: () => 'mock-uuid-123', 566 | }; 567 | 568 | const result = await screenshotLogic( 569 | { 570 | simulatorUuid: 'test-uuid', 571 | }, 572 | mockExecutor, 573 | mockFileSystemExecutor, 574 | mockPathDeps, 575 | mockUuidDeps, 576 | ); 577 | 578 | expect(result).toEqual({ 579 | content: [ 580 | { 581 | type: 'text', 582 | text: 'Error: Screenshot captured but failed to process image file: File system error', 583 | }, 584 | ], 585 | isError: true, 586 | }); 587 | }); 588 | }); 589 | }); 590 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/key_sequence.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for key_sequence plugin 3 | */ 4 | 5 | import { describe, it, expect } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; 8 | import keySequencePlugin, { key_sequenceLogic } from '../key_sequence.ts'; 9 | 10 | describe('Key Sequence Plugin', () => { 11 | describe('Export Field Validation (Literal)', () => { 12 | it('should have correct name', () => { 13 | expect(keySequencePlugin.name).toBe('key_sequence'); 14 | }); 15 | 16 | it('should have correct description', () => { 17 | expect(keySequencePlugin.description).toBe( 18 | 'Press key sequence using HID keycodes on iOS simulator with configurable delay', 19 | ); 20 | }); 21 | 22 | it('should have handler function', () => { 23 | expect(typeof keySequencePlugin.handler).toBe('function'); 24 | }); 25 | 26 | it('should validate schema fields with safeParse', () => { 27 | const schema = z.object(keySequencePlugin.schema); 28 | 29 | // Valid case 30 | expect( 31 | schema.safeParse({ 32 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 33 | keyCodes: [40, 42, 44], 34 | }).success, 35 | ).toBe(true); 36 | 37 | // Invalid simulatorUuid 38 | expect( 39 | schema.safeParse({ 40 | simulatorUuid: 'invalid-uuid', 41 | keyCodes: [40], 42 | }).success, 43 | ).toBe(false); 44 | 45 | // Invalid keyCodes - empty array 46 | expect( 47 | schema.safeParse({ 48 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 49 | keyCodes: [], 50 | }).success, 51 | ).toBe(false); 52 | 53 | // Invalid keyCodes - out of range 54 | expect( 55 | schema.safeParse({ 56 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 57 | keyCodes: [-1], 58 | }).success, 59 | ).toBe(false); 60 | 61 | expect( 62 | schema.safeParse({ 63 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 64 | keyCodes: [256], 65 | }).success, 66 | ).toBe(false); 67 | 68 | // Invalid delay - negative 69 | expect( 70 | schema.safeParse({ 71 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 72 | keyCodes: [40], 73 | delay: -0.1, 74 | }).success, 75 | ).toBe(false); 76 | 77 | // Valid with optional delay 78 | expect( 79 | schema.safeParse({ 80 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 81 | keyCodes: [40], 82 | delay: 0.1, 83 | }).success, 84 | ).toBe(true); 85 | 86 | // Missing required fields 87 | expect(schema.safeParse({}).success).toBe(false); 88 | }); 89 | }); 90 | 91 | describe('Command Generation', () => { 92 | it('should generate correct axe command for basic key sequence', async () => { 93 | let capturedCommand: string[] = []; 94 | const trackingExecutor = async (command: string[]) => { 95 | capturedCommand = command; 96 | return { 97 | success: true, 98 | output: 'key sequence completed', 99 | error: undefined, 100 | process: { pid: 12345 }, 101 | }; 102 | }; 103 | 104 | const mockAxeHelpers = { 105 | getAxePath: () => '/usr/local/bin/axe', 106 | getBundledAxeEnvironment: () => ({}), 107 | createAxeNotAvailableResponse: () => ({ 108 | content: [ 109 | { 110 | type: 'text', 111 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 112 | }, 113 | ], 114 | isError: true, 115 | }), 116 | }; 117 | 118 | await key_sequenceLogic( 119 | { 120 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 121 | keyCodes: [40, 42, 44], 122 | }, 123 | trackingExecutor, 124 | mockAxeHelpers, 125 | ); 126 | 127 | expect(capturedCommand).toEqual([ 128 | '/usr/local/bin/axe', 129 | 'key-sequence', 130 | '--keycodes', 131 | '40,42,44', 132 | '--udid', 133 | '12345678-1234-1234-1234-123456789012', 134 | ]); 135 | }); 136 | 137 | it('should generate correct axe command for key sequence with delay', async () => { 138 | let capturedCommand: string[] = []; 139 | const trackingExecutor = async (command: string[]) => { 140 | capturedCommand = command; 141 | return { 142 | success: true, 143 | output: 'key sequence completed', 144 | error: undefined, 145 | process: { pid: 12345 }, 146 | }; 147 | }; 148 | 149 | const mockAxeHelpers = { 150 | getAxePath: () => '/usr/local/bin/axe', 151 | getBundledAxeEnvironment: () => ({}), 152 | createAxeNotAvailableResponse: () => ({ 153 | content: [ 154 | { 155 | type: 'text', 156 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 157 | }, 158 | ], 159 | isError: true, 160 | }), 161 | }; 162 | 163 | await key_sequenceLogic( 164 | { 165 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 166 | keyCodes: [58, 59, 60], 167 | delay: 0.5, 168 | }, 169 | trackingExecutor, 170 | mockAxeHelpers, 171 | ); 172 | 173 | expect(capturedCommand).toEqual([ 174 | '/usr/local/bin/axe', 175 | 'key-sequence', 176 | '--keycodes', 177 | '58,59,60', 178 | '--delay', 179 | '0.5', 180 | '--udid', 181 | '12345678-1234-1234-1234-123456789012', 182 | ]); 183 | }); 184 | 185 | it('should generate correct axe command for single key in sequence', async () => { 186 | let capturedCommand: string[] = []; 187 | const trackingExecutor = async (command: string[]) => { 188 | capturedCommand = command; 189 | return { 190 | success: true, 191 | output: 'key sequence completed', 192 | error: undefined, 193 | process: { pid: 12345 }, 194 | }; 195 | }; 196 | 197 | const mockAxeHelpers = { 198 | getAxePath: () => '/usr/local/bin/axe', 199 | getBundledAxeEnvironment: () => ({}), 200 | createAxeNotAvailableResponse: () => ({ 201 | content: [ 202 | { 203 | type: 'text', 204 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 205 | }, 206 | ], 207 | isError: true, 208 | }), 209 | }; 210 | 211 | await key_sequenceLogic( 212 | { 213 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 214 | keyCodes: [255], 215 | }, 216 | trackingExecutor, 217 | mockAxeHelpers, 218 | ); 219 | 220 | expect(capturedCommand).toEqual([ 221 | '/usr/local/bin/axe', 222 | 'key-sequence', 223 | '--keycodes', 224 | '255', 225 | '--udid', 226 | '12345678-1234-1234-1234-123456789012', 227 | ]); 228 | }); 229 | 230 | it('should generate correct axe command with bundled axe path', async () => { 231 | let capturedCommand: string[] = []; 232 | const trackingExecutor = async (command: string[]) => { 233 | capturedCommand = command; 234 | return { 235 | success: true, 236 | output: 'key sequence completed', 237 | error: undefined, 238 | process: { pid: 12345 }, 239 | }; 240 | }; 241 | 242 | const mockAxeHelpers = { 243 | getAxePath: () => '/path/to/bundled/axe', 244 | getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), 245 | createAxeNotAvailableResponse: () => ({ 246 | content: [ 247 | { 248 | type: 'text', 249 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 250 | }, 251 | ], 252 | isError: true, 253 | }), 254 | }; 255 | 256 | await key_sequenceLogic( 257 | { 258 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 259 | keyCodes: [0, 1, 2, 3, 4], 260 | delay: 1.0, 261 | }, 262 | trackingExecutor, 263 | mockAxeHelpers, 264 | ); 265 | 266 | expect(capturedCommand).toEqual([ 267 | '/path/to/bundled/axe', 268 | 'key-sequence', 269 | '--keycodes', 270 | '0,1,2,3,4', 271 | '--delay', 272 | '1', 273 | '--udid', 274 | '12345678-1234-1234-1234-123456789012', 275 | ]); 276 | }); 277 | }); 278 | 279 | describe('Handler Behavior (Complete Literal Returns)', () => { 280 | it('should return success for valid key sequence execution', async () => { 281 | const mockExecutor = createMockExecutor({ 282 | success: true, 283 | output: 'Key sequence executed', 284 | error: undefined, 285 | }); 286 | 287 | const mockAxeHelpers = { 288 | getAxePath: () => '/usr/local/bin/axe', 289 | getBundledAxeEnvironment: () => ({}), 290 | createAxeNotAvailableResponse: () => ({ 291 | content: [ 292 | { 293 | type: 'text', 294 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 295 | }, 296 | ], 297 | isError: true, 298 | }), 299 | }; 300 | 301 | const result = await key_sequenceLogic( 302 | { 303 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 304 | keyCodes: [40, 42, 44], 305 | delay: 0.1, 306 | }, 307 | mockExecutor, 308 | mockAxeHelpers, 309 | ); 310 | 311 | expect(result).toEqual({ 312 | content: [{ type: 'text', text: 'Key sequence [40,42,44] executed successfully.' }], 313 | isError: false, 314 | }); 315 | }); 316 | 317 | it('should return success for key sequence without delay', async () => { 318 | const mockExecutor = createMockExecutor({ 319 | success: true, 320 | output: 'Key sequence executed', 321 | error: undefined, 322 | }); 323 | 324 | const mockAxeHelpers = { 325 | getAxePath: () => '/usr/local/bin/axe', 326 | getBundledAxeEnvironment: () => ({}), 327 | createAxeNotAvailableResponse: () => ({ 328 | content: [ 329 | { 330 | type: 'text', 331 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 332 | }, 333 | ], 334 | isError: true, 335 | }), 336 | }; 337 | 338 | const result = await key_sequenceLogic( 339 | { 340 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 341 | keyCodes: [40], 342 | }, 343 | mockExecutor, 344 | mockAxeHelpers, 345 | ); 346 | 347 | expect(result).toEqual({ 348 | content: [{ type: 'text', text: 'Key sequence [40] executed successfully.' }], 349 | isError: false, 350 | }); 351 | }); 352 | 353 | it('should handle DependencyError when axe binary not found', async () => { 354 | const mockAxeHelpers = { 355 | getAxePath: () => null, 356 | getBundledAxeEnvironment: () => ({}), 357 | createAxeNotAvailableResponse: () => ({ 358 | content: [ 359 | { 360 | type: 'text', 361 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 362 | }, 363 | ], 364 | isError: true, 365 | }), 366 | }; 367 | 368 | const result = await key_sequenceLogic( 369 | { 370 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 371 | keyCodes: [40], 372 | }, 373 | createNoopExecutor(), 374 | mockAxeHelpers, 375 | ); 376 | 377 | expect(result).toEqual({ 378 | content: [ 379 | { 380 | type: 'text', 381 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 382 | }, 383 | ], 384 | isError: true, 385 | }); 386 | }); 387 | 388 | it('should handle AxeError from command execution', async () => { 389 | const mockExecutor = createMockExecutor({ 390 | success: false, 391 | output: '', 392 | error: 'Simulator not found', 393 | }); 394 | 395 | const mockAxeHelpers = { 396 | getAxePath: () => '/usr/local/bin/axe', 397 | getBundledAxeEnvironment: () => ({}), 398 | createAxeNotAvailableResponse: () => ({ 399 | content: [ 400 | { 401 | type: 'text', 402 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 403 | }, 404 | ], 405 | isError: true, 406 | }), 407 | }; 408 | 409 | const result = await key_sequenceLogic( 410 | { 411 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 412 | keyCodes: [40], 413 | }, 414 | mockExecutor, 415 | mockAxeHelpers, 416 | ); 417 | 418 | expect(result).toEqual({ 419 | content: [ 420 | { 421 | type: 'text', 422 | text: "Error: Failed to execute key sequence: axe command 'key-sequence' failed.\nDetails: Simulator not found", 423 | }, 424 | ], 425 | isError: true, 426 | }); 427 | }); 428 | 429 | it('should handle SystemError from command execution', async () => { 430 | const mockExecutor = () => { 431 | throw new Error('ENOENT: no such file or directory'); 432 | }; 433 | 434 | const mockAxeHelpers = { 435 | getAxePath: () => '/usr/local/bin/axe', 436 | getBundledAxeEnvironment: () => ({}), 437 | createAxeNotAvailableResponse: () => ({ 438 | content: [ 439 | { 440 | type: 'text', 441 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 442 | }, 443 | ], 444 | isError: true, 445 | }), 446 | }; 447 | 448 | const result = await key_sequenceLogic( 449 | { 450 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 451 | keyCodes: [40], 452 | }, 453 | mockExecutor, 454 | mockAxeHelpers, 455 | ); 456 | 457 | expect(result.content[0].text).toMatch( 458 | /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, 459 | ); 460 | expect(result.isError).toBe(true); 461 | }); 462 | 463 | it('should handle unexpected Error objects', async () => { 464 | const mockExecutor = () => { 465 | throw new Error('Unexpected error'); 466 | }; 467 | 468 | const mockAxeHelpers = { 469 | getAxePath: () => '/usr/local/bin/axe', 470 | getBundledAxeEnvironment: () => ({}), 471 | createAxeNotAvailableResponse: () => ({ 472 | content: [ 473 | { 474 | type: 'text', 475 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 476 | }, 477 | ], 478 | isError: true, 479 | }), 480 | }; 481 | 482 | const result = await key_sequenceLogic( 483 | { 484 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 485 | keyCodes: [40], 486 | }, 487 | mockExecutor, 488 | mockAxeHelpers, 489 | ); 490 | 491 | expect(result.content[0].text).toMatch( 492 | /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, 493 | ); 494 | expect(result.isError).toBe(true); 495 | }); 496 | 497 | it('should handle unexpected string errors', async () => { 498 | const mockExecutor = () => { 499 | throw 'String error'; 500 | }; 501 | 502 | const mockAxeHelpers = { 503 | getAxePath: () => '/usr/local/bin/axe', 504 | getBundledAxeEnvironment: () => ({}), 505 | createAxeNotAvailableResponse: () => ({ 506 | content: [ 507 | { 508 | type: 'text', 509 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 510 | }, 511 | ], 512 | isError: true, 513 | }), 514 | }; 515 | 516 | const result = await key_sequenceLogic( 517 | { 518 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 519 | keyCodes: [40], 520 | }, 521 | mockExecutor, 522 | mockAxeHelpers, 523 | ); 524 | 525 | expect(result).toEqual({ 526 | content: [ 527 | { 528 | type: 'text', 529 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 530 | }, 531 | ], 532 | isError: true, 533 | }); 534 | }); 535 | }); 536 | }); 537 | ``` -------------------------------------------------------------------------------- /docs/RELOADEROO_XCODEBUILDMCP_PRIMER.md: -------------------------------------------------------------------------------- ```markdown 1 | # Reloaderoo + XcodeBuildMCP: Curated CLI Primer 2 | 3 | Use this primer to drive XcodeBuildMCP entirely through Reloaderoo—treating it like a CLI. It is designed to be included in your agent’s context to show exactly how to invoke the specific tools your project needs. 4 | 5 | Why this file: 6 | - XcodeBuildMCP exposes many tools. Dumping the full tool surface into the context wastes tokens. 7 | - Instead, copy this file into your project and delete everything you don’t need. Keep only the commands relevant to your workflow (e.g., just Simulator tools). 8 | - Your trimmed version becomes a small, project‑specific reference that tells your agent precisely which Reloaderoo tool calls to make. 9 | 10 | How to use this primer: 11 | 1. Copy this file into your repo (e.g., docs/xcodebuildmcp_primer.md or AGENTS.md). 12 | 2. Remove all sections and commands you don’t use. Keep it minimal. 13 | 3. Replace placeholders with your real values (paths, schemes, simulator UUIDs/Names, bundle IDs, etc.). 14 | 4. Use the quiet (-q) examples to reduce noise; pipe output to jq when you only need the content. 15 | 5. Include your curated file in the agent context whenever you want it to call XcodeBuildMCP via Reloaderoo. 16 | 17 | Conventions in the examples: 18 | - Calls use: npx reloaderoo@latest inspect … -q -- npx xcodebuildmcp@latest 19 | - Parameters are passed as JSON via --params. 20 | - Resources are read with read-resource (e.g., xcodebuildmcp://simulators). 21 | - Use jq -r '.contents[].text' to extract the textual results when needed. 22 | 23 | Keep it small. The smaller your curated primer, the less context your agent needs—and the cheaper, faster, and more reliable your interactions will be. 24 | 25 | ## Installation 26 | 27 | Reloaderoo is available via npm and can be used with npx for universal compatibility. 28 | 29 | ```bash 30 | # Use npx to run reloaderoo 31 | npx reloaderoo@latest --help 32 | ``` 33 | 34 | ## Hint 35 | 36 | Use jq to parse the output to get just the content response: 37 | 38 | ```bash 39 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -q -- npx xcodebuildmcp@latest | jq -r '.contents[].text' 40 | ``` 41 | 42 | **Example Tool Calls:** 43 | 44 | ## Dynamic Tool Discovery 45 | 46 | - **`discover_tools`**: Analyzes a task description to enable relevant tools. 47 | ```bash 48 | npx reloaderoo@latest inspect call-tool discover_tools --params '{"task_description": "I want to build and run my iOS app on a simulator."}' -q -- npx xcodebuildmcp@latest 49 | ``` 50 | 51 | ## iOS Device Development 52 | 53 | - **`build_device`**: Builds an app for a physical device. 54 | ```bash 55 | npx reloaderoo@latest inspect -q call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 56 | ``` 57 | - **`get_device_app_path`**: Gets the `.app` bundle path for a device build. 58 | ```bash 59 | npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 60 | ``` 61 | - **`install_app_device`**: Installs an app on a physical device. 62 | ```bash 63 | npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest 64 | ``` 65 | - **`launch_app_device`**: Launches an app on a physical device. 66 | ```bash 67 | npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest 68 | ``` 69 | - **`list_devices`**: Lists connected physical devices. 70 | ```bash 71 | npx reloaderoo@latest inspect call-tool list_devices --params '{}' -q -- npx xcodebuildmcp@latest 72 | ``` 73 | - **`stop_app_device`**: Stops an app on a physical device. 74 | ```bash 75 | npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -q -- npx xcodebuildmcp@latest 76 | ``` 77 | - **`test_device`**: Runs tests on a physical device. 78 | ```bash 79 | npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -q -- npx xcodebuildmcp@latest 80 | ``` 81 | 82 | ## iOS Simulator Development 83 | 84 | - **`boot_sim`**: Boots a simulator. 85 | ```bash 86 | npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest 87 | ``` 88 | - **`build_run_sim`**: Builds and runs an app on a simulator. 89 | ```bash 90 | npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest 91 | ``` 92 | - **`build_sim`**: Builds an app for a simulator. 93 | ```bash 94 | npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest 95 | ``` 96 | - **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build. 97 | ```bash 98 | npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest 99 | ``` 100 | - **`install_app_sim`**: Installs an app on a simulator. 101 | ```bash 102 | npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest 103 | ``` 104 | - **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. 105 | ```bash 106 | npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest 107 | ``` 108 | - **`launch_app_sim`**: Launches an app on a simulator. 109 | ```bash 110 | npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest 111 | ``` 112 | - **`list_sims`**: Lists available simulators. 113 | ```bash 114 | npx reloaderoo@latest inspect call-tool list_sims --params '{}' -q -- npx xcodebuildmcp@latest 115 | ``` 116 | - **`open_sim`**: Opens the Simulator application. 117 | ```bash 118 | npx reloaderoo@latest inspect call-tool open_sim --params '{}' -q -- npx xcodebuildmcp@latest 119 | ``` 120 | - **`stop_app_sim`**: Stops an app on a simulator. 121 | ```bash 122 | npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest 123 | ``` 124 | - **`test_sim`**: Runs tests on a simulator. 125 | ```bash 126 | npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest 127 | ``` 128 | 129 | ## Log Capture & Management 130 | 131 | - **`start_device_log_cap`**: Starts log capture for a physical device. 132 | ```bash 133 | npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest 134 | ``` 135 | - **`start_sim_log_cap`**: Starts log capture for a simulator. 136 | ```bash 137 | npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest 138 | ``` 139 | - **`stop_device_log_cap`**: Stops log capture for a physical device. 140 | ```bash 141 | npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -q -- npx xcodebuildmcp@latest 142 | ``` 143 | - **`stop_sim_log_cap`**: Stops log capture for a simulator. 144 | ```bash 145 | npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -q -- npx xcodebuildmcp@latest 146 | ``` 147 | 148 | ## macOS Development 149 | 150 | - **`build_macos`**: Builds a macOS app. 151 | ```bash 152 | npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 153 | ``` 154 | - **`build_run_macos`**: Builds and runs a macOS app. 155 | ```bash 156 | npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 157 | ``` 158 | - **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build. 159 | ```bash 160 | npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 161 | ``` 162 | - **`launch_mac_app`**: Launches a macOS app. 163 | ```bash 164 | npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -q -- npx xcodebuildmcp@latest 165 | ``` 166 | - **`stop_mac_app`**: Stops a macOS app. 167 | ```bash 168 | npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -q -- npx xcodebuildmcp@latest 169 | ``` 170 | - **`test_macos`**: Runs tests for a macOS project. 171 | ```bash 172 | npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 173 | ``` 174 | 175 | ## Project Discovery 176 | 177 | - **`discover_projs`**: Discovers Xcode projects and workspaces. 178 | ```bash 179 | npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -q -- npx xcodebuildmcp@latest 180 | ``` 181 | - **`get_app_bundle_id`**: Gets an app's bundle identifier. 182 | ```bash 183 | npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest 184 | ``` 185 | - **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier. 186 | ```bash 187 | npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -q -- npx xcodebuildmcp@latest 188 | ``` 189 | - **`list_schemes`**: Lists schemes in a project or workspace. 190 | ```bash 191 | npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -q -- npx xcodebuildmcp@latest 192 | ``` 193 | - **`show_build_settings`**: Shows build settings for a scheme. 194 | ```bash 195 | npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 196 | ``` 197 | 198 | ## Project Scaffolding 199 | 200 | - **`scaffold_ios_project`**: Scaffolds a new iOS project. 201 | ```bash 202 | npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -q -- npx xcodebuildmcp@latest 203 | ``` 204 | - **`scaffold_macos_project`**: Scaffolds a new macOS project. 205 | ```bash 206 | npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -q -- npx xcodebuildmcp@latest 207 | ``` 208 | 209 | ## Project Utilities 210 | 211 | - **`clean`**: Cleans build artifacts. 212 | ```bash 213 | # For a project 214 | npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -q -- npx xcodebuildmcp@latest 215 | # For a workspace 216 | npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest 217 | ``` 218 | 219 | ## Simulator Management 220 | 221 | - **`reset_sim_location`**: Resets a simulator's location. 222 | ```bash 223 | npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest 224 | ``` 225 | - **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode). 226 | ```bash 227 | npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -q -- npx xcodebuildmcp@latest 228 | ``` 229 | - **`set_sim_location`**: Sets a simulator's GPS location. 230 | ```bash 231 | npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -q -- npx xcodebuildmcp@latest 232 | ``` 233 | - **`sim_statusbar`**: Overrides a simulator's status bar. 234 | ```bash 235 | npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -q -- npx xcodebuildmcp@latest 236 | ``` 237 | 238 | ## Swift Package Manager 239 | 240 | - **`swift_package_build`**: Builds a Swift package. 241 | ```bash 242 | npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest 243 | ``` 244 | - **`swift_package_clean`**: Cleans a Swift package. 245 | ```bash 246 | npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest 247 | ``` 248 | - **`swift_package_list`**: Lists running Swift package processes. 249 | ```bash 250 | npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -q -- npx xcodebuildmcp@latest 251 | ``` 252 | - **`swift_package_run`**: Runs a Swift package executable. 253 | ```bash 254 | npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest 255 | ``` 256 | - **`swift_package_stop`**: Stops a running Swift package process. 257 | ```bash 258 | npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -q -- npx xcodebuildmcp@latest 259 | ``` 260 | - **`swift_package_test`**: Tests a Swift package. 261 | ```bash 262 | npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest 263 | ``` 264 | 265 | ## System Doctor 266 | 267 | - **`doctor`**: Runs system diagnostics. 268 | ```bash 269 | npx reloaderoo@latest inspect call-tool doctor --params '{}' -q -- npx xcodebuildmcp@latest 270 | ``` 271 | 272 | ## UI Testing & Automation 273 | 274 | - **`button`**: Simulates a hardware button press. 275 | ```bash 276 | npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -q -- npx xcodebuildmcp@latest 277 | ``` 278 | - **`describe_ui`**: Gets the UI hierarchy of the current screen. 279 | ```bash 280 | npx reloaderoo@latest inspect call-tool describe_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest 281 | ``` 282 | - **`gesture`**: Performs a pre-defined gesture. 283 | ```bash 284 | npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -q -- npx xcodebuildmcp@latest 285 | ``` 286 | - **`key_press`**: Simulates a key press. 287 | ```bash 288 | npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -q -- npx xcodebuildmcp@latest 289 | ``` 290 | - **`key_sequence`**: Simulates a sequence of key presses. 291 | ```bash 292 | npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -q -- npx xcodebuildmcp@latest 293 | ``` 294 | - **`long_press`**: Performs a long press at coordinates. 295 | ```bash 296 | npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -q -- npx xcodebuildmcp@latest 297 | ``` 298 | - **`screenshot`**: Takes a screenshot. 299 | ```bash 300 | npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest 301 | ``` 302 | - **`swipe`**: Performs a swipe gesture. 303 | ```bash 304 | npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -q -- npx xcodebuildmcp@latest 305 | ``` 306 | - **`tap`**: Performs a tap at coordinates. 307 | ```bash 308 | npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -q -- npx xcodebuildmcp@latest 309 | ``` 310 | - **`touch`**: Simulates a touch down or up event. 311 | ```bash 312 | npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -q -- npx xcodebuildmcp@latest 313 | ``` 314 | - **`type_text`**: Types text into the focused element. 315 | ```bash 316 | npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -q -- npx xcodebuildmcp@latest 317 | ``` 318 | 319 | ## Resources 320 | 321 | - **Read devices resource**: 322 | ```bash 323 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -q -- npx xcodebuildmcp@latest 324 | ``` 325 | - **Read simulators resource**: 326 | ```bash 327 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -q -- npx xcodebuildmcp@latest 328 | ``` 329 | - **Read doctor resource**: 330 | ```bash 331 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -q -- npx xcodebuildmcp@latest 332 | ``` 333 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/device/list_devices.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Device Workspace Plugin: List Devices 3 | * 4 | * Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) 5 | * with their UUIDs, names, and connection status. Use this to discover physical devices for testing. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import type { ToolResponse } from '../../../types/common.ts'; 10 | import { log } from '../../../utils/logging/index.ts'; 11 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 14 | import { promises as fs } from 'fs'; 15 | import { tmpdir } from 'os'; 16 | import { join } from 'path'; 17 | 18 | // Define schema as ZodObject (empty schema since this tool takes no parameters) 19 | const listDevicesSchema = z.object({}); 20 | 21 | // Use z.infer for type safety 22 | type ListDevicesParams = z.infer<typeof listDevicesSchema>; 23 | 24 | /** 25 | * Business logic for listing connected devices 26 | */ 27 | export async function list_devicesLogic( 28 | params: ListDevicesParams, 29 | executor: CommandExecutor, 30 | pathDeps?: { tmpdir?: () => string; join?: (...paths: string[]) => string }, 31 | fsDeps?: { 32 | readFile?: (path: string, encoding?: string) => Promise<string>; 33 | unlink?: (path: string) => Promise<void>; 34 | }, 35 | ): Promise<ToolResponse> { 36 | log('info', 'Starting device discovery'); 37 | 38 | try { 39 | // Try modern devicectl with JSON output first (iOS 17+, Xcode 15+) 40 | const tempDir = pathDeps?.tmpdir ? pathDeps.tmpdir() : tmpdir(); 41 | const timestamp = pathDeps?.join ? '123' : Date.now(); // Use fixed timestamp for tests 42 | const tempJsonPath = pathDeps?.join 43 | ? pathDeps.join(tempDir, `devicectl-${timestamp}.json`) 44 | : join(tempDir, `devicectl-${timestamp}.json`); 45 | const devices = []; 46 | let useDevicectl = false; 47 | 48 | try { 49 | const result = await executor( 50 | ['xcrun', 'devicectl', 'list', 'devices', '--json-output', tempJsonPath], 51 | 'List Devices (devicectl with JSON)', 52 | true, 53 | undefined, 54 | ); 55 | 56 | if (result.success) { 57 | useDevicectl = true; 58 | // Read and parse the JSON file 59 | const jsonContent = fsDeps?.readFile 60 | ? await fsDeps.readFile(tempJsonPath, 'utf8') 61 | : await fs.readFile(tempJsonPath, 'utf8'); 62 | const deviceCtlData: unknown = JSON.parse(jsonContent); 63 | 64 | // Type guard to validate the device data structure 65 | const isValidDeviceData = (data: unknown): data is { result?: { devices?: unknown[] } } => { 66 | return ( 67 | typeof data === 'object' && 68 | data !== null && 69 | 'result' in data && 70 | typeof (data as { result?: unknown }).result === 'object' && 71 | (data as { result?: unknown }).result !== null && 72 | 'devices' in ((data as { result?: unknown }).result as { devices?: unknown }) && 73 | Array.isArray( 74 | ((data as { result?: unknown }).result as { devices?: unknown[] }).devices, 75 | ) 76 | ); 77 | }; 78 | 79 | if (isValidDeviceData(deviceCtlData) && deviceCtlData.result?.devices) { 80 | for (const deviceRaw of deviceCtlData.result.devices) { 81 | // Type guard for device object 82 | const isValidDevice = ( 83 | device: unknown, 84 | ): device is { 85 | visibilityClass?: string; 86 | connectionProperties?: { 87 | pairingState?: string; 88 | tunnelState?: string; 89 | transportType?: string; 90 | }; 91 | deviceProperties?: { 92 | platformIdentifier?: string; 93 | name?: string; 94 | osVersionNumber?: string; 95 | developerModeStatus?: string; 96 | marketingName?: string; 97 | }; 98 | hardwareProperties?: { 99 | productType?: string; 100 | cpuType?: { name?: string }; 101 | }; 102 | identifier?: string; 103 | } => { 104 | if (typeof device !== 'object' || device === null) { 105 | return false; 106 | } 107 | 108 | const dev = device as Record<string, unknown>; 109 | 110 | // Check if identifier exists and is a string (most critical property) 111 | if (typeof dev.identifier !== 'string' && dev.identifier !== undefined) { 112 | return false; 113 | } 114 | 115 | // Check visibilityClass if present 116 | if (dev.visibilityClass !== undefined && typeof dev.visibilityClass !== 'string') { 117 | return false; 118 | } 119 | 120 | // Check connectionProperties structure if present 121 | if (dev.connectionProperties !== undefined) { 122 | if ( 123 | typeof dev.connectionProperties !== 'object' || 124 | dev.connectionProperties === null 125 | ) { 126 | return false; 127 | } 128 | const connProps = dev.connectionProperties as Record<string, unknown>; 129 | if ( 130 | connProps.pairingState !== undefined && 131 | typeof connProps.pairingState !== 'string' 132 | ) { 133 | return false; 134 | } 135 | if ( 136 | connProps.tunnelState !== undefined && 137 | typeof connProps.tunnelState !== 'string' 138 | ) { 139 | return false; 140 | } 141 | if ( 142 | connProps.transportType !== undefined && 143 | typeof connProps.transportType !== 'string' 144 | ) { 145 | return false; 146 | } 147 | } 148 | 149 | // Check deviceProperties structure if present 150 | if (dev.deviceProperties !== undefined) { 151 | if (typeof dev.deviceProperties !== 'object' || dev.deviceProperties === null) { 152 | return false; 153 | } 154 | const devProps = dev.deviceProperties as Record<string, unknown>; 155 | if ( 156 | devProps.platformIdentifier !== undefined && 157 | typeof devProps.platformIdentifier !== 'string' 158 | ) { 159 | return false; 160 | } 161 | if (devProps.name !== undefined && typeof devProps.name !== 'string') { 162 | return false; 163 | } 164 | if ( 165 | devProps.osVersionNumber !== undefined && 166 | typeof devProps.osVersionNumber !== 'string' 167 | ) { 168 | return false; 169 | } 170 | if ( 171 | devProps.developerModeStatus !== undefined && 172 | typeof devProps.developerModeStatus !== 'string' 173 | ) { 174 | return false; 175 | } 176 | if ( 177 | devProps.marketingName !== undefined && 178 | typeof devProps.marketingName !== 'string' 179 | ) { 180 | return false; 181 | } 182 | } 183 | 184 | // Check hardwareProperties structure if present 185 | if (dev.hardwareProperties !== undefined) { 186 | if (typeof dev.hardwareProperties !== 'object' || dev.hardwareProperties === null) { 187 | return false; 188 | } 189 | const hwProps = dev.hardwareProperties as Record<string, unknown>; 190 | if (hwProps.productType !== undefined && typeof hwProps.productType !== 'string') { 191 | return false; 192 | } 193 | if (hwProps.cpuType !== undefined) { 194 | if (typeof hwProps.cpuType !== 'object' || hwProps.cpuType === null) { 195 | return false; 196 | } 197 | const cpuType = hwProps.cpuType as Record<string, unknown>; 198 | if (cpuType.name !== undefined && typeof cpuType.name !== 'string') { 199 | return false; 200 | } 201 | } 202 | } 203 | 204 | return true; 205 | }; 206 | 207 | if (!isValidDevice(deviceRaw)) continue; 208 | 209 | const device = deviceRaw; 210 | 211 | // Skip simulators or unavailable devices 212 | if ( 213 | device.visibilityClass === 'Simulator' || 214 | !device.connectionProperties?.pairingState 215 | ) { 216 | continue; 217 | } 218 | 219 | // Determine platform from platformIdentifier 220 | let platform = 'Unknown'; 221 | const platformId = device.deviceProperties?.platformIdentifier?.toLowerCase() ?? ''; 222 | if (typeof platformId === 'string') { 223 | if (platformId.includes('ios') || platformId.includes('iphone')) { 224 | platform = 'iOS'; 225 | } else if (platformId.includes('ipad')) { 226 | platform = 'iPadOS'; 227 | } else if (platformId.includes('watch')) { 228 | platform = 'watchOS'; 229 | } else if (platformId.includes('tv') || platformId.includes('apple tv')) { 230 | platform = 'tvOS'; 231 | } else if (platformId.includes('vision')) { 232 | platform = 'visionOS'; 233 | } 234 | } 235 | 236 | // Determine connection state 237 | const pairingState = device.connectionProperties?.pairingState ?? ''; 238 | const tunnelState = device.connectionProperties?.tunnelState ?? ''; 239 | const transportType = device.connectionProperties?.transportType ?? ''; 240 | 241 | let state = 'Unknown'; 242 | // Consider a device available if it's paired, regardless of tunnel state 243 | // This allows WiFi-connected devices to be used even if tunnelState isn't "connected" 244 | if (pairingState === 'paired') { 245 | if (tunnelState === 'connected') { 246 | state = 'Available'; 247 | } else { 248 | // Device is paired but tunnel state may be different for WiFi connections 249 | // Still mark as available since devicectl commands can work with paired devices 250 | state = 'Available (WiFi)'; 251 | } 252 | } else { 253 | state = 'Unpaired'; 254 | } 255 | 256 | devices.push({ 257 | name: device.deviceProperties?.name ?? 'Unknown Device', 258 | identifier: device.identifier ?? 'Unknown', 259 | platform: platform, 260 | model: 261 | device.deviceProperties?.marketingName ?? device.hardwareProperties?.productType, 262 | osVersion: device.deviceProperties?.osVersionNumber, 263 | state: state, 264 | connectionType: transportType, 265 | trustState: pairingState, 266 | developerModeStatus: device.deviceProperties?.developerModeStatus, 267 | productType: device.hardwareProperties?.productType, 268 | cpuArchitecture: device.hardwareProperties?.cpuType?.name, 269 | }); 270 | } 271 | } 272 | } 273 | } catch { 274 | log('info', 'devicectl with JSON failed, trying xctrace fallback'); 275 | } finally { 276 | // Clean up temp file 277 | try { 278 | if (fsDeps?.unlink) { 279 | await fsDeps.unlink(tempJsonPath); 280 | } else { 281 | await fs.unlink(tempJsonPath); 282 | } 283 | } catch { 284 | // Ignore cleanup errors 285 | } 286 | } 287 | 288 | // If devicectl failed or returned no devices, fallback to xctrace 289 | if (!useDevicectl || devices.length === 0) { 290 | const result = await executor( 291 | ['xcrun', 'xctrace', 'list', 'devices'], 292 | 'List Devices (xctrace)', 293 | true, 294 | undefined, 295 | ); 296 | 297 | if (!result.success) { 298 | return { 299 | content: [ 300 | { 301 | type: 'text', 302 | text: `Failed to list devices: ${result.error}\n\nMake sure Xcode is installed and devices are connected and trusted.`, 303 | }, 304 | ], 305 | isError: true, 306 | }; 307 | } 308 | 309 | // Return raw xctrace output without parsing 310 | return { 311 | content: [ 312 | { 313 | type: 'text', 314 | text: `Device listing (xctrace output):\n\n${result.output}\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.`, 315 | }, 316 | ], 317 | }; 318 | } 319 | 320 | // Format the response 321 | let responseText = 'Connected Devices:\n\n'; 322 | 323 | // Filter out duplicates 324 | const uniqueDevices = devices.filter( 325 | (device, index, self) => index === self.findIndex((d) => d.identifier === device.identifier), 326 | ); 327 | 328 | if (uniqueDevices.length === 0) { 329 | responseText += 'No physical Apple devices found.\n\n'; 330 | responseText += 'Make sure:\n'; 331 | responseText += '1. Devices are connected via USB or WiFi\n'; 332 | responseText += '2. Devices are unlocked and trusted\n'; 333 | responseText += '3. "Trust this computer" has been accepted on the device\n'; 334 | responseText += '4. Developer mode is enabled on the device (iOS 16+)\n'; 335 | responseText += '5. Xcode is properly installed\n\n'; 336 | responseText += 'For simulators, use the list_sims tool instead.\n'; 337 | } else { 338 | // Group devices by availability status 339 | const availableDevices = uniqueDevices.filter( 340 | (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', 341 | ); 342 | const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)'); 343 | const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); 344 | 345 | if (availableDevices.length > 0) { 346 | responseText += '✅ Available Devices:\n'; 347 | for (const device of availableDevices) { 348 | responseText += `\n📱 ${device.name}\n`; 349 | responseText += ` UDID: ${device.identifier}\n`; 350 | responseText += ` Model: ${device.model ?? 'Unknown'}\n`; 351 | if (device.productType) { 352 | responseText += ` Product Type: ${device.productType}\n`; 353 | } 354 | responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; 355 | if (device.cpuArchitecture) { 356 | responseText += ` CPU Architecture: ${device.cpuArchitecture}\n`; 357 | } 358 | responseText += ` Connection: ${device.connectionType ?? 'Unknown'}\n`; 359 | if (device.developerModeStatus) { 360 | responseText += ` Developer Mode: ${device.developerModeStatus}\n`; 361 | } 362 | } 363 | responseText += '\n'; 364 | } 365 | 366 | if (pairedDevices.length > 0) { 367 | responseText += '🔗 Paired but Not Connected:\n'; 368 | for (const device of pairedDevices) { 369 | responseText += `\n📱 ${device.name}\n`; 370 | responseText += ` UDID: ${device.identifier}\n`; 371 | responseText += ` Model: ${device.model ?? 'Unknown'}\n`; 372 | responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; 373 | } 374 | responseText += '\n'; 375 | } 376 | 377 | if (unpairedDevices.length > 0) { 378 | responseText += '❌ Unpaired Devices:\n'; 379 | for (const device of unpairedDevices) { 380 | responseText += `- ${device.name} (${device.identifier})\n`; 381 | } 382 | responseText += '\n'; 383 | } 384 | } 385 | 386 | // Add next steps 387 | const availableDevicesExist = uniqueDevices.some( 388 | (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', 389 | ); 390 | 391 | if (availableDevicesExist) { 392 | responseText += 'Next Steps:\n'; 393 | responseText += 394 | "1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n"; 395 | responseText += "2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n"; 396 | responseText += "3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\n"; 397 | responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n'; 398 | } else if (uniqueDevices.length > 0) { 399 | responseText += 400 | 'Note: No devices are currently available for testing. Make sure devices are:\n'; 401 | responseText += '- Connected via USB\n'; 402 | responseText += '- Unlocked and trusted\n'; 403 | responseText += '- Have developer mode enabled (iOS 16+)\n'; 404 | } 405 | 406 | return { 407 | content: [ 408 | { 409 | type: 'text', 410 | text: responseText, 411 | }, 412 | ], 413 | }; 414 | } catch (error) { 415 | const errorMessage = error instanceof Error ? error.message : String(error); 416 | log('error', `Error listing devices: ${errorMessage}`); 417 | return { 418 | content: [ 419 | { 420 | type: 'text', 421 | text: `Failed to list devices: ${errorMessage}`, 422 | }, 423 | ], 424 | isError: true, 425 | }; 426 | } 427 | } 428 | 429 | export default { 430 | name: 'list_devices', 431 | description: 432 | 'Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) with their UUIDs, names, and connection status. Use this to discover physical devices for testing.', 433 | schema: listDevicesSchema.shape, // MCP SDK compatibility 434 | handler: createTypedTool(listDevicesSchema, list_devicesLogic, getDefaultCommandExecutor), 435 | }; 436 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for start_device_log_cap plugin 3 | * Following CLAUDE.md testing standards with pure dependency injection 4 | */ 5 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 6 | import { EventEmitter } from 'events'; 7 | import type { ChildProcess } from 'child_process'; 8 | import { z } from 'zod'; 9 | import { 10 | createMockExecutor, 11 | createMockFileSystemExecutor, 12 | } from '../../../../test-utils/mock-executors.ts'; 13 | import plugin, { 14 | start_device_log_capLogic, 15 | activeDeviceLogSessions, 16 | } from '../start_device_log_cap.ts'; 17 | import { sessionStore } from '../../../../utils/session-store.ts'; 18 | 19 | describe('start_device_log_cap plugin', () => { 20 | // Mock state tracking 21 | let commandCalls: Array<{ 22 | command: string[]; 23 | logPrefix?: string; 24 | useShell?: boolean; 25 | env?: Record<string, string>; 26 | }> = []; 27 | let mkdirCalls: string[] = []; 28 | let writeFileCalls: Array<{ path: string; content: string }> = []; 29 | 30 | // Reset state 31 | commandCalls = []; 32 | mkdirCalls = []; 33 | writeFileCalls = []; 34 | 35 | const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; 36 | 37 | beforeEach(() => { 38 | sessionStore.clear(); 39 | activeDeviceLogSessions.clear(); 40 | process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25'; 41 | }); 42 | 43 | afterEach(() => { 44 | if (originalJsonWaitEnv === undefined) { 45 | delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS; 46 | } else { 47 | process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv; 48 | } 49 | }); 50 | 51 | describe('Plugin Structure', () => { 52 | it('should export an object with required properties', () => { 53 | expect(plugin).toHaveProperty('name'); 54 | expect(plugin).toHaveProperty('description'); 55 | expect(plugin).toHaveProperty('schema'); 56 | expect(plugin).toHaveProperty('handler'); 57 | }); 58 | 59 | it('should have correct tool name', () => { 60 | expect(plugin.name).toBe('start_device_log_cap'); 61 | }); 62 | 63 | it('should have correct description', () => { 64 | expect(plugin.description).toBe('Starts log capture on a connected device.'); 65 | }); 66 | 67 | it('should have correct schema structure', () => { 68 | // Schema should be a plain object for MCP protocol compliance 69 | expect(typeof plugin.schema).toBe('object'); 70 | expect(Object.keys(plugin.schema)).toEqual(['bundleId']); 71 | 72 | // Validate that schema fields are Zod types that can be used for validation 73 | const schema = z.object(plugin.schema).strict(); 74 | expect(schema.safeParse({ bundleId: 'com.test.app' }).success).toBe(true); 75 | expect(schema.safeParse({}).success).toBe(false); 76 | }); 77 | 78 | it('should have handler as a function', () => { 79 | expect(typeof plugin.handler).toBe('function'); 80 | }); 81 | }); 82 | 83 | describe('Handler Requirements', () => { 84 | it('should require deviceId when not provided', async () => { 85 | const result = await plugin.handler({ bundleId: 'com.example.MyApp' }); 86 | 87 | expect(result.isError).toBe(true); 88 | expect(result.content[0].text).toContain('deviceId is required'); 89 | }); 90 | }); 91 | 92 | describe('Handler Functionality', () => { 93 | it('should start log capture successfully', async () => { 94 | // Mock successful command execution 95 | const mockExecutor = createMockExecutor({ 96 | success: true, 97 | output: 'App launched successfully', 98 | }); 99 | 100 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 101 | mkdir: async (path: string) => { 102 | mkdirCalls.push(path); 103 | }, 104 | writeFile: async (path: string, content: string) => { 105 | writeFileCalls.push({ path, content }); 106 | }, 107 | }); 108 | 109 | const result = await start_device_log_capLogic( 110 | { 111 | deviceId: '00008110-001A2C3D4E5F', 112 | bundleId: 'com.example.MyApp', 113 | }, 114 | mockExecutor, 115 | mockFileSystemExecutor, 116 | ); 117 | 118 | expect(result.content[0].text).toMatch(/✅ Device log capture started successfully/); 119 | expect(result.content[0].text).toMatch(/Session ID: [a-f0-9-]{36}/); 120 | expect(result.isError ?? false).toBe(false); 121 | }); 122 | 123 | it('should include next steps in success response', async () => { 124 | // Mock successful command execution 125 | const mockExecutor = createMockExecutor({ 126 | success: true, 127 | output: 'App launched successfully', 128 | }); 129 | 130 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 131 | mkdir: async (path: string) => { 132 | mkdirCalls.push(path); 133 | }, 134 | writeFile: async (path: string, content: string) => { 135 | writeFileCalls.push({ path, content }); 136 | }, 137 | }); 138 | 139 | const result = await start_device_log_capLogic( 140 | { 141 | deviceId: '00008110-001A2C3D4E5F', 142 | bundleId: 'com.example.MyApp', 143 | }, 144 | mockExecutor, 145 | mockFileSystemExecutor, 146 | ); 147 | 148 | expect(result.content[0].text).toContain('Next Steps:'); 149 | expect(result.content[0].text).toContain('Use stop_device_log_cap'); 150 | }); 151 | 152 | it('should surface early launch failures when process exits immediately', async () => { 153 | const failingProcess = new EventEmitter() as unknown as ChildProcess & { 154 | exitCode: number | null; 155 | killed: boolean; 156 | kill(signal?: string): boolean; 157 | stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; 158 | stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; 159 | }; 160 | 161 | const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { 162 | setEncoding?: (encoding: string) => void; 163 | }; 164 | stubOutput.setEncoding = () => {}; 165 | const stubError = new EventEmitter() as NodeJS.ReadableStream & { 166 | setEncoding?: (encoding: string) => void; 167 | }; 168 | stubError.setEncoding = () => {}; 169 | 170 | failingProcess.stdout = stubOutput; 171 | failingProcess.stderr = stubError; 172 | failingProcess.exitCode = null; 173 | failingProcess.killed = false; 174 | failingProcess.kill = () => { 175 | failingProcess.killed = true; 176 | failingProcess.exitCode = 0; 177 | failingProcess.emit('close', 0, null); 178 | return true; 179 | }; 180 | 181 | const mockExecutor = createMockExecutor({ 182 | success: true, 183 | output: '', 184 | process: failingProcess, 185 | }); 186 | 187 | let createdLogPath = ''; 188 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 189 | mkdir: async () => {}, 190 | writeFile: async (path: string, content: string) => { 191 | createdLogPath = path; 192 | writeFileCalls.push({ path, content }); 193 | }, 194 | }); 195 | 196 | const resultPromise = start_device_log_capLogic( 197 | { 198 | deviceId: '00008110-001A2C3D4E5F', 199 | bundleId: 'com.invalid.App', 200 | }, 201 | mockExecutor, 202 | mockFileSystemExecutor, 203 | ); 204 | 205 | setTimeout(() => { 206 | stubError.emit( 207 | 'data', 208 | 'ERROR: The application failed to launch. (com.apple.dt.CoreDeviceError error 10002)\nNSLocalizedRecoverySuggestion = Provide a valid bundle identifier.\n', 209 | ); 210 | failingProcess.exitCode = 70; 211 | failingProcess.emit('close', 70, null); 212 | }, 10); 213 | 214 | const result = await resultPromise; 215 | 216 | expect(result.isError).toBe(true); 217 | expect(result.content[0].text).toContain('Provide a valid bundle identifier'); 218 | expect(activeDeviceLogSessions.size).toBe(0); 219 | expect(createdLogPath).not.toBe(''); 220 | }); 221 | 222 | it('should surface JSON-reported failures when launch cannot start', async () => { 223 | const jsonFailure = { 224 | error: { 225 | domain: 'com.apple.dt.CoreDeviceError', 226 | code: 10002, 227 | localizedDescription: 'The application failed to launch.', 228 | userInfo: { 229 | NSLocalizedRecoverySuggestion: 'Provide a valid bundle identifier.', 230 | NSLocalizedFailureReason: 'The requested application com.invalid.App is not installed.', 231 | BundleIdentifier: 'com.invalid.App', 232 | }, 233 | }, 234 | }; 235 | 236 | const failingProcess = new EventEmitter() as unknown as ChildProcess & { 237 | exitCode: number | null; 238 | killed: boolean; 239 | kill(signal?: string): boolean; 240 | stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; 241 | stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; 242 | }; 243 | 244 | const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { 245 | setEncoding?: (encoding: string) => void; 246 | }; 247 | stubOutput.setEncoding = () => {}; 248 | const stubError = new EventEmitter() as NodeJS.ReadableStream & { 249 | setEncoding?: (encoding: string) => void; 250 | }; 251 | stubError.setEncoding = () => {}; 252 | 253 | failingProcess.stdout = stubOutput; 254 | failingProcess.stderr = stubError; 255 | failingProcess.exitCode = null; 256 | failingProcess.killed = false; 257 | failingProcess.kill = () => { 258 | failingProcess.killed = true; 259 | return true; 260 | }; 261 | 262 | const mockExecutor = createMockExecutor({ 263 | success: true, 264 | output: '', 265 | process: failingProcess, 266 | }); 267 | 268 | let jsonPathSeen = ''; 269 | let removedJsonPath = ''; 270 | 271 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 272 | mkdir: async () => {}, 273 | writeFile: async () => {}, 274 | existsSync: (filePath: string): boolean => { 275 | if (filePath.includes('devicectl-launch-')) { 276 | jsonPathSeen = filePath; 277 | return true; 278 | } 279 | return false; 280 | }, 281 | readFile: async (filePath: string): Promise<string> => { 282 | if (filePath.includes('devicectl-launch-')) { 283 | jsonPathSeen = filePath; 284 | return JSON.stringify(jsonFailure); 285 | } 286 | return ''; 287 | }, 288 | rm: async (filePath: string) => { 289 | if (filePath.includes('devicectl-launch-')) { 290 | removedJsonPath = filePath; 291 | } 292 | }, 293 | }); 294 | 295 | setTimeout(() => { 296 | failingProcess.exitCode = 0; 297 | failingProcess.emit('close', 0, null); 298 | }, 5); 299 | 300 | const result = await start_device_log_capLogic( 301 | { 302 | deviceId: '00008110-001A2C3D4E5F', 303 | bundleId: 'com.invalid.App', 304 | }, 305 | mockExecutor, 306 | mockFileSystemExecutor, 307 | ); 308 | 309 | expect(result.isError).toBe(true); 310 | expect(result.content[0].text).toContain('Provide a valid bundle identifier'); 311 | expect(jsonPathSeen).not.toBe(''); 312 | expect(removedJsonPath).toBe(jsonPathSeen); 313 | expect(activeDeviceLogSessions.size).toBe(0); 314 | expect(failingProcess.killed).toBe(true); 315 | }); 316 | 317 | it('should treat JSON success payload as confirmation of launch', async () => { 318 | const jsonSuccess = { 319 | result: { 320 | process: { 321 | processIdentifier: 4321, 322 | }, 323 | }, 324 | }; 325 | 326 | const runningProcess = new EventEmitter() as unknown as ChildProcess & { 327 | exitCode: number | null; 328 | killed: boolean; 329 | kill(signal?: string): boolean; 330 | stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; 331 | stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; 332 | }; 333 | 334 | const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { 335 | setEncoding?: (encoding: string) => void; 336 | }; 337 | stubOutput.setEncoding = () => {}; 338 | const stubError = new EventEmitter() as NodeJS.ReadableStream & { 339 | setEncoding?: (encoding: string) => void; 340 | }; 341 | stubError.setEncoding = () => {}; 342 | 343 | runningProcess.stdout = stubOutput; 344 | runningProcess.stderr = stubError; 345 | runningProcess.exitCode = null; 346 | runningProcess.killed = false; 347 | runningProcess.kill = () => { 348 | runningProcess.killed = true; 349 | runningProcess.emit('close', 0, null); 350 | return true; 351 | }; 352 | 353 | const mockExecutor = createMockExecutor({ 354 | success: true, 355 | output: '', 356 | process: runningProcess, 357 | }); 358 | 359 | let jsonPathSeen = ''; 360 | let removedJsonPath = ''; 361 | let jsonRemoved = false; 362 | 363 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 364 | mkdir: async () => {}, 365 | writeFile: async () => {}, 366 | existsSync: (filePath: string): boolean => { 367 | if (filePath.includes('devicectl-launch-')) { 368 | jsonPathSeen = filePath; 369 | return !jsonRemoved; 370 | } 371 | return false; 372 | }, 373 | readFile: async (filePath: string): Promise<string> => { 374 | if (filePath.includes('devicectl-launch-')) { 375 | jsonPathSeen = filePath; 376 | return JSON.stringify(jsonSuccess); 377 | } 378 | return ''; 379 | }, 380 | rm: async (filePath: string) => { 381 | if (filePath.includes('devicectl-launch-')) { 382 | jsonRemoved = true; 383 | removedJsonPath = filePath; 384 | } 385 | }, 386 | }); 387 | 388 | setTimeout(() => { 389 | runningProcess.emit('close', 0, null); 390 | }, 5); 391 | 392 | const result = await start_device_log_capLogic( 393 | { 394 | deviceId: '00008110-001A2C3D4E5F', 395 | bundleId: 'com.example.MyApp', 396 | }, 397 | mockExecutor, 398 | mockFileSystemExecutor, 399 | ); 400 | 401 | expect(result.content[0].text).toContain('Device log capture started successfully'); 402 | expect(result.isError ?? false).toBe(false); 403 | expect(jsonPathSeen).not.toBe(''); 404 | expect(removedJsonPath).toBe(jsonPathSeen); 405 | expect(activeDeviceLogSessions.size).toBe(1); 406 | }); 407 | 408 | it('should handle directory creation failure', async () => { 409 | // Mock mkdir to fail 410 | const mockExecutor = createMockExecutor({ 411 | success: false, 412 | output: '', 413 | error: 'Command failed', 414 | }); 415 | 416 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 417 | mkdir: async (path: string) => { 418 | mkdirCalls.push(path); 419 | throw new Error('Permission denied'); 420 | }, 421 | }); 422 | 423 | const result = await start_device_log_capLogic( 424 | { 425 | deviceId: '00008110-001A2C3D4E5F', 426 | bundleId: 'com.example.MyApp', 427 | }, 428 | mockExecutor, 429 | mockFileSystemExecutor, 430 | ); 431 | 432 | expect(result).toEqual({ 433 | content: [ 434 | { 435 | type: 'text', 436 | text: 'Failed to start device log capture: Permission denied', 437 | }, 438 | ], 439 | isError: true, 440 | }); 441 | }); 442 | 443 | it('should handle file write failure', async () => { 444 | // Mock writeFile to fail 445 | const mockExecutor = createMockExecutor({ 446 | success: false, 447 | output: '', 448 | error: 'Command failed', 449 | }); 450 | 451 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 452 | mkdir: async (path: string) => { 453 | mkdirCalls.push(path); 454 | }, 455 | writeFile: async (path: string, content: string) => { 456 | writeFileCalls.push({ path, content }); 457 | throw new Error('Disk full'); 458 | }, 459 | }); 460 | 461 | const result = await start_device_log_capLogic( 462 | { 463 | deviceId: '00008110-001A2C3D4E5F', 464 | bundleId: 'com.example.MyApp', 465 | }, 466 | mockExecutor, 467 | mockFileSystemExecutor, 468 | ); 469 | 470 | expect(result).toEqual({ 471 | content: [ 472 | { 473 | type: 'text', 474 | text: 'Failed to start device log capture: Disk full', 475 | }, 476 | ], 477 | isError: true, 478 | }); 479 | }); 480 | 481 | it('should handle spawn process error', async () => { 482 | // Mock spawn to throw error 483 | const mockExecutor = createMockExecutor(new Error('Command not found')); 484 | 485 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 486 | mkdir: async (path: string) => { 487 | mkdirCalls.push(path); 488 | }, 489 | writeFile: async (path: string, content: string) => { 490 | writeFileCalls.push({ path, content }); 491 | }, 492 | }); 493 | 494 | const result = await start_device_log_capLogic( 495 | { 496 | deviceId: '00008110-001A2C3D4E5F', 497 | bundleId: 'com.example.MyApp', 498 | }, 499 | mockExecutor, 500 | mockFileSystemExecutor, 501 | ); 502 | 503 | expect(result).toEqual({ 504 | content: [ 505 | { 506 | type: 'text', 507 | text: 'Failed to start device log capture: Command not found', 508 | }, 509 | ], 510 | isError: true, 511 | }); 512 | }); 513 | 514 | it('should handle string error objects', async () => { 515 | // Mock mkdir to fail with string error 516 | const mockExecutor = createMockExecutor('String error message'); 517 | 518 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 519 | mkdir: async (path: string) => { 520 | mkdirCalls.push(path); 521 | }, 522 | writeFile: async (path: string, content: string) => { 523 | writeFileCalls.push({ path, content }); 524 | }, 525 | }); 526 | 527 | const result = await start_device_log_capLogic( 528 | { 529 | deviceId: '00008110-001A2C3D4E5F', 530 | bundleId: 'com.example.MyApp', 531 | }, 532 | mockExecutor, 533 | mockFileSystemExecutor, 534 | ); 535 | 536 | expect(result).toEqual({ 537 | content: [ 538 | { 539 | type: 'text', 540 | text: 'Failed to start device log capture: String error message', 541 | }, 542 | ], 543 | isError: true, 544 | }); 545 | }); 546 | }); 547 | }); 548 | ```