This is page 13 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 -------------------------------------------------------------------------------- /scripts/tools-cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * XcodeBuildMCP Tools CLI 5 | * 6 | * A unified command-line tool that provides comprehensive information about 7 | * XcodeBuildMCP tools and resources. Supports both runtime inspection 8 | * (actual server state) and static analysis (source file analysis). 9 | * 10 | * Usage: 11 | * npm run tools [command] [options] 12 | * npx tsx src/cli/tools-cli.ts [command] [options] 13 | * 14 | * Commands: 15 | * count, c Show tool and workflow counts 16 | * list, l List all tools and resources 17 | * static, s Show static source file analysis 18 | * help, h Show this help message 19 | * 20 | * Options: 21 | * --runtime, -r Use runtime inspection (respects env config) 22 | * --static, -s Use static file analysis (development mode) 23 | * --tools, -t Include tools in output 24 | * --resources Include resources in output 25 | * --workflows, -w Include workflow information 26 | * --verbose, -v Show detailed information 27 | * --json Output JSON format 28 | * --help Show help for specific command 29 | * 30 | * Examples: 31 | * npm run tools # Runtime summary with workflows 32 | * npm run tools:count # Runtime tool count 33 | * npm run tools:static # Static file analysis 34 | * npm run tools:list # List runtime tools 35 | * npx tsx src/cli/tools-cli.ts --json # JSON output 36 | */ 37 | 38 | import { spawn } from 'child_process'; 39 | import * as path from 'path'; 40 | import { fileURLToPath } from 'url'; 41 | import * as fs from 'fs'; 42 | import { getStaticToolAnalysis, type StaticAnalysisResult } from './analysis/tools-analysis.js'; 43 | 44 | // Get project paths 45 | const __filename = fileURLToPath(import.meta.url); 46 | const __dirname = path.dirname(__filename); 47 | 48 | // ANSI color codes 49 | const colors = { 50 | reset: '\x1b[0m', 51 | bright: '\x1b[1m', 52 | red: '\x1b[31m', 53 | green: '\x1b[32m', 54 | yellow: '\x1b[33m', 55 | blue: '\x1b[34m', 56 | cyan: '\x1b[36m', 57 | magenta: '\x1b[35m', 58 | } as const; 59 | 60 | // Types 61 | interface CLIOptions { 62 | runtime: boolean; 63 | static: boolean; 64 | tools: boolean; 65 | resources: boolean; 66 | workflows: boolean; 67 | verbose: boolean; 68 | json: boolean; 69 | help: boolean; 70 | } 71 | 72 | interface RuntimeTool { 73 | name: string; 74 | description: string; 75 | } 76 | 77 | interface RuntimeResource { 78 | uri: string; 79 | name: string; 80 | description: string; 81 | } 82 | 83 | interface RuntimeData { 84 | tools: RuntimeTool[]; 85 | resources: RuntimeResource[]; 86 | toolCount: number; 87 | resourceCount: number; 88 | dynamicMode: boolean; 89 | mode: 'runtime'; 90 | } 91 | 92 | // CLI argument parsing 93 | const args = process.argv.slice(2); 94 | 95 | // Find the command (first non-flag argument) 96 | let command = 'count'; // default 97 | for (const arg of args) { 98 | if (!arg.startsWith('-')) { 99 | command = arg; 100 | break; 101 | } 102 | } 103 | 104 | const options: CLIOptions = { 105 | runtime: args.includes('--runtime') || args.includes('-r'), 106 | static: args.includes('--static') || args.includes('-s'), 107 | tools: args.includes('--tools') || args.includes('-t'), 108 | resources: args.includes('--resources'), 109 | workflows: args.includes('--workflows') || args.includes('-w'), 110 | verbose: args.includes('--verbose') || args.includes('-v'), 111 | json: args.includes('--json'), 112 | help: args.includes('--help') || args.includes('-h'), 113 | }; 114 | 115 | // Set sensible defaults for each command 116 | if (!options.runtime && !options.static) { 117 | if (command === 'static' || command === 's') { 118 | options.static = true; 119 | } else { 120 | // Default to static analysis for development-friendly usage 121 | options.static = true; 122 | } 123 | } 124 | 125 | // Set sensible content defaults 126 | if (command === 'list' || command === 'l') { 127 | if (!options.tools && !options.resources && !options.workflows) { 128 | options.tools = true; // Default to showing tools for list command 129 | } 130 | } else if (!command || command === 'count' || command === 'c') { 131 | // For no command or count, show comprehensive summary 132 | if (!options.tools && !options.resources && !options.workflows) { 133 | options.workflows = true; // Show workflows by default for summary 134 | } 135 | } 136 | 137 | // Help text 138 | const helpText = { 139 | main: ` 140 | ${colors.bright}${colors.blue}XcodeBuildMCP Tools CLI${colors.reset} 141 | 142 | A unified command-line tool for XcodeBuildMCP tool and resource information. 143 | 144 | ${colors.bright}COMMANDS:${colors.reset} 145 | count, c Show tool and workflow counts 146 | list, l List all tools and resources 147 | static, s Show static source file analysis 148 | help, h Show this help message 149 | 150 | ${colors.bright}OPTIONS:${colors.reset} 151 | --runtime, -r Use runtime inspection (respects env config) 152 | --static, -s Use static file analysis (default, development mode) 153 | --tools, -t Include tools in output 154 | --resources Include resources in output 155 | --workflows, -w Include workflow information 156 | --verbose, -v Show detailed information 157 | --json Output JSON format 158 | 159 | ${colors.bright}EXAMPLES:${colors.reset} 160 | ${colors.cyan}npm run tools${colors.reset} # Static summary with workflows (default) 161 | ${colors.cyan}npm run tools list${colors.reset} # List tools 162 | ${colors.cyan}npm run tools --runtime${colors.reset} # Runtime analysis (requires build) 163 | ${colors.cyan}npm run tools static${colors.reset} # Static analysis summary 164 | ${colors.cyan}npm run tools count --json${colors.reset} # JSON output 165 | 166 | ${colors.bright}ANALYSIS MODES:${colors.reset} 167 | ${colors.green}Runtime${colors.reset} Uses actual server inspection via Reloaderoo 168 | - Respects XCODEBUILDMCP_DYNAMIC_TOOLS environment variable 169 | - Shows tools actually enabled at runtime 170 | - Requires built server (npm run build) 171 | 172 | ${colors.yellow}Static${colors.reset} Scans source files directly using AST parsing 173 | - Shows all tools in codebase regardless of config 174 | - Development-time analysis with reliable description extraction 175 | - No server build required 176 | `, 177 | 178 | count: ` 179 | ${colors.bright}COUNT COMMAND${colors.reset} 180 | 181 | Shows tool and workflow counts using runtime or static analysis. 182 | 183 | ${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts count [options] 184 | 185 | ${colors.bright}Options:${colors.reset} 186 | --runtime, -r Count tools from running server 187 | --static, -s Count tools from source files 188 | --workflows, -w Include workflow directory counts 189 | --json Output JSON format 190 | 191 | ${colors.bright}Examples:${colors.reset} 192 | ${colors.cyan}npx tsx scripts/tools-cli.ts count${colors.reset} # Runtime count 193 | ${colors.cyan}npx tsx scripts/tools-cli.ts count --static${colors.reset} # Static count 194 | ${colors.cyan}npx tsx scripts/tools-cli.ts count --workflows${colors.reset} # Include workflows 195 | `, 196 | 197 | list: ` 198 | ${colors.bright}LIST COMMAND${colors.reset} 199 | 200 | Lists tools and resources with optional details. 201 | 202 | ${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts list [options] 203 | 204 | ${colors.bright}Options:${colors.reset} 205 | --runtime, -r List from running server 206 | --static, -s List from source files 207 | --tools, -t Show tool names 208 | --resources Show resource URIs 209 | --verbose, -v Show detailed information 210 | --json Output JSON format 211 | 212 | ${colors.bright}Examples:${colors.reset} 213 | ${colors.cyan}npx tsx scripts/tools-cli.ts list --tools${colors.reset} # Runtime tool list 214 | ${colors.cyan}npx tsx scripts/tools-cli.ts list --resources${colors.reset} # Runtime resource list 215 | ${colors.cyan}npx tsx scripts/tools-cli.ts list --static --verbose${colors.reset} # Static detailed list 216 | `, 217 | 218 | static: ` 219 | ${colors.bright}STATIC COMMAND${colors.reset} 220 | 221 | Performs detailed static analysis of source files using AST parsing. 222 | 223 | ${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts static [options] 224 | 225 | ${colors.bright}Options:${colors.reset} 226 | --tools, -t Show canonical tool details 227 | --workflows, -w Show workflow directory analysis 228 | --verbose, -v Show detailed file information 229 | --json Output JSON format 230 | 231 | ${colors.bright}Examples:${colors.reset} 232 | ${colors.cyan}npx tsx scripts/tools-cli.ts static${colors.reset} # Basic static analysis 233 | ${colors.cyan}npx tsx scripts/tools-cli.ts static --verbose${colors.reset} # Detailed analysis 234 | ${colors.cyan}npx tsx scripts/tools-cli.ts static --workflows${colors.reset} # Include workflow info 235 | `, 236 | }; 237 | 238 | if (options.help) { 239 | console.log(helpText[command as keyof typeof helpText] || helpText.main); 240 | process.exit(0); 241 | } 242 | 243 | if (command === 'help' || command === 'h') { 244 | const helpCommand = args[1]; 245 | console.log(helpText[helpCommand as keyof typeof helpText] || helpText.main); 246 | process.exit(0); 247 | } 248 | 249 | /** 250 | * Execute reloaderoo command and parse JSON response 251 | */ 252 | async function executeReloaderoo(reloaderooArgs: string[]): Promise<unknown> { 253 | const buildPath = path.resolve(__dirname, '..', 'build', 'index.js'); 254 | 255 | if (!fs.existsSync(buildPath)) { 256 | throw new Error('Build not found. Please run "npm run build" first.'); 257 | } 258 | 259 | const tempFile = `/tmp/reloaderoo-output-${Date.now()}.json`; 260 | const command = `npx -y reloaderoo@latest inspect ${reloaderooArgs.join(' ')} -- node "${buildPath}"`; 261 | 262 | return new Promise((resolve, reject) => { 263 | const child = spawn('bash', ['-c', `${command} > "${tempFile}"`], { 264 | stdio: 'inherit', 265 | }); 266 | 267 | child.on('close', (code) => { 268 | try { 269 | if (code !== 0) { 270 | reject(new Error(`Command failed with code ${code}`)); 271 | return; 272 | } 273 | 274 | const content = fs.readFileSync(tempFile, 'utf8'); 275 | 276 | // Remove stderr log lines and find JSON 277 | const lines = content.split('\n'); 278 | const cleanLines: string[] = []; 279 | 280 | for (const line of lines) { 281 | if ( 282 | line.match(/^\[\d{4}-\d{2}-\d{2}T/) || 283 | line.includes('[INFO]') || 284 | line.includes('[DEBUG]') || 285 | line.includes('[ERROR]') 286 | ) { 287 | continue; 288 | } 289 | 290 | const trimmed = line.trim(); 291 | if (trimmed) { 292 | cleanLines.push(line); 293 | } 294 | } 295 | 296 | // Find JSON start 297 | let jsonStartIndex = -1; 298 | for (let i = 0; i < cleanLines.length; i++) { 299 | if (cleanLines[i].trim().startsWith('{')) { 300 | jsonStartIndex = i; 301 | break; 302 | } 303 | } 304 | 305 | if (jsonStartIndex === -1) { 306 | reject( 307 | new Error(`No JSON response found in output.\nOutput: ${content.substring(0, 500)}...`), 308 | ); 309 | return; 310 | } 311 | 312 | const jsonText = cleanLines.slice(jsonStartIndex).join('\n'); 313 | const response = JSON.parse(jsonText); 314 | resolve(response); 315 | } catch (error) { 316 | reject(new Error(`Failed to parse JSON response: ${(error as Error).message}`)); 317 | } finally { 318 | try { 319 | fs.unlinkSync(tempFile); 320 | } catch { 321 | // Ignore cleanup errors 322 | } 323 | } 324 | }); 325 | 326 | child.on('error', (error) => { 327 | reject(new Error(`Failed to spawn process: ${error.message}`)); 328 | }); 329 | }); 330 | } 331 | 332 | /** 333 | * Get runtime server information 334 | */ 335 | async function getRuntimeInfo(): Promise<RuntimeData> { 336 | try { 337 | const toolsResponse = (await executeReloaderoo(['list-tools'])) as { 338 | tools?: { name: string; description: string }[]; 339 | }; 340 | const resourcesResponse = (await executeReloaderoo(['list-resources'])) as { 341 | resources?: { uri: string; name: string; description?: string; title?: string }[]; 342 | }; 343 | 344 | let tools: RuntimeTool[] = []; 345 | let toolCount = 0; 346 | 347 | if (toolsResponse.tools && Array.isArray(toolsResponse.tools)) { 348 | toolCount = toolsResponse.tools.length; 349 | tools = toolsResponse.tools.map((tool) => ({ 350 | name: tool.name, 351 | description: tool.description, 352 | })); 353 | } 354 | 355 | let resources: RuntimeResource[] = []; 356 | let resourceCount = 0; 357 | 358 | if (resourcesResponse.resources && Array.isArray(resourcesResponse.resources)) { 359 | resourceCount = resourcesResponse.resources.length; 360 | resources = resourcesResponse.resources.map((resource) => ({ 361 | uri: resource.uri, 362 | name: resource.name, 363 | description: resource.title ?? resource.description ?? 'No description available', 364 | })); 365 | } 366 | 367 | return { 368 | tools, 369 | resources, 370 | toolCount, 371 | resourceCount, 372 | dynamicMode: process.env.XCODEBUILDMCP_DYNAMIC_TOOLS === 'true', 373 | mode: 'runtime', 374 | }; 375 | } catch (error) { 376 | throw new Error(`Runtime analysis failed: ${(error as Error).message}`); 377 | } 378 | } 379 | 380 | /** 381 | * Display summary information 382 | */ 383 | function displaySummary( 384 | runtimeData: RuntimeData | null, 385 | staticData: StaticAnalysisResult | null, 386 | ): void { 387 | if (options.json) { 388 | return; // JSON output handled separately 389 | } 390 | 391 | console.log(`${colors.bright}${colors.blue}📊 XcodeBuildMCP Tools Summary${colors.reset}`); 392 | console.log('═'.repeat(60)); 393 | 394 | if (runtimeData) { 395 | console.log(`${colors.green}🚀 Runtime Analysis:${colors.reset}`); 396 | console.log(` Mode: ${runtimeData.dynamicMode ? 'Dynamic' : 'Static'}`); 397 | console.log(` Tools: ${runtimeData.toolCount}`); 398 | console.log(` Resources: ${runtimeData.resourceCount}`); 399 | console.log(` Total: ${runtimeData.toolCount + runtimeData.resourceCount}`); 400 | 401 | if (runtimeData.dynamicMode) { 402 | console.log( 403 | ` ${colors.yellow}ℹ️ Dynamic mode: Only enabled workflow tools shown${colors.reset}`, 404 | ); 405 | } 406 | console.log(); 407 | } 408 | 409 | if (staticData) { 410 | console.log(`${colors.cyan}📁 Static Analysis:${colors.reset}`); 411 | console.log(` Workflow directories: ${staticData.stats.workflowCount}`); 412 | console.log(` Canonical tools: ${staticData.stats.canonicalTools}`); 413 | console.log(` Re-export files: ${staticData.stats.reExportTools}`); 414 | console.log(` Total tool files: ${staticData.stats.totalTools}`); 415 | console.log(); 416 | } 417 | } 418 | 419 | /** 420 | * Display workflow information 421 | */ 422 | function displayWorkflows(staticData: StaticAnalysisResult | null): void { 423 | if (!options.workflows || !staticData || options.json) return; 424 | 425 | console.log(`${colors.bright}📂 Workflow Directories:${colors.reset}`); 426 | console.log('─'.repeat(40)); 427 | 428 | for (const workflow of staticData.workflows) { 429 | const totalTools = workflow.toolCount; 430 | console.log(`${colors.green}• ${workflow.displayName}${colors.reset} (${totalTools} tools)`); 431 | 432 | if (options.verbose) { 433 | const canonicalTools = workflow.tools.filter((t) => t.isCanonical).map((t) => t.name); 434 | const reExportTools = workflow.tools.filter((t) => !t.isCanonical).map((t) => t.name); 435 | 436 | if (canonicalTools.length > 0) { 437 | console.log(` ${colors.cyan}Canonical:${colors.reset} ${canonicalTools.join(', ')}`); 438 | } 439 | if (reExportTools.length > 0) { 440 | console.log(` ${colors.yellow}Re-exports:${colors.reset} ${reExportTools.join(', ')}`); 441 | } 442 | } 443 | } 444 | console.log(); 445 | } 446 | 447 | /** 448 | * Display tool lists 449 | */ 450 | function displayTools( 451 | runtimeData: RuntimeData | null, 452 | staticData: StaticAnalysisResult | null, 453 | ): void { 454 | if (!options.tools || options.json) return; 455 | 456 | if (runtimeData) { 457 | console.log(`${colors.bright}🛠️ Runtime Tools (${runtimeData.toolCount}):${colors.reset}`); 458 | console.log('─'.repeat(40)); 459 | 460 | if (runtimeData.tools.length === 0) { 461 | console.log(' No tools available'); 462 | } else { 463 | runtimeData.tools.forEach((tool) => { 464 | if (options.verbose && tool.description) { 465 | console.log( 466 | ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset}`, 467 | ); 468 | console.log(` ${tool.description}`); 469 | } else { 470 | console.log(` ${colors.green}•${colors.reset} ${tool.name}`); 471 | } 472 | }); 473 | } 474 | console.log(); 475 | } 476 | 477 | if (staticData && options.static) { 478 | const canonicalTools = staticData.tools.filter((tool) => tool.isCanonical); 479 | console.log(`${colors.bright}📁 Static Tools (${canonicalTools.length}):${colors.reset}`); 480 | console.log('─'.repeat(40)); 481 | 482 | if (canonicalTools.length === 0) { 483 | console.log(' No tools found'); 484 | } else { 485 | canonicalTools 486 | .sort((a, b) => a.name.localeCompare(b.name)) 487 | .forEach((tool) => { 488 | if (options.verbose) { 489 | console.log( 490 | ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset} (${tool.workflow})`, 491 | ); 492 | console.log(` ${tool.description}`); 493 | console.log(` ${colors.cyan}${tool.relativePath}${colors.reset}`); 494 | } else { 495 | console.log(` ${colors.green}•${colors.reset} ${tool.name}`); 496 | } 497 | }); 498 | } 499 | console.log(); 500 | } 501 | } 502 | 503 | /** 504 | * Display resource lists 505 | */ 506 | function displayResources(runtimeData: RuntimeData | null): void { 507 | if (!options.resources || !runtimeData || options.json) return; 508 | 509 | console.log(`${colors.bright}📚 Resources (${runtimeData.resourceCount}):${colors.reset}`); 510 | console.log('─'.repeat(40)); 511 | 512 | if (runtimeData.resources.length === 0) { 513 | console.log(' No resources available'); 514 | } else { 515 | runtimeData.resources.forEach((resource) => { 516 | if (options.verbose) { 517 | console.log( 518 | ` ${colors.magenta}•${colors.reset} ${colors.bright}${resource.uri}${colors.reset}`, 519 | ); 520 | console.log(` ${resource.description}`); 521 | } else { 522 | console.log(` ${colors.magenta}•${colors.reset} ${resource.uri}`); 523 | } 524 | }); 525 | } 526 | console.log(); 527 | } 528 | 529 | /** 530 | * Output JSON format - matches the structure of human-readable output 531 | */ 532 | function outputJSON( 533 | runtimeData: RuntimeData | null, 534 | staticData: StaticAnalysisResult | null, 535 | ): void { 536 | const output: Record<string, unknown> = {}; 537 | 538 | // Add summary stats (equivalent to the summary table) 539 | if (runtimeData) { 540 | output.runtime = { 541 | toolCount: runtimeData.toolCount, 542 | resourceCount: runtimeData.resourceCount, 543 | totalCount: runtimeData.toolCount + runtimeData.resourceCount, 544 | dynamicMode: runtimeData.dynamicMode, 545 | }; 546 | } 547 | 548 | if (staticData) { 549 | output.static = { 550 | workflowCount: staticData.stats.workflowCount, 551 | canonicalTools: staticData.stats.canonicalTools, 552 | reExportTools: staticData.stats.reExportTools, 553 | totalTools: staticData.stats.totalTools, 554 | }; 555 | } 556 | 557 | // Add detailed data only if requested 558 | if (options.workflows && staticData) { 559 | output.workflows = staticData.workflows.map((w) => ({ 560 | name: w.displayName, 561 | toolCount: w.toolCount, 562 | canonicalCount: w.canonicalCount, 563 | reExportCount: w.reExportCount, 564 | })); 565 | } 566 | 567 | if (options.tools) { 568 | if (runtimeData) { 569 | output.runtimeTools = runtimeData.tools.map((t) => t.name); 570 | } 571 | if (staticData) { 572 | output.staticTools = staticData.tools 573 | .filter((t) => t.isCanonical) 574 | .map((t) => t.name) 575 | .sort(); 576 | } 577 | } 578 | 579 | if (options.resources && runtimeData) { 580 | output.resources = runtimeData.resources.map((r) => r.uri); 581 | } 582 | 583 | console.log(JSON.stringify(output, null, 2)); 584 | } 585 | 586 | /** 587 | * Main execution function 588 | */ 589 | async function main(): Promise<void> { 590 | try { 591 | let runtimeData: RuntimeData | null = null; 592 | let staticData: StaticAnalysisResult | null = null; 593 | 594 | // Gather data based on options 595 | if (options.runtime) { 596 | if (!options.json) { 597 | console.log(`${colors.cyan}🔍 Gathering runtime information...${colors.reset}`); 598 | } 599 | runtimeData = await getRuntimeInfo(); 600 | } 601 | 602 | if (options.static) { 603 | if (!options.json) { 604 | console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}`); 605 | } 606 | staticData = await getStaticToolAnalysis(); 607 | } 608 | 609 | // For default command or workflows option, always gather static data for workflow info 610 | if (options.workflows && !staticData) { 611 | if (!options.json) { 612 | console.log(`${colors.cyan}📁 Gathering workflow information...${colors.reset}`); 613 | } 614 | staticData = await getStaticToolAnalysis(); 615 | } 616 | 617 | if (!options.json) { 618 | console.log(); // Blank line after gathering 619 | } 620 | 621 | // Handle JSON output 622 | if (options.json) { 623 | outputJSON(runtimeData, staticData); 624 | return; 625 | } 626 | 627 | // Display based on command 628 | switch (command) { 629 | case 'count': 630 | case 'c': 631 | displaySummary(runtimeData, staticData); 632 | displayWorkflows(staticData); 633 | break; 634 | 635 | case 'list': 636 | case 'l': 637 | displaySummary(runtimeData, staticData); 638 | displayTools(runtimeData, staticData); 639 | displayResources(runtimeData); 640 | break; 641 | 642 | case 'static': 643 | case 's': 644 | if (!staticData) { 645 | console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}\n`); 646 | staticData = await getStaticToolAnalysis(); 647 | } 648 | displaySummary(null, staticData); 649 | displayWorkflows(staticData); 650 | 651 | if (options.verbose) { 652 | displayTools(null, staticData); 653 | const reExportTools = staticData.tools.filter((t) => !t.isCanonical); 654 | console.log( 655 | `${colors.bright}🔄 Re-export Files (${reExportTools.length}):${colors.reset}`, 656 | ); 657 | console.log('─'.repeat(40)); 658 | reExportTools.forEach((file) => { 659 | console.log(` ${colors.yellow}•${colors.reset} ${file.name} (${file.workflow})`); 660 | console.log(` ${file.relativePath}`); 661 | }); 662 | } 663 | break; 664 | 665 | default: 666 | // Default case (no command) - show runtime summary with workflows 667 | displaySummary(runtimeData, staticData); 668 | displayWorkflows(staticData); 669 | break; 670 | } 671 | 672 | if (!options.json) { 673 | console.log(`${colors.green}✅ Analysis complete!${colors.reset}`); 674 | } 675 | } catch (error) { 676 | if (options.json) { 677 | console.error( 678 | JSON.stringify( 679 | { 680 | success: false, 681 | error: (error as Error).message, 682 | timestamp: new Date().toISOString(), 683 | }, 684 | null, 685 | 2, 686 | ), 687 | ); 688 | } else { 689 | console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`); 690 | } 691 | process.exit(1); 692 | } 693 | } 694 | 695 | // Run the CLI 696 | main(); 697 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/tap.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for tap plugin 3 | */ 4 | 5 | import { describe, it, expect, beforeEach } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 8 | 9 | import tapPlugin, { AxeHelpers, tapLogic } from '../tap.ts'; 10 | 11 | // Helper function to create mock axe helpers 12 | function createMockAxeHelpers(): AxeHelpers { 13 | return { 14 | getAxePath: () => '/mocked/axe/path', 15 | getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), 16 | createAxeNotAvailableResponse: () => ({ 17 | content: [ 18 | { 19 | type: 'text', 20 | 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.', 21 | }, 22 | ], 23 | isError: true, 24 | }), 25 | }; 26 | } 27 | 28 | // Helper function to create mock axe helpers with null path (for dependency error tests) 29 | function createMockAxeHelpersWithNullPath(): AxeHelpers { 30 | return { 31 | getAxePath: () => null, 32 | getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), 33 | createAxeNotAvailableResponse: () => ({ 34 | content: [ 35 | { 36 | type: 'text', 37 | 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.', 38 | }, 39 | ], 40 | isError: true, 41 | }), 42 | }; 43 | } 44 | 45 | describe('Tap Plugin', () => { 46 | describe('Export Field Validation (Literal)', () => { 47 | it('should have correct name', () => { 48 | expect(tapPlugin.name).toBe('tap'); 49 | }); 50 | 51 | it('should have correct description', () => { 52 | expect(tapPlugin.description).toBe( 53 | "Tap at specific coordinates. Use describe_ui to get precise element coordinates (don't guess from screenshots). Supports optional timing delays.", 54 | ); 55 | }); 56 | 57 | it('should have handler function', () => { 58 | expect(typeof tapPlugin.handler).toBe('function'); 59 | }); 60 | 61 | it('should validate schema fields with safeParse', () => { 62 | const schema = z.object(tapPlugin.schema); 63 | 64 | // Valid case 65 | expect( 66 | schema.safeParse({ 67 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 68 | x: 100, 69 | y: 200, 70 | }).success, 71 | ).toBe(true); 72 | 73 | // Invalid simulatorUuid 74 | expect( 75 | schema.safeParse({ 76 | simulatorUuid: 'invalid-uuid', 77 | x: 100, 78 | y: 200, 79 | }).success, 80 | ).toBe(false); 81 | 82 | // Invalid x coordinate - non-integer 83 | expect( 84 | schema.safeParse({ 85 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 86 | x: 3.14, 87 | y: 200, 88 | }).success, 89 | ).toBe(false); 90 | 91 | // Invalid y coordinate - non-integer 92 | expect( 93 | schema.safeParse({ 94 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 95 | x: 100, 96 | y: 3.14, 97 | }).success, 98 | ).toBe(false); 99 | 100 | // Invalid preDelay - negative 101 | expect( 102 | schema.safeParse({ 103 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 104 | x: 100, 105 | y: 200, 106 | preDelay: -1, 107 | }).success, 108 | ).toBe(false); 109 | 110 | // Invalid postDelay - negative 111 | expect( 112 | schema.safeParse({ 113 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 114 | x: 100, 115 | y: 200, 116 | postDelay: -1, 117 | }).success, 118 | ).toBe(false); 119 | 120 | // Valid with optional delays 121 | expect( 122 | schema.safeParse({ 123 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 124 | x: 100, 125 | y: 200, 126 | preDelay: 0.5, 127 | postDelay: 1.0, 128 | }).success, 129 | ).toBe(true); 130 | 131 | // Missing required fields 132 | expect(schema.safeParse({}).success).toBe(false); 133 | }); 134 | }); 135 | 136 | describe('Command Generation', () => { 137 | let callHistory: Array<{ 138 | command: string[]; 139 | logPrefix?: string; 140 | useShell?: boolean; 141 | env?: Record<string, string>; 142 | }>; 143 | 144 | beforeEach(() => { 145 | callHistory = []; 146 | }); 147 | 148 | it('should generate correct axe command with minimal parameters', async () => { 149 | const mockExecutor = createMockExecutor({ 150 | success: true, 151 | output: 'Tap completed', 152 | }); 153 | 154 | const wrappedExecutor = async ( 155 | command: string[], 156 | logPrefix?: string, 157 | useShell?: boolean, 158 | env?: Record<string, string>, 159 | ) => { 160 | callHistory.push({ command, logPrefix, useShell, env }); 161 | return mockExecutor(command, logPrefix, useShell, env); 162 | }; 163 | 164 | const mockAxeHelpers = createMockAxeHelpers(); 165 | 166 | await tapLogic( 167 | { 168 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 169 | x: 100, 170 | y: 200, 171 | }, 172 | wrappedExecutor, 173 | mockAxeHelpers, 174 | ); 175 | 176 | expect(callHistory).toHaveLength(1); 177 | expect(callHistory[0]).toEqual({ 178 | command: [ 179 | '/mocked/axe/path', 180 | 'tap', 181 | '-x', 182 | '100', 183 | '-y', 184 | '200', 185 | '--udid', 186 | '12345678-1234-1234-1234-123456789012', 187 | ], 188 | logPrefix: '[AXe]: tap', 189 | useShell: false, 190 | env: { SOME_ENV: 'value' }, 191 | }); 192 | }); 193 | 194 | it('should generate correct axe command with pre-delay', async () => { 195 | const mockExecutor = createMockExecutor({ 196 | success: true, 197 | output: 'Tap completed', 198 | }); 199 | 200 | const wrappedExecutor = async ( 201 | command: string[], 202 | logPrefix?: string, 203 | useShell?: boolean, 204 | env?: Record<string, string>, 205 | ) => { 206 | callHistory.push({ command, logPrefix, useShell, env }); 207 | return mockExecutor(command, logPrefix, useShell, env); 208 | }; 209 | 210 | const mockAxeHelpers = createMockAxeHelpers(); 211 | 212 | await tapLogic( 213 | { 214 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 215 | x: 150, 216 | y: 300, 217 | preDelay: 0.5, 218 | }, 219 | wrappedExecutor, 220 | mockAxeHelpers, 221 | ); 222 | 223 | expect(callHistory).toHaveLength(1); 224 | expect(callHistory[0]).toEqual({ 225 | command: [ 226 | '/mocked/axe/path', 227 | 'tap', 228 | '-x', 229 | '150', 230 | '-y', 231 | '300', 232 | '--pre-delay', 233 | '0.5', 234 | '--udid', 235 | '12345678-1234-1234-1234-123456789012', 236 | ], 237 | logPrefix: '[AXe]: tap', 238 | useShell: false, 239 | env: { SOME_ENV: 'value' }, 240 | }); 241 | }); 242 | 243 | it('should generate correct axe command with post-delay', async () => { 244 | const mockExecutor = createMockExecutor({ 245 | success: true, 246 | output: 'Tap completed', 247 | }); 248 | 249 | const wrappedExecutor = async ( 250 | command: string[], 251 | logPrefix?: string, 252 | useShell?: boolean, 253 | env?: Record<string, string>, 254 | ) => { 255 | callHistory.push({ command, logPrefix, useShell, env }); 256 | return mockExecutor(command, logPrefix, useShell, env); 257 | }; 258 | 259 | const mockAxeHelpers = createMockAxeHelpers(); 260 | 261 | await tapLogic( 262 | { 263 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 264 | x: 250, 265 | y: 400, 266 | postDelay: 1.0, 267 | }, 268 | wrappedExecutor, 269 | mockAxeHelpers, 270 | ); 271 | 272 | expect(callHistory).toHaveLength(1); 273 | expect(callHistory[0]).toEqual({ 274 | command: [ 275 | '/mocked/axe/path', 276 | 'tap', 277 | '-x', 278 | '250', 279 | '-y', 280 | '400', 281 | '--post-delay', 282 | '1', 283 | '--udid', 284 | '12345678-1234-1234-1234-123456789012', 285 | ], 286 | logPrefix: '[AXe]: tap', 287 | useShell: false, 288 | env: { SOME_ENV: 'value' }, 289 | }); 290 | }); 291 | 292 | it('should generate correct axe command with both delays', async () => { 293 | const mockExecutor = createMockExecutor({ 294 | success: true, 295 | output: 'Tap completed', 296 | }); 297 | 298 | const wrappedExecutor = async ( 299 | command: string[], 300 | logPrefix?: string, 301 | useShell?: boolean, 302 | env?: Record<string, string>, 303 | ) => { 304 | callHistory.push({ command, logPrefix, useShell, env }); 305 | return mockExecutor(command, logPrefix, useShell, env); 306 | }; 307 | 308 | const mockAxeHelpers = createMockAxeHelpers(); 309 | 310 | await tapLogic( 311 | { 312 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 313 | x: 350, 314 | y: 500, 315 | preDelay: 0.3, 316 | postDelay: 0.7, 317 | }, 318 | wrappedExecutor, 319 | mockAxeHelpers, 320 | ); 321 | 322 | expect(callHistory).toHaveLength(1); 323 | expect(callHistory[0]).toEqual({ 324 | command: [ 325 | '/mocked/axe/path', 326 | 'tap', 327 | '-x', 328 | '350', 329 | '-y', 330 | '500', 331 | '--pre-delay', 332 | '0.3', 333 | '--post-delay', 334 | '0.7', 335 | '--udid', 336 | '12345678-1234-1234-1234-123456789012', 337 | ], 338 | logPrefix: '[AXe]: tap', 339 | useShell: false, 340 | env: { SOME_ENV: 'value' }, 341 | }); 342 | }); 343 | }); 344 | 345 | describe('Success Response Processing', () => { 346 | it('should return successful response for basic tap', async () => { 347 | const mockExecutor = createMockExecutor({ 348 | success: true, 349 | output: 'Tap completed', 350 | }); 351 | 352 | const mockAxeHelpers = createMockAxeHelpers(); 353 | 354 | const result = await tapLogic( 355 | { 356 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 357 | x: 100, 358 | y: 200, 359 | }, 360 | mockExecutor, 361 | mockAxeHelpers, 362 | ); 363 | 364 | expect(result).toEqual({ 365 | content: [ 366 | { 367 | type: 'text', 368 | text: 'Tap at (100, 200) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 369 | }, 370 | ], 371 | isError: false, 372 | }); 373 | }); 374 | 375 | it('should return successful response with coordinate warning when describe_ui not called', async () => { 376 | const mockExecutor = createMockExecutor({ 377 | success: true, 378 | output: 'Tap completed', 379 | }); 380 | 381 | const mockAxeHelpers = createMockAxeHelpers(); 382 | 383 | const result = await tapLogic( 384 | { 385 | simulatorUuid: '87654321-4321-4321-4321-210987654321', 386 | x: 150, 387 | y: 300, 388 | }, 389 | mockExecutor, 390 | mockAxeHelpers, 391 | ); 392 | 393 | expect(result).toEqual({ 394 | content: [ 395 | { 396 | type: 'text', 397 | text: 'Tap at (150, 300) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 398 | }, 399 | ], 400 | isError: false, 401 | }); 402 | }); 403 | 404 | it('should return successful response with delays', async () => { 405 | const mockExecutor = createMockExecutor({ 406 | success: true, 407 | output: 'Tap completed', 408 | }); 409 | 410 | const mockAxeHelpers = createMockAxeHelpers(); 411 | 412 | const result = await tapLogic( 413 | { 414 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 415 | x: 250, 416 | y: 400, 417 | preDelay: 0.5, 418 | postDelay: 1.0, 419 | }, 420 | mockExecutor, 421 | mockAxeHelpers, 422 | ); 423 | 424 | expect(result).toEqual({ 425 | content: [ 426 | { 427 | type: 'text', 428 | text: 'Tap at (250, 400) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 429 | }, 430 | ], 431 | isError: false, 432 | }); 433 | }); 434 | 435 | it('should return successful response with integer coordinates', async () => { 436 | const mockExecutor = createMockExecutor({ 437 | success: true, 438 | output: 'Tap completed', 439 | }); 440 | 441 | const mockAxeHelpers = createMockAxeHelpers(); 442 | 443 | const result = await tapLogic( 444 | { 445 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 446 | x: 0, 447 | y: 0, 448 | }, 449 | mockExecutor, 450 | mockAxeHelpers, 451 | ); 452 | 453 | expect(result).toEqual({ 454 | content: [ 455 | { 456 | type: 'text', 457 | text: 'Tap at (0, 0) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 458 | }, 459 | ], 460 | isError: false, 461 | }); 462 | }); 463 | 464 | it('should return successful response with large coordinates', async () => { 465 | const mockExecutor = createMockExecutor({ 466 | success: true, 467 | output: 'Tap completed', 468 | }); 469 | 470 | const mockAxeHelpers = createMockAxeHelpers(); 471 | 472 | const result = await tapLogic( 473 | { 474 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 475 | x: 1920, 476 | y: 1080, 477 | }, 478 | mockExecutor, 479 | mockAxeHelpers, 480 | ); 481 | 482 | expect(result).toEqual({ 483 | content: [ 484 | { 485 | type: 'text', 486 | text: 'Tap at (1920, 1080) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 487 | }, 488 | ], 489 | isError: false, 490 | }); 491 | }); 492 | }); 493 | 494 | describe('Plugin Handler Validation', () => { 495 | it('should return Zod validation error for missing simulatorUuid', async () => { 496 | const result = await tapPlugin.handler({ 497 | x: 100, 498 | y: 200, 499 | }); 500 | 501 | expect(result).toEqual({ 502 | content: [ 503 | { 504 | type: 'text', 505 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', 506 | }, 507 | ], 508 | isError: true, 509 | }); 510 | }); 511 | 512 | it('should return Zod validation error for missing x coordinate', async () => { 513 | const result = await tapPlugin.handler({ 514 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 515 | y: 200, 516 | }); 517 | 518 | expect(result).toEqual({ 519 | content: [ 520 | { 521 | type: 'text', 522 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nx: Required', 523 | }, 524 | ], 525 | isError: true, 526 | }); 527 | }); 528 | 529 | it('should return Zod validation error for missing y coordinate', async () => { 530 | const result = await tapPlugin.handler({ 531 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 532 | x: 100, 533 | }); 534 | 535 | expect(result).toEqual({ 536 | content: [ 537 | { 538 | type: 'text', 539 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\ny: Required', 540 | }, 541 | ], 542 | isError: true, 543 | }); 544 | }); 545 | 546 | it('should return Zod validation error for invalid UUID format', async () => { 547 | const result = await tapPlugin.handler({ 548 | simulatorUuid: 'invalid-uuid', 549 | x: 100, 550 | y: 200, 551 | }); 552 | 553 | expect(result).toEqual({ 554 | content: [ 555 | { 556 | type: 'text', 557 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Invalid Simulator UUID format', 558 | }, 559 | ], 560 | isError: true, 561 | }); 562 | }); 563 | 564 | it('should return Zod validation error for non-integer x coordinate', async () => { 565 | const result = await tapPlugin.handler({ 566 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 567 | x: 3.14, 568 | y: 200, 569 | }); 570 | 571 | expect(result).toEqual({ 572 | content: [ 573 | { 574 | type: 'text', 575 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nx: X coordinate must be an integer', 576 | }, 577 | ], 578 | isError: true, 579 | }); 580 | }); 581 | 582 | it('should return Zod validation error for non-integer y coordinate', async () => { 583 | const result = await tapPlugin.handler({ 584 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 585 | x: 100, 586 | y: 3.14, 587 | }); 588 | 589 | expect(result).toEqual({ 590 | content: [ 591 | { 592 | type: 'text', 593 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\ny: Y coordinate must be an integer', 594 | }, 595 | ], 596 | isError: true, 597 | }); 598 | }); 599 | 600 | it('should return Zod validation error for negative preDelay', async () => { 601 | const result = await tapPlugin.handler({ 602 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 603 | x: 100, 604 | y: 200, 605 | preDelay: -1, 606 | }); 607 | 608 | expect(result).toEqual({ 609 | content: [ 610 | { 611 | type: 'text', 612 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npreDelay: Pre-delay must be non-negative', 613 | }, 614 | ], 615 | isError: true, 616 | }); 617 | }); 618 | 619 | it('should return Zod validation error for negative postDelay', async () => { 620 | const result = await tapPlugin.handler({ 621 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 622 | x: 100, 623 | y: 200, 624 | postDelay: -1, 625 | }); 626 | 627 | expect(result).toEqual({ 628 | content: [ 629 | { 630 | type: 'text', 631 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npostDelay: Post-delay must be non-negative', 632 | }, 633 | ], 634 | isError: true, 635 | }); 636 | }); 637 | }); 638 | 639 | describe('Handler Behavior (Complete Literal Returns)', () => { 640 | it('should return DependencyError when axe binary is not found', async () => { 641 | const mockExecutor = createMockExecutor({ 642 | success: true, 643 | output: 'Tap completed', 644 | error: undefined, 645 | }); 646 | 647 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 648 | 649 | const result = await tapLogic( 650 | { 651 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 652 | x: 100, 653 | y: 200, 654 | preDelay: 0.5, 655 | postDelay: 1.0, 656 | }, 657 | mockExecutor, 658 | mockAxeHelpers, 659 | ); 660 | 661 | expect(result).toEqual({ 662 | content: [ 663 | { 664 | type: 'text', 665 | 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.', 666 | }, 667 | ], 668 | isError: true, 669 | }); 670 | }); 671 | 672 | it('should handle DependencyError when axe binary not found (second test)', async () => { 673 | const mockExecutor = createMockExecutor({ 674 | success: false, 675 | output: '', 676 | error: 'Coordinates out of bounds', 677 | }); 678 | 679 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 680 | 681 | const result = await tapLogic( 682 | { 683 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 684 | x: 100, 685 | y: 200, 686 | }, 687 | mockExecutor, 688 | mockAxeHelpers, 689 | ); 690 | 691 | expect(result).toEqual({ 692 | content: [ 693 | { 694 | type: 'text', 695 | 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.', 696 | }, 697 | ], 698 | isError: true, 699 | }); 700 | }); 701 | 702 | it('should handle DependencyError when axe binary not found (third test)', async () => { 703 | const mockExecutor = createMockExecutor({ 704 | success: false, 705 | output: '', 706 | error: 'System error occurred', 707 | }); 708 | 709 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 710 | 711 | const result = await tapLogic( 712 | { 713 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 714 | x: 100, 715 | y: 200, 716 | }, 717 | mockExecutor, 718 | mockAxeHelpers, 719 | ); 720 | 721 | expect(result).toEqual({ 722 | content: [ 723 | { 724 | type: 'text', 725 | 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.', 726 | }, 727 | ], 728 | isError: true, 729 | }); 730 | }); 731 | 732 | it('should handle DependencyError when axe binary not found (fourth test)', async () => { 733 | const mockExecutor = async () => { 734 | throw new Error('ENOENT: no such file or directory'); 735 | }; 736 | 737 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 738 | 739 | const result = await tapLogic( 740 | { 741 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 742 | x: 100, 743 | y: 200, 744 | }, 745 | mockExecutor, 746 | mockAxeHelpers, 747 | ); 748 | 749 | expect(result).toEqual({ 750 | content: [ 751 | { 752 | type: 'text', 753 | 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.', 754 | }, 755 | ], 756 | isError: true, 757 | }); 758 | }); 759 | 760 | it('should handle DependencyError when axe binary not found (fifth test)', async () => { 761 | const mockExecutor = async () => { 762 | throw new Error('Unexpected error'); 763 | }; 764 | 765 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 766 | 767 | const result = await tapLogic( 768 | { 769 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 770 | x: 100, 771 | y: 200, 772 | }, 773 | mockExecutor, 774 | mockAxeHelpers, 775 | ); 776 | 777 | expect(result).toEqual({ 778 | content: [ 779 | { 780 | type: 'text', 781 | 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.', 782 | }, 783 | ], 784 | isError: true, 785 | }); 786 | }); 787 | 788 | it('should handle DependencyError when axe binary not found (sixth test)', async () => { 789 | const mockExecutor = async () => { 790 | throw 'String error'; 791 | }; 792 | 793 | const mockAxeHelpers = createMockAxeHelpersWithNullPath(); 794 | 795 | const result = await tapLogic( 796 | { 797 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 798 | x: 100, 799 | y: 200, 800 | }, 801 | mockExecutor, 802 | mockAxeHelpers, 803 | ); 804 | 805 | expect(result).toEqual({ 806 | content: [ 807 | { 808 | type: 'text', 809 | 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.', 810 | }, 811 | ], 812 | isError: true, 813 | }); 814 | }); 815 | }); 816 | }); 817 | ``` -------------------------------------------------------------------------------- /docs/ARCHITECTURE.md: -------------------------------------------------------------------------------- ```markdown 1 | # XcodeBuildMCP Architecture 2 | 3 | ## Table of Contents 4 | 5 | 1. [Overview](#overview) 6 | 2. [Core Architecture](#core-architecture) 7 | 3. [Design Principles](#design-principles) 8 | 4. [Component Details](#component-details) 9 | 5. [Registration System](#registration-system) 10 | 6. [Tool Naming Conventions & Glossary](#tool-naming-conventions--glossary) 11 | 7. [Testing Architecture](#testing-architecture) 12 | 8. [Build and Deployment](#build-and-deployment) 13 | 9. [Extension Guidelines](#extension-guidelines) 14 | 10. [Performance Considerations](#performance-considerations) 15 | 11. [Security Considerations](#security-considerations) 16 | 17 | ## Overview 18 | 19 | XcodeBuildMCP is a Model Context Protocol (MCP) server that exposes Xcode operations as tools for AI assistants. The architecture emphasizes modularity, type safety, and selective enablement to support diverse development workflows. 20 | 21 | ### High-Level Objectives 22 | 23 | - Expose Xcode-related tools (build, test, deploy, UI automation, etc.) through MCP 24 | - Run as a long-lived stdio-based server for LLM agents, CLIs, or editors 25 | - Enable fine-grained, opt-in activation of individual tools or tool groups 26 | - Support incremental builds via experimental xcodemake with xcodebuild fallback 27 | 28 | ## Core Architecture 29 | 30 | ### Runtime Flow 31 | 32 | 1. **Initialization** 33 | - The `xcodebuildmcp` executable, as defined in `package.json`, points to the compiled `build/index.js` which executes the main logic from `src/index.ts`. 34 | - Sentry initialized for error tracking (optional) 35 | - Version information loaded from `package.json` 36 | 37 | 2. **Server Creation** 38 | - MCP server created with stdio transport 39 | - Plugin discovery system initialized 40 | 41 | 3. **Plugin Discovery (Build-Time)** 42 | - A build-time script (`build-plugins/plugin-discovery.ts`) scans the `src/mcp/tools/` and `src/mcp/resources/` directories 43 | - It generates `src/core/generated-plugins.ts` and `src/core/generated-resources.ts` with dynamic import maps 44 | - This approach improves startup performance by avoiding synchronous file system scans and enables code-splitting 45 | - Tool code is only loaded when needed, reducing initial memory footprint 46 | 47 | 4. **Plugin & Resource Loading (Runtime)** 48 | - At runtime, `loadPlugins()` and `loadResources()` use the generated loaders from the previous step 49 | - In **Static Mode**, all workflow loaders are executed at startup to register all tools 50 | - In **Dynamic Mode**, only the `discover_tools` tool is registered initially 51 | - The `enableWorkflows` function in `src/core/dynamic-tools.ts` uses generated loaders to dynamically import and register selected workflow tools on demand 52 | 53 | 5. **Tool Registration** 54 | - Discovered tools automatically registered with server using pre-generated maps 55 | - No manual registration or configuration required 56 | - Environment variables control dynamic tool discovery behavior 57 | 58 | 5. **Request Handling** 59 | - MCP client calls tool → server routes to tool handler 60 | - Zod validates parameters before execution 61 | - Tool handler uses shared utilities (build, simctl, etc.) 62 | - Returns standardized `ToolResponse` 63 | 64 | 6. **Response Streaming** 65 | - Server streams response back to client 66 | - Consistent error handling with `isError` flag 67 | 68 | ## Design Principles 69 | 70 | ### 1. **Plugin Autonomy** 71 | Tools are self-contained units that export a standardized interface. They don't know about the server implementation, ensuring loose coupling and high testability. 72 | 73 | ### 2. **Pure Functions vs Stateful Components** 74 | - Most utilities are stateless pure functions 75 | - Stateful components (e.g., process tracking) isolated in specific tool modules 76 | - Clear separation between computation and side effects 77 | 78 | ### 3. **Single Source of Truth** 79 | - Version from `package.json` drives all version references 80 | - Tool directory structure is authoritative tool source 81 | - Environment variables provide consistent configuration interface 82 | 83 | ### 4. **Feature Isolation** 84 | - Experimental features behind environment flags 85 | - Optional dependencies (Sentry, xcodemake) gracefully degrade 86 | - Tool directory structure enables workflow-specific organization 87 | 88 | ### 5. **Type Safety Throughout** 89 | - TypeScript strict mode enabled 90 | - Zod schemas for runtime validation 91 | - Generic type constraints ensure compile-time safety 92 | 93 | ## Module Organization and Import Strategy 94 | 95 | ### Focused Facades Pattern 96 | 97 | XcodeBuildMCP has migrated from a traditional "barrel file" export pattern (`src/utils/index.ts`) to a more structured **focused facades** pattern. Each distinct area of functionality within `src/utils` is exposed through its own `index.ts` file in a dedicated subdirectory. 98 | 99 | **Example Structure:** 100 | 101 | ``` 102 | src/utils/ 103 | ├── execution/ 104 | │ └── index.ts # Facade for CommandExecutor, FileSystemExecutor 105 | ├── logging/ 106 | │ └── index.ts # Facade for the logger 107 | ├── responses/ 108 | │ └── index.ts # Facade for error types and response creators 109 | ├── validation/ 110 | │ └── index.ts # Facade for validation utilities 111 | ├── axe/ 112 | │ └── index.ts # Facade for axe UI automation helpers 113 | ├── plugin-registry/ 114 | │ └── index.ts # Facade for plugin system utilities 115 | ├── xcodemake/ 116 | │ └── index.ts # Facade for xcodemake utilities 117 | ├── template/ 118 | │ └── index.ts # Facade for template management utilities 119 | ├── version/ 120 | │ └── index.ts # Facade for version information 121 | ├── test/ 122 | │ └── index.ts # Facade for test utilities 123 | ├── log-capture/ 124 | │ └── index.ts # Facade for log capture utilities 125 | └── index.ts # Deprecated barrel file (legacy/external use only) 126 | ``` 127 | 128 | This approach offers several architectural benefits: 129 | 130 | - **Clear Dependencies**: It makes the dependency graph explicit. Importing from `utils/execution` clearly indicates a dependency on command execution logic 131 | - **Reduced Coupling**: Modules only import the functionality they need, reducing coupling between unrelated utility components 132 | - **Prevention of Circular Dependencies**: It's much harder to create circular dependencies, which were a risk with the large barrel file 133 | - **Improved Tree-Shaking**: Bundlers can more effectively eliminate unused code 134 | - **Performance**: Eliminates loading of unused modules, reducing startup time and memory usage 135 | 136 | ### ESLint Enforcement 137 | 138 | To maintain this architecture, an ESLint rule in `eslint.config.js` explicitly forbids importing from the deprecated barrel file within the `src/` directory. 139 | 140 | **ESLint Rule Snippet** (`eslint.config.js`): 141 | 142 | ```javascript 143 | 'no-restricted-imports': ['error', { 144 | patterns: [{ 145 | group: ['**/utils/index.js', '../utils/index.js', '../../utils/index.js', '../../../utils/index.js'], 146 | message: 'Barrel imports from utils/index.js are prohibited. Use focused facade imports instead (e.g., utils/logging/index.js, utils/execution/index.js).' 147 | }] 148 | }], 149 | ``` 150 | 151 | This rule prevents regression to the previous barrel import pattern and ensures all new code follows the focused facade architecture. 152 | 153 | ## Component Details 154 | 155 | ### Entry Points 156 | 157 | #### `src/index.ts` 158 | Main server entry point responsible for: 159 | - Sentry initialization (if enabled) 160 | - xcodemake availability check 161 | - Server creation and startup 162 | - Process lifecycle management (SIGTERM, SIGINT) 163 | - Error handling and logging 164 | 165 | #### `src/doctor-cli.ts` 166 | Standalone doctor tool for: 167 | - Environment validation 168 | - Dependency checking 169 | - Configuration verification 170 | - Troubleshooting assistance 171 | 172 | ### Server Layer 173 | 174 | #### `src/server/server.ts` 175 | MCP server wrapper providing: 176 | - Server instance creation 177 | - stdio transport configuration 178 | - Request/response handling 179 | - Error boundary implementation 180 | 181 | ### Tool Discovery System 182 | 183 | #### `src/core/plugin-registry.ts` 184 | Runtime plugin loading system that leverages build-time generated code: 185 | - Uses `WORKFLOW_LOADERS` and `WORKFLOW_METADATA` maps from the generated `src/core/generated-plugins.ts` file 186 | - `loadWorkflowGroups()` iterates through the loaders, dynamically importing each workflow module using `await loader()` 187 | - Validates that each imported module contains the required `workflow` metadata export 188 | - Aggregates all tools from the loaded workflows into a single map 189 | - This system eliminates runtime file system scanning, providing significant startup performance boost 190 | 191 | #### `src/core/plugin-types.ts` 192 | Plugin type definitions: 193 | - `PluginMeta` interface for plugin structure 194 | - `WorkflowMeta` interface for workflow metadata 195 | - `WorkflowGroup` interface for directory organization 196 | 197 | ### Tool Implementation 198 | 199 | Each tool is implemented in TypeScript and follows a standardized pattern that separates the core business logic from the MCP handler boilerplate. This is achieved using the `createTypedTool` factory, which provides compile-time and runtime type safety. 200 | 201 | **Standard Tool Pattern** (`src/mcp/tools/some-workflow/some_tool.ts`): 202 | 203 | ```typescript 204 | import { z } from 'zod'; 205 | import { createTypedTool } from '../../../utils/typed-tool-factory.js'; 206 | import type { CommandExecutor } from '../../../utils/execution/index.js'; 207 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.js'; 208 | import { log } from '../../../utils/logging/index.js'; 209 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.js'; 210 | 211 | // 1. Define the Zod schema for parameters 212 | const someToolSchema = z.object({ 213 | requiredParam: z.string().describe('Description for AI'), 214 | optionalParam: z.boolean().optional().describe('Optional parameter'), 215 | }); 216 | 217 | // 2. Infer the parameter type from the schema 218 | type SomeToolParams = z.infer<typeof someToolSchema>; 219 | 220 | // 3. Implement the core logic in a separate, testable function 221 | // This function receives strongly-typed parameters and an injected executor. 222 | export async function someToolLogic( 223 | params: SomeToolParams, 224 | executor: CommandExecutor, 225 | ): Promise<ToolResponse> { 226 | log('info', `Executing some_tool with param: ${params.requiredParam}`); 227 | 228 | try { 229 | const result = await executor(['some', 'command'], 'Some Tool Operation'); 230 | 231 | if (!result.success) { 232 | return createErrorResponse('Operation failed', result.error); 233 | } 234 | 235 | return createTextResponse(`✅ Success: ${result.output}`); 236 | } catch (error) { 237 | const errorMessage = error instanceof Error ? error.message : String(error); 238 | return createErrorResponse('Tool execution failed', errorMessage); 239 | } 240 | } 241 | 242 | // 4. Export the tool definition for auto-discovery 243 | export default { 244 | name: 'some_tool', 245 | description: 'Tool description for AI agents. Example: some_tool({ requiredParam: "value" })', 246 | schema: someToolSchema.shape, // Expose shape for MCP SDK 247 | 248 | // 5. Create the handler using the type-safe factory 249 | handler: createTypedTool( 250 | someToolSchema, 251 | someToolLogic, 252 | getDefaultCommandExecutor, 253 | ), 254 | }; 255 | ``` 256 | 257 | This pattern ensures that: 258 | - The `someToolLogic` function is highly testable via dependency injection 259 | - Zod handles all runtime parameter validation automatically 260 | - The handler is type-safe, preventing unsafe access to parameters 261 | - Import paths use focused facades for clear dependency management 262 | ``` 263 | 264 | ### MCP Resources System 265 | 266 | XcodeBuildMCP provides dual interfaces: traditional MCP tools and efficient MCP resources for supported clients. Resources are located in `src/mcp/resources/` and are automatically discovered **at build time**. The build process generates `src/core/generated-resources.ts`, which contains dynamic loaders for each resource, improving startup performance. For more details on creating resources, see the [Plugin Development Guide](docs/PLUGIN_DEVELOPMENT.md). 267 | 268 | #### Resource Architecture 269 | 270 | ``` 271 | src/mcp/resources/ 272 | ├── simulators.ts # Simulator data resource 273 | └── __tests__/ # Resource-specific tests 274 | ``` 275 | 276 | #### Client Capability Detection 277 | 278 | The system automatically detects client MCP capabilities: 279 | 280 | ```typescript 281 | // src/core/resources.ts 282 | export function supportsResources(server?: unknown): boolean { 283 | // Detects client capabilities via getClientCapabilities() 284 | // Conservative fallback: assumes resource support 285 | } 286 | ``` 287 | 288 | #### Resource Implementation Pattern 289 | 290 | Resources can reuse existing tool logic for consistency: 291 | 292 | ```typescript 293 | // src/mcp/resources/some_resource.ts 294 | import { log } from '../../utils/logging/index.js'; 295 | import { getDefaultCommandExecutor, CommandExecutor } from '../../utils/execution/index.js'; 296 | import { getSomeResourceLogic } from '../tools/some-workflow/get_some_resource.js'; 297 | 298 | // Testable resource logic separated from MCP handler 299 | export async function someResourceResourceLogic( 300 | executor: CommandExecutor = getDefaultCommandExecutor(), 301 | ): Promise<{ contents: Array<{ text: string }> }> { 302 | try { 303 | log('info', 'Processing some resource request'); 304 | 305 | const result = await getSomeResourceLogic({}, executor); 306 | 307 | if (result.isError) { 308 | const errorText = result.content[0]?.text; 309 | throw new Error( 310 | typeof errorText === 'string' ? errorText : 'Failed to retrieve some resource data', 311 | ); 312 | } 313 | 314 | return { 315 | contents: [ 316 | { 317 | text: 318 | typeof result.content[0]?.text === 'string' 319 | ? result.content[0].text 320 | : 'No data for that resource is available', 321 | }, 322 | ], 323 | }; 324 | } catch (error) { 325 | const errorMessage = error instanceof Error ? error.message : String(error); 326 | log('error', `Error in some_resource resource handler: ${errorMessage}`); 327 | 328 | return { 329 | contents: [ 330 | { 331 | text: `Error retrieving resource data: ${errorMessage}`, 332 | }, 333 | ], 334 | }; 335 | } 336 | } 337 | 338 | export default { 339 | uri: 'xcodebuildmcp://some_resource', 340 | name: 'some_resource', 341 | description: 'Returns some resource information', 342 | mimeType: 'text/plain', 343 | async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { 344 | return someResourceResourceLogic(); 345 | }, 346 | }; 347 | ``` 348 | 349 | ## Registration System 350 | 351 | XcodeBuildMCP supports two primary operating modes for tool registration, controlled by the `XCODEBUILDMCP_DYNAMIC_TOOLS` environment variable. 352 | 353 | ### Static Mode (Default) 354 | 355 | - **Environment**: `XCODEBUILDMCP_DYNAMIC_TOOLS` is `false` or not set. 356 | - **Behavior**: All available tools are loaded and registered with the MCP server at startup. 357 | - **Use Case**: This mode is ideal for environments where the full suite of tools is desired immediately, providing a comprehensive and predictable toolset for the AI assistant. 358 | 359 | ### Dynamic Mode (AI-Powered Workflow Selection) 360 | 361 | - **Environment**: `XCODEBUILDMCP_DYNAMIC_TOOLS=true` 362 | - **Behavior**: At startup, only the `discover_tools` tool is registered. This tool is designed to analyze a natural language task description from the user. 363 | - **Workflow**: 364 | 1. The client sends a task description (e.g., "I want to build and test my iOS app") to the `discover_tools` tool. 365 | 2. The tool uses the client's LLM via an MCP sampling request to determine the most relevant workflow group (e.g., `simulator-workspace`). 366 | 3. The server then dynamically loads and registers all tools from the selected workflow group. 367 | 4. The client is notified of the newly available tools. 368 | - **Use Case**: This mode is beneficial for conserving the LLM's context window by only loading a relevant subset of tools, leading to more focused and efficient interactions. 369 | 370 | ## Tool Naming Conventions & Glossary 371 | 372 | Tools follow a consistent naming pattern to ensure predictability and clarity. Understanding this convention is crucial for both using and developing tools. 373 | 374 | ### Naming Pattern 375 | 376 | The standard naming convention for tools is: 377 | 378 | `{action}_{target}_{specifier}_{projectType}` 379 | 380 | - **action**: The primary verb describing the tool's function (e.g., `build`, `test`, `get`, `list`). 381 | - **target**: The main subject of the action (e.g., `sim` for simulator, `dev` for device, `mac` for macOS). 382 | - **specifier**: A variant that specifies *how* the target is identified (e.g., `id` for UUID, `name` for by-name). 383 | - **projectType**: The type of Xcode project the tool operates on (e.g., `ws` for workspace, `proj` for project). 384 | 385 | Not all parts are required for every tool. For example, `swift_package_build` has an action and a target, but no specifier or project type. 386 | 387 | ### Examples 388 | 389 | - `build_sim_id_ws`: **Build** for a **simulator** identified by its **ID (UUID)** from a **workspace**. 390 | - `test_dev_proj`: **Test** on a **device** from a **project**. 391 | - `get_mac_app_path_ws`: **Get** the app path for a **macOS** application from a **workspace**. 392 | - `list_sims`: **List** all **simulators**. 393 | 394 | ### Glossary 395 | 396 | | Term/Abbreviation | Meaning | Description | 397 | |---|---|---| 398 | | `ws` | Workspace | Refers to an `.xcworkspace` file. Used for projects with multiple `.xcodeproj` files or dependencies managed by CocoaPods or SPM. | 399 | | `proj` | Project | Refers to an `.xcodeproj` file. Used for single-project setups. | 400 | | `sim` | Simulator | Refers to the iOS, watchOS, tvOS, or visionOS simulator. | 401 | | `dev` | Device | Refers to a physical Apple device (iPhone, iPad, etc.). | 402 | | `mac` | macOS | Refers to a native macOS application target. | 403 | | `id` | Identifier | Refers to the unique identifier (UUID/UDID) of a simulator or device. | 404 | | `name` | Name | Refers to the human-readable name of a simulator (e.g., "iPhone 15 Pro"). | 405 | | `cap` | Capture | Used in logging tools, e.g., `start_sim_log_cap`. | 406 | 407 | ## Testing Architecture 408 | 409 | ### Framework and Configuration 410 | 411 | - **Test Runner**: Vitest 3.x 412 | - **Environment**: Node.js 413 | - **Configuration**: `vitest.config.ts` 414 | - **Test Pattern**: `*.test.ts` files alongside implementation 415 | 416 | ### Testing Principles 417 | 418 | XcodeBuildMCP uses a strict **Dependency Injection (DI)** pattern for testing, which completely bans the use of traditional mocking libraries like Vitest's `vi.mock` or `vi.fn`. This ensures that tests are robust, maintainable, and verify the actual integration between components. 419 | 420 | For detailed guidelines, see the [Testing Guide](docs/TESTING.md). 421 | 422 | ### Test Structure Example 423 | 424 | Tests inject mock "executors" for external interactions like command-line execution or file system access. This allows for deterministic testing of tool logic without mocking the implementation itself. The project provides helper functions like `createMockExecutor` and `createMockFileSystemExecutor` in `src/test-utils/mock-executors.ts` to facilitate this pattern. 425 | 426 | ```typescript 427 | import { describe, it, expect } from 'vitest'; 428 | import { someToolLogic } from '../tool-file.js'; // Import the logic function 429 | import { createMockExecutor } from '../../../test-utils/mock-executors.js'; 430 | 431 | describe('Tool Name', () => { 432 | it('should execute successfully', async () => { 433 | // 1. Create a mock executor to simulate command-line results 434 | const mockExecutor = createMockExecutor({ 435 | success: true, 436 | output: 'Command output' 437 | }); 438 | 439 | // 2. Call the tool's logic function, injecting the mock executor 440 | const result = await someToolLogic({ requiredParam: 'value' }, mockExecutor); 441 | 442 | // 3. Assert the final result 443 | expect(result).toEqual({ 444 | content: [{ type: 'text', text: 'Expected output' }], 445 | isError: false 446 | }); 447 | }); 448 | }); 449 | ``` 450 | 451 | ## Build and Deployment 452 | 453 | ### Build Process 454 | 455 | 1. **Version Generation** 456 | ```bash 457 | npm run build 458 | ``` 459 | - Reads version from `package.json` 460 | - Generates `src/version.ts` 461 | 462 | 2. **Plugin & Resource Loader Generation** 463 | - The `build-plugins/plugin-discovery.ts` script is executed 464 | - It scans `src/mcp/tools/` and `src/mcp/resources/` to find all workflows and resources 465 | - It generates `src/core/generated-plugins.ts` and `src/core/generated-resources.ts` with dynamic import maps 466 | - This eliminates runtime file system scanning and enables code-splitting 467 | 468 | 3. **TypeScript Compilation** 469 | - `tsup` compiles the TypeScript source, including the newly generated files, into JavaScript 470 | - Compiles TypeScript with tsup 471 | 472 | 4. **Build Configuration** (`tsup.config.ts`) 473 | - Entry points: `index.ts`, `doctor-cli.ts` 474 | - Output format: ESM 475 | - Target: Node 18+ 476 | - Source maps enabled 477 | 478 | 5. **Distribution Structure** 479 | ``` 480 | build/ 481 | ├── index.js # Main server executable 482 | ├── doctor-cli.js # Doctor tool 483 | └── *.js.map # Source maps 484 | ``` 485 | 486 | ### npm Package 487 | 488 | - **Name**: `xcodebuildmcp` 489 | - **Executables**: 490 | - `xcodebuildmcp` → Main server 491 | - `xcodebuildmcp-doctor` → Doctor tool 492 | - **Dependencies**: Minimal runtime dependencies 493 | - **Platform**: macOS only (due to Xcode requirement) 494 | 495 | ### Bundled Resources 496 | 497 | ``` 498 | bundled/ 499 | ├── axe # UI automation binary 500 | └── Frameworks/ # Facebook device frameworks 501 | ├── FBControlCore.framework 502 | ├── FBDeviceControl.framework 503 | └── FBSimulatorControl.framework 504 | ``` 505 | 506 | ## Extension Guidelines 507 | 508 | This project is designed to be extensible. For comprehensive instructions on creating new tools, workflow groups, and resources, please refer to the dedicated [**Plugin Development Guide**](docs/PLUGIN_DEVELOPMENT.md). 509 | 510 | The guide covers: 511 | - The auto-discovery system architecture. 512 | - The dependency injection pattern required for all new tools. 513 | - How to organize tools into workflow groups. 514 | - Testing guidelines and patterns. 515 | 516 | ## Performance Considerations 517 | 518 | ### Startup Performance 519 | 520 | - **Build-Time Plugin Discovery**: The server avoids expensive and slow file system scans at startup by using pre-generated loader maps. This is the single most significant performance optimization 521 | - **Code-Splitting**: In Dynamic Mode, tool code is only loaded into memory when its workflow is enabled, reducing the initial memory footprint and parse time 522 | - **Focused Facades**: Using targeted imports instead of a large barrel file improves module resolution speed for the Node.js runtime 523 | - **Lazy Loading**: Tools only initialized when registered 524 | - **Selective Registration**: Fewer tools = faster startup 525 | - **Minimal Dependencies**: Fast module resolution 526 | 527 | ### Runtime Performance 528 | 529 | - **Stateless Operations**: Most tools complete quickly 530 | - **Process Management**: Long-running processes tracked separately 531 | - **Incremental Builds**: xcodemake provides significant speedup 532 | - **Parallel Execution**: Tools can run concurrently 533 | 534 | ### Memory Management 535 | 536 | - **Process Cleanup**: Proper process termination handling 537 | - **Log Rotation**: Captured logs have size limits 538 | - **Resource Disposal**: Explicit cleanup in lifecycle hooks 539 | 540 | ### Optimization Strategies 541 | 542 | 1. **Use Tool Groups**: Enable only needed workflows 543 | 2. **Enable Incremental Builds**: Set `INCREMENTAL_BUILDS_ENABLED=true` 544 | 3. **Limit Log Capture**: Use structured logging when possible 545 | 546 | ## Security Considerations 547 | 548 | ### Input Validation 549 | 550 | - All tool inputs validated with Zod schemas 551 | - Command injection prevented via proper escaping 552 | - Path traversal protection in file operations 553 | 554 | ### Process Isolation 555 | 556 | - Tools run with user permissions 557 | - No privilege escalation 558 | - Sandboxed execution environment 559 | 560 | ### Error Handling 561 | 562 | - Sensitive information scrubbed from errors 563 | - Stack traces limited to application code 564 | - Sentry integration respects privacy settings 565 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Vitest test for scaffold_ios_project plugin 3 | * 4 | * Tests the plugin structure and iOS scaffold tool functionality 5 | * including parameter validation, file operations, template processing, and response formatting. 6 | * 7 | * Plugin location: plugins/utilities/scaffold_ios_project.js 8 | */ 9 | 10 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 11 | import { z } from 'zod'; 12 | import scaffoldIosProject, { scaffold_ios_projectLogic } from '../scaffold_ios_project.ts'; 13 | import { 14 | createMockExecutor, 15 | createMockFileSystemExecutor, 16 | } from '../../../../test-utils/mock-executors.ts'; 17 | 18 | describe('scaffold_ios_project plugin', () => { 19 | let mockCommandExecutor: any; 20 | let mockFileSystemExecutor: any; 21 | let originalEnv: string | undefined; 22 | 23 | beforeEach(() => { 24 | // Create mock executor using approved utility 25 | mockCommandExecutor = createMockExecutor({ 26 | success: true, 27 | output: 'Command executed successfully', 28 | }); 29 | 30 | mockFileSystemExecutor = createMockFileSystemExecutor({ 31 | existsSync: (path) => { 32 | // Mock template directories exist but project files don't 33 | return ( 34 | path.includes('xcodebuild-mcp-template') || 35 | path.includes('XcodeBuildMCP-iOS-Template') || 36 | path.includes('/template') || 37 | path.endsWith('template') || 38 | path.includes('extracted') || 39 | path.includes('/mock/template/path') 40 | ); 41 | }, 42 | readFile: async () => 'template content with MyProject placeholder', 43 | readdir: async () => [ 44 | { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, 45 | { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, 46 | ], 47 | mkdir: async () => {}, 48 | rm: async () => {}, 49 | cp: async () => {}, 50 | writeFile: async () => {}, 51 | stat: async () => ({ isDirectory: () => true }), 52 | }); 53 | 54 | // Store original environment for cleanup 55 | originalEnv = process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 56 | // Set local template path to avoid download and chdir issues 57 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 58 | }); 59 | 60 | afterEach(() => { 61 | // Restore original environment 62 | if (originalEnv !== undefined) { 63 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = originalEnv; 64 | } else { 65 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 66 | } 67 | }); 68 | 69 | describe('Export Field Validation (Literal)', () => { 70 | it('should have correct name field', () => { 71 | expect(scaffoldIosProject.name).toBe('scaffold_ios_project'); 72 | }); 73 | 74 | it('should have correct description field', () => { 75 | expect(scaffoldIosProject.description).toBe( 76 | 'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.', 77 | ); 78 | }); 79 | 80 | it('should have handler as function', () => { 81 | expect(typeof scaffoldIosProject.handler).toBe('function'); 82 | }); 83 | 84 | it('should have valid schema with required fields', () => { 85 | const schema = z.object(scaffoldIosProject.schema); 86 | 87 | // Test valid input 88 | expect( 89 | schema.safeParse({ 90 | projectName: 'MyTestApp', 91 | outputPath: '/path/to/output', 92 | bundleIdentifier: 'com.test.myapp', 93 | displayName: 'My Test App', 94 | marketingVersion: '1.0', 95 | currentProjectVersion: '1', 96 | customizeNames: true, 97 | deploymentTarget: '18.4', 98 | targetedDeviceFamily: ['iphone', 'ipad'], 99 | supportedOrientations: ['portrait', 'landscape-left'], 100 | supportedOrientationsIpad: ['portrait', 'landscape-left', 'landscape-right'], 101 | }).success, 102 | ).toBe(true); 103 | 104 | // Test minimal valid input 105 | expect( 106 | schema.safeParse({ 107 | projectName: 'MyTestApp', 108 | outputPath: '/path/to/output', 109 | }).success, 110 | ).toBe(true); 111 | 112 | // Test invalid input - missing projectName 113 | expect( 114 | schema.safeParse({ 115 | outputPath: '/path/to/output', 116 | }).success, 117 | ).toBe(false); 118 | 119 | // Test invalid input - missing outputPath 120 | expect( 121 | schema.safeParse({ 122 | projectName: 'MyTestApp', 123 | }).success, 124 | ).toBe(false); 125 | 126 | // Test invalid input - wrong type for customizeNames 127 | expect( 128 | schema.safeParse({ 129 | projectName: 'MyTestApp', 130 | outputPath: '/path/to/output', 131 | customizeNames: 'true', 132 | }).success, 133 | ).toBe(false); 134 | 135 | // Test invalid input - wrong enum value for targetedDeviceFamily 136 | expect( 137 | schema.safeParse({ 138 | projectName: 'MyTestApp', 139 | outputPath: '/path/to/output', 140 | targetedDeviceFamily: ['invalid-device'], 141 | }).success, 142 | ).toBe(false); 143 | 144 | // Test invalid input - wrong enum value for supportedOrientations 145 | expect( 146 | schema.safeParse({ 147 | projectName: 'MyTestApp', 148 | outputPath: '/path/to/output', 149 | supportedOrientations: ['invalid-orientation'], 150 | }).success, 151 | ).toBe(false); 152 | }); 153 | }); 154 | 155 | describe('Command Generation Tests', () => { 156 | it('should generate correct curl command for iOS template download', async () => { 157 | // Temporarily disable local template to force download 158 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 159 | 160 | // Track commands executed 161 | let capturedCommands: string[][] = []; 162 | const trackingCommandExecutor = createMockExecutor({ 163 | success: true, 164 | output: 'Command executed successfully', 165 | }); 166 | // Wrap to capture commands 167 | const capturingExecutor = async (command: string[], ...args: any[]) => { 168 | capturedCommands.push(command); 169 | return trackingCommandExecutor(command, ...args); 170 | }; 171 | 172 | await scaffold_ios_projectLogic( 173 | { 174 | projectName: 'TestIOSApp', 175 | outputPath: '/tmp/test-projects', 176 | }, 177 | capturingExecutor, 178 | mockFileSystemExecutor, 179 | ); 180 | 181 | // Verify curl command was executed 182 | const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); 183 | expect(curlCommand).toBeDefined(); 184 | expect(curlCommand).toEqual([ 185 | 'curl', 186 | '-L', 187 | '-f', 188 | '-o', 189 | expect.stringMatching(/template\.zip$/), 190 | expect.stringMatching( 191 | /https:\/\/github\.com\/cameroncooke\/XcodeBuildMCP-iOS-Template\/releases\/download\/v\d+\.\d+\.\d+\/XcodeBuildMCP-iOS-Template-\d+\.\d+\.\d+\.zip/, 192 | ), 193 | ]); 194 | 195 | // Restore environment variable 196 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 197 | }); 198 | 199 | it.skip('should generate correct unzip command for iOS template extraction', async () => { 200 | // Temporarily disable local template to force download 201 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 202 | 203 | // Create a mock that returns false for local template paths to force download 204 | const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ 205 | existsSync: (path) => { 206 | // Only return true for extracted template directories, false for local template paths 207 | return ( 208 | path.includes('xcodebuild-mcp-template') || 209 | path.includes('XcodeBuildMCP-iOS-Template') || 210 | path.includes('extracted') 211 | ); 212 | }, 213 | readFile: async () => 'template content with MyProject placeholder', 214 | readdir: async () => [ 215 | { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, 216 | { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, 217 | ], 218 | mkdir: async () => {}, 219 | rm: async () => {}, 220 | cp: async () => {}, 221 | writeFile: async () => {}, 222 | stat: async () => ({ isDirectory: () => true }), 223 | }); 224 | 225 | // Track commands executed 226 | let capturedCommands: string[][] = []; 227 | const trackingCommandExecutor = createMockExecutor({ 228 | success: true, 229 | output: 'Command executed successfully', 230 | }); 231 | // Wrap to capture commands 232 | const capturingExecutor = async (command: string[], ...args: any[]) => { 233 | capturedCommands.push(command); 234 | return trackingCommandExecutor(command, ...args); 235 | }; 236 | 237 | await scaffold_ios_projectLogic( 238 | { 239 | projectName: 'TestIOSApp', 240 | outputPath: '/tmp/test-projects', 241 | }, 242 | capturingExecutor, 243 | downloadMockFileSystemExecutor, 244 | ); 245 | 246 | // Verify unzip command was executed 247 | const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); 248 | expect(unzipCommand).toBeDefined(); 249 | expect(unzipCommand).toEqual(['unzip', '-q', expect.stringMatching(/template\.zip$/)]); 250 | 251 | // Restore environment variable 252 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 253 | }); 254 | 255 | it('should generate correct commands when using custom template version', async () => { 256 | // Temporarily disable local template to force download 257 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 258 | 259 | // Set custom template version 260 | const originalVersion = process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION; 261 | process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION = 'v2.0.0'; 262 | 263 | // Track commands executed 264 | let capturedCommands: string[][] = []; 265 | const trackingCommandExecutor = createMockExecutor({ 266 | success: true, 267 | output: 'Command executed successfully', 268 | }); 269 | // Wrap to capture commands 270 | const capturingExecutor = async (command: string[], ...args: any[]) => { 271 | capturedCommands.push(command); 272 | return trackingCommandExecutor(command, ...args); 273 | }; 274 | 275 | await scaffold_ios_projectLogic( 276 | { 277 | projectName: 'TestIOSApp', 278 | outputPath: '/tmp/test-projects', 279 | }, 280 | capturingExecutor, 281 | mockFileSystemExecutor, 282 | ); 283 | 284 | // Verify curl command uses custom version 285 | const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); 286 | expect(curlCommand).toBeDefined(); 287 | expect(curlCommand).toEqual([ 288 | 'curl', 289 | '-L', 290 | '-f', 291 | '-o', 292 | expect.stringMatching(/template\.zip$/), 293 | 'https://github.com/cameroncooke/XcodeBuildMCP-iOS-Template/releases/download/v2.0.0/XcodeBuildMCP-iOS-Template-2.0.0.zip', 294 | ]); 295 | 296 | // Restore original version 297 | if (originalVersion) { 298 | process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION = originalVersion; 299 | } else { 300 | delete process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION; 301 | } 302 | 303 | // Restore environment variable 304 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 305 | }); 306 | 307 | it.skip('should generate correct commands with no command executor passed', async () => { 308 | // Temporarily disable local template to force download 309 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 310 | 311 | // Create a mock that returns false for local template paths to force download 312 | const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ 313 | existsSync: (path) => { 314 | // Only return true for extracted template directories, false for local template paths 315 | return ( 316 | path.includes('xcodebuild-mcp-template') || 317 | path.includes('XcodeBuildMCP-iOS-Template') || 318 | path.includes('extracted') 319 | ); 320 | }, 321 | readFile: async () => 'template content with MyProject placeholder', 322 | readdir: async () => [ 323 | { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, 324 | { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, 325 | ], 326 | mkdir: async () => {}, 327 | rm: async () => {}, 328 | cp: async () => {}, 329 | writeFile: async () => {}, 330 | stat: async () => ({ isDirectory: () => true }), 331 | }); 332 | 333 | // Track commands executed - using default executor path 334 | let capturedCommands: string[][] = []; 335 | const trackingCommandExecutor = createMockExecutor({ 336 | success: true, 337 | output: 'Command executed successfully', 338 | }); 339 | // Wrap to capture commands 340 | const capturingExecutor = async (command: string[], ...args: any[]) => { 341 | capturedCommands.push(command); 342 | return trackingCommandExecutor(command, ...args); 343 | }; 344 | 345 | await scaffold_ios_projectLogic( 346 | { 347 | projectName: 'TestIOSApp', 348 | outputPath: '/tmp/test-projects', 349 | }, 350 | capturingExecutor, 351 | downloadMockFileSystemExecutor, 352 | ); 353 | 354 | // Verify both curl and unzip commands were executed in sequence 355 | expect(capturedCommands.length).toBeGreaterThanOrEqual(2); 356 | 357 | const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); 358 | const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); 359 | 360 | expect(curlCommand).toBeDefined(); 361 | expect(unzipCommand).toBeDefined(); 362 | expect(curlCommand[0]).toBe('curl'); 363 | expect(unzipCommand[0]).toBe('unzip'); 364 | 365 | // Restore environment variable 366 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 367 | }); 368 | }); 369 | 370 | describe('Handler Behavior (Complete Literal Returns)', () => { 371 | it('should return success response for valid scaffold iOS project request', async () => { 372 | const result = await scaffold_ios_projectLogic( 373 | { 374 | projectName: 'TestIOSApp', 375 | outputPath: '/tmp/test-projects', 376 | bundleIdentifier: 'com.test.iosapp', 377 | }, 378 | mockCommandExecutor, 379 | mockFileSystemExecutor, 380 | ); 381 | 382 | expect(result).toEqual({ 383 | content: [ 384 | { 385 | type: 'text', 386 | text: JSON.stringify( 387 | { 388 | success: true, 389 | projectPath: '/tmp/test-projects', 390 | platform: 'iOS', 391 | message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', 392 | nextSteps: [ 393 | 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', 394 | 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', 395 | 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', 396 | ], 397 | }, 398 | null, 399 | 2, 400 | ), 401 | }, 402 | ], 403 | }); 404 | }); 405 | 406 | it('should return success response with all optional parameters', async () => { 407 | const result = await scaffold_ios_projectLogic( 408 | { 409 | projectName: 'TestIOSApp', 410 | outputPath: '/tmp/test-projects', 411 | bundleIdentifier: 'com.test.iosapp', 412 | displayName: 'Test iOS App', 413 | marketingVersion: '2.0', 414 | currentProjectVersion: '5', 415 | customizeNames: true, 416 | deploymentTarget: '17.0', 417 | targetedDeviceFamily: ['iphone'], 418 | supportedOrientations: ['portrait'], 419 | supportedOrientationsIpad: ['portrait', 'landscape-left'], 420 | }, 421 | mockCommandExecutor, 422 | mockFileSystemExecutor, 423 | ); 424 | 425 | expect(result).toEqual({ 426 | content: [ 427 | { 428 | type: 'text', 429 | text: JSON.stringify( 430 | { 431 | success: true, 432 | projectPath: '/tmp/test-projects', 433 | platform: 'iOS', 434 | message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', 435 | nextSteps: [ 436 | 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', 437 | 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', 438 | 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', 439 | ], 440 | }, 441 | null, 442 | 2, 443 | ), 444 | }, 445 | ], 446 | }); 447 | }); 448 | 449 | it('should return success response with customizeNames false', async () => { 450 | const result = await scaffold_ios_projectLogic( 451 | { 452 | projectName: 'TestIOSApp', 453 | outputPath: '/tmp/test-projects', 454 | customizeNames: false, 455 | }, 456 | mockCommandExecutor, 457 | mockFileSystemExecutor, 458 | ); 459 | 460 | expect(result).toEqual({ 461 | content: [ 462 | { 463 | type: 'text', 464 | text: JSON.stringify( 465 | { 466 | success: true, 467 | projectPath: '/tmp/test-projects', 468 | platform: 'iOS', 469 | message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', 470 | nextSteps: [ 471 | 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', 472 | 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', 473 | 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', 474 | ], 475 | }, 476 | null, 477 | 2, 478 | ), 479 | }, 480 | ], 481 | }); 482 | }); 483 | 484 | it('should return error response for invalid project name', async () => { 485 | const result = await scaffold_ios_projectLogic( 486 | { 487 | projectName: '123InvalidName', 488 | outputPath: '/tmp/test-projects', 489 | }, 490 | mockCommandExecutor, 491 | mockFileSystemExecutor, 492 | ); 493 | 494 | expect(result).toEqual({ 495 | content: [ 496 | { 497 | type: 'text', 498 | text: JSON.stringify( 499 | { 500 | success: false, 501 | error: 502 | 'Project name must start with a letter and contain only letters, numbers, and underscores', 503 | }, 504 | null, 505 | 2, 506 | ), 507 | }, 508 | ], 509 | isError: true, 510 | }); 511 | }); 512 | 513 | it('should return error response for existing project files', async () => { 514 | // Update mock to return true for existing files 515 | mockFileSystemExecutor = createMockFileSystemExecutor({ 516 | existsSync: () => true, 517 | readFile: async () => 'template content with MyProject placeholder', 518 | readdir: async () => [ 519 | { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, 520 | { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, 521 | ], 522 | }); 523 | 524 | const result = await scaffold_ios_projectLogic( 525 | { 526 | projectName: 'TestIOSApp', 527 | outputPath: '/tmp/test-projects', 528 | }, 529 | mockCommandExecutor, 530 | mockFileSystemExecutor, 531 | ); 532 | 533 | expect(result).toEqual({ 534 | content: [ 535 | { 536 | type: 'text', 537 | text: JSON.stringify( 538 | { 539 | success: false, 540 | error: 'Xcode project files already exist in /tmp/test-projects', 541 | }, 542 | null, 543 | 2, 544 | ), 545 | }, 546 | ], 547 | isError: true, 548 | }); 549 | }); 550 | 551 | it('should return error response for template download failure', async () => { 552 | // Temporarily disable local template to force download 553 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 554 | 555 | // Mock command executor to fail for curl commands 556 | const failingMockCommandExecutor = createMockExecutor({ 557 | success: false, 558 | output: '', 559 | error: 'Template download failed', 560 | }); 561 | 562 | const result = await scaffold_ios_projectLogic( 563 | { 564 | projectName: 'TestIOSApp', 565 | outputPath: '/tmp/test-projects', 566 | }, 567 | failingMockCommandExecutor, 568 | mockFileSystemExecutor, 569 | ); 570 | 571 | expect(result).toEqual({ 572 | content: [ 573 | { 574 | type: 'text', 575 | text: JSON.stringify( 576 | { 577 | success: false, 578 | error: 579 | 'Failed to get template for iOS: Failed to download template: Template download failed', 580 | }, 581 | null, 582 | 2, 583 | ), 584 | }, 585 | ], 586 | isError: true, 587 | }); 588 | 589 | // Restore environment variable 590 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 591 | }); 592 | 593 | it.skip('should return error response for template extraction failure', async () => { 594 | // Temporarily disable local template to force download 595 | delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; 596 | 597 | // Create a mock that returns false for local template paths to force download 598 | const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ 599 | existsSync: (path) => { 600 | // Only return true for extracted template directories, false for local template paths 601 | return ( 602 | path.includes('xcodebuild-mcp-template') || 603 | path.includes('XcodeBuildMCP-iOS-Template') || 604 | path.includes('extracted') 605 | ); 606 | }, 607 | readFile: async () => 'template content with MyProject placeholder', 608 | readdir: async () => [ 609 | { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, 610 | { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, 611 | ], 612 | mkdir: async () => {}, 613 | rm: async () => {}, 614 | cp: async () => {}, 615 | writeFile: async () => {}, 616 | stat: async () => ({ isDirectory: () => true }), 617 | }); 618 | 619 | // Mock command executor to fail for unzip commands 620 | const failingMockCommandExecutor = createMockExecutor({ 621 | success: false, 622 | output: '', 623 | error: 'Extraction failed', 624 | }); 625 | 626 | const result = await scaffold_ios_projectLogic( 627 | { 628 | projectName: 'TestIOSApp', 629 | outputPath: '/tmp/test-projects', 630 | }, 631 | failingMockCommandExecutor, 632 | downloadMockFileSystemExecutor, 633 | ); 634 | 635 | expect(result).toEqual({ 636 | content: [ 637 | { 638 | type: 'text', 639 | text: JSON.stringify( 640 | { 641 | success: false, 642 | error: 643 | 'Failed to get template for iOS: Failed to extract template: Extraction failed', 644 | }, 645 | null, 646 | 2, 647 | ), 648 | }, 649 | ], 650 | isError: true, 651 | }); 652 | 653 | // Restore environment variable 654 | process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; 655 | }); 656 | }); 657 | }); 658 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/discovery/__tests__/discover_tools.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for discover_tools plugin 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using dependency injection for deterministic testing 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 8 | import { z } from 'zod'; 9 | import discoverTools, { discover_toolsLogic } from '../discover_tools.ts'; 10 | 11 | // Mock dependencies interface for dependency injection 12 | interface MockDependencies { 13 | getAvailableWorkflows?: () => string[]; 14 | generateWorkflowDescriptions?: () => string; 15 | enableWorkflows?: (server: any, workflows: string[], additive?: boolean) => Promise<void>; 16 | } 17 | 18 | // Track function calls manually for verification 19 | interface CallTracker { 20 | getAvailableWorkflowsCalls: Array<any[]>; 21 | generateWorkflowDescriptionsCalls: Array<any[]>; 22 | enableWorkflowsCalls: Array<any[]>; 23 | } 24 | 25 | function createMockDependencies( 26 | config: { 27 | availableWorkflows?: string[]; 28 | workflowDescriptions?: string; 29 | enableWorkflowsError?: Error; 30 | getAvailableWorkflowsError?: Error; 31 | }, 32 | callTracker: CallTracker, 33 | ): MockDependencies { 34 | const workflowNames = config.availableWorkflows ?? ['simulator-workspace']; 35 | const descriptions = 36 | config.workflowDescriptions ?? 37 | `Available workflows: 38 | 1. simulator-workspace: iOS Simulator Workspace - iOS development for workspaces`; 39 | 40 | return { 41 | getAvailableWorkflows: config.getAvailableWorkflowsError 42 | ? () => { 43 | callTracker.getAvailableWorkflowsCalls.push([]); 44 | throw config.getAvailableWorkflowsError; 45 | } 46 | : () => { 47 | callTracker.getAvailableWorkflowsCalls.push([]); 48 | return workflowNames; 49 | }, 50 | generateWorkflowDescriptions: () => { 51 | callTracker.generateWorkflowDescriptionsCalls.push([]); 52 | return descriptions; 53 | }, 54 | enableWorkflows: config.enableWorkflowsError 55 | ? async (server: any, workflows: string[], additive?: boolean) => { 56 | callTracker.enableWorkflowsCalls.push([server, workflows, additive]); 57 | throw config.enableWorkflowsError; 58 | } 59 | : async (server: any, workflows: string[], additive?: boolean) => { 60 | callTracker.enableWorkflowsCalls.push([server, workflows, additive]); 61 | return undefined; 62 | }, 63 | }; 64 | } 65 | 66 | describe('discover_tools', () => { 67 | let mockServer: Record<string, unknown>; 68 | let originalGlobalThis: Record<string, unknown>; 69 | let callTracker: CallTracker; 70 | let requestCalls: Array<any[]>; 71 | let sendToolListChangedCalls: Array<any[]>; 72 | 73 | beforeEach(() => { 74 | // Save original globalThis 75 | originalGlobalThis = globalThis.mcpServer; 76 | // Initialize call trackers 77 | callTracker = { 78 | getAvailableWorkflowsCalls: [], 79 | generateWorkflowDescriptionsCalls: [], 80 | enableWorkflowsCalls: [], 81 | }; 82 | requestCalls = []; 83 | sendToolListChangedCalls = []; 84 | // Create mock server 85 | mockServer = { 86 | server: { 87 | _clientCapabilities: { sampling: true }, 88 | getClientCapabilities: () => ({ sampling: true }), 89 | createMessage: async (...args: any[]) => { 90 | requestCalls.push(args); 91 | throw new Error('Mock createMessage not configured'); 92 | }, 93 | request: async (...args: any[]) => { 94 | requestCalls.push(args); 95 | throw new Error('Mock request not configured'); 96 | }, 97 | }, 98 | sendToolListChanged: (...args: any[]) => { 99 | sendToolListChangedCalls.push(args); 100 | }, 101 | }; 102 | // Set up global server 103 | (globalThis as any).mcpServer = mockServer; 104 | // Reset all mocks 105 | }); 106 | 107 | afterEach(() => { 108 | // Restore original globalThis 109 | globalThis.mcpServer = originalGlobalThis; 110 | }); 111 | 112 | describe('Export Field Validation (Literal)', () => { 113 | it('should have correct name', () => { 114 | expect(discoverTools.name).toBe('discover_tools'); 115 | }); 116 | 117 | it('should have correct description', () => { 118 | expect(discoverTools.description).toBe( 119 | '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.', 120 | ); 121 | }); 122 | 123 | it('should have handler function', () => { 124 | expect(typeof discover_toolsLogic).toBe('function'); 125 | }); 126 | 127 | it('should have correct schema with task_description string field', () => { 128 | const schema = z.object(discoverTools.schema); 129 | 130 | // Valid inputs 131 | expect(schema.safeParse({ task_description: 'Build my iOS app' }).success).toBe(true); 132 | expect(schema.safeParse({ task_description: 'Test my React Native app' }).success).toBe(true); 133 | 134 | // Invalid inputs 135 | expect(schema.safeParse({ task_description: 123 }).success).toBe(false); 136 | expect(schema.safeParse({ task_description: null }).success).toBe(false); 137 | expect(schema.safeParse({ task_description: undefined }).success).toBe(false); 138 | expect(schema.safeParse({}).success).toBe(false); 139 | }); 140 | }); 141 | 142 | describe('Capability Detection', () => { 143 | it('should return error when client lacks sampling capability', async () => { 144 | // Mock server without sampling capability 145 | mockServer.server._clientCapabilities = {}; 146 | (mockServer.server as any).getClientCapabilities = () => ({}); 147 | 148 | const result = await discover_toolsLogic({ task_description: 'Build my app' }, undefined); 149 | 150 | expect(result).toEqual({ 151 | content: [ 152 | { 153 | type: 'text', 154 | text: 'Your client does not support the sampling feature required for dynamic tool discovery. Please use XCODEBUILDMCP_DYNAMIC_TOOLS=false to use the standard tool set.', 155 | }, 156 | ], 157 | isError: true, 158 | }); 159 | }); 160 | 161 | it('should proceed when client has sampling capability', async () => { 162 | const mockDeps = createMockDependencies( 163 | { 164 | availableWorkflows: ['simulator-workspace'], 165 | workflowDescriptions: `Available workflows: 166 | 1. simulator-workspace: iOS Simulator Workspace - iOS development for workspaces`, 167 | }, 168 | callTracker, 169 | ); 170 | 171 | // Configure mock request to return successful response 172 | (mockServer.server as any).createMessage = async (...args: any[]) => { 173 | requestCalls.push(args); 174 | return { 175 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 176 | }; 177 | }; 178 | 179 | const result = await discover_toolsLogic( 180 | { task_description: 'Build my iOS app' }, 181 | undefined, 182 | mockDeps, 183 | ); 184 | 185 | expect(result.isError).toBeFalsy(); 186 | expect(callTracker.getAvailableWorkflowsCalls).toHaveLength(1); 187 | }); 188 | }); 189 | 190 | describe('Workflow Loading', () => { 191 | it('should load workflow groups and build descriptions', async () => { 192 | const mockDeps = createMockDependencies( 193 | { 194 | availableWorkflows: ['simulator-workspace', 'macos-project'], 195 | workflowDescriptions: `Available workflows: 196 | 1. simulator-workspace: iOS Simulator Workspace - Complete iOS development workflow for .xcworkspace files targeting simulators 197 | 2. macos-project: macOS Project - Complete macOS development workflow for .xcodeproj files`, 198 | }, 199 | callTracker, 200 | ); 201 | 202 | // Configure mock request to capture calls and return response 203 | (mockServer.server as any).createMessage = async (...args: any[]) => { 204 | requestCalls.push(args); 205 | return { 206 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 207 | }; 208 | }; 209 | 210 | await discover_toolsLogic({ task_description: 'Build my iOS app' }, undefined, mockDeps); 211 | 212 | // Verify workflow groups were loaded 213 | expect(callTracker.getAvailableWorkflowsCalls).toHaveLength(1); 214 | 215 | // Verify LLM prompt includes workflow descriptions 216 | expect(requestCalls).toHaveLength(1); 217 | const requestCall = requestCalls[0]; 218 | const prompt = requestCall[0].messages[0].content.text; 219 | 220 | expect(prompt).toContain('simulator-workspace'); 221 | expect(prompt).toContain( 222 | 'Complete iOS development workflow for .xcworkspace files targeting simulators', 223 | ); 224 | expect(prompt).toContain('macos-project'); 225 | expect(prompt).toContain('Complete macOS development workflow for .xcodeproj files'); 226 | }); 227 | }); 228 | 229 | describe('LLM Interaction', () => { 230 | let mockDeps: MockDependencies; 231 | let localCallTracker: CallTracker; 232 | 233 | beforeEach(() => { 234 | // Reset local call tracker for this describe block 235 | localCallTracker = { 236 | getAvailableWorkflowsCalls: [], 237 | generateWorkflowDescriptionsCalls: [], 238 | enableWorkflowsCalls: [], 239 | }; 240 | mockDeps = createMockDependencies( 241 | { 242 | availableWorkflows: ['simulator-workspace'], 243 | workflowDescriptions: `Available workflows: 244 | 1. simulator-workspace: iOS Simulator Workspace - iOS development for workspaces`, 245 | }, 246 | localCallTracker, 247 | ); 248 | }); 249 | 250 | it('should send correct sampling request to LLM', async () => { 251 | // Reset request calls for this test 252 | requestCalls.length = 0; 253 | 254 | // Configure mock request to capture calls and return response 255 | (mockServer.server as any).createMessage = async (...args: any[]) => { 256 | requestCalls.push(args); 257 | return { 258 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 259 | }; 260 | }; 261 | 262 | await discover_toolsLogic( 263 | { task_description: 'Build my iOS app and test it' }, 264 | undefined, 265 | mockDeps, 266 | ); 267 | 268 | expect(requestCalls).toHaveLength(1); 269 | const requestCall = requestCalls[0]; 270 | expect(requestCall[0]).toEqual({ 271 | messages: [ 272 | { 273 | role: 'user', 274 | content: { 275 | type: 'text', 276 | text: expect.stringContaining('Build my iOS app and test it'), 277 | }, 278 | }, 279 | ], 280 | maxTokens: 200, 281 | }); 282 | // Note: Schema parameter was removed in TypeScript fix - request method now only accepts one parameter 283 | }); 284 | 285 | it('should handle array content format in LLM response', async () => { 286 | // Reset call trackers for this test 287 | localCallTracker.enableWorkflowsCalls.length = 0; 288 | 289 | // Configure mock request to return response 290 | (mockServer.server as any).createMessage = async (...args: any[]) => { 291 | return { 292 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 293 | }; 294 | }; 295 | 296 | const result = await discover_toolsLogic( 297 | { task_description: 'Build my app' }, 298 | undefined, 299 | mockDeps, 300 | ); 301 | 302 | expect(result.isError).toBeFalsy(); 303 | expect(localCallTracker.enableWorkflowsCalls).toHaveLength(1); 304 | expect(localCallTracker.enableWorkflowsCalls[0]).toEqual([ 305 | mockServer, 306 | ['simulator-workspace'], 307 | false, 308 | ]); 309 | }); 310 | 311 | it('should handle single object content format in LLM response', async () => { 312 | // Reset call trackers for this test 313 | localCallTracker.enableWorkflowsCalls.length = 0; 314 | 315 | // Configure mock request to return response with single object format 316 | (mockServer.server as any).createMessage = async (...args: any[]) => { 317 | return { 318 | content: { type: 'text', text: '["simulator-workspace"]' }, 319 | }; 320 | }; 321 | 322 | const result = await discover_toolsLogic( 323 | { task_description: 'Build my app' }, 324 | undefined, 325 | mockDeps, 326 | ); 327 | 328 | expect(result.isError).toBeFalsy(); 329 | expect(localCallTracker.enableWorkflowsCalls).toHaveLength(1); 330 | expect(localCallTracker.enableWorkflowsCalls[0]).toEqual([ 331 | mockServer, 332 | ['simulator-workspace'], 333 | false, 334 | ]); 335 | }); 336 | 337 | it('should filter out invalid workflow names from LLM response', async () => { 338 | // Reset call trackers for this test 339 | localCallTracker.enableWorkflowsCalls.length = 0; 340 | 341 | // Configure mock request to return response with invalid workflows 342 | (mockServer.server as any).createMessage = async (...args: any[]) => { 343 | return { 344 | content: [ 345 | { 346 | type: 'text', 347 | text: '["simulator-workspace", "invalid-workflow", "another-invalid"]', 348 | }, 349 | ], 350 | }; 351 | }; 352 | 353 | const result = await discover_toolsLogic( 354 | { task_description: 'Build my app' }, 355 | undefined, 356 | mockDeps, 357 | ); 358 | 359 | expect(result.isError).toBeFalsy(); 360 | expect(localCallTracker.enableWorkflowsCalls).toHaveLength(1); 361 | expect(localCallTracker.enableWorkflowsCalls[0]).toEqual([ 362 | mockServer, 363 | ['simulator-workspace'], // Only valid workflow should remain 364 | false, 365 | ]); 366 | }); 367 | 368 | it('should handle malformed JSON in LLM response', async () => { 369 | // Configure mock request to return malformed JSON 370 | (mockServer.server as any).createMessage = async (...args: any[]) => { 371 | return { 372 | content: [{ type: 'text', text: 'This is not JSON at all!' }], 373 | }; 374 | }; 375 | 376 | const result = await discover_toolsLogic( 377 | { task_description: 'Build my app' }, 378 | undefined, 379 | mockDeps, 380 | ); 381 | 382 | expect(result).toEqual({ 383 | content: [ 384 | { 385 | type: 'text', 386 | text: 'I was unable to determine the right tools for your task. The AI model returned: "This is not JSON at all!". Could you please rephrase your request or try a more specific description?', 387 | }, 388 | ], 389 | isError: true, 390 | }); 391 | }); 392 | 393 | it('should handle non-array JSON in LLM response', async () => { 394 | // Configure mock request to return non-array JSON 395 | (mockServer.server as any).createMessage = async (...args: any[]) => { 396 | return { 397 | content: [{ type: 'text', text: '{"workflow": "simulator-workspace"}' }], 398 | }; 399 | }; 400 | 401 | const result = await discover_toolsLogic( 402 | { task_description: 'Build my app' }, 403 | undefined, 404 | mockDeps, 405 | ); 406 | 407 | expect(result).toEqual({ 408 | content: [ 409 | { 410 | type: 'text', 411 | text: 'I was unable to determine the right tools for your task. The AI model returned: "{"workflow": "simulator-workspace"}". Could you please rephrase your request or try a more specific description?', 412 | }, 413 | ], 414 | isError: true, 415 | }); 416 | }); 417 | 418 | it('should handle empty workflow selection', async () => { 419 | // Reset call trackers for this test 420 | localCallTracker.enableWorkflowsCalls.length = 0; 421 | 422 | // Configure mock request to return empty array 423 | (mockServer.server as any).createMessage = async (...args: any[]) => { 424 | return { 425 | content: [{ type: 'text', text: '[]' }], 426 | }; 427 | }; 428 | 429 | const result = await discover_toolsLogic( 430 | { task_description: 'Just saying hello' }, 431 | undefined, 432 | mockDeps, 433 | ); 434 | 435 | expect(result).toEqual({ 436 | content: [ 437 | { 438 | type: 'text', 439 | text: "No specific Xcode tools seem necessary for that task. Could you provide more details about what you'd like to accomplish with Xcode?", 440 | }, 441 | ], 442 | isError: false, 443 | }); 444 | expect(localCallTracker.enableWorkflowsCalls).toHaveLength(0); 445 | }); 446 | }); 447 | 448 | describe('Workflow Enabling', () => { 449 | let mockDeps: MockDependencies; 450 | let workflowCallTracker: CallTracker; 451 | 452 | beforeEach(() => { 453 | // Reset call tracker for this describe block 454 | workflowCallTracker = { 455 | getAvailableWorkflowsCalls: [], 456 | generateWorkflowDescriptionsCalls: [], 457 | enableWorkflowsCalls: [], 458 | }; 459 | mockDeps = createMockDependencies( 460 | { 461 | availableWorkflows: ['simulator-workspace'], 462 | workflowDescriptions: `Available workflows: 463 | 1. simulator-workspace: iOS Simulator Workspace - iOS development for workspaces`, 464 | }, 465 | workflowCallTracker, 466 | ); 467 | }); 468 | 469 | it('should enable selected workflows and return success message', async () => { 470 | // Configure mock request to return successful response 471 | (mockServer.server as any).createMessage = async (...args: any[]) => { 472 | return { 473 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 474 | }; 475 | }; 476 | 477 | const result = await discover_toolsLogic( 478 | { task_description: 'Build my iOS app' }, 479 | undefined, 480 | mockDeps, 481 | ); 482 | 483 | expect(workflowCallTracker.enableWorkflowsCalls).toHaveLength(1); 484 | expect(workflowCallTracker.enableWorkflowsCalls[0]).toEqual([ 485 | mockServer, 486 | ['simulator-workspace'], 487 | false, 488 | ]); 489 | 490 | expect(result).toEqual({ 491 | content: [ 492 | { 493 | type: 'text', 494 | text: '✅ Enabled XcodeBuildMCP tools for: simulator-workspace.\n\nReplaced previous tools with simulator-workspace workflow tools.\n\nUse XcodeBuildMCP tools for all Apple platform development tasks from now on. Call tools/list to see all available tools for your workflow.', 495 | }, 496 | ], 497 | isError: false, 498 | }); 499 | }); 500 | 501 | it('should handle workflow enabling errors gracefully', async () => { 502 | const errorCallTracker: CallTracker = { 503 | getAvailableWorkflowsCalls: [], 504 | generateWorkflowDescriptionsCalls: [], 505 | enableWorkflowsCalls: [], 506 | }; 507 | 508 | const mockDepsWithError = createMockDependencies( 509 | { 510 | availableWorkflows: ['simulator-workspace'], 511 | enableWorkflowsError: new Error('Failed to enable workflows'), 512 | }, 513 | errorCallTracker, 514 | ); 515 | 516 | // Configure mock request to return successful response 517 | (mockServer.server as any).createMessage = async (...args: any[]) => { 518 | return { 519 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 520 | }; 521 | }; 522 | 523 | const result = await discover_toolsLogic( 524 | { task_description: 'Build my app' }, 525 | undefined, 526 | mockDepsWithError, 527 | ); 528 | 529 | expect(result).toEqual({ 530 | content: [ 531 | { 532 | type: 'text', 533 | text: 'An error occurred while discovering tools: Failed to enable workflows', 534 | }, 535 | ], 536 | isError: true, 537 | }); 538 | }); 539 | }); 540 | 541 | describe('Error Handling', () => { 542 | it('should handle missing server instance', async () => { 543 | (globalThis as any).mcpServer = undefined; 544 | 545 | const result = await discover_toolsLogic({ task_description: 'Build my app' }, undefined); 546 | 547 | expect(result).toEqual({ 548 | content: [ 549 | { 550 | type: 'text', 551 | text: 'An error occurred while discovering tools: Server instance not available', 552 | }, 553 | ], 554 | isError: true, 555 | }); 556 | }); 557 | 558 | it('should handle workflow loading errors', async () => { 559 | const errorCallTracker: CallTracker = { 560 | getAvailableWorkflowsCalls: [], 561 | generateWorkflowDescriptionsCalls: [], 562 | enableWorkflowsCalls: [], 563 | }; 564 | 565 | const mockDepsWithError = createMockDependencies( 566 | { 567 | getAvailableWorkflowsError: new Error('Failed to load workflows'), 568 | }, 569 | errorCallTracker, 570 | ); 571 | 572 | const result = await discover_toolsLogic( 573 | { task_description: 'Build my app' }, 574 | undefined, 575 | mockDepsWithError, 576 | ); 577 | 578 | expect(result).toEqual({ 579 | content: [ 580 | { 581 | type: 'text', 582 | text: 'An error occurred while discovering tools: Failed to load workflows', 583 | }, 584 | ], 585 | isError: true, 586 | }); 587 | }); 588 | 589 | it('should handle LLM request errors', async () => { 590 | const errorCallTracker: CallTracker = { 591 | getAvailableWorkflowsCalls: [], 592 | generateWorkflowDescriptionsCalls: [], 593 | enableWorkflowsCalls: [], 594 | }; 595 | 596 | const mockDeps = createMockDependencies({ availableWorkflows: [] }, errorCallTracker); 597 | 598 | // Configure mock request to throw error 599 | (mockServer.server as any).createMessage = async (...args: any[]) => { 600 | throw new Error('LLM request failed'); 601 | }; 602 | 603 | const result = await discover_toolsLogic( 604 | { task_description: 'Build my app' }, 605 | undefined, 606 | mockDeps, 607 | ); 608 | 609 | expect(result).toEqual({ 610 | content: [ 611 | { 612 | type: 'text', 613 | text: 'An error occurred while discovering tools: LLM request failed', 614 | }, 615 | ], 616 | isError: true, 617 | }); 618 | }); 619 | }); 620 | 621 | describe('Prompt Generation', () => { 622 | it('should include task description in LLM prompt', async () => { 623 | const promptCallTracker: CallTracker = { 624 | getAvailableWorkflowsCalls: [], 625 | generateWorkflowDescriptionsCalls: [], 626 | enableWorkflowsCalls: [], 627 | }; 628 | 629 | const mockDeps = createMockDependencies( 630 | { 631 | availableWorkflows: ['simulator-workspace'], 632 | workflowDescriptions: `Available workflows: 633 | 1. simulator-workspace: iOS Simulator Workspace - iOS development for workspaces`, 634 | }, 635 | promptCallTracker, 636 | ); 637 | 638 | // Reset request calls for this test 639 | requestCalls.length = 0; 640 | 641 | // Configure mock request to capture calls and return response 642 | (mockServer.server as any).createMessage = async (...args: any[]) => { 643 | requestCalls.push(args); 644 | return { 645 | content: [{ type: 'text', text: '["simulator-workspace"]' }], 646 | }; 647 | }; 648 | 649 | const taskDescription = 650 | 'I need to build my React Native iOS app for the simulator and run tests'; 651 | 652 | await discover_toolsLogic({ task_description: taskDescription }, undefined, mockDeps); 653 | 654 | expect(requestCalls).toHaveLength(1); 655 | const requestCall = requestCalls[0]; 656 | const prompt = requestCall[0].messages[0].content.text; 657 | 658 | expect(prompt).toContain(taskDescription); 659 | expect(prompt).toContain('Select EXACTLY ONE workflow'); 660 | expect(prompt).toContain('Primary (project/workspace-based) workflows:'); 661 | expect(prompt).toContain('Secondary (task-based, no project/workspace needed):'); 662 | expect(prompt).toContain('All available workflows:'); 663 | }); 664 | 665 | it('should provide clear selection guidelines in prompt', async () => { 666 | const promptCallTracker: CallTracker = { 667 | getAvailableWorkflowsCalls: [], 668 | generateWorkflowDescriptionsCalls: [], 669 | enableWorkflowsCalls: [], 670 | }; 671 | 672 | const mockDeps = createMockDependencies( 673 | { 674 | availableWorkflows: [], 675 | workflowDescriptions: `Available workflows:`, 676 | }, 677 | promptCallTracker, 678 | ); 679 | 680 | // Reset request calls for this test 681 | requestCalls.length = 0; 682 | 683 | // Configure mock request to capture calls and return response 684 | (mockServer.server as any).createMessage = async (...args: any[]) => { 685 | requestCalls.push(args); 686 | return { 687 | content: [{ type: 'text', text: '[]' }], 688 | }; 689 | }; 690 | 691 | await discover_toolsLogic({ task_description: 'Build my app' }, undefined, mockDeps); 692 | 693 | expect(requestCalls).toHaveLength(1); 694 | const requestCall = requestCalls[0]; 695 | const prompt = requestCall[0].messages[0].content.text; 696 | 697 | expect(prompt).toContain('Select EXACTLY ONE workflow'); 698 | expect(prompt).toContain('.xcworkspace'); 699 | expect(prompt).toContain('.xcodeproj'); 700 | expect(prompt).toContain('simulator-management'); 701 | expect(prompt).toContain('macOS'); 702 | expect(prompt).toContain('Respond with ONLY a JSON array'); 703 | }); 704 | }); 705 | }); 706 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/touch.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for touch tool plugin 3 | * Following CLAUDE.md testing standards with dependency injection 4 | */ 5 | 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { z } from 'zod'; 8 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 9 | import touchPlugin, { touchLogic } from '../touch.ts'; 10 | 11 | describe('Touch Plugin', () => { 12 | describe('Export Field Validation (Literal)', () => { 13 | it('should have correct name', () => { 14 | expect(touchPlugin.name).toBe('touch'); 15 | }); 16 | 17 | it('should have correct description', () => { 18 | expect(touchPlugin.description).toBe( 19 | "Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots).", 20 | ); 21 | }); 22 | 23 | it('should have handler function', () => { 24 | expect(typeof touchPlugin.handler).toBe('function'); 25 | }); 26 | 27 | it('should validate schema fields with safeParse', () => { 28 | const schema = z.object(touchPlugin.schema); 29 | 30 | // Valid case with down 31 | expect( 32 | schema.safeParse({ 33 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 34 | x: 100, 35 | y: 200, 36 | down: true, 37 | }).success, 38 | ).toBe(true); 39 | 40 | // Valid case with up 41 | expect( 42 | schema.safeParse({ 43 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 44 | x: 100, 45 | y: 200, 46 | up: true, 47 | }).success, 48 | ).toBe(true); 49 | 50 | // Invalid simulatorUuid 51 | expect( 52 | schema.safeParse({ 53 | simulatorUuid: 'invalid-uuid', 54 | x: 100, 55 | y: 200, 56 | down: true, 57 | }).success, 58 | ).toBe(false); 59 | 60 | // Invalid x (not integer) 61 | expect( 62 | schema.safeParse({ 63 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 64 | x: 100.5, 65 | y: 200, 66 | down: true, 67 | }).success, 68 | ).toBe(false); 69 | 70 | // Invalid y (not integer) 71 | expect( 72 | schema.safeParse({ 73 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 74 | x: 100, 75 | y: 200.5, 76 | down: true, 77 | }).success, 78 | ).toBe(false); 79 | 80 | // Valid with delay 81 | expect( 82 | schema.safeParse({ 83 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 84 | x: 100, 85 | y: 200, 86 | down: true, 87 | delay: 1.5, 88 | }).success, 89 | ).toBe(true); 90 | 91 | // Invalid delay (negative) 92 | expect( 93 | schema.safeParse({ 94 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 95 | x: 100, 96 | y: 200, 97 | down: true, 98 | delay: -1, 99 | }).success, 100 | ).toBe(false); 101 | }); 102 | }); 103 | 104 | describe('Command Generation', () => { 105 | it('should generate correct axe command for touch down', async () => { 106 | let capturedCommand: string[] = []; 107 | const trackingExecutor = async (command: string[]) => { 108 | capturedCommand = command; 109 | return { 110 | success: true, 111 | output: 'touch completed', 112 | error: undefined, 113 | process: { pid: 12345 }, 114 | }; 115 | }; 116 | 117 | const mockAxeHelpers = { 118 | getAxePath: () => '/usr/local/bin/axe', 119 | getBundledAxeEnvironment: () => ({}), 120 | createAxeNotAvailableResponse: () => ({ 121 | content: [ 122 | { 123 | type: 'text', 124 | 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.', 125 | }, 126 | ], 127 | isError: true, 128 | }), 129 | }; 130 | 131 | await touchLogic( 132 | { 133 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 134 | x: 100, 135 | y: 200, 136 | down: true, 137 | }, 138 | trackingExecutor, 139 | mockAxeHelpers, 140 | ); 141 | 142 | expect(capturedCommand).toEqual([ 143 | '/usr/local/bin/axe', 144 | 'touch', 145 | '-x', 146 | '100', 147 | '-y', 148 | '200', 149 | '--down', 150 | '--udid', 151 | '12345678-1234-1234-1234-123456789012', 152 | ]); 153 | }); 154 | 155 | it('should generate correct axe command for touch up', async () => { 156 | let capturedCommand: string[] = []; 157 | const trackingExecutor = async (command: string[]) => { 158 | capturedCommand = command; 159 | return { 160 | success: true, 161 | output: 'touch completed', 162 | error: undefined, 163 | process: { pid: 12345 }, 164 | }; 165 | }; 166 | 167 | const mockAxeHelpers = { 168 | getAxePath: () => '/usr/local/bin/axe', 169 | getBundledAxeEnvironment: () => ({}), 170 | createAxeNotAvailableResponse: () => ({ 171 | content: [ 172 | { 173 | type: 'text', 174 | 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.', 175 | }, 176 | ], 177 | isError: true, 178 | }), 179 | }; 180 | 181 | await touchLogic( 182 | { 183 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 184 | x: 150, 185 | y: 250, 186 | up: true, 187 | }, 188 | trackingExecutor, 189 | mockAxeHelpers, 190 | ); 191 | 192 | expect(capturedCommand).toEqual([ 193 | '/usr/local/bin/axe', 194 | 'touch', 195 | '-x', 196 | '150', 197 | '-y', 198 | '250', 199 | '--up', 200 | '--udid', 201 | '12345678-1234-1234-1234-123456789012', 202 | ]); 203 | }); 204 | 205 | it('should generate correct axe command for touch down+up', async () => { 206 | let capturedCommand: string[] = []; 207 | const trackingExecutor = async (command: string[]) => { 208 | capturedCommand = command; 209 | return { 210 | success: true, 211 | output: 'touch completed', 212 | error: undefined, 213 | process: { pid: 12345 }, 214 | }; 215 | }; 216 | 217 | const mockAxeHelpers = { 218 | getAxePath: () => '/usr/local/bin/axe', 219 | getBundledAxeEnvironment: () => ({}), 220 | createAxeNotAvailableResponse: () => ({ 221 | content: [ 222 | { 223 | type: 'text', 224 | 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.', 225 | }, 226 | ], 227 | isError: true, 228 | }), 229 | }; 230 | 231 | await touchLogic( 232 | { 233 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 234 | x: 300, 235 | y: 400, 236 | down: true, 237 | up: true, 238 | }, 239 | trackingExecutor, 240 | mockAxeHelpers, 241 | ); 242 | 243 | expect(capturedCommand).toEqual([ 244 | '/usr/local/bin/axe', 245 | 'touch', 246 | '-x', 247 | '300', 248 | '-y', 249 | '400', 250 | '--down', 251 | '--up', 252 | '--udid', 253 | '12345678-1234-1234-1234-123456789012', 254 | ]); 255 | }); 256 | 257 | it('should generate correct axe command for touch with delay', async () => { 258 | let capturedCommand: string[] = []; 259 | const trackingExecutor = async (command: string[]) => { 260 | capturedCommand = command; 261 | return { 262 | success: true, 263 | output: 'touch completed', 264 | error: undefined, 265 | process: { pid: 12345 }, 266 | }; 267 | }; 268 | 269 | const mockAxeHelpers = { 270 | getAxePath: () => '/usr/local/bin/axe', 271 | getBundledAxeEnvironment: () => ({}), 272 | createAxeNotAvailableResponse: () => ({ 273 | content: [ 274 | { 275 | type: 'text', 276 | 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.', 277 | }, 278 | ], 279 | isError: true, 280 | }), 281 | }; 282 | 283 | await touchLogic( 284 | { 285 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 286 | x: 50, 287 | y: 75, 288 | down: true, 289 | up: true, 290 | delay: 1.5, 291 | }, 292 | trackingExecutor, 293 | mockAxeHelpers, 294 | ); 295 | 296 | expect(capturedCommand).toEqual([ 297 | '/usr/local/bin/axe', 298 | 'touch', 299 | '-x', 300 | '50', 301 | '-y', 302 | '75', 303 | '--down', 304 | '--up', 305 | '--delay', 306 | '1.5', 307 | '--udid', 308 | '12345678-1234-1234-1234-123456789012', 309 | ]); 310 | }); 311 | 312 | it('should generate correct axe command with bundled axe path', async () => { 313 | let capturedCommand: string[] = []; 314 | const trackingExecutor = async (command: string[]) => { 315 | capturedCommand = command; 316 | return { 317 | success: true, 318 | output: 'touch completed', 319 | error: undefined, 320 | process: { pid: 12345 }, 321 | }; 322 | }; 323 | 324 | const mockAxeHelpers = { 325 | getAxePath: () => '/path/to/bundled/axe', 326 | getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), 327 | }; 328 | 329 | await touchLogic( 330 | { 331 | simulatorUuid: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 332 | x: 0, 333 | y: 0, 334 | up: true, 335 | delay: 0.5, 336 | }, 337 | trackingExecutor, 338 | mockAxeHelpers, 339 | ); 340 | 341 | expect(capturedCommand).toEqual([ 342 | '/path/to/bundled/axe', 343 | 'touch', 344 | '-x', 345 | '0', 346 | '-y', 347 | '0', 348 | '--up', 349 | '--delay', 350 | '0.5', 351 | '--udid', 352 | 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 353 | ]); 354 | }); 355 | }); 356 | 357 | describe('Handler Behavior (Complete Literal Returns)', () => { 358 | it('should handle axe dependency error', async () => { 359 | const mockExecutor = createMockExecutor({ success: true }); 360 | const mockAxeHelpers = { 361 | getAxePath: () => null, 362 | getBundledAxeEnvironment: () => ({}), 363 | createAxeNotAvailableResponse: () => ({ 364 | content: [ 365 | { 366 | type: 'text', 367 | 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.', 368 | }, 369 | ], 370 | isError: true, 371 | }), 372 | }; 373 | 374 | const result = await touchLogic( 375 | { 376 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 377 | x: 100, 378 | y: 200, 379 | down: true, 380 | }, 381 | mockExecutor, 382 | mockAxeHelpers, 383 | ); 384 | 385 | expect(result).toEqual({ 386 | content: [ 387 | { 388 | type: 'text', 389 | 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.', 390 | }, 391 | ], 392 | isError: true, 393 | }); 394 | }); 395 | 396 | it('should successfully perform touch down', async () => { 397 | const mockExecutor = createMockExecutor({ success: true, output: 'Touch down completed' }); 398 | const mockAxeHelpers = { 399 | getAxePath: () => '/usr/local/bin/axe', 400 | getBundledAxeEnvironment: () => ({}), 401 | createAxeNotAvailableResponse: () => ({ 402 | content: [ 403 | { 404 | type: 'text', 405 | 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.', 406 | }, 407 | ], 408 | isError: true, 409 | }), 410 | }; 411 | 412 | const result = await touchLogic( 413 | { 414 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 415 | x: 100, 416 | y: 200, 417 | down: true, 418 | }, 419 | mockExecutor, 420 | mockAxeHelpers, 421 | ); 422 | 423 | expect(result).toEqual({ 424 | content: [ 425 | { 426 | type: 'text', 427 | text: 'Touch event (touch down) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 428 | }, 429 | ], 430 | isError: false, 431 | }); 432 | }); 433 | 434 | it('should successfully perform touch up', async () => { 435 | const mockExecutor = createMockExecutor({ success: true, output: 'Touch up completed' }); 436 | const mockAxeHelpers = { 437 | getAxePath: () => '/usr/local/bin/axe', 438 | getBundledAxeEnvironment: () => ({}), 439 | createAxeNotAvailableResponse: () => ({ 440 | content: [ 441 | { 442 | type: 'text', 443 | 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.', 444 | }, 445 | ], 446 | isError: true, 447 | }), 448 | }; 449 | 450 | const result = await touchLogic( 451 | { 452 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 453 | x: 100, 454 | y: 200, 455 | up: true, 456 | }, 457 | mockExecutor, 458 | mockAxeHelpers, 459 | ); 460 | 461 | expect(result).toEqual({ 462 | content: [ 463 | { 464 | type: 'text', 465 | text: 'Touch event (touch up) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 466 | }, 467 | ], 468 | isError: false, 469 | }); 470 | }); 471 | 472 | it('should return error when neither down nor up is specified', async () => { 473 | const mockExecutor = createMockExecutor({ success: true }); 474 | 475 | const result = await touchLogic( 476 | { 477 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 478 | x: 100, 479 | y: 200, 480 | }, 481 | mockExecutor, 482 | ); 483 | 484 | expect(result).toEqual({ 485 | content: [{ type: 'text', text: 'Error: At least one of "down" or "up" must be true' }], 486 | isError: true, 487 | }); 488 | }); 489 | 490 | it('should return success for touch down event', async () => { 491 | const mockExecutor = createMockExecutor({ 492 | success: true, 493 | output: 'touch completed', 494 | error: undefined, 495 | }); 496 | 497 | const mockAxeHelpers = { 498 | getAxePath: () => '/usr/local/bin/axe', 499 | getBundledAxeEnvironment: () => ({}), 500 | createAxeNotAvailableResponse: () => ({ 501 | content: [ 502 | { 503 | type: 'text', 504 | 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.', 505 | }, 506 | ], 507 | isError: true, 508 | }), 509 | }; 510 | 511 | const result = await touchLogic( 512 | { 513 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 514 | x: 100, 515 | y: 200, 516 | down: true, 517 | }, 518 | mockExecutor, 519 | mockAxeHelpers, 520 | ); 521 | 522 | expect(result).toEqual({ 523 | content: [ 524 | { 525 | type: 'text', 526 | text: 'Touch event (touch down) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 527 | }, 528 | ], 529 | isError: false, 530 | }); 531 | }); 532 | 533 | it('should return success for touch up event', async () => { 534 | const mockExecutor = createMockExecutor({ 535 | success: true, 536 | output: 'touch completed', 537 | error: undefined, 538 | }); 539 | 540 | const mockAxeHelpers = { 541 | getAxePath: () => '/usr/local/bin/axe', 542 | getBundledAxeEnvironment: () => ({}), 543 | createAxeNotAvailableResponse: () => ({ 544 | content: [ 545 | { 546 | type: 'text', 547 | 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.', 548 | }, 549 | ], 550 | isError: true, 551 | }), 552 | }; 553 | 554 | const result = await touchLogic( 555 | { 556 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 557 | x: 100, 558 | y: 200, 559 | up: true, 560 | }, 561 | mockExecutor, 562 | mockAxeHelpers, 563 | ); 564 | 565 | expect(result).toEqual({ 566 | content: [ 567 | { 568 | type: 'text', 569 | text: 'Touch event (touch up) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 570 | }, 571 | ], 572 | isError: false, 573 | }); 574 | }); 575 | 576 | it('should return success for touch down+up event', async () => { 577 | const mockExecutor = createMockExecutor({ 578 | success: true, 579 | output: 'touch completed', 580 | error: undefined, 581 | }); 582 | 583 | const mockAxeHelpers = { 584 | getAxePath: () => '/usr/local/bin/axe', 585 | getBundledAxeEnvironment: () => ({}), 586 | createAxeNotAvailableResponse: () => ({ 587 | content: [ 588 | { 589 | type: 'text', 590 | 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.', 591 | }, 592 | ], 593 | isError: true, 594 | }), 595 | }; 596 | 597 | const result = await touchLogic( 598 | { 599 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 600 | x: 100, 601 | y: 200, 602 | down: true, 603 | up: true, 604 | }, 605 | mockExecutor, 606 | mockAxeHelpers, 607 | ); 608 | 609 | expect(result).toEqual({ 610 | content: [ 611 | { 612 | type: 'text', 613 | text: 'Touch event (touch down+up) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', 614 | }, 615 | ], 616 | isError: false, 617 | }); 618 | }); 619 | 620 | it('should handle DependencyError when axe is not available', async () => { 621 | const mockExecutor = createMockExecutor({ success: true }); 622 | 623 | const mockAxeHelpers = { 624 | getAxePath: () => null, 625 | getBundledAxeEnvironment: () => ({}), 626 | createAxeNotAvailableResponse: () => ({ 627 | content: [ 628 | { 629 | type: 'text', 630 | 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.', 631 | }, 632 | ], 633 | isError: true, 634 | }), 635 | }; 636 | 637 | const result = await touchLogic( 638 | { 639 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 640 | x: 100, 641 | y: 200, 642 | down: true, 643 | }, 644 | mockExecutor, 645 | mockAxeHelpers, 646 | ); 647 | 648 | expect(result).toEqual({ 649 | content: [ 650 | { 651 | type: 'text', 652 | 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.', 653 | }, 654 | ], 655 | isError: true, 656 | }); 657 | }); 658 | 659 | it('should handle AxeError from failed command execution', async () => { 660 | const mockExecutor = createMockExecutor({ 661 | success: false, 662 | output: '', 663 | error: 'axe command failed', 664 | }); 665 | 666 | const mockAxeHelpers = { 667 | getAxePath: () => '/usr/local/bin/axe', 668 | getBundledAxeEnvironment: () => ({}), 669 | createAxeNotAvailableResponse: () => ({ 670 | content: [ 671 | { 672 | type: 'text', 673 | 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.', 674 | }, 675 | ], 676 | isError: true, 677 | }), 678 | }; 679 | 680 | const result = await touchLogic( 681 | { 682 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 683 | x: 100, 684 | y: 200, 685 | down: true, 686 | }, 687 | mockExecutor, 688 | mockAxeHelpers, 689 | ); 690 | 691 | expect(result).toEqual({ 692 | content: [ 693 | { 694 | type: 'text', 695 | text: "Error: Failed to execute touch event: axe command 'touch' failed.\nDetails: axe command failed", 696 | }, 697 | ], 698 | isError: true, 699 | }); 700 | }); 701 | 702 | it('should handle SystemError from command execution', async () => { 703 | const mockExecutor = async () => { 704 | throw new Error('System error occurred'); 705 | }; 706 | 707 | const mockAxeHelpers = { 708 | getAxePath: () => '/usr/local/bin/axe', 709 | getBundledAxeEnvironment: () => ({}), 710 | createAxeNotAvailableResponse: () => ({ 711 | content: [ 712 | { 713 | type: 'text', 714 | 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.', 715 | }, 716 | ], 717 | isError: true, 718 | }), 719 | }; 720 | 721 | const result = await touchLogic( 722 | { 723 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 724 | x: 100, 725 | y: 200, 726 | down: true, 727 | }, 728 | mockExecutor, 729 | mockAxeHelpers, 730 | ); 731 | 732 | expect(result).toMatchObject({ 733 | content: [ 734 | { 735 | type: 'text', 736 | text: expect.stringContaining( 737 | 'Error: System error executing axe: Failed to execute axe command: System error occurred', 738 | ), 739 | }, 740 | ], 741 | isError: true, 742 | }); 743 | }); 744 | 745 | it('should handle unexpected Error objects', async () => { 746 | const mockExecutor = async () => { 747 | throw new Error('Unexpected error'); 748 | }; 749 | 750 | const mockAxeHelpers = { 751 | getAxePath: () => '/usr/local/bin/axe', 752 | getBundledAxeEnvironment: () => ({}), 753 | createAxeNotAvailableResponse: () => ({ 754 | content: [ 755 | { 756 | type: 'text', 757 | 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.', 758 | }, 759 | ], 760 | isError: true, 761 | }), 762 | }; 763 | 764 | const result = await touchLogic( 765 | { 766 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 767 | x: 100, 768 | y: 200, 769 | down: true, 770 | }, 771 | mockExecutor, 772 | mockAxeHelpers, 773 | ); 774 | 775 | expect(result).toMatchObject({ 776 | content: [ 777 | { 778 | type: 'text', 779 | text: expect.stringContaining( 780 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error', 781 | ), 782 | }, 783 | ], 784 | isError: true, 785 | }); 786 | }); 787 | 788 | it('should handle unexpected string errors', async () => { 789 | const mockExecutor = async () => { 790 | throw 'String error'; 791 | }; 792 | 793 | const mockAxeHelpers = { 794 | getAxePath: () => '/usr/local/bin/axe', 795 | getBundledAxeEnvironment: () => ({}), 796 | createAxeNotAvailableResponse: () => ({ 797 | content: [ 798 | { 799 | type: 'text', 800 | 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.', 801 | }, 802 | ], 803 | isError: true, 804 | }), 805 | }; 806 | 807 | const result = await touchLogic( 808 | { 809 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 810 | x: 100, 811 | y: 200, 812 | down: true, 813 | }, 814 | mockExecutor, 815 | mockAxeHelpers, 816 | ); 817 | 818 | expect(result).toEqual({ 819 | content: [ 820 | { 821 | type: 'text', 822 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 823 | }, 824 | ], 825 | isError: true, 826 | }); 827 | }); 828 | }); 829 | }); 830 | ```