This is page 10 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__/long_press.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for long_press tool plugin 3 | */ 4 | 5 | import { describe, it, expect } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 8 | import longPressPlugin, { long_pressLogic } from '../long_press.ts'; 9 | 10 | describe('Long Press Plugin', () => { 11 | // Setup for each test - no vitest mocks to clear 12 | 13 | describe('Export Field Validation (Literal)', () => { 14 | it('should have correct name', () => { 15 | expect(longPressPlugin.name).toBe('long_press'); 16 | }); 17 | 18 | it('should have correct description', () => { 19 | expect(longPressPlugin.description).toBe( 20 | "Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots).", 21 | ); 22 | }); 23 | 24 | it('should have handler function', () => { 25 | expect(typeof longPressPlugin.handler).toBe('function'); 26 | }); 27 | 28 | it('should validate schema fields with safeParse', () => { 29 | const schema = z.object(longPressPlugin.schema); 30 | 31 | // Valid case 32 | expect( 33 | schema.safeParse({ 34 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 35 | x: 100, 36 | y: 200, 37 | duration: 1500, 38 | }).success, 39 | ).toBe(true); 40 | 41 | // Invalid simulatorUuid 42 | expect( 43 | schema.safeParse({ 44 | simulatorUuid: 'invalid-uuid', 45 | x: 100, 46 | y: 200, 47 | duration: 1500, 48 | }).success, 49 | ).toBe(false); 50 | 51 | // Invalid x (not integer) 52 | expect( 53 | schema.safeParse({ 54 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 55 | x: 100.5, 56 | y: 200, 57 | duration: 1500, 58 | }).success, 59 | ).toBe(false); 60 | 61 | // Invalid y (not integer) 62 | expect( 63 | schema.safeParse({ 64 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 65 | x: 100, 66 | y: 200.5, 67 | duration: 1500, 68 | }).success, 69 | ).toBe(false); 70 | 71 | // Invalid duration (not positive) 72 | expect( 73 | schema.safeParse({ 74 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 75 | x: 100, 76 | y: 200, 77 | duration: 0, 78 | }).success, 79 | ).toBe(false); 80 | 81 | // Invalid duration (negative) 82 | expect( 83 | schema.safeParse({ 84 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 85 | x: 100, 86 | y: 200, 87 | duration: -100, 88 | }).success, 89 | ).toBe(false); 90 | }); 91 | }); 92 | 93 | describe('Command Generation', () => { 94 | it('should generate correct axe command for basic long press', async () => { 95 | let capturedCommand: string[] = []; 96 | const trackingExecutor = async (command: string[]) => { 97 | capturedCommand = command; 98 | return { 99 | success: true, 100 | output: 'long 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: [{ type: 'text', text: 'Mock axe not available' }], 111 | isError: true, 112 | }), 113 | }; 114 | 115 | await long_pressLogic( 116 | { 117 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 118 | x: 100, 119 | y: 200, 120 | duration: 1500, 121 | }, 122 | trackingExecutor, 123 | mockAxeHelpers, 124 | ); 125 | 126 | expect(capturedCommand).toEqual([ 127 | '/usr/local/bin/axe', 128 | 'touch', 129 | '-x', 130 | '100', 131 | '-y', 132 | '200', 133 | '--down', 134 | '--up', 135 | '--delay', 136 | '1.5', 137 | '--udid', 138 | '12345678-1234-1234-1234-123456789012', 139 | ]); 140 | }); 141 | 142 | it('should generate correct axe command for long press with different coordinates', async () => { 143 | let capturedCommand: string[] = []; 144 | const trackingExecutor = async (command: string[]) => { 145 | capturedCommand = command; 146 | return { 147 | success: true, 148 | output: 'long press completed', 149 | error: undefined, 150 | process: { pid: 12345 }, 151 | }; 152 | }; 153 | 154 | const mockAxeHelpers = { 155 | getAxePath: () => '/usr/local/bin/axe', 156 | getBundledAxeEnvironment: () => ({}), 157 | createAxeNotAvailableResponse: () => ({ 158 | content: [{ type: 'text', text: 'Mock axe not available' }], 159 | isError: true, 160 | }), 161 | }; 162 | 163 | await long_pressLogic( 164 | { 165 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 166 | x: 50, 167 | y: 75, 168 | duration: 2000, 169 | }, 170 | trackingExecutor, 171 | mockAxeHelpers, 172 | ); 173 | 174 | expect(capturedCommand).toEqual([ 175 | '/usr/local/bin/axe', 176 | 'touch', 177 | '-x', 178 | '50', 179 | '-y', 180 | '75', 181 | '--down', 182 | '--up', 183 | '--delay', 184 | '2', 185 | '--udid', 186 | '12345678-1234-1234-1234-123456789012', 187 | ]); 188 | }); 189 | 190 | it('should generate correct axe command for short duration long press', async () => { 191 | let capturedCommand: string[] = []; 192 | const trackingExecutor = async (command: string[]) => { 193 | capturedCommand = command; 194 | return { 195 | success: true, 196 | output: 'long press completed', 197 | error: undefined, 198 | process: { pid: 12345 }, 199 | }; 200 | }; 201 | 202 | const mockAxeHelpers = { 203 | getAxePath: () => '/usr/local/bin/axe', 204 | getBundledAxeEnvironment: () => ({}), 205 | createAxeNotAvailableResponse: () => ({ 206 | content: [{ type: 'text', text: 'Mock axe not available' }], 207 | isError: true, 208 | }), 209 | }; 210 | 211 | await long_pressLogic( 212 | { 213 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 214 | x: 300, 215 | y: 400, 216 | duration: 500, 217 | }, 218 | trackingExecutor, 219 | mockAxeHelpers, 220 | ); 221 | 222 | expect(capturedCommand).toEqual([ 223 | '/usr/local/bin/axe', 224 | 'touch', 225 | '-x', 226 | '300', 227 | '-y', 228 | '400', 229 | '--down', 230 | '--up', 231 | '--delay', 232 | '0.5', 233 | '--udid', 234 | '12345678-1234-1234-1234-123456789012', 235 | ]); 236 | }); 237 | 238 | it('should generate correct axe command with bundled axe path', async () => { 239 | let capturedCommand: string[] = []; 240 | const trackingExecutor = async (command: string[]) => { 241 | capturedCommand = command; 242 | return { 243 | success: true, 244 | output: 'long press completed', 245 | error: undefined, 246 | process: { pid: 12345 }, 247 | }; 248 | }; 249 | 250 | const mockAxeHelpers = { 251 | getAxePath: () => '/path/to/bundled/axe', 252 | getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), 253 | createAxeNotAvailableResponse: () => ({ 254 | content: [{ type: 'text', text: 'Mock axe not available' }], 255 | isError: true, 256 | }), 257 | }; 258 | 259 | await long_pressLogic( 260 | { 261 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 262 | x: 150, 263 | y: 250, 264 | duration: 3000, 265 | }, 266 | trackingExecutor, 267 | mockAxeHelpers, 268 | ); 269 | 270 | expect(capturedCommand).toEqual([ 271 | '/path/to/bundled/axe', 272 | 'touch', 273 | '-x', 274 | '150', 275 | '-y', 276 | '250', 277 | '--down', 278 | '--up', 279 | '--delay', 280 | '3', 281 | '--udid', 282 | '12345678-1234-1234-1234-123456789012', 283 | ]); 284 | }); 285 | }); 286 | 287 | describe('Handler Behavior (Complete Literal Returns)', () => { 288 | it('should return success for valid long press execution', async () => { 289 | const mockExecutor = createMockExecutor({ 290 | success: true, 291 | output: 'long press completed', 292 | error: '', 293 | }); 294 | 295 | const mockAxeHelpers = { 296 | getAxePath: () => '/usr/local/bin/axe', 297 | getBundledAxeEnvironment: () => ({}), 298 | createAxeNotAvailableResponse: () => ({ 299 | content: [{ type: 'text', text: 'Mock axe not available' }], 300 | isError: true, 301 | }), 302 | }; 303 | 304 | const result = await long_pressLogic( 305 | { 306 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 307 | x: 100, 308 | y: 200, 309 | duration: 1500, 310 | }, 311 | mockExecutor, 312 | mockAxeHelpers, 313 | ); 314 | 315 | expect(result).toEqual({ 316 | content: [ 317 | { 318 | type: 'text', 319 | text: 'Long press at (100, 200) for 1500ms simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 320 | }, 321 | ], 322 | isError: false, 323 | }); 324 | }); 325 | 326 | it('should handle DependencyError when axe is not available', async () => { 327 | const mockExecutor = createMockExecutor({ 328 | success: true, 329 | output: '', 330 | error: undefined, 331 | process: { pid: 12345 }, 332 | }); 333 | 334 | const mockAxeHelpers = { 335 | getAxePath: () => null, // Mock axe not found 336 | getBundledAxeEnvironment: () => ({}), 337 | createAxeNotAvailableResponse: () => ({ 338 | content: [ 339 | { 340 | type: 'text', 341 | 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.', 342 | }, 343 | ], 344 | isError: true, 345 | }), 346 | }; 347 | 348 | const result = await long_pressLogic( 349 | { 350 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 351 | x: 100, 352 | y: 200, 353 | duration: 1500, 354 | }, 355 | mockExecutor, 356 | mockAxeHelpers, 357 | ); 358 | 359 | expect(result).toEqual({ 360 | content: [ 361 | { 362 | type: 'text', 363 | 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.', 364 | }, 365 | ], 366 | isError: true, 367 | }); 368 | }); 369 | 370 | it('should handle AxeError from failed command execution', async () => { 371 | const mockExecutor = createMockExecutor({ 372 | success: false, 373 | output: '', 374 | error: 'axe command failed', 375 | process: { pid: 12345 }, 376 | }); 377 | 378 | const mockAxeHelpers = { 379 | getAxePath: () => '/usr/local/bin/axe', 380 | getBundledAxeEnvironment: () => ({}), 381 | createAxeNotAvailableResponse: () => ({ 382 | content: [{ type: 'text', text: 'Mock axe not available' }], 383 | isError: true, 384 | }), 385 | }; 386 | 387 | const result = await long_pressLogic( 388 | { 389 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 390 | x: 100, 391 | y: 200, 392 | duration: 1500, 393 | }, 394 | mockExecutor, 395 | mockAxeHelpers, 396 | ); 397 | 398 | expect(result).toEqual({ 399 | content: [ 400 | { 401 | type: 'text', 402 | text: "Error: Failed to simulate long press at (100, 200): axe command 'touch' failed.\nDetails: axe command failed", 403 | }, 404 | ], 405 | isError: true, 406 | }); 407 | }); 408 | 409 | it('should handle SystemError from command execution', async () => { 410 | const mockExecutor = () => { 411 | throw new Error('ENOENT: no such file or directory'); 412 | }; 413 | 414 | const mockAxeHelpers = { 415 | getAxePath: () => '/usr/local/bin/axe', 416 | getBundledAxeEnvironment: () => ({}), 417 | createAxeNotAvailableResponse: () => ({ 418 | content: [{ type: 'text', text: 'Mock axe not available' }], 419 | isError: true, 420 | }), 421 | }; 422 | 423 | const result = await long_pressLogic( 424 | { 425 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 426 | x: 100, 427 | y: 200, 428 | duration: 1500, 429 | }, 430 | mockExecutor, 431 | mockAxeHelpers, 432 | ); 433 | 434 | expect(result).toEqual({ 435 | content: [ 436 | { 437 | type: 'text', 438 | text: expect.stringContaining( 439 | 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', 440 | ), 441 | }, 442 | ], 443 | isError: true, 444 | }); 445 | }); 446 | 447 | it('should handle unexpected Error objects', async () => { 448 | const mockExecutor = () => { 449 | throw new Error('Unexpected error'); 450 | }; 451 | 452 | const mockAxeHelpers = { 453 | getAxePath: () => '/usr/local/bin/axe', 454 | getBundledAxeEnvironment: () => ({}), 455 | createAxeNotAvailableResponse: () => ({ 456 | content: [{ type: 'text', text: 'Mock axe not available' }], 457 | isError: true, 458 | }), 459 | }; 460 | 461 | const result = await long_pressLogic( 462 | { 463 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 464 | x: 100, 465 | y: 200, 466 | duration: 1500, 467 | }, 468 | mockExecutor, 469 | mockAxeHelpers, 470 | ); 471 | 472 | expect(result).toEqual({ 473 | content: [ 474 | { 475 | type: 'text', 476 | text: expect.stringContaining( 477 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error', 478 | ), 479 | }, 480 | ], 481 | isError: true, 482 | }); 483 | }); 484 | 485 | it('should handle unexpected string errors', async () => { 486 | const mockExecutor = () => { 487 | throw 'String error'; 488 | }; 489 | 490 | const mockAxeHelpers = { 491 | getAxePath: () => '/usr/local/bin/axe', 492 | getBundledAxeEnvironment: () => ({}), 493 | createAxeNotAvailableResponse: () => ({ 494 | content: [{ type: 'text', text: 'Mock axe not available' }], 495 | isError: true, 496 | }), 497 | }; 498 | 499 | const result = await long_pressLogic( 500 | { 501 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 502 | x: 100, 503 | y: 200, 504 | duration: 1500, 505 | }, 506 | mockExecutor, 507 | mockAxeHelpers, 508 | ); 509 | 510 | expect(result).toEqual({ 511 | content: [ 512 | { 513 | type: 'text', 514 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 515 | }, 516 | ], 517 | isError: true, 518 | }); 519 | }); 520 | }); 521 | }); 522 | ``` -------------------------------------------------------------------------------- /docs/RELOADEROO.md: -------------------------------------------------------------------------------- ```markdown 1 | # Reloaderoo Integration Guide 2 | 3 | This guide explains how to use Reloaderoo v1.1.2+ for testing and developing XcodeBuildMCP with both CLI inspection tools and transparent proxy capabilities. 4 | 5 | ## Overview 6 | 7 | **Reloaderoo** is a dual-mode MCP development tool that operates as both a CLI inspection tool and a transparent proxy server for the Model Context Protocol (MCP). It provides two distinct operational modes for different development workflows. 8 | 9 | ## Installation 10 | 11 | Reloaderoo is available via npm and can be used with npx for universal compatibility. 12 | 13 | ```bash 14 | # Use npx to run reloaderoo (works on any system) 15 | npx reloaderoo@latest --help 16 | 17 | # Or install globally if preferred 18 | npm install -g reloaderoo 19 | reloaderoo --help 20 | ``` 21 | 22 | ## Two Operational Modes 23 | 24 | ### 🔍 **CLI Mode** (Inspection & Testing) 25 | 26 | Direct command-line access to MCP servers without client setup - perfect for testing and debugging: 27 | 28 | **Key Benefits:** 29 | - ✅ **One-shot commands** - Test tools, list resources, get server info 30 | - ✅ **No MCP client required** - Perfect for testing and debugging 31 | - ✅ **Raw JSON output** - Ideal for scripts and automation 32 | - ✅ **8 inspection commands** - Complete MCP protocol coverage 33 | - ✅ **AI agent friendly** - Designed for terminal-based AI development workflows 34 | 35 | **Basic Commands:** 36 | 37 | ```bash 38 | # List all available tools 39 | npx reloaderoo@latest inspect list-tools -- node build/index.js 40 | 41 | # Call any tool with parameters 42 | npx reloaderoo@latest inspect call-tool <tool_name> --params '<json>' -- node build/index.js 43 | 44 | # Get server information 45 | npx reloaderoo@latest inspect server-info -- node build/index.js 46 | 47 | # List available resources 48 | npx reloaderoo@latest inspect list-resources -- node build/index.js 49 | 50 | # Read a specific resource 51 | npx reloaderoo@latest inspect read-resource "<uri>" -- node build/index.js 52 | 53 | # List available prompts 54 | npx reloaderoo@latest inspect list-prompts -- node build/index.js 55 | 56 | # Get a specific prompt 57 | npx reloaderoo@latest inspect get-prompt <name> --args '<json>' -- node build/index.js 58 | 59 | # Check server connectivity 60 | npx reloaderoo@latest inspect ping -- node build/index.js 61 | ``` 62 | 63 | **Example Tool Calls:** 64 | 65 | ```bash 66 | # List connected devices 67 | npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js 68 | 69 | # Get doctor information 70 | npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js 71 | 72 | # List iOS simulators 73 | npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js 74 | 75 | # Read devices resource 76 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js 77 | ``` 78 | 79 | ### 🔄 **Proxy Mode** (Hot-Reload Development) 80 | 81 | Transparent MCP proxy server that enables seamless hot-reloading during development: 82 | 83 | **Key Benefits:** 84 | - ✅ **Hot-reload MCP servers** without disconnecting your AI client 85 | - ✅ **Session persistence** - Keep your development context intact 86 | - ✅ **Automatic `restart_server` tool** - AI agents can restart servers on demand 87 | - ✅ **Transparent forwarding** - Full MCP protocol passthrough 88 | - ✅ **Process management** - Spawns, monitors, and restarts your server process 89 | 90 | **Usage:** 91 | 92 | ```bash 93 | # Start proxy mode (your AI client connects to this) 94 | npx reloaderoo@latest proxy -- node build/index.js 95 | 96 | # With debug logging 97 | npx reloaderoo@latest proxy --log-level debug -- node build/index.js 98 | 99 | # Then in your AI session, request: 100 | # "Please restart the MCP server to load my latest changes" 101 | ``` 102 | 103 | The AI agent will automatically call the `restart_server` tool, preserving your session while reloading code changes. 104 | 105 | ## MCP Inspection Server Mode 106 | 107 | Start CLI mode as a persistent MCP server for interactive debugging through MCP clients: 108 | 109 | ```bash 110 | # Start reloaderoo in CLI mode as an MCP server 111 | npx reloaderoo@latest inspect mcp -- node build/index.js 112 | ``` 113 | 114 | This runs CLI mode as a persistent MCP server, exposing 8 debug tools through the MCP protocol: 115 | - `list_tools` - List all server tools 116 | - `call_tool` - Call any server tool 117 | - `list_resources` - List all server resources 118 | - `read_resource` - Read any server resource 119 | - `list_prompts` - List all server prompts 120 | - `get_prompt` - Get any server prompt 121 | - `get_server_info` - Get comprehensive server information 122 | - `ping` - Test server connectivity 123 | 124 | ## Claude Code Compatibility 125 | 126 | When running under Claude Code, XcodeBuildMCP automatically detects the environment and consolidates multiple content blocks into single responses with `---` separators. 127 | 128 | **Automatic Detection Methods:** 129 | 1. **Environment Variables**: `CLAUDECODE=1` or `CLAUDE_CODE_ENTRYPOINT=cli` 130 | 2. **Parent Process Analysis**: Checks if parent process contains 'claude' 131 | 3. **Graceful Fallback**: Falls back to environment variables if process detection fails 132 | 133 | **No Configuration Required**: The consolidation happens automatically when Claude Code is detected. 134 | 135 | ## Command Reference 136 | 137 | ### Command Structure 138 | 139 | ```bash 140 | npx reloaderoo@latest [options] [command] 141 | 142 | Two modes, one tool: 143 | • Proxy MCP server that adds support for hot-reloading MCP servers. 144 | • CLI tool for inspecting MCP servers. 145 | 146 | Global Options: 147 | -V, --version Output the version number 148 | -h, --help Display help for command 149 | 150 | Commands: 151 | proxy [options] 🔄 Run as MCP proxy server (default behavior) 152 | inspect 🔍 Inspect and debug MCP servers 153 | info [options] 📊 Display version and configuration information 154 | help [command] ❓ Display help for command 155 | ``` 156 | 157 | ### 🔄 **Proxy Mode Commands** 158 | 159 | ```bash 160 | npx reloaderoo@latest proxy [options] -- <child-command> [child-args...] 161 | 162 | Options: 163 | -w, --working-dir <directory> Working directory for the child process 164 | -l, --log-level <level> Log level (debug, info, notice, warning, error, critical) 165 | -f, --log-file <path> Custom log file path (logs to stderr by default) 166 | -t, --restart-timeout <ms> Timeout for restart operations (default: 30000ms) 167 | -m, --max-restarts <number> Maximum restart attempts (0-10, default: 3) 168 | -d, --restart-delay <ms> Delay between restart attempts (default: 1000ms) 169 | -q, --quiet Suppress non-essential output 170 | --no-auto-restart Disable automatic restart on crashes 171 | --debug Enable debug mode with verbose logging 172 | --dry-run Validate configuration without starting proxy 173 | 174 | Examples: 175 | npx reloaderoo proxy -- node build/index.js 176 | npx reloaderoo -- node build/index.js # Same as above (proxy is default) 177 | npx reloaderoo proxy --log-level debug -- node build/index.js 178 | ``` 179 | 180 | ### 🔍 **CLI Mode Commands** 181 | 182 | ```bash 183 | npx reloaderoo@latest inspect [subcommand] [options] -- <child-command> [child-args...] 184 | 185 | Subcommands: 186 | server-info [options] Get server information and capabilities 187 | list-tools [options] List all available tools 188 | call-tool [options] <name> Call a specific tool 189 | list-resources [options] List all available resources 190 | read-resource [options] <uri> Read a specific resource 191 | list-prompts [options] List all available prompts 192 | get-prompt [options] <name> Get a specific prompt 193 | ping [options] Check server connectivity 194 | 195 | Examples: 196 | npx reloaderoo@latest inspect list-tools -- node build/index.js 197 | npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js 198 | npx reloaderoo@latest inspect server-info -- node build/index.js 199 | ``` 200 | 201 | ### **Info Command** 202 | 203 | ```bash 204 | npx reloaderoo@latest info [options] 205 | 206 | Options: 207 | -v, --verbose Show detailed information 208 | -h, --help Display help for command 209 | 210 | Examples: 211 | npx reloaderoo@latest info # Show basic system information 212 | npx reloaderoo@latest info --verbose # Show detailed system information 213 | ``` 214 | 215 | ### Response Format 216 | 217 | All CLI commands return structured JSON: 218 | 219 | ```json 220 | { 221 | "success": true, 222 | "data": { 223 | // Command-specific response data 224 | }, 225 | "metadata": { 226 | "command": "call-tool:list_devices", 227 | "timestamp": "2025-07-25T08:32:47.042Z", 228 | "duration": 1782 229 | } 230 | } 231 | ``` 232 | 233 | ### Error Handling 234 | 235 | When commands fail, you'll receive: 236 | 237 | ```json 238 | { 239 | "success": false, 240 | "error": { 241 | "message": "Error description", 242 | "code": "ERROR_CODE" 243 | }, 244 | "metadata": { 245 | "command": "failed-command", 246 | "timestamp": "2025-07-25T08:32:47.042Z", 247 | "duration": 100 248 | } 249 | } 250 | ``` 251 | 252 | ## Development Workflow 253 | 254 | ### 🔍 **CLI Mode Workflow** (Testing & Debugging) 255 | 256 | Perfect for testing individual tools or debugging server issues without MCP client setup: 257 | 258 | ```bash 259 | # 1. Build XcodeBuildMCP 260 | npm run build 261 | 262 | # 2. Test your server quickly 263 | npx reloaderoo@latest inspect list-tools -- node build/index.js 264 | 265 | # 3. Call specific tools to verify behavior 266 | npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js 267 | 268 | # 4. Check server health and resources 269 | npx reloaderoo@latest inspect ping -- node build/index.js 270 | npx reloaderoo@latest inspect list-resources -- node build/index.js 271 | ``` 272 | 273 | ### 🔄 **Proxy Mode Workflow** (Hot-Reload Development) 274 | 275 | For full development sessions with AI clients that need persistent connections: 276 | 277 | #### 1. **Start Development Session** 278 | Configure your AI client to connect to reloaderoo proxy instead of your server directly: 279 | ```bash 280 | npx reloaderoo@latest proxy -- node build/index.js 281 | # or with debug logging: 282 | npx reloaderoo@latest proxy --log-level debug -- node build/index.js 283 | ``` 284 | 285 | #### 2. **Develop Your MCP Server** 286 | Work on your XcodeBuildMCP code as usual - make changes, add tools, modify functionality. 287 | 288 | #### 3. **Test Changes Instantly** 289 | ```bash 290 | # Rebuild your changes 291 | npm run build 292 | 293 | # Then ask your AI agent to restart the server: 294 | # "Please restart the MCP server to load my latest changes" 295 | ``` 296 | 297 | The agent will call the `restart_server` tool automatically. Your new capabilities are immediately available! 298 | 299 | #### 4. **Continue Development** 300 | Your AI session continues with the updated server capabilities. No connection loss, no context reset. 301 | 302 | ### 🛠️ **MCP Inspection Server** (Interactive CLI Debugging) 303 | 304 | For interactive debugging through MCP clients: 305 | 306 | ```bash 307 | # Start reloaderoo CLI mode as an MCP server 308 | npx reloaderoo@latest inspect mcp -- node build/index.js 309 | 310 | # Then connect with an MCP client to access debug tools 311 | # Available tools: list_tools, call_tool, list_resources, etc. 312 | ``` 313 | 314 | ## Troubleshooting 315 | 316 | ### 🔄 **Proxy Mode Issues** 317 | 318 | **Server won't start in proxy mode:** 319 | ```bash 320 | # Check if XcodeBuildMCP runs independently first 321 | node build/index.js 322 | 323 | # Then try with reloaderoo proxy to validate configuration 324 | npx reloaderoo@latest proxy -- node build/index.js 325 | ``` 326 | 327 | **Connection problems with MCP clients:** 328 | ```bash 329 | # Enable debug logging to see what's happening 330 | npx reloaderoo@latest proxy --log-level debug -- node build/index.js 331 | 332 | # Check system info and configuration 333 | npx reloaderoo@latest info --verbose 334 | ``` 335 | 336 | **Restart failures in proxy mode:** 337 | ```bash 338 | # Increase restart timeout 339 | npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js 340 | 341 | # Check restart limits 342 | npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js 343 | ``` 344 | 345 | ### 🔍 **CLI Mode Issues** 346 | 347 | **CLI commands failing:** 348 | ```bash 349 | # Test basic connectivity first 350 | npx reloaderoo@latest inspect ping -- node build/index.js 351 | 352 | # Enable debug logging for CLI commands (via proxy debug mode) 353 | npx reloaderoo@latest proxy --log-level debug -- node build/index.js 354 | ``` 355 | 356 | **JSON parsing errors:** 357 | ```bash 358 | # Check server information for troubleshooting 359 | npx reloaderoo@latest inspect server-info -- node build/index.js 360 | 361 | # Ensure your server outputs valid JSON 362 | node build/index.js | head -10 363 | ``` 364 | 365 | ### **General Issues** 366 | 367 | **Command not found:** 368 | ```bash 369 | # Ensure npx can find reloaderoo 370 | npx reloaderoo@latest --help 371 | 372 | # If that fails, try installing globally 373 | npm install -g reloaderoo 374 | ``` 375 | 376 | **Parameter validation:** 377 | ```bash 378 | # Ensure JSON parameters are properly quoted 379 | npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js 380 | ``` 381 | 382 | ### **General Debug Mode** 383 | 384 | ```bash 385 | # Get detailed information about what's happening 386 | npx reloaderoo@latest proxy --debug -- node build/index.js # For proxy mode 387 | npx reloaderoo@latest proxy --log-level debug -- node build/index.js # For detailed proxy logging 388 | 389 | # View system information 390 | npx reloaderoo@latest info --verbose 391 | ``` 392 | 393 | ### Debug Tips 394 | 395 | 1. **Always build first**: Run `npm run build` before testing 396 | 2. **Check tool names**: Use `inspect list-tools` to see exact tool names 397 | 3. **Validate JSON**: Ensure parameters are valid JSON strings 398 | 4. **Enable debug logging**: Use `--log-level debug` or `--debug` for verbose output 399 | 5. **Test connectivity**: Use `inspect ping` to verify server communication 400 | 401 | ## Advanced Usage 402 | 403 | ### Environment Variables 404 | 405 | Configure reloaderoo behavior via environment variables: 406 | 407 | ```bash 408 | # Logging Configuration 409 | export MCPDEV_PROXY_LOG_LEVEL=debug # Log level (debug, info, notice, warning, error, critical) 410 | export MCPDEV_PROXY_LOG_FILE=/path/to/log # Custom log file path (default: stderr) 411 | export MCPDEV_PROXY_DEBUG_MODE=true # Enable debug mode (true/false) 412 | 413 | # Process Management 414 | export MCPDEV_PROXY_RESTART_LIMIT=5 # Maximum restart attempts (0-10, default: 3) 415 | export MCPDEV_PROXY_AUTO_RESTART=true # Enable/disable auto-restart (true/false) 416 | export MCPDEV_PROXY_TIMEOUT=30000 # Operation timeout in milliseconds 417 | export MCPDEV_PROXY_RESTART_DELAY=1000 # Delay between restart attempts in milliseconds 418 | export MCPDEV_PROXY_CWD=/path/to/directory # Default working directory 419 | ``` 420 | 421 | ### Custom Working Directory 422 | 423 | ```bash 424 | npx reloaderoo@latest proxy --working-dir /custom/path -- node build/index.js 425 | npx reloaderoo@latest inspect list-tools --working-dir /custom/path -- node build/index.js 426 | ``` 427 | 428 | ### Timeout Configuration 429 | 430 | ```bash 431 | npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js 432 | ``` 433 | 434 | ## Integration with XcodeBuildMCP 435 | 436 | Reloaderoo is specifically configured to work with XcodeBuildMCP's: 437 | 438 | - **84+ Tools**: All workflow groups accessible via CLI 439 | - **4 Resources**: Direct access to devices, simulators, environment, swift-packages 440 | - **Dynamic Tool Discovery**: Compatible with `discover_tools` functionality 441 | - **Claude Code Detection**: Automatic consolidation of multiple content blocks 442 | - **Hot-Reload Support**: Seamless development workflow with `restart_server` 443 | 444 | For more information about XcodeBuildMCP's architecture and capabilities, see: 445 | - [Architecture Guide](ARCHITECTURE.md) 446 | - [Plugin Development Guide](PLUGIN_DEVELOPMENT.md) 447 | - [Testing Guide](TESTING.md) ``` -------------------------------------------------------------------------------- /example_projects/iOS_Calculator/CalculatorAppPackage/Tests/CalculatorAppFeatureTests/CalculatorServiceTests.swift: -------------------------------------------------------------------------------- ```swift 1 | import Testing 2 | import Foundation 3 | @testable import CalculatorAppFeature 4 | 5 | // MARK: - Calculator Basic Tests 6 | @Suite("Calculator Basic Functionality") 7 | struct CalculatorBasicTests { 8 | 9 | @Test("Calculator initializes with correct default values") 10 | func testInitialState() { 11 | let calculator = CalculatorService() 12 | #expect(calculator.display == "0") 13 | #expect(calculator.currentValue == 0) 14 | #expect(calculator.previousValue == 0) 15 | #expect(calculator.currentOperation == nil) 16 | #expect(calculator.willResetDisplay == false) 17 | } 18 | 19 | @Test("Clear function resets calculator to initial state") 20 | func testClear() { 21 | let calculator = CalculatorService() 22 | calculator.inputNumber("5") 23 | calculator.setOperation(.add) 24 | calculator.inputNumber("3") 25 | 26 | calculator.clear() 27 | 28 | #expect(calculator.display == "0") 29 | #expect(calculator.currentValue == 0) 30 | #expect(calculator.previousValue == 0) 31 | } 32 | 33 | @Test("This test should fail to verify error reporting") 34 | func testIntentionalFailure() { 35 | let calculator = CalculatorService() 36 | // This test is designed to fail to test error reporting 37 | #expect(calculator.display == "999", "This should fail - display should be 0, not 999") 38 | #expect(calculator.currentOperation == nil) 39 | #expect(calculator.willResetDisplay == false) 40 | } 41 | } 42 | 43 | // MARK: - Number Input Tests 44 | @Suite("Number Input") 45 | struct NumberInputTests { 46 | 47 | @Test("Adding single digit numbers") 48 | func testSingleDigitInput() { 49 | let calculator = CalculatorService() 50 | 51 | calculator.inputNumber("5") 52 | #expect(calculator.display == "5") 53 | #expect(calculator.currentValue == 5) 54 | } 55 | 56 | @Test("Adding multiple digit numbers") 57 | func testMultipleDigitInput() { 58 | let calculator = CalculatorService() 59 | 60 | calculator.inputNumber("1") 61 | calculator.inputNumber("2") 62 | calculator.inputNumber("3") 63 | 64 | #expect(calculator.display == "123") 65 | #expect(calculator.currentValue == 123) 66 | } 67 | 68 | @Test("Adding decimal numbers") 69 | func testDecimalInput() { 70 | let calculator = CalculatorService() 71 | 72 | calculator.inputNumber("1") 73 | calculator.inputDecimal() 74 | calculator.inputNumber("5") 75 | 76 | #expect(calculator.display == "1.5") 77 | #expect(calculator.currentValue == 1.5) 78 | } 79 | 80 | @Test("Multiple decimal points should be ignored") 81 | func testMultipleDecimalPoints() { 82 | let calculator = CalculatorService() 83 | 84 | calculator.inputNumber("1") 85 | calculator.inputDecimal() 86 | calculator.inputNumber("5") 87 | calculator.inputDecimal() // This should be ignored 88 | calculator.inputNumber("2") 89 | 90 | #expect(calculator.display == "1.52") 91 | #expect(calculator.currentValue == 1.52) 92 | } 93 | 94 | @Test("Decimal point at start creates 0.") 95 | func testDecimalAtStart() { 96 | let calculator = CalculatorService() 97 | 98 | calculator.inputDecimal() 99 | calculator.inputNumber("5") 100 | 101 | #expect(calculator.display == "0.5") 102 | #expect(calculator.currentValue == 0.5) 103 | } 104 | } 105 | 106 | // MARK: - Operation Tests 107 | @Suite("Mathematical Operations") 108 | struct OperationTests { 109 | 110 | @Test("Addition operation", arguments: [ 111 | (5.0, 3.0, 8.0), 112 | (10.0, -2.0, 8.0), 113 | (0.0, 5.0, 5.0), 114 | (-3.0, -7.0, -10.0) 115 | ]) 116 | func testAddition(a: Double, b: Double, expected: Double) { 117 | let result = CalculatorService.Operation.add.calculate(a, b) 118 | #expect(result == expected) 119 | } 120 | 121 | @Test("Subtraction operation", arguments: [ 122 | (10.0, 3.0, 7.0), 123 | (5.0, 8.0, -3.0), 124 | (0.0, 5.0, -5.0), 125 | (-3.0, -7.0, 4.0) 126 | ]) 127 | func testSubtraction(a: Double, b: Double, expected: Double) { 128 | let result = CalculatorService.Operation.subtract.calculate(a, b) 129 | #expect(result == expected) 130 | } 131 | 132 | @Test("Multiplication operation", arguments: [ 133 | (5.0, 3.0, 15.0), 134 | (4.0, -2.0, -8.0), 135 | (0.0, 5.0, 0.0), 136 | (-3.0, -7.0, 21.0) 137 | ]) 138 | func testMultiplication(a: Double, b: Double, expected: Double) { 139 | let result = CalculatorService.Operation.multiply.calculate(a, b) 140 | #expect(result == expected) 141 | } 142 | 143 | @Test("Division operation", arguments: [ 144 | (10.0, 2.0, 5.0), 145 | (15.0, 3.0, 5.0), 146 | (-8.0, 2.0, -4.0), 147 | (7.0, 2.0, 3.5) 148 | ]) 149 | func testDivision(a: Double, b: Double, expected: Double) { 150 | let result = CalculatorService.Operation.divide.calculate(a, b) 151 | #expect(result == expected) 152 | } 153 | 154 | @Test("Division by zero returns zero") 155 | func testDivisionByZero() { 156 | let result = CalculatorService.Operation.divide.calculate(10.0, 0.0) 157 | #expect(result == 0.0) 158 | } 159 | } 160 | 161 | // MARK: - Calculator Integration Tests 162 | @Suite("Calculator Integration Tests") 163 | struct CalculatorIntegrationTests { 164 | 165 | @Test("Simple addition calculation") 166 | func testSimpleAddition() { 167 | let calculator = CalculatorService() 168 | 169 | calculator.inputNumber("5") 170 | calculator.setOperation(.add) 171 | calculator.inputNumber("3") 172 | calculator.calculate() 173 | 174 | #expect(calculator.display == "8") 175 | #expect(calculator.currentValue == 8) 176 | } 177 | 178 | @Test("Chain calculations") 179 | func testChainCalculations() { 180 | let calculator = CalculatorService() 181 | 182 | calculator.inputNumber("5") 183 | calculator.setOperation(.add) 184 | calculator.inputNumber("3") 185 | calculator.setOperation(.multiply) // Should calculate 5+3=8 first 186 | calculator.inputNumber("2") 187 | calculator.calculate() 188 | 189 | #expect(calculator.currentValue == 16) // (5+3) * 2 = 16 190 | } 191 | 192 | @Test("Complex calculation sequence") 193 | func testComplexCalculation() { 194 | let calculator = CalculatorService() 195 | 196 | // Calculate: 10 + 5 * 2 - 3 197 | calculator.inputNumber("1") 198 | calculator.inputNumber("0") 199 | calculator.setOperation(.add) 200 | calculator.inputNumber("5") 201 | calculator.setOperation(.multiply) 202 | calculator.inputNumber("2") 203 | calculator.setOperation(.subtract) 204 | calculator.inputNumber("3") 205 | calculator.calculate() 206 | 207 | #expect(calculator.currentValue == 27) // ((10+5)*2)-3 = 27 208 | } 209 | 210 | @Test("Repetitive equals press repeats last operation") 211 | func testRepetitiveEquals() { 212 | let calculator = CalculatorService() 213 | 214 | calculator.inputNumber("5") 215 | calculator.setOperation(.add) 216 | calculator.inputNumber("3") 217 | calculator.calculate() // 5 + 3 = 8 218 | 219 | #expect(calculator.currentValue == 8) 220 | 221 | calculator.calculate() // Should be 8 + 3 = 11 222 | #expect(calculator.currentValue == 11) 223 | 224 | calculator.calculate() // Should be 11 + 3 = 14 225 | #expect(calculator.currentValue == 14) 226 | } 227 | 228 | @Test("Expression display updates correctly") 229 | func testExpressionDisplay() { 230 | let calculator = CalculatorService() 231 | 232 | calculator.inputNumber("1") 233 | calculator.inputNumber("2") 234 | #expect(calculator.expressionDisplay == "") 235 | 236 | calculator.setOperation(.add) 237 | #expect(calculator.expressionDisplay == "12 +") 238 | 239 | calculator.inputNumber("3") 240 | #expect(calculator.expressionDisplay == "12 +") 241 | 242 | calculator.calculate() 243 | #expect(calculator.expressionDisplay == "12 + 3 =") 244 | } 245 | } 246 | 247 | // MARK: - Special Functions Tests 248 | @Suite("Special Functions") 249 | struct SpecialFunctionsTests { 250 | 251 | @Test("Toggle sign on positive number") 252 | func testToggleSignPositive() { 253 | let calculator = CalculatorService() 254 | 255 | calculator.inputNumber("5") 256 | calculator.toggleSign() 257 | 258 | #expect(calculator.display == "-5") 259 | #expect(calculator.currentValue == -5) 260 | } 261 | 262 | @Test("Toggle sign on negative number") 263 | func testToggleSignNegative() { 264 | let calculator = CalculatorService() 265 | 266 | calculator.inputNumber("5") 267 | calculator.toggleSign() 268 | calculator.toggleSign() 269 | 270 | #expect(calculator.display == "5") 271 | #expect(calculator.currentValue == 5) 272 | } 273 | 274 | @Test("Toggle sign on zero has no effect") 275 | func testToggleSignZero() { 276 | let calculator = CalculatorService() 277 | 278 | calculator.toggleSign() 279 | 280 | #expect(calculator.display == "0") 281 | #expect(calculator.currentValue == 0) 282 | } 283 | 284 | @Test("Percentage calculation", arguments: [ 285 | ("100", 1.0), 286 | ("50", 0.5), 287 | ("25", 0.25), 288 | ("200", 2.0) 289 | ]) 290 | func testPercentage(input: String, expected: Double) { 291 | let calculator = CalculatorService() 292 | 293 | calculator.inputNumber(input) 294 | calculator.percentage() 295 | 296 | #expect(calculator.currentValue == expected) 297 | } 298 | } 299 | 300 | // MARK: - Input Handler Tests 301 | @Suite("Input Handler Integration") 302 | struct InputHandlerTests { 303 | 304 | @Test("Number input through handler") 305 | func testNumberInputThroughHandler() { 306 | let calculator = CalculatorService() 307 | let handler = CalculatorInputHandler(service: calculator) 308 | 309 | handler.handleInput("1") 310 | handler.handleInput("2") 311 | handler.handleInput("3") 312 | 313 | #expect(calculator.display == "123") 314 | } 315 | 316 | @Test("Operation input through handler") 317 | func testOperationInputThroughHandler() { 318 | let calculator = CalculatorService() 319 | let handler = CalculatorInputHandler(service: calculator) 320 | 321 | handler.handleInput("5") 322 | handler.handleInput("+") 323 | handler.handleInput("3") 324 | handler.handleInput("=") 325 | 326 | #expect(calculator.currentValue == 8) 327 | } 328 | 329 | @Test("Clear input through handler") 330 | func testClearInputThroughHandler() { 331 | let calculator = CalculatorService() 332 | let handler = CalculatorInputHandler(service: calculator) 333 | 334 | handler.handleInput("5") 335 | handler.handleInput("+") 336 | handler.handleInput("3") 337 | handler.handleInput("C") 338 | 339 | #expect(calculator.display == "0") 340 | #expect(calculator.currentValue == 0) 341 | } 342 | 343 | @Test("Decimal input through handler") 344 | func testDecimalInputThroughHandler() { 345 | let calculator = CalculatorService() 346 | let handler = CalculatorInputHandler(service: calculator) 347 | 348 | handler.handleInput("1") 349 | handler.handleInput(".") 350 | handler.handleInput("5") 351 | 352 | #expect(calculator.display == "1.5") 353 | } 354 | } 355 | 356 | // MARK: - Edge Cases Tests 357 | @Suite("Edge Cases") 358 | struct EdgeCaseTests { 359 | 360 | @Test("Calculate without setting operation") 361 | func testCalculateWithoutOperation() { 362 | let calculator = CalculatorService() 363 | 364 | calculator.inputNumber("5") 365 | calculator.calculate() 366 | 367 | #expect(calculator.currentValue == 5) // Should remain unchanged 368 | } 369 | 370 | @Test("Setting operation without previous number") 371 | func testOperationWithoutPreviousNumber() { 372 | let calculator = CalculatorService() 373 | 374 | calculator.setOperation(.add) 375 | calculator.inputNumber("5") 376 | calculator.calculate() 377 | 378 | #expect(calculator.currentValue == 5) // 0 + 5 = 5 379 | } 380 | 381 | @Test("Multiple equals presses") 382 | func testMultipleEquals() { 383 | let calculator = CalculatorService() 384 | 385 | calculator.inputNumber("5") 386 | calculator.setOperation(.add) 387 | calculator.inputNumber("3") 388 | calculator.calculate() 389 | 390 | let firstResult = calculator.currentValue 391 | calculator.calculate() // Second equals press 392 | 393 | #expect(firstResult == 8) 394 | #expect(calculator.currentValue == 11) // Should repeat last operation: 8 + 3 = 11 395 | } 396 | } 397 | 398 | // MARK: - Error Handling Tests 399 | @Suite("Error Handling") 400 | struct ErrorHandlingTests { 401 | 402 | @Test("Calculator handles invalid input gracefully") 403 | func testInvalidInputHandling() { 404 | let calculator = CalculatorService() 405 | let handler = CalculatorInputHandler(service: calculator) 406 | 407 | // Test pressing operation without any number 408 | handler.handleInput("+") 409 | handler.handleInput("5") 410 | handler.handleInput("=") 411 | 412 | #expect(calculator.currentValue == 5) // Should be 0 + 5 = 5 413 | } 414 | 415 | @Test("Calculator state after multiple clears") 416 | func testMultipleClearOperations() { 417 | let calculator = CalculatorService() 418 | 419 | calculator.inputNumber("123") 420 | calculator.setOperation(.add) 421 | calculator.inputNumber("456") 422 | 423 | // Multiple clear operations 424 | calculator.clear() 425 | calculator.clear() 426 | calculator.clear() 427 | 428 | #expect(calculator.display == "0") 429 | #expect(calculator.currentValue == 0) 430 | #expect(calculator.currentOperation == nil) 431 | } 432 | 433 | @Test("Large number error handling") 434 | func testLargeNumberError() { 435 | let calculator = CalculatorService() 436 | calculator.inputNumber("1000000000000") // 1e12 437 | calculator.setOperation(.multiply) 438 | calculator.inputNumber("2") 439 | calculator.calculate() 440 | 441 | #expect(calculator.hasError == true) 442 | #expect(calculator.display == "Error") 443 | #expect(calculator.expressionDisplay == "Number too large") 444 | } 445 | } 446 | 447 | // MARK: - Decimal Edge Cases 448 | @Suite("Decimal Edge Cases") 449 | struct DecimalEdgeCaseTests { 450 | 451 | @Test("Very small decimal numbers") 452 | func testVerySmallDecimals() { 453 | let calculator = CalculatorService() 454 | 455 | calculator.inputNumber("0") 456 | calculator.inputDecimal() 457 | calculator.inputNumber("0") 458 | calculator.inputNumber("0") 459 | calculator.inputNumber("1") 460 | 461 | #expect(calculator.display == "0.001") 462 | #expect(calculator.currentValue == 0.001) 463 | } 464 | 465 | @Test("Decimal operations precision") 466 | func testDecimalPrecision() { 467 | let calculator = CalculatorService() 468 | 469 | calculator.inputNumber("0") 470 | calculator.inputDecimal() 471 | calculator.inputNumber("1") 472 | calculator.setOperation(.add) 473 | calculator.inputNumber("0") 474 | calculator.inputDecimal() 475 | calculator.inputNumber("2") 476 | calculator.calculate() 477 | 478 | // 0.1 + 0.2 should equal 0.3 (within floating point precision) 479 | #expect(abs(calculator.currentValue - 0.3) < 0.0001) 480 | } 481 | } 482 | ``` -------------------------------------------------------------------------------- /scripts/analysis/tools-analysis.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * XcodeBuildMCP Tools Analysis 5 | * 6 | * Core TypeScript module for analyzing XcodeBuildMCP tools using AST parsing. 7 | * Provides reliable extraction of tool information without fallback strategies. 8 | */ 9 | 10 | import { 11 | createSourceFile, 12 | forEachChild, 13 | isExportAssignment, 14 | isIdentifier, 15 | isNoSubstitutionTemplateLiteral, 16 | isObjectLiteralExpression, 17 | isPropertyAssignment, 18 | isStringLiteral, 19 | isTemplateExpression, 20 | isVariableDeclaration, 21 | isVariableStatement, 22 | type Node, 23 | type ObjectLiteralExpression, 24 | ScriptTarget, 25 | type SourceFile, 26 | SyntaxKind, 27 | } from 'typescript'; 28 | import * as fs from 'fs'; 29 | import * as path from 'path'; 30 | import { glob } from 'glob'; 31 | import { fileURLToPath } from 'url'; 32 | 33 | // Get project root 34 | const __filename = fileURLToPath(import.meta.url); 35 | const __dirname = path.dirname(__filename); 36 | const projectRoot = path.resolve(__dirname, '..', '..'); 37 | const toolsDir = path.join(projectRoot, 'src', 'mcp', 'tools'); 38 | 39 | export interface ToolInfo { 40 | name: string; 41 | workflow: string; 42 | path: string; 43 | relativePath: string; 44 | description: string; 45 | isCanonical: boolean; 46 | } 47 | 48 | export interface WorkflowInfo { 49 | name: string; 50 | displayName: string; 51 | description: string; 52 | tools: ToolInfo[]; 53 | toolCount: number; 54 | canonicalCount: number; 55 | reExportCount: number; 56 | } 57 | 58 | export interface AnalysisStats { 59 | totalTools: number; 60 | canonicalTools: number; 61 | reExportTools: number; 62 | workflowCount: number; 63 | } 64 | 65 | export interface StaticAnalysisResult { 66 | workflows: WorkflowInfo[]; 67 | tools: ToolInfo[]; 68 | stats: AnalysisStats; 69 | } 70 | 71 | /** 72 | * Extract the description from a tool's default export using TypeScript AST 73 | */ 74 | function extractToolDescription(sourceFile: SourceFile): string { 75 | let description: string | null = null; 76 | 77 | function visit(node: Node): void { 78 | let objectExpression: ObjectLiteralExpression | null = null; 79 | 80 | // Look for export default { ... } - the standard TypeScript pattern 81 | // isExportEquals is undefined for `export default` and true for `export = ` 82 | if (isExportAssignment(node) && !node.isExportEquals) { 83 | if (isObjectLiteralExpression(node.expression)) { 84 | objectExpression = node.expression; 85 | } 86 | } 87 | 88 | if (objectExpression) { 89 | // Found export default { ... }, now look for description property 90 | for (const property of objectExpression.properties) { 91 | if ( 92 | isPropertyAssignment(property) && 93 | isIdentifier(property.name) && 94 | property.name.text === 'description' 95 | ) { 96 | // Extract the description value 97 | if (isStringLiteral(property.initializer)) { 98 | // This is the most common case - simple string literal 99 | description = property.initializer.text; 100 | } else if ( 101 | isTemplateExpression(property.initializer) || 102 | isNoSubstitutionTemplateLiteral(property.initializer) 103 | ) { 104 | // Handle template literals - get the raw text and clean it 105 | description = property.initializer.getFullText(sourceFile).trim(); 106 | // Remove surrounding backticks 107 | if (description.startsWith('`') && description.endsWith('`')) { 108 | description = description.slice(1, -1); 109 | } 110 | } else { 111 | // Handle any other expression (multiline strings, computed values) 112 | const fullText = property.initializer.getFullText(sourceFile).trim(); 113 | // This covers cases where the description spans multiple lines 114 | // Remove surrounding quotes and normalize whitespace 115 | let cleaned = fullText; 116 | if ( 117 | (cleaned.startsWith('"') && cleaned.endsWith('"')) || 118 | (cleaned.startsWith("'") && cleaned.endsWith("'")) 119 | ) { 120 | cleaned = cleaned.slice(1, -1); 121 | } 122 | // Collapse multiple whitespaces and newlines into single spaces 123 | description = cleaned.replace(/\s+/g, ' ').trim(); 124 | } 125 | return; // Found description, stop looking 126 | } 127 | } 128 | } 129 | 130 | forEachChild(node, visit); 131 | } 132 | 133 | visit(sourceFile); 134 | 135 | if (description === null) { 136 | throw new Error('Could not extract description from tool export default object'); 137 | } 138 | 139 | return description; 140 | } 141 | 142 | /** 143 | * Check if a file is a re-export by examining its content 144 | */ 145 | function isReExportFile(filePath: string): boolean { 146 | const content = fs.readFileSync(filePath, 'utf-8'); 147 | 148 | // Remove comments and empty lines, then check for re-export pattern 149 | // First remove multi-line comments 150 | const contentWithoutBlockComments = content.replace(/\/\*[\s\S]*?\*\//g, ''); 151 | 152 | const cleanedLines = contentWithoutBlockComments 153 | .split('\n') 154 | .map((line) => { 155 | // Remove inline comments but preserve the code before them 156 | const codeBeforeComment = line.split('//')[0].trim(); 157 | return codeBeforeComment; 158 | }) 159 | .filter((line) => line.length > 0); 160 | 161 | // Should have exactly one line: export { default } from '...'; 162 | if (cleanedLines.length !== 1) { 163 | return false; 164 | } 165 | 166 | const exportLine = cleanedLines[0]; 167 | return /^export\s*{\s*default\s*}\s*from\s*['"][^'"]+['"];?\s*$/.test(exportLine); 168 | } 169 | 170 | /** 171 | * Get workflow metadata from index.ts file if it exists 172 | */ 173 | async function getWorkflowMetadata( 174 | workflowDir: string, 175 | ): Promise<{ displayName: string; description: string } | null> { 176 | const indexPath = path.join(toolsDir, workflowDir, 'index.ts'); 177 | 178 | if (!fs.existsSync(indexPath)) { 179 | return null; 180 | } 181 | 182 | try { 183 | const content = fs.readFileSync(indexPath, 'utf-8'); 184 | const sourceFile = createSourceFile(indexPath, content, ScriptTarget.Latest, true); 185 | 186 | const workflowExport: { name?: string; description?: string } = {}; 187 | 188 | function visit(node: Node): void { 189 | // Look for: export const workflow = { ... } 190 | if ( 191 | isVariableStatement(node) && 192 | node.modifiers?.some((mod) => mod.kind === SyntaxKind.ExportKeyword) 193 | ) { 194 | for (const declaration of node.declarationList.declarations) { 195 | if ( 196 | isVariableDeclaration(declaration) && 197 | isIdentifier(declaration.name) && 198 | declaration.name.text === 'workflow' && 199 | declaration.initializer && 200 | isObjectLiteralExpression(declaration.initializer) 201 | ) { 202 | // Extract name and description properties 203 | for (const property of declaration.initializer.properties) { 204 | if (isPropertyAssignment(property) && isIdentifier(property.name)) { 205 | const propertyName = property.name.text; 206 | 207 | if (propertyName === 'name' && isStringLiteral(property.initializer)) { 208 | workflowExport.name = property.initializer.text; 209 | } else if ( 210 | propertyName === 'description' && 211 | isStringLiteral(property.initializer) 212 | ) { 213 | workflowExport.description = property.initializer.text; 214 | } 215 | } 216 | } 217 | } 218 | } 219 | } 220 | 221 | forEachChild(node, visit); 222 | } 223 | 224 | visit(sourceFile); 225 | 226 | if (workflowExport.name && workflowExport.description) { 227 | return { 228 | displayName: workflowExport.name, 229 | description: workflowExport.description, 230 | }; 231 | } 232 | } catch (error) { 233 | console.error(`Warning: Could not parse workflow metadata from ${indexPath}: ${error}`); 234 | } 235 | 236 | return null; 237 | } 238 | 239 | /** 240 | * Get a human-readable workflow name from directory name 241 | */ 242 | function getWorkflowDisplayName(workflowDir: string): string { 243 | const displayNames: Record<string, string> = { 244 | device: 'iOS Device Development', 245 | discovery: 'Dynamic Tool Discovery', 246 | doctor: 'System Doctor', 247 | logging: 'Logging & Monitoring', 248 | macos: 'macOS Development', 249 | 'project-discovery': 'Project Discovery', 250 | 'project-scaffolding': 'Project Scaffolding', 251 | simulator: 'iOS Simulator Development', 252 | 'simulator-management': 'Simulator Management', 253 | 'swift-package': 'Swift Package Manager', 254 | 'ui-testing': 'UI Testing & Automation', 255 | utilities: 'Utilities', 256 | }; 257 | 258 | return displayNames[workflowDir] || workflowDir; 259 | } 260 | 261 | /** 262 | * Get workflow description 263 | */ 264 | function getWorkflowDescription(workflowDir: string): string { 265 | const descriptions: Record<string, string> = { 266 | device: 'Physical device development, testing, and deployment', 267 | discovery: 'Intelligent workflow enablement based on task descriptions', 268 | doctor: 'System health checks and environment validation', 269 | logging: 'Log capture and monitoring across platforms', 270 | macos: 'Native macOS application development and testing', 271 | 'project-discovery': 'Project analysis and information gathering', 272 | 'project-scaffolding': 'Create new projects from templates', 273 | simulator: 'Simulator-based development, testing, and deployment', 274 | 'simulator-management': 'Simulator environment and configuration management', 275 | 'swift-package': 'Swift Package development and testing', 276 | 'ui-testing': 'Automated UI interaction and testing', 277 | utilities: 'General utility operations', 278 | }; 279 | 280 | return descriptions[workflowDir] || `${workflowDir} related tools`; 281 | } 282 | 283 | /** 284 | * Perform static analysis of all tools in the project 285 | */ 286 | export async function getStaticToolAnalysis(): Promise<StaticAnalysisResult> { 287 | // Find all workflow directories 288 | const workflowDirs = fs 289 | .readdirSync(toolsDir, { withFileTypes: true }) 290 | .filter((dirent) => dirent.isDirectory()) 291 | .map((dirent) => dirent.name) 292 | .sort(); 293 | 294 | // Find all tool files 295 | const files = await glob('**/*.ts', { 296 | cwd: toolsDir, 297 | ignore: [ 298 | '**/__tests__/**', 299 | '**/index.ts', 300 | '**/*.test.ts', 301 | '**/lib/**', 302 | '**/*-processes.ts', // Process management utilities 303 | '**/*.deps.ts', // Dependency files 304 | '**/*-utils.ts', // Utility files 305 | '**/*-common.ts', // Common/shared code 306 | '**/*-types.ts', // Type definition files 307 | ], 308 | absolute: true, 309 | }); 310 | 311 | const allTools: ToolInfo[] = []; 312 | const workflowMap = new Map<string, ToolInfo[]>(); 313 | 314 | let canonicalCount = 0; 315 | let reExportCount = 0; 316 | 317 | // Initialize workflow map 318 | for (const workflowDir of workflowDirs) { 319 | workflowMap.set(workflowDir, []); 320 | } 321 | 322 | // Process each tool file 323 | for (const filePath of files) { 324 | const toolName = path.basename(filePath, '.ts'); 325 | const workflowDir = path.basename(path.dirname(filePath)); 326 | const relativePath = path.relative(projectRoot, filePath); 327 | 328 | const isReExport = isReExportFile(filePath); 329 | 330 | let description = ''; 331 | 332 | if (!isReExport) { 333 | // Extract description from canonical tool using AST 334 | try { 335 | const content = fs.readFileSync(filePath, 'utf-8'); 336 | const sourceFile = createSourceFile(filePath, content, ScriptTarget.Latest, true); 337 | 338 | description = extractToolDescription(sourceFile); 339 | canonicalCount++; 340 | } catch (error) { 341 | throw new Error(`Failed to extract description from ${relativePath}: ${error}`); 342 | } 343 | } else { 344 | description = '(Re-exported from shared workflow)'; 345 | reExportCount++; 346 | } 347 | 348 | const toolInfo: ToolInfo = { 349 | name: toolName, 350 | workflow: workflowDir, 351 | path: filePath, 352 | relativePath, 353 | description, 354 | isCanonical: !isReExport, 355 | }; 356 | 357 | allTools.push(toolInfo); 358 | 359 | const workflowTools = workflowMap.get(workflowDir); 360 | if (workflowTools) { 361 | workflowTools.push(toolInfo); 362 | } 363 | } 364 | 365 | // Build workflow information 366 | const workflows: WorkflowInfo[] = []; 367 | 368 | for (const workflowDir of workflowDirs) { 369 | const workflowTools = workflowMap.get(workflowDir) ?? []; 370 | const canonicalTools = workflowTools.filter((t) => t.isCanonical); 371 | const reExportTools = workflowTools.filter((t) => !t.isCanonical); 372 | 373 | // Try to get metadata from index.ts, fall back to hardcoded names/descriptions 374 | const metadata = await getWorkflowMetadata(workflowDir); 375 | 376 | const workflowInfo: WorkflowInfo = { 377 | name: workflowDir, 378 | displayName: metadata?.displayName ?? getWorkflowDisplayName(workflowDir), 379 | description: metadata?.description ?? getWorkflowDescription(workflowDir), 380 | tools: workflowTools.sort((a, b) => a.name.localeCompare(b.name)), 381 | toolCount: workflowTools.length, 382 | canonicalCount: canonicalTools.length, 383 | reExportCount: reExportTools.length, 384 | }; 385 | 386 | workflows.push(workflowInfo); 387 | } 388 | 389 | const stats: AnalysisStats = { 390 | totalTools: allTools.length, 391 | canonicalTools: canonicalCount, 392 | reExportTools: reExportCount, 393 | workflowCount: workflows.length, 394 | }; 395 | 396 | return { 397 | workflows: workflows.sort((a, b) => a.displayName.localeCompare(b.displayName)), 398 | tools: allTools.sort((a, b) => a.name.localeCompare(b.name)), 399 | stats, 400 | }; 401 | } 402 | 403 | /** 404 | * Get only canonical tools (excluding re-exports) for documentation generation 405 | */ 406 | export async function getCanonicalTools(): Promise<ToolInfo[]> { 407 | const analysis = await getStaticToolAnalysis(); 408 | return analysis.tools.filter((tool) => tool.isCanonical); 409 | } 410 | 411 | /** 412 | * Get tools grouped by workflow for documentation generation 413 | */ 414 | export async function getToolsByWorkflow(): Promise<Map<string, ToolInfo[]>> { 415 | const analysis = await getStaticToolAnalysis(); 416 | const workflowMap = new Map<string, ToolInfo[]>(); 417 | 418 | for (const workflow of analysis.workflows) { 419 | // Only include canonical tools for documentation 420 | const canonicalTools = workflow.tools.filter((tool) => tool.isCanonical); 421 | if (canonicalTools.length > 0) { 422 | workflowMap.set(workflow.name, canonicalTools); 423 | } 424 | } 425 | 426 | return workflowMap; 427 | } 428 | 429 | // CLI support - if run directly, perform analysis and output results 430 | if (import.meta.url === `file://${process.argv[1]}`) { 431 | async function main(): Promise<void> { 432 | try { 433 | console.log('🔍 Performing static analysis...'); 434 | const analysis = await getStaticToolAnalysis(); 435 | 436 | console.log('\n📊 Analysis Results:'); 437 | console.log(` Workflows: ${analysis.stats.workflowCount}`); 438 | console.log(` Total tools: ${analysis.stats.totalTools}`); 439 | console.log(` Canonical tools: ${analysis.stats.canonicalTools}`); 440 | console.log(` Re-export tools: ${analysis.stats.reExportTools}`); 441 | 442 | if (process.argv.includes('--json')) { 443 | console.log('\n' + JSON.stringify(analysis, null, 2)); 444 | } else { 445 | console.log('\n📂 Workflows:'); 446 | for (const workflow of analysis.workflows) { 447 | console.log( 448 | ` • ${workflow.displayName} (${workflow.canonicalCount} canonical, ${workflow.reExportCount} re-exports)`, 449 | ); 450 | } 451 | } 452 | } catch (error) { 453 | console.error('❌ Analysis failed:', error); 454 | process.exit(1); 455 | } 456 | } 457 | 458 | main(); 459 | } 460 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Test for scaffold_macos_project plugin - Dependency Injection Architecture 3 | * 4 | * Tests the plugin structure and exported components for scaffold_macos_project tool. 5 | * Uses pure dependency injection with createMockFileSystemExecutor. 6 | * NO VITEST MOCKING ALLOWED - Only createMockExecutor/createMockFileSystemExecutor 7 | * 8 | * Plugin location: plugins/utilities/scaffold_macos_project.js 9 | */ 10 | 11 | import { describe, it, expect, beforeEach } from 'vitest'; 12 | import { z } from 'zod'; 13 | import { 14 | createMockFileSystemExecutor, 15 | createNoopExecutor, 16 | createMockExecutor, 17 | } from '../../../../test-utils/mock-executors.ts'; 18 | import plugin, { scaffold_macos_projectLogic } from '../scaffold_macos_project.ts'; 19 | import { TemplateManager } from '../../../../utils/template/index.ts'; 20 | 21 | // ONLY ALLOWED MOCKING: createMockFileSystemExecutor 22 | 23 | describe('scaffold_macos_project plugin', () => { 24 | let mockFileSystemExecutor: ReturnType<typeof createMockFileSystemExecutor>; 25 | let templateManagerStub: { 26 | getTemplatePath: ( 27 | platform: string, 28 | commandExecutor?: unknown, 29 | fileSystemExecutor?: unknown, 30 | ) => Promise<string>; 31 | cleanup: (path: string) => Promise<void>; 32 | setError: (error: Error | string | null) => void; 33 | getCalls: () => string; 34 | resetCalls: () => void; 35 | }; 36 | 37 | beforeEach(async () => { 38 | // Create template manager stub using pure JavaScript approach 39 | let templateManagerCall = ''; 40 | let templateManagerError: Error | string | null = null; 41 | 42 | templateManagerStub = { 43 | getTemplatePath: async ( 44 | platform: string, 45 | commandExecutor?: unknown, 46 | fileSystemExecutor?: unknown, 47 | ) => { 48 | templateManagerCall = `getTemplatePath(${platform})`; 49 | if (templateManagerError) { 50 | throw templateManagerError; 51 | } 52 | return '/tmp/test-templates/macos'; 53 | }, 54 | cleanup: async (path: string) => { 55 | templateManagerCall += `,cleanup(${path})`; 56 | return undefined; 57 | }, 58 | // Test helpers 59 | setError: (error: Error | string | null) => { 60 | templateManagerError = error; 61 | }, 62 | getCalls: () => templateManagerCall, 63 | resetCalls: () => { 64 | templateManagerCall = ''; 65 | }, 66 | }; 67 | 68 | // Create fresh mock file system executor for each test 69 | mockFileSystemExecutor = createMockFileSystemExecutor({ 70 | existsSync: () => false, 71 | mkdir: async () => {}, 72 | cp: async () => {}, 73 | readFile: async () => 'template content with MyProject placeholder', 74 | writeFile: async () => {}, 75 | readdir: async () => [ 76 | { name: 'Package.swift', isDirectory: () => false, isFile: () => true }, 77 | { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true }, 78 | ], 79 | }); 80 | 81 | // Replace the real TemplateManager with our stub for most tests 82 | (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; 83 | (TemplateManager as any).cleanup = templateManagerStub.cleanup; 84 | }); 85 | 86 | describe('Export Field Validation (Literal)', () => { 87 | it('should have correct name field', () => { 88 | expect(plugin.name).toBe('scaffold_macos_project'); 89 | }); 90 | 91 | it('should have correct description field', () => { 92 | expect(plugin.description).toBe( 93 | 'Scaffold a new macOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper macOS configuration.', 94 | ); 95 | }); 96 | 97 | it('should have handler as function', () => { 98 | expect(typeof plugin.handler).toBe('function'); 99 | }); 100 | 101 | it('should have valid schema with required fields', () => { 102 | // Test the schema object exists 103 | expect(plugin.schema).toBeDefined(); 104 | expect(plugin.schema.projectName).toBeDefined(); 105 | expect(plugin.schema.outputPath).toBeDefined(); 106 | expect(plugin.schema.bundleIdentifier).toBeDefined(); 107 | expect(plugin.schema.customizeNames).toBeDefined(); 108 | expect(plugin.schema.deploymentTarget).toBeDefined(); 109 | }); 110 | }); 111 | 112 | describe('Command Generation', () => { 113 | it('should generate correct curl command for macOS template download', async () => { 114 | // This test validates that the curl command would be generated correctly 115 | // by verifying the URL construction logic 116 | const expectedUrl = 117 | 'https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template/releases/download/'; 118 | 119 | // The curl command should be structured correctly for macOS template 120 | expect(expectedUrl).toContain('XcodeBuildMCP-macOS-Template'); 121 | expect(expectedUrl).toContain('releases/download'); 122 | 123 | // The template zip file should follow the expected pattern 124 | const expectedFilename = 'template.zip'; 125 | expect(expectedFilename).toMatch(/template\.zip$/); 126 | 127 | // The curl command flags should be correct 128 | const expectedCurlFlags = ['-L', '-f', '-o']; 129 | expect(expectedCurlFlags).toContain('-L'); // Follow redirects 130 | expect(expectedCurlFlags).toContain('-f'); // Fail on HTTP errors 131 | expect(expectedCurlFlags).toContain('-o'); // Output to file 132 | }); 133 | 134 | it('should generate correct unzip command for template extraction', async () => { 135 | // This test validates that the unzip command would be generated correctly 136 | // by verifying the command structure 137 | const expectedUnzipCommand = ['unzip', '-q', 'template.zip']; 138 | 139 | // The unzip command should use the quiet flag 140 | expect(expectedUnzipCommand).toContain('-q'); 141 | 142 | // The unzip command should target the template zip file 143 | expect(expectedUnzipCommand).toContain('template.zip'); 144 | 145 | // The unzip command should be structured correctly 146 | expect(expectedUnzipCommand[0]).toBe('unzip'); 147 | expect(expectedUnzipCommand[1]).toBe('-q'); 148 | expect(expectedUnzipCommand[2]).toMatch(/template\.zip$/); 149 | }); 150 | 151 | it('should generate correct commands for template with version', async () => { 152 | // This test validates that the curl command would be generated correctly with version 153 | const testVersion = 'v1.0.0'; 154 | const expectedUrlWithVersion = `https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`; 155 | 156 | // The URL should contain the specific version 157 | expect(expectedUrlWithVersion).toContain(testVersion); 158 | expect(expectedUrlWithVersion).toContain('XcodeBuildMCP-macOS-Template'); 159 | expect(expectedUrlWithVersion).toContain('releases/download'); 160 | 161 | // The version should be in the correct format 162 | expect(testVersion).toMatch(/^v\d+\.\d+\.\d+$/); 163 | 164 | // The full URL should be correctly constructed 165 | expect(expectedUrlWithVersion).toBe( 166 | `https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`, 167 | ); 168 | }); 169 | 170 | it('should not generate commands when using local template path', async () => { 171 | let capturedCommands: string[][] = []; 172 | const trackingExecutor = async (command: string[]) => { 173 | capturedCommands.push(command); 174 | return { 175 | success: true, 176 | output: 'Command successful', 177 | error: undefined, 178 | process: { pid: 12345 }, 179 | }; 180 | }; 181 | 182 | // Store original environment variable 183 | const originalEnv = process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH; 184 | 185 | // Mock local template path exists 186 | mockFileSystemExecutor.existsSync = (path: string) => { 187 | return path === '/local/template/path' || path === '/local/template/path/template'; 188 | }; 189 | 190 | // Set environment variable for local template path 191 | process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH = '/local/template/path'; 192 | 193 | // Restore original TemplateManager for command generation tests 194 | const { TemplateManager: OriginalTemplateManager } = await import( 195 | '../../../../utils/template/index.ts' 196 | ); 197 | (TemplateManager as any).getTemplatePath = OriginalTemplateManager.getTemplatePath; 198 | (TemplateManager as any).cleanup = OriginalTemplateManager.cleanup; 199 | 200 | await scaffold_macos_projectLogic( 201 | { 202 | projectName: 'TestMacApp', 203 | outputPath: '/tmp/test-projects', 204 | }, 205 | trackingExecutor, 206 | mockFileSystemExecutor, 207 | ); 208 | 209 | // Should not generate any curl or unzip commands when using local template 210 | expect(capturedCommands).not.toContainEqual( 211 | expect.arrayContaining(['curl', expect.anything(), expect.anything()]), 212 | ); 213 | expect(capturedCommands).not.toContainEqual( 214 | expect.arrayContaining(['unzip', expect.anything(), expect.anything()]), 215 | ); 216 | 217 | // Clean up environment variable 218 | process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH = originalEnv; 219 | 220 | // Restore stub after test 221 | (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; 222 | (TemplateManager as any).cleanup = templateManagerStub.cleanup; 223 | }); 224 | }); 225 | 226 | describe('Handler Behavior (Complete Literal Returns)', () => { 227 | it('should return success response for valid scaffold macOS project request', async () => { 228 | const result = await scaffold_macos_projectLogic( 229 | { 230 | projectName: 'TestMacApp', 231 | outputPath: '/tmp/test-projects', 232 | bundleIdentifier: 'com.test.macapp', 233 | customizeNames: false, 234 | }, 235 | createNoopExecutor(), 236 | mockFileSystemExecutor, 237 | ); 238 | 239 | expect(result).toEqual({ 240 | content: [ 241 | { 242 | type: 'text', 243 | text: JSON.stringify( 244 | { 245 | success: true, 246 | projectPath: '/tmp/test-projects', 247 | platform: 'macOS', 248 | message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', 249 | nextSteps: [ 250 | 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', 251 | 'Build for macOS: build_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', 252 | 'Build & Run on macOS: build_run_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', 253 | ], 254 | }, 255 | null, 256 | 2, 257 | ), 258 | }, 259 | ], 260 | }); 261 | 262 | // Verify template manager calls using manual tracking 263 | expect(templateManagerStub.getCalls()).toBe( 264 | 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', 265 | ); 266 | }); 267 | 268 | it('should return success response with customizeNames false', async () => { 269 | const result = await scaffold_macos_projectLogic( 270 | { 271 | projectName: 'TestMacApp', 272 | outputPath: '/tmp/test-projects', 273 | customizeNames: false, 274 | }, 275 | createNoopExecutor(), 276 | mockFileSystemExecutor, 277 | ); 278 | 279 | expect(result).toEqual({ 280 | content: [ 281 | { 282 | type: 'text', 283 | text: JSON.stringify( 284 | { 285 | success: true, 286 | projectPath: '/tmp/test-projects', 287 | platform: 'macOS', 288 | message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', 289 | nextSteps: [ 290 | 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', 291 | 'Build for macOS: build_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', 292 | 'Build & Run on macOS: build_run_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', 293 | ], 294 | }, 295 | null, 296 | 2, 297 | ), 298 | }, 299 | ], 300 | }); 301 | }); 302 | 303 | it('should return error response for invalid project name', async () => { 304 | const result = await scaffold_macos_projectLogic( 305 | { 306 | projectName: '123InvalidName', 307 | outputPath: '/tmp/test-projects', 308 | }, 309 | createNoopExecutor(), 310 | mockFileSystemExecutor, 311 | ); 312 | 313 | expect(result).toEqual({ 314 | content: [ 315 | { 316 | type: 'text', 317 | text: JSON.stringify( 318 | { 319 | success: false, 320 | error: 321 | 'Project name must start with a letter and contain only letters, numbers, and underscores', 322 | }, 323 | null, 324 | 2, 325 | ), 326 | }, 327 | ], 328 | isError: true, 329 | }); 330 | }); 331 | 332 | it('should return error response for existing project files', async () => { 333 | // Override existsSync to return true for workspace file 334 | mockFileSystemExecutor.existsSync = () => true; 335 | 336 | const result = await scaffold_macos_projectLogic( 337 | { 338 | projectName: 'TestMacApp', 339 | outputPath: '/tmp/test-projects', 340 | }, 341 | createNoopExecutor(), 342 | mockFileSystemExecutor, 343 | ); 344 | 345 | expect(result).toEqual({ 346 | content: [ 347 | { 348 | type: 'text', 349 | text: JSON.stringify( 350 | { 351 | success: false, 352 | error: 'Xcode project files already exist in /tmp/test-projects', 353 | }, 354 | null, 355 | 2, 356 | ), 357 | }, 358 | ], 359 | isError: true, 360 | }); 361 | }); 362 | 363 | it('should return error response for template manager failure', async () => { 364 | templateManagerStub.setError(new Error('Template not found')); 365 | 366 | const result = await scaffold_macos_projectLogic( 367 | { 368 | projectName: 'TestMacApp', 369 | outputPath: '/tmp/test-projects', 370 | }, 371 | createNoopExecutor(), 372 | mockFileSystemExecutor, 373 | ); 374 | 375 | expect(result).toEqual({ 376 | content: [ 377 | { 378 | type: 'text', 379 | text: JSON.stringify( 380 | { 381 | success: false, 382 | error: 'Failed to get template for macOS: Template not found', 383 | }, 384 | null, 385 | 2, 386 | ), 387 | }, 388 | ], 389 | isError: true, 390 | }); 391 | }); 392 | }); 393 | 394 | describe('File System Operations', () => { 395 | it('should create directories and process files correctly', async () => { 396 | await scaffold_macos_projectLogic( 397 | { 398 | projectName: 'TestApp', 399 | outputPath: '/tmp/test', 400 | customizeNames: true, 401 | }, 402 | createNoopExecutor(), 403 | mockFileSystemExecutor, 404 | ); 405 | 406 | // Verify template manager calls using manual tracking 407 | expect(templateManagerStub.getCalls()).toBe( 408 | 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', 409 | ); 410 | 411 | // File system operations are called by the mock implementation 412 | // but we can't verify them without vitest mocking patterns 413 | // This test validates the integration works correctly 414 | }); 415 | }); 416 | }); 417 | ``` -------------------------------------------------------------------------------- /docs/RELOADEROO_FOR_XCODEBUILDMCP.md: -------------------------------------------------------------------------------- ```markdown 1 | # Reloaderoo Usage Guide for XcodeBuildMCP 2 | 3 | This guide explains how to use Reloaderoo for interacting with XcodeBuildMCP as a CLI to save context window space. 4 | 5 | You can use this guide to prompt your agent, but providing the entire document will give you no actual benefits. You will end up using more context than just using MCP server directly. So it's recommended that you curate this document by removing the example commands that you don't need and just keeping the ones that are right for your project. You'll then want to keep this file within your project workspace and then include it in the context window when you need to interact your agent to use XcodeBuildMCP tools. 6 | 7 | > [!IMPORTANT] 8 | > Please remove this introduction before you prompt your agent with this file or any derrived version of it. 9 | 10 | ## Installation 11 | 12 | Reloaderoo is available via npm and can be used with npx for universal compatibility. 13 | 14 | ```bash 15 | # Use npx to run reloaderoo 16 | npx reloaderoo@latest --help 17 | ``` 18 | 19 | **Example Tool Calls:** 20 | 21 | ### Dynamic Tool Discovery 22 | 23 | - **`discover_tools`**: Analyzes a task description to enable relevant tools. 24 | ```bash 25 | npx reloaderoo@latest inspect call-tool discover_tools --params '{"task_description": "I want to build and run my iOS app on a simulator."}' -- node build/index.js 26 | ``` 27 | 28 | ### iOS Device Development 29 | 30 | - **`build_device`**: Builds an app for a physical device. 31 | ```bash 32 | npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 33 | ``` 34 | - **`get_device_app_path`**: Gets the `.app` bundle path for a device build. 35 | ```bash 36 | npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 37 | ``` 38 | - **`install_app_device`**: Installs an app on a physical device. 39 | ```bash 40 | npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js 41 | ``` 42 | - **`launch_app_device`**: Launches an app on a physical device. 43 | ```bash 44 | npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js 45 | ``` 46 | - **`list_devices`**: Lists connected physical devices. 47 | ```bash 48 | npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js 49 | ``` 50 | - **`stop_app_device`**: Stops an app on a physical device. 51 | ```bash 52 | npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/index.js 53 | ``` 54 | - **`test_device`**: Runs tests on a physical device. 55 | ```bash 56 | npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/index.js 57 | ``` 58 | 59 | ### iOS Simulator Development 60 | 61 | - **`boot_sim`**: Boots a simulator. 62 | ```bash 63 | npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/index.js 64 | ``` 65 | - **`build_run_sim`**: Builds and runs an app on a simulator. 66 | ```bash 67 | npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js 68 | ``` 69 | - **`build_sim`**: Builds an app for a simulator. 70 | ```bash 71 | npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js 72 | ``` 73 | - **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build. 74 | ```bash 75 | npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/index.js 76 | ``` 77 | - **`install_app_sim`**: Installs an app on a simulator. 78 | ```bash 79 | npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js 80 | ``` 81 | - **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. 82 | ```bash 83 | npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js 84 | ``` 85 | - **`launch_app_sim`**: Launches an app on a simulator. 86 | ```bash 87 | npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js 88 | ``` 89 | - **`list_sims`**: Lists available simulators. 90 | ```bash 91 | npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js 92 | ``` 93 | - **`open_sim`**: Opens the Simulator application. 94 | ```bash 95 | npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/index.js 96 | ``` 97 | - **`stop_app_sim`**: Stops an app on a simulator. 98 | ```bash 99 | npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js 100 | ``` 101 | - **`test_sim`**: Runs tests on a simulator. 102 | ```bash 103 | npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js 104 | ``` 105 | 106 | ### Log Capture & Management 107 | 108 | - **`start_device_log_cap`**: Starts log capture for a physical device. 109 | ```bash 110 | npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js 111 | ``` 112 | - **`start_sim_log_cap`**: Starts log capture for a simulator. 113 | ```bash 114 | npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js 115 | ``` 116 | - **`stop_device_log_cap`**: Stops log capture for a physical device. 117 | ```bash 118 | npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js 119 | ``` 120 | - **`stop_sim_log_cap`**: Stops log capture for a simulator. 121 | ```bash 122 | npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js 123 | ``` 124 | 125 | ### macOS Development 126 | 127 | - **`build_macos`**: Builds a macOS app. 128 | ```bash 129 | npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 130 | ``` 131 | - **`build_run_macos`**: Builds and runs a macOS app. 132 | ```bash 133 | npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 134 | ``` 135 | - **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build. 136 | ```bash 137 | npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 138 | ``` 139 | - **`launch_mac_app`**: Launches a macOS app. 140 | ```bash 141 | npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js 142 | ``` 143 | - **`stop_mac_app`**: Stops a macOS app. 144 | ```bash 145 | npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/index.js 146 | ``` 147 | - **`test_macos`**: Runs tests for a macOS project. 148 | ```bash 149 | npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 150 | ``` 151 | 152 | ### Project Discovery 153 | 154 | - **`discover_projs`**: Discovers Xcode projects and workspaces. 155 | ```bash 156 | npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/index.js 157 | ``` 158 | - **`get_app_bundle_id`**: Gets an app's bundle identifier. 159 | ```bash 160 | npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/index.js 161 | ``` 162 | - **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier. 163 | ```bash 164 | npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js 165 | ``` 166 | - **`list_schemes`**: Lists schemes in a project or workspace. 167 | ```bash 168 | npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js 169 | ``` 170 | - **`show_build_settings`**: Shows build settings for a scheme. 171 | ```bash 172 | npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js 173 | ``` 174 | 175 | ### Project Scaffolding 176 | 177 | - **`scaffold_ios_project`**: Scaffolds a new iOS project. 178 | ```bash 179 | npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/index.js 180 | ``` 181 | - **`scaffold_macos_project`**: Scaffolds a new macOS project. 182 | ```bash 183 | npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/index.js 184 | ``` 185 | 186 | ### Project Utilities 187 | 188 | - **`clean`**: Cleans build artifacts. 189 | ```bash 190 | # For a project 191 | npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js 192 | # For a workspace 193 | npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/index.js 194 | ``` 195 | 196 | ### Simulator Management 197 | 198 | - **`reset_sim_location`**: Resets a simulator's location. 199 | ```bash 200 | npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js 201 | ``` 202 | - **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode). 203 | ```bash 204 | npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/index.js 205 | ``` 206 | - **`set_sim_location`**: Sets a simulator's GPS location. 207 | ```bash 208 | npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/index.js 209 | ``` 210 | - **`sim_statusbar`**: Overrides a simulator's status bar. 211 | ```bash 212 | npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/index.js 213 | ``` 214 | 215 | ### Swift Package Manager 216 | 217 | - **`swift_package_build`**: Builds a Swift package. 218 | ```bash 219 | npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/index.js 220 | ``` 221 | - **`swift_package_clean`**: Cleans a Swift package. 222 | ```bash 223 | npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/index.js 224 | ``` 225 | - **`swift_package_list`**: Lists running Swift package processes. 226 | ```bash 227 | npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/index.js 228 | ``` 229 | - **`swift_package_run`**: Runs a Swift package executable. 230 | ```bash 231 | npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/index.js 232 | ``` 233 | - **`swift_package_stop`**: Stops a running Swift package process. 234 | ```bash 235 | npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/index.js 236 | ``` 237 | - **`swift_package_test`**: Tests a Swift package. 238 | ```bash 239 | npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/index.js 240 | ``` 241 | 242 | ### System Doctor 243 | 244 | - **`doctor`**: Runs system diagnostics. 245 | ```bash 246 | npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js 247 | ``` 248 | 249 | ### UI Testing & Automation 250 | 251 | - **`button`**: Simulates a hardware button press. 252 | ```bash 253 | npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/index.js 254 | ``` 255 | - **`describe_ui`**: Gets the UI hierarchy of the current screen. 256 | ```bash 257 | npx reloaderoo@latest inspect call-tool describe_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js 258 | ``` 259 | - **`gesture`**: Performs a pre-defined gesture. 260 | ```bash 261 | npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/index.js 262 | ``` 263 | - **`key_press`**: Simulates a key press. 264 | ```bash 265 | npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/index.js 266 | ``` 267 | - **`key_sequence`**: Simulates a sequence of key presses. 268 | ```bash 269 | npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/index.js 270 | ``` 271 | - **`long_press`**: Performs a long press at coordinates. 272 | ```bash 273 | npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/index.js 274 | ``` 275 | - **`screenshot`**: Takes a screenshot. 276 | ```bash 277 | npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js 278 | ``` 279 | - **`swipe`**: Performs a swipe gesture. 280 | ```bash 281 | npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/index.js 282 | ``` 283 | - **`tap`**: Performs a tap at coordinates. 284 | ```bash 285 | npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/index.js 286 | ``` 287 | - **`touch`**: Simulates a touch down or up event. 288 | ```bash 289 | npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/index.js 290 | ``` 291 | - **`type_text`**: Types text into the focused element. 292 | ```bash 293 | npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/index.js 294 | ``` 295 | 296 | ### Resources 297 | 298 | - **Read devices resource**: 299 | ```bash 300 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js 301 | ``` 302 | - **Read simulators resource**: 303 | ```bash 304 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/index.js 305 | ``` 306 | - **Read doctor resource**: 307 | ```bash 308 | npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/index.js 309 | ``` 310 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/build_macos.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for build_macos plugin (unified) 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using pure dependency injection for deterministic testing 5 | * NO VITEST MOCKING ALLOWED - Only createMockExecutor and createMockFileSystemExecutor 6 | */ 7 | 8 | import { describe, it, expect, beforeEach } from 'vitest'; 9 | import { z } from 'zod'; 10 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 11 | import { sessionStore } from '../../../../utils/session-store.ts'; 12 | import buildMacOS, { buildMacOSLogic } from '../build_macos.ts'; 13 | 14 | describe('build_macos plugin', () => { 15 | beforeEach(() => { 16 | sessionStore.clear(); 17 | }); 18 | 19 | describe('Export Field Validation (Literal)', () => { 20 | it('should have correct name', () => { 21 | expect(buildMacOS.name).toBe('build_macos'); 22 | }); 23 | 24 | it('should have correct description', () => { 25 | expect(buildMacOS.description).toBe('Builds a macOS app.'); 26 | }); 27 | 28 | it('should have handler function', () => { 29 | expect(typeof buildMacOS.handler).toBe('function'); 30 | }); 31 | 32 | it('should validate schema correctly', () => { 33 | const schema = z.object(buildMacOS.schema); 34 | 35 | expect(schema.safeParse({}).success).toBe(true); 36 | expect( 37 | schema.safeParse({ 38 | derivedDataPath: '/path/to/derived-data', 39 | extraArgs: ['--arg1', '--arg2'], 40 | preferXcodebuild: true, 41 | }).success, 42 | ).toBe(true); 43 | 44 | expect(schema.safeParse({ derivedDataPath: 42 }).success).toBe(false); 45 | expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); 46 | expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); 47 | 48 | const schemaKeys = Object.keys(buildMacOS.schema).sort(); 49 | expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); 50 | }); 51 | }); 52 | 53 | describe('Handler Requirements', () => { 54 | it('should require scheme when no defaults provided', async () => { 55 | const result = await buildMacOS.handler({}); 56 | 57 | expect(result.isError).toBe(true); 58 | expect(result.content[0].text).toContain('scheme is required'); 59 | expect(result.content[0].text).toContain('session-set-defaults'); 60 | }); 61 | 62 | it('should require project or workspace once scheme default exists', async () => { 63 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 64 | 65 | const result = await buildMacOS.handler({}); 66 | 67 | expect(result.isError).toBe(true); 68 | expect(result.content[0].text).toContain('Provide a project or workspace'); 69 | }); 70 | 71 | it('should reject when both projectPath and workspacePath provided explicitly', async () => { 72 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 73 | 74 | const result = await buildMacOS.handler({ 75 | projectPath: '/path/to/project.xcodeproj', 76 | workspacePath: '/path/to/workspace.xcworkspace', 77 | }); 78 | 79 | expect(result.isError).toBe(true); 80 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 81 | expect(result.content[0].text).toContain('projectPath'); 82 | expect(result.content[0].text).toContain('workspacePath'); 83 | }); 84 | }); 85 | 86 | describe('Handler Behavior (Complete Literal Returns)', () => { 87 | it('should return exact successful build response', async () => { 88 | const mockExecutor = createMockExecutor({ 89 | success: true, 90 | output: 'BUILD SUCCEEDED', 91 | }); 92 | 93 | const result = await buildMacOSLogic( 94 | { 95 | projectPath: '/path/to/MyProject.xcodeproj', 96 | scheme: 'MyScheme', 97 | }, 98 | mockExecutor, 99 | ); 100 | 101 | expect(result).toEqual({ 102 | content: [ 103 | { 104 | type: 'text', 105 | text: '✅ macOS Build build succeeded for scheme MyScheme.', 106 | }, 107 | { 108 | type: 'text', 109 | text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", 110 | }, 111 | ], 112 | }); 113 | }); 114 | 115 | it('should return exact build failure response', async () => { 116 | const mockExecutor = createMockExecutor({ 117 | success: false, 118 | error: 'error: Compilation error in main.swift', 119 | }); 120 | 121 | const result = await buildMacOSLogic( 122 | { 123 | projectPath: '/path/to/MyProject.xcodeproj', 124 | scheme: 'MyScheme', 125 | }, 126 | mockExecutor, 127 | ); 128 | 129 | expect(result).toEqual({ 130 | content: [ 131 | { 132 | type: 'text', 133 | text: '❌ [stderr] error: Compilation error in main.swift', 134 | }, 135 | { 136 | type: 'text', 137 | text: '❌ macOS Build build failed for scheme MyScheme.', 138 | }, 139 | ], 140 | isError: true, 141 | }); 142 | }); 143 | 144 | it('should return exact successful build response with optional parameters', async () => { 145 | const mockExecutor = createMockExecutor({ 146 | success: true, 147 | output: 'BUILD SUCCEEDED', 148 | }); 149 | 150 | const result = await buildMacOSLogic( 151 | { 152 | projectPath: '/path/to/MyProject.xcodeproj', 153 | scheme: 'MyScheme', 154 | configuration: 'Release', 155 | arch: 'arm64', 156 | derivedDataPath: '/path/to/derived-data', 157 | extraArgs: ['--verbose'], 158 | preferXcodebuild: true, 159 | }, 160 | mockExecutor, 161 | ); 162 | 163 | expect(result).toEqual({ 164 | content: [ 165 | { 166 | type: 'text', 167 | text: '✅ macOS Build build succeeded for scheme MyScheme.', 168 | }, 169 | { 170 | type: 'text', 171 | text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", 172 | }, 173 | ], 174 | }); 175 | }); 176 | 177 | it('should return exact exception handling response', async () => { 178 | // Create executor that throws error during command execution 179 | // This will be caught by executeXcodeBuildCommand's try-catch block 180 | const mockExecutor = async () => { 181 | throw new Error('Network error'); 182 | }; 183 | 184 | const result = await buildMacOSLogic( 185 | { 186 | projectPath: '/path/to/MyProject.xcodeproj', 187 | scheme: 'MyScheme', 188 | }, 189 | mockExecutor, 190 | ); 191 | 192 | expect(result).toEqual({ 193 | content: [ 194 | { 195 | type: 'text', 196 | text: 'Error during macOS Build build: Network error', 197 | }, 198 | ], 199 | isError: true, 200 | }); 201 | }); 202 | 203 | it('should return exact spawn error handling response', async () => { 204 | // Create executor that throws spawn error during command execution 205 | // This will be caught by executeXcodeBuildCommand's try-catch block 206 | const mockExecutor = async () => { 207 | throw new Error('Spawn error'); 208 | }; 209 | 210 | const result = await buildMacOSLogic( 211 | { 212 | projectPath: '/path/to/MyProject.xcodeproj', 213 | scheme: 'MyScheme', 214 | }, 215 | mockExecutor, 216 | ); 217 | 218 | expect(result).toEqual({ 219 | content: [ 220 | { 221 | type: 'text', 222 | text: 'Error during macOS Build build: Spawn error', 223 | }, 224 | ], 225 | isError: true, 226 | }); 227 | }); 228 | }); 229 | 230 | describe('Command Generation', () => { 231 | it('should generate correct xcodebuild command with minimal parameters', async () => { 232 | let capturedCommand: string[] = []; 233 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 234 | 235 | // Override the executor to capture the command 236 | const spyExecutor = async (command: string[]) => { 237 | capturedCommand = command; 238 | return mockExecutor(command); 239 | }; 240 | 241 | const result = await buildMacOSLogic( 242 | { 243 | projectPath: '/path/to/project.xcodeproj', 244 | scheme: 'MyScheme', 245 | }, 246 | spyExecutor, 247 | ); 248 | 249 | expect(capturedCommand).toEqual([ 250 | 'xcodebuild', 251 | '-project', 252 | '/path/to/project.xcodeproj', 253 | '-scheme', 254 | 'MyScheme', 255 | '-configuration', 256 | 'Debug', 257 | '-skipMacroValidation', 258 | '-destination', 259 | 'platform=macOS', 260 | 'build', 261 | ]); 262 | }); 263 | 264 | it('should generate correct xcodebuild command with all parameters', async () => { 265 | let capturedCommand: string[] = []; 266 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 267 | 268 | // Override the executor to capture the command 269 | const spyExecutor = async (command: string[]) => { 270 | capturedCommand = command; 271 | return mockExecutor(command); 272 | }; 273 | 274 | const result = await buildMacOSLogic( 275 | { 276 | projectPath: '/path/to/project.xcodeproj', 277 | scheme: 'MyScheme', 278 | configuration: 'Release', 279 | arch: 'x86_64', 280 | derivedDataPath: '/custom/derived', 281 | extraArgs: ['--verbose'], 282 | preferXcodebuild: true, 283 | }, 284 | spyExecutor, 285 | ); 286 | 287 | expect(capturedCommand).toEqual([ 288 | 'xcodebuild', 289 | '-project', 290 | '/path/to/project.xcodeproj', 291 | '-scheme', 292 | 'MyScheme', 293 | '-configuration', 294 | 'Release', 295 | '-skipMacroValidation', 296 | '-destination', 297 | 'platform=macOS,arch=x86_64', 298 | '-derivedDataPath', 299 | '/custom/derived', 300 | '--verbose', 301 | 'build', 302 | ]); 303 | }); 304 | 305 | it('should generate correct xcodebuild command with only derivedDataPath', async () => { 306 | let capturedCommand: string[] = []; 307 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 308 | 309 | // Override the executor to capture the command 310 | const spyExecutor = async (command: string[]) => { 311 | capturedCommand = command; 312 | return mockExecutor(command); 313 | }; 314 | 315 | const result = await buildMacOSLogic( 316 | { 317 | projectPath: '/path/to/project.xcodeproj', 318 | scheme: 'MyScheme', 319 | derivedDataPath: '/custom/derived/data', 320 | }, 321 | spyExecutor, 322 | ); 323 | 324 | expect(capturedCommand).toEqual([ 325 | 'xcodebuild', 326 | '-project', 327 | '/path/to/project.xcodeproj', 328 | '-scheme', 329 | 'MyScheme', 330 | '-configuration', 331 | 'Debug', 332 | '-skipMacroValidation', 333 | '-destination', 334 | 'platform=macOS', 335 | '-derivedDataPath', 336 | '/custom/derived/data', 337 | 'build', 338 | ]); 339 | }); 340 | 341 | it('should generate correct xcodebuild command with arm64 architecture only', async () => { 342 | let capturedCommand: string[] = []; 343 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 344 | 345 | // Override the executor to capture the command 346 | const spyExecutor = async (command: string[]) => { 347 | capturedCommand = command; 348 | return mockExecutor(command); 349 | }; 350 | 351 | const result = await buildMacOSLogic( 352 | { 353 | projectPath: '/path/to/project.xcodeproj', 354 | scheme: 'MyScheme', 355 | arch: 'arm64', 356 | }, 357 | spyExecutor, 358 | ); 359 | 360 | expect(capturedCommand).toEqual([ 361 | 'xcodebuild', 362 | '-project', 363 | '/path/to/project.xcodeproj', 364 | '-scheme', 365 | 'MyScheme', 366 | '-configuration', 367 | 'Debug', 368 | '-skipMacroValidation', 369 | '-destination', 370 | 'platform=macOS,arch=arm64', 371 | 'build', 372 | ]); 373 | }); 374 | 375 | it('should handle paths with spaces in command generation', async () => { 376 | let capturedCommand: string[] = []; 377 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 378 | 379 | // Override the executor to capture the command 380 | const spyExecutor = async (command: string[]) => { 381 | capturedCommand = command; 382 | return mockExecutor(command); 383 | }; 384 | 385 | const result = await buildMacOSLogic( 386 | { 387 | projectPath: '/Users/dev/My Project/MyProject.xcodeproj', 388 | scheme: 'MyScheme', 389 | }, 390 | spyExecutor, 391 | ); 392 | 393 | expect(capturedCommand).toEqual([ 394 | 'xcodebuild', 395 | '-project', 396 | '/Users/dev/My Project/MyProject.xcodeproj', 397 | '-scheme', 398 | 'MyScheme', 399 | '-configuration', 400 | 'Debug', 401 | '-skipMacroValidation', 402 | '-destination', 403 | 'platform=macOS', 404 | 'build', 405 | ]); 406 | }); 407 | 408 | it('should generate correct xcodebuild workspace command with minimal parameters', async () => { 409 | let capturedCommand: string[] = []; 410 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 411 | 412 | // Override the executor to capture the command 413 | const spyExecutor = async (command: string[]) => { 414 | capturedCommand = command; 415 | return mockExecutor(command); 416 | }; 417 | 418 | const result = await buildMacOSLogic( 419 | { 420 | workspacePath: '/path/to/workspace.xcworkspace', 421 | scheme: 'MyScheme', 422 | }, 423 | spyExecutor, 424 | ); 425 | 426 | expect(capturedCommand).toEqual([ 427 | 'xcodebuild', 428 | '-workspace', 429 | '/path/to/workspace.xcworkspace', 430 | '-scheme', 431 | 'MyScheme', 432 | '-configuration', 433 | 'Debug', 434 | '-skipMacroValidation', 435 | '-destination', 436 | 'platform=macOS', 437 | 'build', 438 | ]); 439 | }); 440 | }); 441 | 442 | describe('XOR Validation', () => { 443 | it('should error when neither projectPath nor workspacePath provided', async () => { 444 | const result = await buildMacOS.handler({ scheme: 'MyScheme' }); 445 | expect(result.isError).toBe(true); 446 | expect(result.content[0].text).toContain('Provide a project or workspace'); 447 | }); 448 | 449 | it('should error when both projectPath and workspacePath provided', async () => { 450 | const result = await buildMacOS.handler({ 451 | projectPath: '/path/to/project.xcodeproj', 452 | workspacePath: '/path/to/workspace.xcworkspace', 453 | scheme: 'MyScheme', 454 | }); 455 | expect(result.isError).toBe(true); 456 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 457 | }); 458 | 459 | it('should succeed with valid projectPath', async () => { 460 | const mockExecutor = createMockExecutor({ 461 | success: true, 462 | output: 'BUILD SUCCEEDED', 463 | }); 464 | 465 | const result = await buildMacOSLogic( 466 | { 467 | projectPath: '/path/to/project.xcodeproj', 468 | scheme: 'MyScheme', 469 | }, 470 | mockExecutor, 471 | ); 472 | 473 | expect(result.isError).toBeUndefined(); 474 | }); 475 | 476 | it('should succeed with valid workspacePath', async () => { 477 | const mockExecutor = createMockExecutor({ 478 | success: true, 479 | output: 'BUILD SUCCEEDED', 480 | }); 481 | 482 | const result = await buildMacOSLogic( 483 | { 484 | workspacePath: '/path/to/workspace.xcworkspace', 485 | scheme: 'MyScheme', 486 | }, 487 | mockExecutor, 488 | ); 489 | 490 | expect(result.isError).toBeUndefined(); 491 | }); 492 | }); 493 | }); 494 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/discovery/discover_tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { createTextResponse } from '../../../utils/responses/index.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | // Removed CreateMessageResultSchema import as it's no longer used 5 | import { ToolResponse } from '../../../types/common.ts'; 6 | import { 7 | enableWorkflows, 8 | getAvailableWorkflows, 9 | generateWorkflowDescriptions, 10 | } from '../../../core/dynamic-tools.ts'; 11 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { McpServer } from '@camsoft/mcp-sdk/server/mcp.js'; 14 | 15 | // Using McpServer type from SDK instead of custom interface 16 | 17 | // Configuration for LLM parameters - made configurable instead of hardcoded 18 | interface LLMConfig { 19 | maxTokens: number; 20 | temperature?: number; 21 | } 22 | 23 | // Default LLM configuration with environment variable overrides 24 | const getLLMConfig = (): LLMConfig => { 25 | let maxTokens = 200; // default 26 | if (process.env.XCODEBUILDMCP_LLM_MAX_TOKENS) { 27 | const parsed = parseInt(process.env.XCODEBUILDMCP_LLM_MAX_TOKENS, 10); 28 | if (!isNaN(parsed) && parsed > 0) { 29 | maxTokens = parsed; 30 | } 31 | } 32 | 33 | let temperature: number | undefined; 34 | if (process.env.XCODEBUILDMCP_LLM_TEMPERATURE) { 35 | const parsed = parseFloat(process.env.XCODEBUILDMCP_LLM_TEMPERATURE); 36 | if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) { 37 | temperature = parsed; 38 | } 39 | } 40 | 41 | return { 42 | maxTokens, 43 | temperature, 44 | }; 45 | }; 46 | 47 | /** 48 | * Sanitizes user input to prevent injection attacks and ensure safe LLM usage 49 | * @param input The raw user input to sanitize 50 | * @returns Sanitized input safe for LLM processing 51 | */ 52 | function sanitizeTaskDescription(input: string): string { 53 | if (!input || typeof input !== 'string') { 54 | throw new Error('Task description must be a non-empty string'); 55 | } 56 | 57 | // Remove control characters and normalize whitespace 58 | let sanitized = input 59 | // eslint-disable-next-line no-control-regex -- Intentional control character removal for security 60 | .replace(/[\x00-\x1F\x7F-\x9F]/g, '') // Remove control characters 61 | .replace(/\s+/g, ' ') // Normalize whitespace 62 | .trim(); 63 | 64 | // Length validation - prevent excessively long inputs 65 | if (sanitized.length === 0) { 66 | throw new Error('Task description cannot be empty after sanitization'); 67 | } 68 | 69 | if (sanitized.length > 2000) { 70 | sanitized = sanitized.substring(0, 2000); 71 | log('warn', 'Task description truncated to 2000 characters for safety'); 72 | } 73 | 74 | // Basic injection prevention - remove potential prompt injection patterns 75 | const suspiciousPatterns = [ 76 | /ignore\s+previous\s+instructions/gi, 77 | /forget\s+everything/gi, 78 | /system\s*:/gi, 79 | /assistant\s*:/gi, 80 | /you\s+are\s+now/gi, 81 | /act\s+as/gi, 82 | ]; 83 | 84 | for (const pattern of suspiciousPatterns) { 85 | if (pattern.test(sanitized)) { 86 | log('warn', 'Potentially suspicious pattern detected in task description'); 87 | sanitized = sanitized.replace(pattern, '[filtered]'); 88 | } 89 | } 90 | 91 | return sanitized; 92 | } 93 | 94 | // Define schema as ZodObject 95 | const discoverToolsSchema = z.object({ 96 | task_description: z 97 | .string() 98 | .describe( 99 | 'A detailed description of the development task you want to accomplish. ' + 100 | "For example: 'I need to build my iOS app and run it on the iPhone 16 simulator.' " + 101 | 'If working with Xcode projects, explicitly state whether you are using a .xcworkspace (workspace) or a .xcodeproj (project).', 102 | ), 103 | additive: z 104 | .boolean() 105 | .optional() 106 | .describe( 107 | 'If true, add the discovered tools to existing enabled workflows. ' + 108 | 'If false (default), replace all existing workflows with the newly discovered one. ' + 109 | 'Use additive mode when you need tools from multiple workflows simultaneously.', 110 | ), 111 | }); 112 | 113 | // Use z.infer for type safety 114 | type DiscoverToolsParams = z.infer<typeof discoverToolsSchema>; 115 | 116 | // Dependencies interface for dependency injection 117 | interface Dependencies { 118 | getAvailableWorkflows?: () => string[]; 119 | generateWorkflowDescriptions?: () => string; 120 | enableWorkflows?: (server: McpServer, workflows: string[], additive?: boolean) => Promise<void>; 121 | } 122 | 123 | export async function discover_toolsLogic( 124 | args: DiscoverToolsParams, 125 | _executor?: unknown, 126 | deps?: Dependencies, 127 | ): Promise<ToolResponse> { 128 | // Enhanced null safety checks 129 | if (!args || typeof args !== 'object') { 130 | return createTextResponse('Invalid arguments provided to discover_tools', true); 131 | } 132 | 133 | const { task_description, additive } = args; 134 | 135 | // Sanitize the task description to prevent injection attacks 136 | let sanitizedTaskDescription: string; 137 | try { 138 | sanitizedTaskDescription = sanitizeTaskDescription(task_description); 139 | log('info', `Discovering tools for task: ${sanitizedTaskDescription}`); 140 | } catch (error) { 141 | const errorMessage = error instanceof Error ? error.message : 'Invalid task description'; 142 | log('error', `Task description sanitization failed: ${errorMessage}`); 143 | return createTextResponse(`Invalid task description: ${errorMessage}`, true); 144 | } 145 | 146 | try { 147 | // Get the server instance from the global context 148 | const server = (globalThis as { mcpServer?: McpServer }).mcpServer; 149 | if (!server) { 150 | throw new Error('Server instance not available'); 151 | } 152 | 153 | // 1. Check for sampling capability 154 | const clientCapabilities = server.server?.getClientCapabilities?.(); 155 | if (!clientCapabilities?.sampling) { 156 | log('warn', 'Client does not support sampling capability'); 157 | return createTextResponse( 158 | 'Your client does not support the sampling feature required for dynamic tool discovery. ' + 159 | 'Please use XCODEBUILDMCP_DYNAMIC_TOOLS=false to use the standard tool set.', 160 | true, 161 | ); 162 | } 163 | 164 | // 2. Get available workflows using generated metadata 165 | const workflowNames = (deps?.getAvailableWorkflows ?? getAvailableWorkflows)(); 166 | const workflowDescriptions = ( 167 | deps?.generateWorkflowDescriptions ?? generateWorkflowDescriptions 168 | )(); 169 | 170 | // 3. Construct the prompt for the LLM using sanitized input 171 | const userPrompt = `You are an expert assistant for the XcodeBuildMCP server. Your task is to select the most relevant workflow for a user's Apple development request. 172 | 173 | The user wants to perform the following task: "${sanitizedTaskDescription}" 174 | 175 | IMPORTANT: Select EXACTLY ONE workflow that best matches the user's task. In most cases, users are working with a project or workspace. Use this selection guide: 176 | 177 | Primary (project/workspace-based) workflows: 178 | - iOS simulator (supports both .xcworkspace and .xcodeproj): choose "simulator" 179 | - iOS physical device (supports both .xcworkspace and .xcodeproj): choose "device" 180 | - macOS (supports both .xcworkspace and .xcodeproj): choose "macos" 181 | - Swift Package Manager (no Xcode project): choose "swift-package" 182 | 183 | Secondary (task-based, no project/workspace needed): 184 | - Simulator management (boot, list, open, status bar, appearance, GPS/location): choose "simulator-management" 185 | - Logging or log capture (simulator or device): choose "logging" 186 | - UI automation/gestures/screenshots on a simulator app: choose "ui-testing" 187 | - System/environment diagnostics or validation: choose "doctor" 188 | - Create new iOS/macOS projects from templates: choose "project-scaffolding" 189 | - Project discovery and analysis: choose "project-discovery" 190 | - General utilities: choose "utilities" 191 | 192 | All available workflows: 193 | ${workflowDescriptions} 194 | 195 | Respond with ONLY a JSON array containing ONE workflow name that best matches the task (e.g., ["simulator"]).`; 196 | 197 | // 4. Send sampling request with configurable parameters 198 | const llmConfig = getLLMConfig(); 199 | log('debug', `Sending sampling request to client LLM with maxTokens: ${llmConfig.maxTokens}`); 200 | if (!server.server?.createMessage) { 201 | throw new Error('Server does not support message creation'); 202 | } 203 | 204 | const samplingOptions: { 205 | messages: Array<{ role: 'user'; content: { type: 'text'; text: string } }>; 206 | maxTokens: number; 207 | temperature?: number; 208 | } = { 209 | messages: [{ role: 'user', content: { type: 'text', text: userPrompt } }], 210 | maxTokens: llmConfig.maxTokens, 211 | }; 212 | 213 | // Only add temperature if configured 214 | if (llmConfig.temperature !== undefined) { 215 | samplingOptions.temperature = llmConfig.temperature; 216 | } 217 | 218 | const samplingResult = await server.server.createMessage(samplingOptions); 219 | 220 | // 5. Parse the response with enhanced null safety checks 221 | let selectedWorkflows: string[] = []; 222 | try { 223 | // Enhanced null safety - check if samplingResult exists and has expected structure 224 | if (!samplingResult || typeof samplingResult !== 'object') { 225 | throw new Error('Invalid sampling result: null or not an object'); 226 | } 227 | 228 | const content = ( 229 | samplingResult as { 230 | content?: Array<{ type: 'text'; text: string }> | { type: 'text'; text: string } | null; 231 | } 232 | ).content; 233 | 234 | if (!content) { 235 | throw new Error('No content in sampling response'); 236 | } 237 | 238 | let responseText = ''; 239 | 240 | // Handle both array and single object content formats with enhanced null checks 241 | if (Array.isArray(content)) { 242 | if (content.length === 0) { 243 | throw new Error('Empty content array in sampling response'); 244 | } 245 | const firstItem = content[0]; 246 | if (!firstItem || typeof firstItem !== 'object' || firstItem.type !== 'text') { 247 | throw new Error('Invalid first content item in array'); 248 | } 249 | if (!firstItem.text || typeof firstItem.text !== 'string') { 250 | throw new Error('Invalid text content in first array item'); 251 | } 252 | responseText = firstItem.text.trim(); 253 | } else if ( 254 | content && 255 | typeof content === 'object' && 256 | 'type' in content && 257 | content.type === 'text' && 258 | 'text' in content && 259 | typeof content.text === 'string' 260 | ) { 261 | responseText = content.text.trim(); 262 | } else { 263 | throw new Error('Invalid content format in sampling response'); 264 | } 265 | 266 | if (!responseText) { 267 | throw new Error('Empty response text after parsing'); 268 | } 269 | 270 | log('debug', `LLM response: ${responseText}`); 271 | 272 | const parsedResponse: unknown = JSON.parse(responseText); 273 | 274 | if (!Array.isArray(parsedResponse)) { 275 | throw new Error('Response is not an array'); 276 | } 277 | 278 | // Validate that all items are strings 279 | if (!parsedResponse.every((item): item is string => typeof item === 'string')) { 280 | throw new Error('Response array contains non-string items'); 281 | } 282 | 283 | selectedWorkflows = parsedResponse; 284 | 285 | // Validate that all selected workflows are valid 286 | const validWorkflows = selectedWorkflows.filter((workflow) => 287 | workflowNames.includes(workflow), 288 | ); 289 | if (validWorkflows.length !== selectedWorkflows.length) { 290 | const invalidWorkflows = selectedWorkflows.filter( 291 | (workflow) => !workflowNames.includes(workflow), 292 | ); 293 | log('warn', `LLM selected invalid workflows: ${invalidWorkflows.join(', ')}`); 294 | selectedWorkflows = validWorkflows; 295 | } 296 | } catch (error) { 297 | log('error', `Failed to parse LLM response: ${error}`); 298 | // Extract the response text for error reporting with enhanced null safety 299 | let errorResponseText = 'Unknown response format'; 300 | try { 301 | if (samplingResult && typeof samplingResult === 'object') { 302 | const content = ( 303 | samplingResult as { 304 | content?: 305 | | Array<{ type: 'text'; text: string }> 306 | | { type: 'text'; text: string } 307 | | null; 308 | } 309 | ).content; 310 | 311 | if (content && Array.isArray(content) && content.length > 0) { 312 | const firstItem = content[0]; 313 | if ( 314 | firstItem && 315 | typeof firstItem === 'object' && 316 | firstItem.type === 'text' && 317 | typeof firstItem.text === 'string' 318 | ) { 319 | errorResponseText = firstItem.text; 320 | } 321 | } else if ( 322 | content && 323 | typeof content === 'object' && 324 | 'type' in content && 325 | content.type === 'text' && 326 | 'text' in content && 327 | typeof content.text === 'string' 328 | ) { 329 | errorResponseText = content.text; 330 | } 331 | } 332 | } catch { 333 | // Keep default error message 334 | } 335 | 336 | return createTextResponse( 337 | `I was unable to determine the right tools for your task. The AI model returned: "${errorResponseText}". ` + 338 | `Could you please rephrase your request or try a more specific description?`, 339 | true, 340 | ); 341 | } 342 | 343 | // 6. Handle empty selection 344 | if (selectedWorkflows.length === 0) { 345 | log('info', 'LLM returned empty workflow selection'); 346 | return createTextResponse( 347 | "No specific Xcode tools seem necessary for that task. Could you provide more details about what you'd like to accomplish with Xcode?", 348 | ); 349 | } 350 | 351 | // 7. Enable the selected workflows 352 | const isAdditive = Boolean(additive); 353 | log( 354 | 'info', 355 | `${isAdditive ? 'Adding' : 'Replacing with'} workflows: ${selectedWorkflows.join(', ')}`, 356 | ); 357 | await (deps?.enableWorkflows ?? enableWorkflows)(server, selectedWorkflows, isAdditive); 358 | 359 | // 8. Return success response - we can't easily get tool count ahead of time with dynamic loading 360 | // but that's okay since the user will see the tools when they're loaded 361 | 362 | const actionWord = isAdditive ? 'Added' : 'Enabled'; 363 | const modeDescription = isAdditive 364 | ? `Added tools from ${selectedWorkflows.join(', ')} to your existing workflow tools.` 365 | : `Replaced previous tools with ${selectedWorkflows.join(', ')} workflow tools.`; 366 | 367 | return createTextResponse( 368 | `✅ ${actionWord} XcodeBuildMCP tools for: ${selectedWorkflows.join(', ')}.\n\n` + 369 | `${modeDescription}\n\n` + 370 | `Use XcodeBuildMCP tools for all Apple platform development tasks from now on. ` + 371 | `Call tools/list to see all available tools for your workflow.`, 372 | ); 373 | } catch (error) { 374 | log('error', `Error in discoverTools: ${error}`); 375 | return createTextResponse( 376 | `An error occurred while discovering tools: ${error instanceof Error ? error.message : 'Unknown error'}`, 377 | true, 378 | ); 379 | } 380 | } 381 | 382 | export default { 383 | name: 'discover_tools', 384 | description: 385 | 'Analyzes a natural language task description and enables the most relevant development workflow. Prioritizes project/workspace workflows (simulator/device/macOS) and also supports task-based workflows (simulator-management, logging) and Swift packages.', 386 | schema: discoverToolsSchema.shape, // MCP SDK compatibility 387 | handler: createTypedTool( 388 | discoverToolsSchema, 389 | (params: DiscoverToolsParams, executor) => { 390 | return discover_toolsLogic(params, executor); 391 | }, 392 | getDefaultCommandExecutor, 393 | ), 394 | }; 395 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/build_run_macos.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { z } from 'zod'; 3 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 4 | import { sessionStore } from '../../../../utils/session-store.ts'; 5 | import tool, { buildRunMacOSLogic } from '../build_run_macos.ts'; 6 | 7 | describe('build_run_macos', () => { 8 | beforeEach(() => { 9 | sessionStore.clear(); 10 | }); 11 | 12 | describe('Export Field Validation (Literal)', () => { 13 | it('should export the correct name', () => { 14 | expect(tool.name).toBe('build_run_macos'); 15 | }); 16 | 17 | it('should export the correct description', () => { 18 | expect(tool.description).toBe('Builds and runs a macOS app.'); 19 | }); 20 | 21 | it('should export a handler function', () => { 22 | expect(typeof tool.handler).toBe('function'); 23 | }); 24 | 25 | it('should expose only non-session fields in schema', () => { 26 | const schema = z.object(tool.schema); 27 | 28 | expect(schema.safeParse({}).success).toBe(true); 29 | expect( 30 | schema.safeParse({ 31 | derivedDataPath: '/tmp/derived', 32 | extraArgs: ['--verbose'], 33 | preferXcodebuild: true, 34 | }).success, 35 | ).toBe(true); 36 | 37 | expect(schema.safeParse({ derivedDataPath: 1 }).success).toBe(false); 38 | expect(schema.safeParse({ extraArgs: ['--ok', 2] }).success).toBe(false); 39 | expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); 40 | 41 | const schemaKeys = Object.keys(tool.schema).sort(); 42 | expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); 43 | }); 44 | }); 45 | 46 | describe('Handler Requirements', () => { 47 | it('should require scheme before executing', async () => { 48 | const result = await tool.handler({}); 49 | 50 | expect(result.isError).toBe(true); 51 | expect(result.content[0].text).toContain('scheme is required'); 52 | }); 53 | 54 | it('should require project or workspace once scheme is set', async () => { 55 | sessionStore.setDefaults({ scheme: 'MyApp' }); 56 | 57 | const result = await tool.handler({}); 58 | 59 | expect(result.isError).toBe(true); 60 | expect(result.content[0].text).toContain('Provide a project or workspace'); 61 | }); 62 | 63 | it('should fail when both project and workspace provided explicitly', async () => { 64 | sessionStore.setDefaults({ scheme: 'MyApp' }); 65 | 66 | const result = await tool.handler({ 67 | projectPath: '/path/to/project.xcodeproj', 68 | workspacePath: '/path/to/workspace.xcworkspace', 69 | }); 70 | 71 | expect(result.isError).toBe(true); 72 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 73 | }); 74 | }); 75 | 76 | describe('Command Generation and Response Logic', () => { 77 | it('should successfully build and run macOS app from project', async () => { 78 | // Track executor calls manually 79 | let callCount = 0; 80 | const executorCalls: any[] = []; 81 | const mockExecutor = ( 82 | command: string[], 83 | description: string, 84 | logOutput: boolean, 85 | timeout?: number, 86 | ) => { 87 | callCount++; 88 | executorCalls.push({ command, description, logOutput, timeout }); 89 | 90 | if (callCount === 1) { 91 | // First call for build 92 | return Promise.resolve({ 93 | success: true, 94 | output: 'BUILD SUCCEEDED', 95 | error: '', 96 | }); 97 | } else if (callCount === 2) { 98 | // Second call for build settings 99 | return Promise.resolve({ 100 | success: true, 101 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 102 | error: '', 103 | }); 104 | } 105 | return Promise.resolve({ success: true, output: '', error: '' }); 106 | }; 107 | 108 | const args = { 109 | projectPath: '/path/to/project.xcodeproj', 110 | scheme: 'MyApp', 111 | configuration: 'Debug', 112 | preferXcodebuild: false, 113 | }; 114 | 115 | const result = await buildRunMacOSLogic(args, mockExecutor); 116 | 117 | // Verify build command was called 118 | expect(executorCalls[0]).toEqual({ 119 | command: [ 120 | 'xcodebuild', 121 | '-project', 122 | '/path/to/project.xcodeproj', 123 | '-scheme', 124 | 'MyApp', 125 | '-configuration', 126 | 'Debug', 127 | '-skipMacroValidation', 128 | '-destination', 129 | 'platform=macOS', 130 | 'build', 131 | ], 132 | description: 'macOS Build', 133 | logOutput: true, 134 | timeout: undefined, 135 | }); 136 | 137 | // Verify build settings command was called 138 | expect(executorCalls[1]).toEqual({ 139 | command: [ 140 | 'xcodebuild', 141 | '-showBuildSettings', 142 | '-project', 143 | '/path/to/project.xcodeproj', 144 | '-scheme', 145 | 'MyApp', 146 | '-configuration', 147 | 'Debug', 148 | ], 149 | description: 'Get Build Settings for Launch', 150 | logOutput: true, 151 | timeout: undefined, 152 | }); 153 | 154 | expect(result).toEqual({ 155 | content: [ 156 | { 157 | type: 'text', 158 | text: '✅ macOS Build build succeeded for scheme MyApp.', 159 | }, 160 | { 161 | type: 'text', 162 | text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", 163 | }, 164 | { 165 | type: 'text', 166 | text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', 167 | }, 168 | ], 169 | isError: false, 170 | }); 171 | }); 172 | 173 | it('should successfully build and run macOS app from workspace', async () => { 174 | // Track executor calls manually 175 | let callCount = 0; 176 | const executorCalls: any[] = []; 177 | const mockExecutor = ( 178 | command: string[], 179 | description: string, 180 | logOutput: boolean, 181 | timeout?: number, 182 | ) => { 183 | callCount++; 184 | executorCalls.push({ command, description, logOutput, timeout }); 185 | 186 | if (callCount === 1) { 187 | // First call for build 188 | return Promise.resolve({ 189 | success: true, 190 | output: 'BUILD SUCCEEDED', 191 | error: '', 192 | }); 193 | } else if (callCount === 2) { 194 | // Second call for build settings 195 | return Promise.resolve({ 196 | success: true, 197 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 198 | error: '', 199 | }); 200 | } 201 | return Promise.resolve({ success: true, output: '', error: '' }); 202 | }; 203 | 204 | const args = { 205 | workspacePath: '/path/to/workspace.xcworkspace', 206 | scheme: 'MyApp', 207 | configuration: 'Debug', 208 | preferXcodebuild: false, 209 | }; 210 | 211 | const result = await buildRunMacOSLogic(args, mockExecutor); 212 | 213 | // Verify build command was called 214 | expect(executorCalls[0]).toEqual({ 215 | command: [ 216 | 'xcodebuild', 217 | '-workspace', 218 | '/path/to/workspace.xcworkspace', 219 | '-scheme', 220 | 'MyApp', 221 | '-configuration', 222 | 'Debug', 223 | '-skipMacroValidation', 224 | '-destination', 225 | 'platform=macOS', 226 | 'build', 227 | ], 228 | description: 'macOS Build', 229 | logOutput: true, 230 | timeout: undefined, 231 | }); 232 | 233 | // Verify build settings command was called 234 | expect(executorCalls[1]).toEqual({ 235 | command: [ 236 | 'xcodebuild', 237 | '-showBuildSettings', 238 | '-workspace', 239 | '/path/to/workspace.xcworkspace', 240 | '-scheme', 241 | 'MyApp', 242 | '-configuration', 243 | 'Debug', 244 | ], 245 | description: 'Get Build Settings for Launch', 246 | logOutput: true, 247 | timeout: undefined, 248 | }); 249 | 250 | expect(result).toEqual({ 251 | content: [ 252 | { 253 | type: 'text', 254 | text: '✅ macOS Build build succeeded for scheme MyApp.', 255 | }, 256 | { 257 | type: 'text', 258 | text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", 259 | }, 260 | { 261 | type: 'text', 262 | text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', 263 | }, 264 | ], 265 | isError: false, 266 | }); 267 | }); 268 | 269 | it('should handle build failure', async () => { 270 | const mockExecutor = createMockExecutor({ 271 | success: false, 272 | output: '', 273 | error: 'error: Build failed', 274 | }); 275 | 276 | const args = { 277 | projectPath: '/path/to/project.xcodeproj', 278 | scheme: 'MyApp', 279 | configuration: 'Debug', 280 | preferXcodebuild: false, 281 | }; 282 | 283 | const result = await buildRunMacOSLogic(args, mockExecutor); 284 | 285 | expect(result).toEqual({ 286 | content: [ 287 | { type: 'text', text: '❌ [stderr] error: Build failed' }, 288 | { type: 'text', text: '❌ macOS Build build failed for scheme MyApp.' }, 289 | ], 290 | isError: true, 291 | }); 292 | }); 293 | 294 | it('should handle build settings failure', async () => { 295 | // Track executor calls manually 296 | let callCount = 0; 297 | const mockExecutor = ( 298 | command: string[], 299 | description: string, 300 | logOutput: boolean, 301 | timeout?: number, 302 | ) => { 303 | callCount++; 304 | if (callCount === 1) { 305 | // First call for build succeeds 306 | return Promise.resolve({ 307 | success: true, 308 | output: 'BUILD SUCCEEDED', 309 | error: '', 310 | }); 311 | } else if (callCount === 2) { 312 | // Second call for build settings fails 313 | return Promise.resolve({ 314 | success: false, 315 | output: '', 316 | error: 'error: Failed to get settings', 317 | }); 318 | } 319 | return Promise.resolve({ success: true, output: '', error: '' }); 320 | }; 321 | 322 | const args = { 323 | projectPath: '/path/to/project.xcodeproj', 324 | scheme: 'MyApp', 325 | configuration: 'Debug', 326 | preferXcodebuild: false, 327 | }; 328 | 329 | const result = await buildRunMacOSLogic(args, mockExecutor); 330 | 331 | expect(result).toEqual({ 332 | content: [ 333 | { 334 | type: 'text', 335 | text: '✅ macOS Build build succeeded for scheme MyApp.', 336 | }, 337 | { 338 | type: 'text', 339 | text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", 340 | }, 341 | { 342 | type: 'text', 343 | text: '✅ Build succeeded, but failed to get app path to launch: error: Failed to get settings', 344 | }, 345 | ], 346 | isError: false, 347 | }); 348 | }); 349 | 350 | it('should handle app launch failure', async () => { 351 | // Track executor calls manually 352 | let callCount = 0; 353 | const mockExecutor = ( 354 | command: string[], 355 | description: string, 356 | logOutput: boolean, 357 | timeout?: number, 358 | ) => { 359 | callCount++; 360 | if (callCount === 1) { 361 | // First call for build succeeds 362 | return Promise.resolve({ 363 | success: true, 364 | output: 'BUILD SUCCEEDED', 365 | error: '', 366 | }); 367 | } else if (callCount === 2) { 368 | // Second call for build settings succeeds 369 | return Promise.resolve({ 370 | success: true, 371 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 372 | error: '', 373 | }); 374 | } else if (callCount === 3) { 375 | // Third call for open command fails 376 | return Promise.resolve({ 377 | success: false, 378 | output: '', 379 | error: 'Failed to launch', 380 | }); 381 | } 382 | return Promise.resolve({ success: true, output: '', error: '' }); 383 | }; 384 | 385 | const args = { 386 | projectPath: '/path/to/project.xcodeproj', 387 | scheme: 'MyApp', 388 | configuration: 'Debug', 389 | preferXcodebuild: false, 390 | }; 391 | 392 | const result = await buildRunMacOSLogic(args, mockExecutor); 393 | 394 | expect(result).toEqual({ 395 | content: [ 396 | { 397 | type: 'text', 398 | text: '✅ macOS Build build succeeded for scheme MyApp.', 399 | }, 400 | { 401 | type: 'text', 402 | text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", 403 | }, 404 | { 405 | type: 'text', 406 | text: '✅ Build succeeded, but failed to launch app /path/to/build/MyApp.app. Error: Failed to launch', 407 | }, 408 | ], 409 | isError: false, 410 | }); 411 | }); 412 | 413 | it('should handle spawn error', async () => { 414 | const mockExecutor = ( 415 | command: string[], 416 | description: string, 417 | logOutput: boolean, 418 | timeout?: number, 419 | ) => { 420 | return Promise.reject(new Error('spawn xcodebuild ENOENT')); 421 | }; 422 | 423 | const args = { 424 | projectPath: '/path/to/project.xcodeproj', 425 | scheme: 'MyApp', 426 | configuration: 'Debug', 427 | preferXcodebuild: false, 428 | }; 429 | 430 | const result = await buildRunMacOSLogic(args, mockExecutor); 431 | 432 | expect(result).toEqual({ 433 | content: [ 434 | { type: 'text', text: 'Error during macOS Build build: spawn xcodebuild ENOENT' }, 435 | ], 436 | isError: true, 437 | }); 438 | }); 439 | 440 | it('should use default configuration when not provided', async () => { 441 | // Track executor calls manually 442 | let callCount = 0; 443 | const executorCalls: any[] = []; 444 | const mockExecutor = ( 445 | command: string[], 446 | description: string, 447 | logOutput: boolean, 448 | timeout?: number, 449 | ) => { 450 | callCount++; 451 | executorCalls.push({ command, description, logOutput, timeout }); 452 | 453 | if (callCount === 1) { 454 | // First call for build 455 | return Promise.resolve({ 456 | success: true, 457 | output: 'BUILD SUCCEEDED', 458 | error: '', 459 | }); 460 | } else if (callCount === 2) { 461 | // Second call for build settings 462 | return Promise.resolve({ 463 | success: true, 464 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 465 | error: '', 466 | }); 467 | } 468 | return Promise.resolve({ success: true, output: '', error: '' }); 469 | }; 470 | 471 | const args = { 472 | projectPath: '/path/to/project.xcodeproj', 473 | scheme: 'MyApp', 474 | configuration: 'Debug', 475 | preferXcodebuild: false, 476 | }; 477 | 478 | await buildRunMacOSLogic(args, mockExecutor); 479 | 480 | expect(executorCalls[0]).toEqual({ 481 | command: [ 482 | 'xcodebuild', 483 | '-project', 484 | '/path/to/project.xcodeproj', 485 | '-scheme', 486 | 'MyApp', 487 | '-configuration', 488 | 'Debug', 489 | '-skipMacroValidation', 490 | '-destination', 491 | 'platform=macOS', 492 | 'build', 493 | ], 494 | description: 'macOS Build', 495 | logOutput: true, 496 | timeout: undefined, 497 | }); 498 | }); 499 | }); 500 | }); 501 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for get_mac_app_path plugin (unified project/workspace) 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using dependency injection for deterministic testing 5 | */ 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { z } from 'zod'; 8 | import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; 9 | import { sessionStore } from '../../../../utils/session-store.ts'; 10 | import getMacAppPath, { get_mac_app_pathLogic } from '../get_mac_app_path.ts'; 11 | 12 | describe('get_mac_app_path plugin', () => { 13 | beforeEach(() => { 14 | sessionStore.clear(); 15 | }); 16 | 17 | describe('Export Field Validation (Literal)', () => { 18 | it('should have correct name', () => { 19 | expect(getMacAppPath.name).toBe('get_mac_app_path'); 20 | }); 21 | 22 | it('should have correct description', () => { 23 | expect(getMacAppPath.description).toBe('Retrieves the built macOS app bundle path.'); 24 | }); 25 | 26 | it('should have handler function', () => { 27 | expect(typeof getMacAppPath.handler).toBe('function'); 28 | }); 29 | 30 | it('should validate schema correctly', () => { 31 | const schema = z.object(getMacAppPath.schema); 32 | 33 | expect(schema.safeParse({}).success).toBe(true); 34 | expect( 35 | schema.safeParse({ 36 | derivedDataPath: '/path/to/derived', 37 | extraArgs: ['--verbose'], 38 | }).success, 39 | ).toBe(true); 40 | 41 | expect(schema.safeParse({ derivedDataPath: 7 }).success).toBe(false); 42 | expect(schema.safeParse({ extraArgs: ['--bad', 1] }).success).toBe(false); 43 | 44 | const schemaKeys = Object.keys(getMacAppPath.schema).sort(); 45 | expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs'].sort()); 46 | }); 47 | }); 48 | 49 | describe('Handler Requirements', () => { 50 | it('should require scheme before running', async () => { 51 | const result = await getMacAppPath.handler({}); 52 | 53 | expect(result.isError).toBe(true); 54 | expect(result.content[0].text).toContain('scheme is required'); 55 | }); 56 | 57 | it('should require project or workspace when scheme default exists', async () => { 58 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 59 | 60 | const result = await getMacAppPath.handler({}); 61 | 62 | expect(result.isError).toBe(true); 63 | expect(result.content[0].text).toContain('Provide a project or workspace'); 64 | }); 65 | 66 | it('should reject when both projectPath and workspacePath provided explicitly', async () => { 67 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 68 | 69 | const result = await getMacAppPath.handler({ 70 | projectPath: '/path/to/project.xcodeproj', 71 | workspacePath: '/path/to/workspace.xcworkspace', 72 | }); 73 | 74 | expect(result.isError).toBe(true); 75 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 76 | }); 77 | }); 78 | 79 | describe('XOR Validation', () => { 80 | it('should error when neither projectPath nor workspacePath provided', async () => { 81 | const result = await getMacAppPath.handler({ 82 | scheme: 'MyScheme', 83 | }); 84 | 85 | expect(result.isError).toBe(true); 86 | expect(result.content[0].text).toContain('Provide a project or workspace'); 87 | }); 88 | 89 | it('should error when both projectPath and workspacePath provided', async () => { 90 | const result = await getMacAppPath.handler({ 91 | projectPath: '/path/to/project.xcodeproj', 92 | workspacePath: '/path/to/workspace.xcworkspace', 93 | scheme: 'MyScheme', 94 | }); 95 | 96 | expect(result.isError).toBe(true); 97 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 98 | }); 99 | }); 100 | 101 | describe('Command Generation', () => { 102 | it('should generate correct command with workspace minimal parameters', async () => { 103 | // Manual call tracking for command verification 104 | const calls: any[] = []; 105 | const mockExecutor: CommandExecutor = async (...args) => { 106 | calls.push(args); 107 | return { 108 | success: true, 109 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 110 | error: undefined, 111 | process: { pid: 12345 }, 112 | }; 113 | }; 114 | 115 | const args = { 116 | workspacePath: '/path/to/MyProject.xcworkspace', 117 | scheme: 'MyScheme', 118 | }; 119 | 120 | await get_mac_app_pathLogic(args, mockExecutor); 121 | 122 | // Verify command generation with manual call tracking 123 | expect(calls).toHaveLength(1); 124 | expect(calls[0]).toEqual([ 125 | [ 126 | 'xcodebuild', 127 | '-showBuildSettings', 128 | '-workspace', 129 | '/path/to/MyProject.xcworkspace', 130 | '-scheme', 131 | 'MyScheme', 132 | '-configuration', 133 | 'Debug', 134 | ], 135 | 'Get App Path', 136 | true, 137 | undefined, 138 | ]); 139 | }); 140 | 141 | it('should generate correct command with project minimal parameters', async () => { 142 | // Manual call tracking for command verification 143 | const calls: any[] = []; 144 | const mockExecutor: CommandExecutor = async (...args) => { 145 | calls.push(args); 146 | return { 147 | success: true, 148 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 149 | error: undefined, 150 | process: { pid: 12345 }, 151 | }; 152 | }; 153 | 154 | const args = { 155 | projectPath: '/path/to/MyProject.xcodeproj', 156 | scheme: 'MyScheme', 157 | }; 158 | 159 | await get_mac_app_pathLogic(args, mockExecutor); 160 | 161 | // Verify command generation with manual call tracking 162 | expect(calls).toHaveLength(1); 163 | expect(calls[0]).toEqual([ 164 | [ 165 | 'xcodebuild', 166 | '-showBuildSettings', 167 | '-project', 168 | '/path/to/MyProject.xcodeproj', 169 | '-scheme', 170 | 'MyScheme', 171 | '-configuration', 172 | 'Debug', 173 | ], 174 | 'Get App Path', 175 | true, 176 | undefined, 177 | ]); 178 | }); 179 | 180 | it('should generate correct command with workspace all parameters', async () => { 181 | // Manual call tracking for command verification 182 | const calls: any[] = []; 183 | const mockExecutor: CommandExecutor = async (...args) => { 184 | calls.push(args); 185 | return { 186 | success: true, 187 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 188 | error: undefined, 189 | process: { pid: 12345 }, 190 | }; 191 | }; 192 | 193 | const args = { 194 | workspacePath: '/path/to/MyProject.xcworkspace', 195 | scheme: 'MyScheme', 196 | configuration: 'Release', 197 | arch: 'arm64', 198 | }; 199 | 200 | await get_mac_app_pathLogic(args, mockExecutor); 201 | 202 | // Verify command generation with manual call tracking 203 | expect(calls).toHaveLength(1); 204 | expect(calls[0]).toEqual([ 205 | [ 206 | 'xcodebuild', 207 | '-showBuildSettings', 208 | '-workspace', 209 | '/path/to/MyProject.xcworkspace', 210 | '-scheme', 211 | 'MyScheme', 212 | '-configuration', 213 | 'Release', 214 | '-destination', 215 | 'platform=macOS,arch=arm64', 216 | ], 217 | 'Get App Path', 218 | true, 219 | undefined, 220 | ]); 221 | }); 222 | 223 | it('should generate correct command with x86_64 architecture', async () => { 224 | // Manual call tracking for command verification 225 | const calls: any[] = []; 226 | const mockExecutor: CommandExecutor = async (...args) => { 227 | calls.push(args); 228 | return { 229 | success: true, 230 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 231 | error: undefined, 232 | process: { pid: 12345 }, 233 | }; 234 | }; 235 | 236 | const args = { 237 | workspacePath: '/path/to/MyProject.xcworkspace', 238 | scheme: 'MyScheme', 239 | configuration: 'Debug', 240 | arch: 'x86_64', 241 | }; 242 | 243 | await get_mac_app_pathLogic(args, mockExecutor); 244 | 245 | // Verify command generation with manual call tracking 246 | expect(calls).toHaveLength(1); 247 | expect(calls[0]).toEqual([ 248 | [ 249 | 'xcodebuild', 250 | '-showBuildSettings', 251 | '-workspace', 252 | '/path/to/MyProject.xcworkspace', 253 | '-scheme', 254 | 'MyScheme', 255 | '-configuration', 256 | 'Debug', 257 | '-destination', 258 | 'platform=macOS,arch=x86_64', 259 | ], 260 | 'Get App Path', 261 | true, 262 | undefined, 263 | ]); 264 | }); 265 | 266 | it('should generate correct command with project all parameters', async () => { 267 | // Manual call tracking for command verification 268 | const calls: any[] = []; 269 | const mockExecutor: CommandExecutor = async (...args) => { 270 | calls.push(args); 271 | return { 272 | success: true, 273 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 274 | error: undefined, 275 | process: { pid: 12345 }, 276 | }; 277 | }; 278 | 279 | const args = { 280 | projectPath: '/path/to/MyProject.xcodeproj', 281 | scheme: 'MyScheme', 282 | configuration: 'Release', 283 | derivedDataPath: '/path/to/derived', 284 | extraArgs: ['--verbose'], 285 | }; 286 | 287 | await get_mac_app_pathLogic(args, mockExecutor); 288 | 289 | // Verify command generation with manual call tracking 290 | expect(calls).toHaveLength(1); 291 | expect(calls[0]).toEqual([ 292 | [ 293 | 'xcodebuild', 294 | '-showBuildSettings', 295 | '-project', 296 | '/path/to/MyProject.xcodeproj', 297 | '-scheme', 298 | 'MyScheme', 299 | '-configuration', 300 | 'Release', 301 | '-derivedDataPath', 302 | '/path/to/derived', 303 | '--verbose', 304 | ], 305 | 'Get App Path', 306 | true, 307 | undefined, 308 | ]); 309 | }); 310 | 311 | it('should use default configuration when not provided', async () => { 312 | // Manual call tracking for command verification 313 | const calls: any[] = []; 314 | const mockExecutor: CommandExecutor = async (...args) => { 315 | calls.push(args); 316 | return { 317 | success: true, 318 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', 319 | error: undefined, 320 | process: { pid: 12345 }, 321 | }; 322 | }; 323 | 324 | const args = { 325 | workspacePath: '/path/to/MyProject.xcworkspace', 326 | scheme: 'MyScheme', 327 | arch: 'arm64', 328 | }; 329 | 330 | await get_mac_app_pathLogic(args, mockExecutor); 331 | 332 | // Verify command generation with manual call tracking 333 | expect(calls).toHaveLength(1); 334 | expect(calls[0]).toEqual([ 335 | [ 336 | 'xcodebuild', 337 | '-showBuildSettings', 338 | '-workspace', 339 | '/path/to/MyProject.xcworkspace', 340 | '-scheme', 341 | 'MyScheme', 342 | '-configuration', 343 | 'Debug', 344 | '-destination', 345 | 'platform=macOS,arch=arm64', 346 | ], 347 | 'Get App Path', 348 | true, 349 | undefined, 350 | ]); 351 | }); 352 | }); 353 | 354 | describe('Handler Behavior (Complete Literal Returns)', () => { 355 | it('should return Zod validation error for missing scheme', async () => { 356 | const result = await getMacAppPath.handler({ 357 | workspacePath: '/path/to/MyProject.xcworkspace', 358 | }); 359 | 360 | expect(result.isError).toBe(true); 361 | expect(result.content[0].text).toContain('scheme is required'); 362 | expect(result.content[0].text).toContain('session-set-defaults'); 363 | }); 364 | 365 | it('should return exact successful app path response with workspace', async () => { 366 | const mockExecutor = createMockExecutor({ 367 | success: true, 368 | output: ` 369 | BUILT_PRODUCTS_DIR = /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug 370 | FULL_PRODUCT_NAME = MyApp.app 371 | `, 372 | }); 373 | 374 | const result = await get_mac_app_pathLogic( 375 | { 376 | workspacePath: '/path/to/MyProject.xcworkspace', 377 | scheme: 'MyScheme', 378 | }, 379 | mockExecutor, 380 | ); 381 | 382 | expect(result).toEqual({ 383 | content: [ 384 | { 385 | type: 'text', 386 | text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', 387 | }, 388 | { 389 | type: 'text', 390 | text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })', 391 | }, 392 | ], 393 | }); 394 | }); 395 | 396 | it('should return exact successful app path response with project', async () => { 397 | const mockExecutor = createMockExecutor({ 398 | success: true, 399 | output: ` 400 | BUILT_PRODUCTS_DIR = /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug 401 | FULL_PRODUCT_NAME = MyApp.app 402 | `, 403 | }); 404 | 405 | const result = await get_mac_app_pathLogic( 406 | { 407 | projectPath: '/path/to/MyProject.xcodeproj', 408 | scheme: 'MyScheme', 409 | }, 410 | mockExecutor, 411 | ); 412 | 413 | expect(result).toEqual({ 414 | content: [ 415 | { 416 | type: 'text', 417 | text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', 418 | }, 419 | { 420 | type: 'text', 421 | text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })', 422 | }, 423 | ], 424 | }); 425 | }); 426 | 427 | it('should return exact build settings failure response', async () => { 428 | const mockExecutor = createMockExecutor({ 429 | success: false, 430 | error: 'error: No such scheme', 431 | }); 432 | 433 | const result = await get_mac_app_pathLogic( 434 | { 435 | workspacePath: '/path/to/MyProject.xcworkspace', 436 | scheme: 'MyScheme', 437 | }, 438 | mockExecutor, 439 | ); 440 | 441 | expect(result).toEqual({ 442 | content: [ 443 | { 444 | type: 'text', 445 | text: 'Error: Failed to get macOS app path\nDetails: error: No such scheme', 446 | }, 447 | ], 448 | isError: true, 449 | }); 450 | }); 451 | 452 | it('should return exact missing build settings response', async () => { 453 | const mockExecutor = createMockExecutor({ 454 | success: true, 455 | output: 'OTHER_SETTING = value', 456 | }); 457 | 458 | const result = await get_mac_app_pathLogic( 459 | { 460 | workspacePath: '/path/to/MyProject.xcworkspace', 461 | scheme: 'MyScheme', 462 | }, 463 | mockExecutor, 464 | ); 465 | 466 | expect(result).toEqual({ 467 | content: [ 468 | { 469 | type: 'text', 470 | text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', 471 | }, 472 | ], 473 | isError: true, 474 | }); 475 | }); 476 | 477 | it('should return exact exception handling response', async () => { 478 | const mockExecutor = async () => { 479 | throw new Error('Network error'); 480 | }; 481 | 482 | const result = await get_mac_app_pathLogic( 483 | { 484 | workspacePath: '/path/to/MyProject.xcworkspace', 485 | scheme: 'MyScheme', 486 | }, 487 | mockExecutor, 488 | ); 489 | 490 | expect(result).toEqual({ 491 | content: [ 492 | { 493 | type: 'text', 494 | text: 'Error: Failed to get macOS app path\nDetails: Network error', 495 | }, 496 | ], 497 | isError: true, 498 | }); 499 | }); 500 | }); 501 | }); 502 | ```