This is page 4 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .axe-version ├── .claude │ └── agents │ └── xcodebuild-mcp-qa-tester.md ├── .cursor │ ├── BUGBOT.md │ └── environment.json ├── .cursorrules ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude-dispatch.yml │ ├── claude.yml │ ├── droid-code-review.yml │ ├── README.md │ ├── release.yml │ └── sentry.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── mcp.json │ ├── settings.json │ └── tasks.json ├── AGENTS.md ├── banner.png ├── build-plugins │ ├── plugin-discovery.js │ ├── plugin-discovery.ts │ └── tsconfig.json ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── docs │ ├── ARCHITECTURE.md │ ├── CODE_QUALITY.md │ ├── CONTRIBUTING.md │ ├── ESLINT_TYPE_SAFETY.md │ ├── MANUAL_TESTING.md │ ├── NODEJS_2025.md │ ├── PLUGIN_DEVELOPMENT.md │ ├── RELEASE_PROCESS.md │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md │ ├── RELOADEROO.md │ ├── session_management_plan.md │ ├── session-aware-migration-todo.md │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md │ ├── TESTING.md │ └── TOOLS.md ├── eslint.config.js ├── example_projects │ ├── .vscode │ │ └── launch.json │ ├── iOS │ │ ├── .cursor │ │ │ └── rules │ │ │ └── errors.mdc │ │ ├── .vscode │ │ │ └── settings.json │ │ ├── Makefile │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ ├── MCPTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── MCPTest.xcscheme │ │ └── MCPTestUITests │ │ └── MCPTestUITests.swift │ ├── iOS_Calculator │ │ ├── CalculatorApp │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── CalculatorApp.swift │ │ │ └── CalculatorApp.xctestplan │ │ ├── CalculatorApp.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── CalculatorApp.xcscheme │ │ ├── CalculatorApp.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── CalculatorAppPackage │ │ │ ├── .gitignore │ │ │ ├── Package.swift │ │ │ ├── Sources │ │ │ │ └── CalculatorAppFeature │ │ │ │ ├── BackgroundEffect.swift │ │ │ │ ├── CalculatorButton.swift │ │ │ │ ├── CalculatorDisplay.swift │ │ │ │ ├── CalculatorInputHandler.swift │ │ │ │ ├── CalculatorService.swift │ │ │ │ └── ContentView.swift │ │ │ └── Tests │ │ │ └── CalculatorAppFeatureTests │ │ │ └── CalculatorServiceTests.swift │ │ ├── CalculatorAppTests │ │ │ └── CalculatorAppTests.swift │ │ └── Config │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ ├── Shared.xcconfig │ │ └── Tests.xcconfig │ ├── macOS │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTest.entitlements │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── MCPTest.xcodeproj │ │ ├── project.pbxproj │ │ └── xcshareddata │ │ └── xcschemes │ │ └── MCPTest.xcscheme │ └── spm │ ├── .gitignore │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ │ ├── long-server │ │ │ └── main.swift │ │ ├── quick-task │ │ │ └── main.swift │ │ ├── spm │ │ │ └── main.swift │ │ └── TestLib │ │ └── TaskManager.swift │ └── Tests │ └── TestLibTests │ └── SimpleTests.swift ├── LICENSE ├── mcp-install-dark.png ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── analysis │ │ └── tools-analysis.ts │ ├── bundle-axe.sh │ ├── check-code-patterns.js │ ├── release.sh │ ├── tools-cli.ts │ └── update-tools-docs.ts ├── server.json ├── smithery.yaml ├── src │ ├── core │ │ ├── __tests__ │ │ │ └── resources.test.ts │ │ ├── dynamic-tools.ts │ │ ├── plugin-registry.ts │ │ ├── plugin-types.ts │ │ └── resources.ts │ ├── doctor-cli.ts │ ├── index.ts │ ├── mcp │ │ ├── resources │ │ │ ├── __tests__ │ │ │ │ ├── devices.test.ts │ │ │ │ ├── doctor.test.ts │ │ │ │ └── simulators.test.ts │ │ │ ├── devices.ts │ │ │ ├── doctor.ts │ │ │ └── simulators.ts │ │ └── tools │ │ ├── device │ │ │ ├── __tests__ │ │ │ │ ├── build_device.test.ts │ │ │ │ ├── get_device_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_device.test.ts │ │ │ │ ├── launch_app_device.test.ts │ │ │ │ ├── list_devices.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_app_device.test.ts │ │ │ │ └── test_device.test.ts │ │ │ ├── build_device.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_device_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_device.ts │ │ │ ├── launch_app_device.ts │ │ │ ├── list_devices.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── stop_app_device.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── test_device.ts │ │ ├── discovery │ │ │ ├── __tests__ │ │ │ │ └── discover_tools.test.ts │ │ │ ├── discover_tools.ts │ │ │ └── index.ts │ │ ├── doctor │ │ │ ├── __tests__ │ │ │ │ ├── doctor.test.ts │ │ │ │ └── index.test.ts │ │ │ ├── doctor.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ └── doctor.deps.ts │ │ ├── logging │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── start_device_log_cap.test.ts │ │ │ │ ├── start_sim_log_cap.test.ts │ │ │ │ ├── stop_device_log_cap.test.ts │ │ │ │ └── stop_sim_log_cap.test.ts │ │ │ ├── index.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── start_sim_log_cap.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── stop_sim_log_cap.ts │ │ ├── macos │ │ │ ├── __tests__ │ │ │ │ ├── build_macos.test.ts │ │ │ │ ├── build_run_macos.test.ts │ │ │ │ ├── get_mac_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── launch_mac_app.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_mac_app.test.ts │ │ │ │ └── test_macos.test.ts │ │ │ ├── build_macos.ts │ │ │ ├── build_run_macos.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_mac_app_path.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── launch_mac_app.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_mac_app.ts │ │ │ └── test_macos.ts │ │ ├── project-discovery │ │ │ ├── __tests__ │ │ │ │ ├── discover_projs.test.ts │ │ │ │ ├── get_app_bundle_id.test.ts │ │ │ │ ├── get_mac_bundle_id.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── list_schemes.test.ts │ │ │ │ └── show_build_settings.test.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── list_schemes.ts │ │ │ └── show_build_settings.ts │ │ ├── project-scaffolding │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── scaffold_ios_project.test.ts │ │ │ │ └── scaffold_macos_project.test.ts │ │ │ ├── index.ts │ │ │ ├── scaffold_ios_project.ts │ │ │ └── scaffold_macos_project.ts │ │ ├── session-management │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── session_clear_defaults.test.ts │ │ │ │ ├── session_set_defaults.test.ts │ │ │ │ └── session_show_defaults.test.ts │ │ │ ├── index.ts │ │ │ ├── session_clear_defaults.ts │ │ │ ├── session_set_defaults.ts │ │ │ └── session_show_defaults.ts │ │ ├── simulator │ │ │ ├── __tests__ │ │ │ │ ├── boot_sim.test.ts │ │ │ │ ├── build_run_sim.test.ts │ │ │ │ ├── build_sim.test.ts │ │ │ │ ├── get_sim_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_sim.test.ts │ │ │ │ ├── launch_app_logs_sim.test.ts │ │ │ │ ├── launch_app_sim.test.ts │ │ │ │ ├── list_sims.test.ts │ │ │ │ ├── open_sim.test.ts │ │ │ │ ├── record_sim_video.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── stop_app_sim.test.ts │ │ │ │ └── test_sim.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── build_run_sim.ts │ │ │ ├── build_sim.ts │ │ │ ├── clean.ts │ │ │ ├── describe_ui.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_sim_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_sim.ts │ │ │ ├── launch_app_logs_sim.ts │ │ │ ├── launch_app_sim.ts │ │ │ ├── list_schemes.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── record_sim_video.ts │ │ │ ├── screenshot.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_app_sim.ts │ │ │ └── test_sim.ts │ │ ├── simulator-management │ │ │ ├── __tests__ │ │ │ │ ├── erase_sims.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── reset_sim_location.test.ts │ │ │ │ ├── set_sim_appearance.test.ts │ │ │ │ ├── set_sim_location.test.ts │ │ │ │ └── sim_statusbar.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── erase_sims.ts │ │ │ ├── index.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── reset_sim_location.ts │ │ │ ├── set_sim_appearance.ts │ │ │ ├── set_sim_location.ts │ │ │ └── sim_statusbar.ts │ │ ├── swift-package │ │ │ ├── __tests__ │ │ │ │ ├── active-processes.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── swift_package_build.test.ts │ │ │ │ ├── swift_package_clean.test.ts │ │ │ │ ├── swift_package_list.test.ts │ │ │ │ ├── swift_package_run.test.ts │ │ │ │ ├── swift_package_stop.test.ts │ │ │ │ └── swift_package_test.test.ts │ │ │ ├── active-processes.ts │ │ │ ├── index.ts │ │ │ ├── swift_package_build.ts │ │ │ ├── swift_package_clean.ts │ │ │ ├── swift_package_list.ts │ │ │ ├── swift_package_run.ts │ │ │ ├── swift_package_stop.ts │ │ │ └── swift_package_test.ts │ │ ├── ui-testing │ │ │ ├── __tests__ │ │ │ │ ├── button.test.ts │ │ │ │ ├── describe_ui.test.ts │ │ │ │ ├── gesture.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── key_press.test.ts │ │ │ │ ├── key_sequence.test.ts │ │ │ │ ├── long_press.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── swipe.test.ts │ │ │ │ ├── tap.test.ts │ │ │ │ ├── touch.test.ts │ │ │ │ └── type_text.test.ts │ │ │ ├── button.ts │ │ │ ├── describe_ui.ts │ │ │ ├── gesture.ts │ │ │ ├── index.ts │ │ │ ├── key_press.ts │ │ │ ├── key_sequence.ts │ │ │ ├── long_press.ts │ │ │ ├── screenshot.ts │ │ │ ├── swipe.ts │ │ │ ├── tap.ts │ │ │ ├── touch.ts │ │ │ └── type_text.ts │ │ └── utilities │ │ ├── __tests__ │ │ │ ├── clean.test.ts │ │ │ └── index.test.ts │ │ ├── clean.ts │ │ └── index.ts │ ├── server │ │ └── server.ts │ ├── test-utils │ │ └── mock-executors.ts │ ├── types │ │ └── common.ts │ └── utils │ ├── __tests__ │ │ ├── build-utils.test.ts │ │ ├── environment.test.ts │ │ ├── session-aware-tool-factory.test.ts │ │ ├── session-store.test.ts │ │ ├── simulator-utils.test.ts │ │ ├── test-runner-env-integration.test.ts │ │ └── typed-tool-factory.test.ts │ ├── axe │ │ └── index.ts │ ├── axe-helpers.ts │ ├── build │ │ └── index.ts │ ├── build-utils.ts │ ├── capabilities.ts │ ├── command.ts │ ├── CommandExecutor.ts │ ├── environment.ts │ ├── errors.ts │ ├── execution │ │ └── index.ts │ ├── FileSystemExecutor.ts │ ├── log_capture.ts │ ├── log-capture │ │ └── index.ts │ ├── logger.ts │ ├── logging │ │ └── index.ts │ ├── plugin-registry │ │ └── index.ts │ ├── responses │ │ └── index.ts │ ├── schema-helpers.ts │ ├── sentry.ts │ ├── session-store.ts │ ├── simulator-utils.ts │ ├── template │ │ └── index.ts │ ├── template-manager.ts │ ├── test │ │ └── index.ts │ ├── test-common.ts │ ├── tool-registry.ts │ ├── typed-tool-factory.ts │ ├── validation │ │ └── index.ts │ ├── validation.ts │ ├── version │ │ └── index.ts │ ├── video_capture.ts │ ├── video-capture │ │ └── index.ts │ ├── xcode.ts │ ├── xcodemake │ │ └── index.ts │ └── xcodemake.ts ├── tsconfig.json ├── tsconfig.test.json ├── tsup.config.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/screenshot.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Screenshot tool plugin - Capture screenshots from iOS Simulator 3 | */ 4 | import * as path from 'path'; 5 | import { tmpdir } from 'os'; 6 | import { z } from 'zod'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | import { ToolResponse, createImageContent } from '../../../types/common.ts'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import { createErrorResponse, SystemError } from '../../../utils/responses/index.ts'; 11 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; 12 | import { 13 | getDefaultFileSystemExecutor, 14 | getDefaultCommandExecutor, 15 | } from '../../../utils/execution/index.ts'; 16 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 17 | 18 | const LOG_PREFIX = '[Screenshot]'; 19 | 20 | // Define schema as ZodObject 21 | const screenshotSchema = z.object({ 22 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 23 | }); 24 | 25 | // Use z.infer for type safety 26 | type ScreenshotParams = z.infer<typeof screenshotSchema>; 27 | 28 | export async function screenshotLogic( 29 | params: ScreenshotParams, 30 | executor: CommandExecutor, 31 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), 32 | pathUtils: { tmpdir: () => string; join: (...paths: string[]) => string } = { ...path, tmpdir }, 33 | uuidUtils: { v4: () => string } = { v4: uuidv4 }, 34 | ): Promise<ToolResponse> { 35 | const { simulatorUuid } = params; 36 | const tempDir = pathUtils.tmpdir(); 37 | const screenshotFilename = `screenshot_${uuidUtils.v4()}.png`; 38 | const screenshotPath = pathUtils.join(tempDir, screenshotFilename); 39 | const optimizedFilename = `screenshot_optimized_${uuidUtils.v4()}.jpg`; 40 | const optimizedPath = pathUtils.join(tempDir, optimizedFilename); 41 | // Use xcrun simctl to take screenshot 42 | const commandArgs: string[] = [ 43 | 'xcrun', 44 | 'simctl', 45 | 'io', 46 | simulatorUuid, 47 | 'screenshot', 48 | screenshotPath, 49 | ]; 50 | 51 | log( 52 | 'info', 53 | `${LOG_PREFIX}/screenshot: Starting capture to ${screenshotPath} on ${simulatorUuid}`, 54 | ); 55 | 56 | try { 57 | // Execute the screenshot command 58 | const result = await executor(commandArgs, `${LOG_PREFIX}: screenshot`, false); 59 | 60 | if (!result.success) { 61 | throw new SystemError(`Failed to capture screenshot: ${result.error ?? result.output}`); 62 | } 63 | 64 | log('info', `${LOG_PREFIX}/screenshot: Success for ${simulatorUuid}`); 65 | 66 | try { 67 | // Optimize the image for LLM consumption: resize to max 800px width and convert to JPEG 68 | const optimizeArgs = [ 69 | 'sips', 70 | '-Z', 71 | '800', // Resize to max 800px (maintains aspect ratio) 72 | '-s', 73 | 'format', 74 | 'jpeg', // Convert to JPEG 75 | '-s', 76 | 'formatOptions', 77 | '75', // 75% quality compression 78 | screenshotPath, 79 | '--out', 80 | optimizedPath, 81 | ]; 82 | 83 | const optimizeResult = await executor(optimizeArgs, `${LOG_PREFIX}: optimize image`, false); 84 | 85 | if (!optimizeResult.success) { 86 | log('warning', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`); 87 | // Fallback to original PNG if optimization fails 88 | const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64'); 89 | 90 | // Clean up 91 | try { 92 | await fileSystemExecutor.rm(screenshotPath); 93 | } catch (err) { 94 | log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); 95 | } 96 | 97 | return { 98 | content: [createImageContent(base64Image, 'image/png')], 99 | isError: false, 100 | }; 101 | } 102 | 103 | log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`); 104 | 105 | // Read the optimized image file as base64 106 | const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64'); 107 | 108 | log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`); 109 | 110 | // Clean up both temporary files 111 | try { 112 | await fileSystemExecutor.rm(screenshotPath); 113 | await fileSystemExecutor.rm(optimizedPath); 114 | } catch (err) { 115 | log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`); 116 | } 117 | 118 | // Return the optimized image (JPEG format, smaller size) 119 | return { 120 | content: [createImageContent(base64Image, 'image/jpeg')], 121 | isError: false, 122 | }; 123 | } catch (fileError) { 124 | log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`); 125 | return createErrorResponse( 126 | `Screenshot captured but failed to process image file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, 127 | ); 128 | } 129 | } catch (_error) { 130 | log('error', `${LOG_PREFIX}/screenshot: Failed - ${_error}`); 131 | if (_error instanceof SystemError) { 132 | return createErrorResponse( 133 | `System error executing screenshot: ${_error.message}`, 134 | _error.originalError?.stack, 135 | ); 136 | } 137 | return createErrorResponse( 138 | `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, 139 | ); 140 | } 141 | } 142 | 143 | export default { 144 | name: 'screenshot', 145 | description: 146 | "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", 147 | schema: screenshotSchema.shape, // MCP SDK compatibility 148 | handler: createTypedTool( 149 | screenshotSchema, 150 | (params: ScreenshotParams, executor: CommandExecutor) => { 151 | return screenshotLogic(params, executor); 152 | }, 153 | getDefaultCommandExecutor, 154 | ), 155 | }; 156 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/test_sim.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Simulator Test Plugin: Test Simulator (Unified) 3 | * 4 | * Runs tests for a project or workspace on a simulator by UUID or name. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | * Accepts mutually exclusive `simulatorId` or `simulatorName`. 7 | */ 8 | 9 | import { z } from 'zod'; 10 | import { handleTestLogic } from '../../../utils/test/index.ts'; 11 | import { log } from '../../../utils/logging/index.ts'; 12 | import { XcodePlatform } from '../../../types/common.ts'; 13 | import { ToolResponse } from '../../../types/common.ts'; 14 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 15 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 16 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 17 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 18 | 19 | // Define base schema object with all fields 20 | const baseSchemaObject = z.object({ 21 | projectPath: z 22 | .string() 23 | .optional() 24 | .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), 25 | workspacePath: z 26 | .string() 27 | .optional() 28 | .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), 29 | scheme: z.string().describe('The scheme to use (Required)'), 30 | simulatorId: z 31 | .string() 32 | .optional() 33 | .describe( 34 | 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', 35 | ), 36 | simulatorName: z 37 | .string() 38 | .optional() 39 | .describe( 40 | "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", 41 | ), 42 | configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), 43 | derivedDataPath: z 44 | .string() 45 | .optional() 46 | .describe('Path where build products and other derived data will go'), 47 | extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), 48 | useLatestOS: z 49 | .boolean() 50 | .optional() 51 | .describe('Whether to use the latest OS version for the named simulator'), 52 | preferXcodebuild: z 53 | .boolean() 54 | .optional() 55 | .describe( 56 | 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', 57 | ), 58 | testRunnerEnv: z 59 | .record(z.string(), z.string()) 60 | .optional() 61 | .describe( 62 | 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', 63 | ), 64 | }); 65 | 66 | // Apply preprocessor to handle empty strings 67 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 68 | 69 | // Apply XOR validation: exactly one of projectPath OR workspacePath, and exactly one of simulatorId OR simulatorName required 70 | const testSimulatorSchema = baseSchema 71 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 72 | message: 'Either projectPath or workspacePath is required.', 73 | }) 74 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 75 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 76 | }) 77 | .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { 78 | message: 'Either simulatorId or simulatorName is required.', 79 | }) 80 | .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { 81 | message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', 82 | }); 83 | 84 | // Use z.infer for type safety 85 | type TestSimulatorParams = z.infer<typeof testSimulatorSchema>; 86 | 87 | export async function test_simLogic( 88 | params: TestSimulatorParams, 89 | executor: CommandExecutor, 90 | ): Promise<ToolResponse> { 91 | // Log warning if useLatestOS is provided with simulatorId 92 | if (params.simulatorId && params.useLatestOS !== undefined) { 93 | log( 94 | 'warning', 95 | `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, 96 | ); 97 | } 98 | 99 | return handleTestLogic( 100 | { 101 | projectPath: params.projectPath, 102 | workspacePath: params.workspacePath, 103 | scheme: params.scheme, 104 | simulatorId: params.simulatorId, 105 | simulatorName: params.simulatorName, 106 | configuration: params.configuration ?? 'Debug', 107 | derivedDataPath: params.derivedDataPath, 108 | extraArgs: params.extraArgs, 109 | useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), 110 | preferXcodebuild: params.preferXcodebuild ?? false, 111 | platform: XcodePlatform.iOSSimulator, 112 | testRunnerEnv: params.testRunnerEnv, 113 | }, 114 | executor, 115 | ); 116 | } 117 | 118 | const publicSchemaObject = baseSchemaObject.omit({ 119 | projectPath: true, 120 | workspacePath: true, 121 | scheme: true, 122 | simulatorId: true, 123 | simulatorName: true, 124 | configuration: true, 125 | useLatestOS: true, 126 | } as const); 127 | 128 | export default { 129 | name: 'test_sim', 130 | description: 'Runs tests on an iOS simulator.', 131 | schema: publicSchemaObject.shape, 132 | handler: createSessionAwareTool<TestSimulatorParams>({ 133 | internalSchema: testSimulatorSchema as unknown as z.ZodType<TestSimulatorParams>, 134 | logicFunction: test_simLogic, 135 | getExecutor: getDefaultCommandExecutor, 136 | requirements: [ 137 | { allOf: ['scheme'], message: 'scheme is required' }, 138 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 139 | { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, 140 | ], 141 | exclusivePairs: [ 142 | ['projectPath', 'workspacePath'], 143 | ['simulatorId', 'simulatorName'], 144 | ], 145 | }), 146 | }; 147 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/utilities/__tests__/clean.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { z } from 'zod'; 3 | import tool, { cleanLogic } from '../clean.ts'; 4 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 5 | import { sessionStore } from '../../../../utils/session-store.ts'; 6 | 7 | describe('clean (unified) tool', () => { 8 | beforeEach(() => { 9 | sessionStore.clear(); 10 | }); 11 | 12 | it('exports correct name/description/schema/handler', () => { 13 | expect(tool.name).toBe('clean'); 14 | expect(tool.description).toBe('Cleans build products with xcodebuild.'); 15 | expect(typeof tool.handler).toBe('function'); 16 | 17 | const schema = z.object(tool.schema).strict(); 18 | expect(schema.safeParse({}).success).toBe(true); 19 | expect( 20 | schema.safeParse({ 21 | derivedDataPath: '/tmp/Derived', 22 | extraArgs: ['--quiet'], 23 | preferXcodebuild: true, 24 | platform: 'iOS Simulator', 25 | }).success, 26 | ).toBe(true); 27 | expect(schema.safeParse({ configuration: 'Debug' }).success).toBe(false); 28 | 29 | const schemaKeys = Object.keys(tool.schema).sort(); 30 | expect(schemaKeys).toEqual( 31 | ['derivedDataPath', 'extraArgs', 'platform', 'preferXcodebuild'].sort(), 32 | ); 33 | }); 34 | 35 | it('handler validation: error when neither projectPath nor workspacePath provided', async () => { 36 | const result = await (tool as any).handler({}); 37 | expect(result.isError).toBe(true); 38 | const text = String(result.content?.[0]?.text ?? ''); 39 | expect(text).toContain('Missing required session defaults'); 40 | expect(text).toContain('Provide a project or workspace'); 41 | }); 42 | 43 | it('handler validation: error when both projectPath and workspacePath provided', async () => { 44 | const result = await (tool as any).handler({ 45 | projectPath: '/p.xcodeproj', 46 | workspacePath: '/w.xcworkspace', 47 | }); 48 | expect(result.isError).toBe(true); 49 | const text = String(result.content?.[0]?.text ?? ''); 50 | expect(text).toContain('Mutually exclusive parameters provided'); 51 | }); 52 | 53 | it('runs project-path flow via logic', async () => { 54 | const mock = createMockExecutor({ success: true, output: 'ok' }); 55 | const result = await cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mock); 56 | expect(result.isError).not.toBe(true); 57 | }); 58 | 59 | it('runs workspace-path flow via logic', async () => { 60 | const mock = createMockExecutor({ success: true, output: 'ok' }); 61 | const result = await cleanLogic( 62 | { workspacePath: '/w.xcworkspace', scheme: 'App' } as any, 63 | mock, 64 | ); 65 | expect(result.isError).not.toBe(true); 66 | }); 67 | 68 | it('handler validation: requires scheme when workspacePath is provided', async () => { 69 | const result = await (tool as any).handler({ workspacePath: '/w.xcworkspace' }); 70 | expect(result.isError).toBe(true); 71 | const text = String(result.content?.[0]?.text ?? ''); 72 | expect(text).toContain('Parameter validation failed'); 73 | expect(text).toContain('scheme is required when workspacePath is provided'); 74 | }); 75 | 76 | it('uses iOS platform by default', async () => { 77 | let capturedCommand: string[] = []; 78 | const mockExecutor = async (command: string[]) => { 79 | capturedCommand = command; 80 | return { success: true, output: 'clean success' }; 81 | }; 82 | 83 | const result = await cleanLogic( 84 | { projectPath: '/p.xcodeproj', scheme: 'App' } as any, 85 | mockExecutor, 86 | ); 87 | expect(result.isError).not.toBe(true); 88 | 89 | // Check that the command contains iOS platform destination 90 | const commandStr = capturedCommand.join(' '); 91 | expect(commandStr).toContain('-destination'); 92 | expect(commandStr).toContain('platform=iOS'); 93 | }); 94 | 95 | it('accepts custom platform parameter', async () => { 96 | let capturedCommand: string[] = []; 97 | const mockExecutor = async (command: string[]) => { 98 | capturedCommand = command; 99 | return { success: true, output: 'clean success' }; 100 | }; 101 | 102 | const result = await cleanLogic( 103 | { 104 | projectPath: '/p.xcodeproj', 105 | scheme: 'App', 106 | platform: 'macOS', 107 | } as any, 108 | mockExecutor, 109 | ); 110 | expect(result.isError).not.toBe(true); 111 | 112 | // Check that the command contains macOS platform destination 113 | const commandStr = capturedCommand.join(' '); 114 | expect(commandStr).toContain('-destination'); 115 | expect(commandStr).toContain('platform=macOS'); 116 | }); 117 | 118 | it('accepts iOS Simulator platform parameter (maps to iOS for clean)', async () => { 119 | let capturedCommand: string[] = []; 120 | const mockExecutor = async (command: string[]) => { 121 | capturedCommand = command; 122 | return { success: true, output: 'clean success' }; 123 | }; 124 | 125 | const result = await cleanLogic( 126 | { 127 | projectPath: '/p.xcodeproj', 128 | scheme: 'App', 129 | platform: 'iOS Simulator', 130 | } as any, 131 | mockExecutor, 132 | ); 133 | expect(result.isError).not.toBe(true); 134 | 135 | // For clean operations, iOS Simulator should be mapped to iOS platform 136 | const commandStr = capturedCommand.join(' '); 137 | expect(commandStr).toContain('-destination'); 138 | expect(commandStr).toContain('platform=iOS'); 139 | }); 140 | 141 | it('handler validation: rejects invalid platform values', async () => { 142 | const result = await (tool as any).handler({ 143 | projectPath: '/p.xcodeproj', 144 | scheme: 'App', 145 | platform: 'InvalidPlatform', 146 | }); 147 | expect(result.isError).toBe(true); 148 | const text = String(result.content?.[0]?.text ?? ''); 149 | expect(text).toContain('Parameter validation failed'); 150 | expect(text).toContain('platform'); 151 | }); 152 | }); 153 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/swift-package/__tests__/active-processes.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for active-processes module 3 | * Following CLAUDE.md testing standards with literal validation 4 | */ 5 | 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { 8 | activeProcesses, 9 | getProcess, 10 | addProcess, 11 | removeProcess, 12 | clearAllProcesses, 13 | type ProcessInfo, 14 | } from '../active-processes.ts'; 15 | 16 | describe('active-processes module', () => { 17 | // Clear the map before each test 18 | beforeEach(() => { 19 | clearAllProcesses(); 20 | }); 21 | 22 | describe('activeProcesses Map', () => { 23 | it('should be a Map instance', () => { 24 | expect(activeProcesses instanceof Map).toBe(true); 25 | }); 26 | 27 | it('should start empty after clearing', () => { 28 | expect(activeProcesses.size).toBe(0); 29 | }); 30 | }); 31 | 32 | describe('getProcess function', () => { 33 | it('should return undefined for non-existent process', () => { 34 | const result = getProcess(12345); 35 | expect(result).toBe(undefined); 36 | }); 37 | 38 | it('should return process info for existing process', () => { 39 | const mockProcess = { 40 | kill: () => {}, 41 | on: () => {}, 42 | pid: 12345, 43 | }; 44 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 45 | const processInfo: ProcessInfo = { 46 | process: mockProcess, 47 | startedAt: startedAt, 48 | }; 49 | 50 | activeProcesses.set(12345, processInfo); 51 | const result = getProcess(12345); 52 | 53 | expect(result).toEqual({ 54 | process: mockProcess, 55 | startedAt: startedAt, 56 | }); 57 | }); 58 | }); 59 | 60 | describe('addProcess function', () => { 61 | it('should add process to the map', () => { 62 | const mockProcess = { 63 | kill: () => {}, 64 | on: () => {}, 65 | pid: 67890, 66 | }; 67 | const startedAt = new Date('2023-02-15T14:30:00.000Z'); 68 | const processInfo: ProcessInfo = { 69 | process: mockProcess, 70 | startedAt: startedAt, 71 | }; 72 | 73 | addProcess(67890, processInfo); 74 | 75 | expect(activeProcesses.size).toBe(1); 76 | expect(activeProcesses.get(67890)).toEqual(processInfo); 77 | }); 78 | 79 | it('should overwrite existing process with same pid', () => { 80 | const mockProcess1 = { 81 | kill: () => {}, 82 | on: () => {}, 83 | pid: 11111, 84 | }; 85 | const mockProcess2 = { 86 | kill: () => {}, 87 | on: () => {}, 88 | pid: 11111, 89 | }; 90 | const startedAt1 = new Date('2023-01-01T10:00:00.000Z'); 91 | const startedAt2 = new Date('2023-01-01T11:00:00.000Z'); 92 | 93 | addProcess(11111, { process: mockProcess1, startedAt: startedAt1 }); 94 | addProcess(11111, { process: mockProcess2, startedAt: startedAt2 }); 95 | 96 | expect(activeProcesses.size).toBe(1); 97 | expect(activeProcesses.get(11111)).toEqual({ 98 | process: mockProcess2, 99 | startedAt: startedAt2, 100 | }); 101 | }); 102 | }); 103 | 104 | describe('removeProcess function', () => { 105 | it('should return false for non-existent process', () => { 106 | const result = removeProcess(99999); 107 | expect(result).toBe(false); 108 | }); 109 | 110 | it('should return true and remove existing process', () => { 111 | const mockProcess = { 112 | kill: () => {}, 113 | on: () => {}, 114 | pid: 54321, 115 | }; 116 | const processInfo: ProcessInfo = { 117 | process: mockProcess, 118 | startedAt: new Date('2023-03-20T09:15:00.000Z'), 119 | }; 120 | 121 | addProcess(54321, processInfo); 122 | expect(activeProcesses.size).toBe(1); 123 | 124 | const result = removeProcess(54321); 125 | 126 | expect(result).toBe(true); 127 | expect(activeProcesses.size).toBe(0); 128 | expect(activeProcesses.get(54321)).toBe(undefined); 129 | }); 130 | }); 131 | 132 | describe('clearAllProcesses function', () => { 133 | it('should clear all processes from the map', () => { 134 | const mockProcess1 = { 135 | kill: () => {}, 136 | on: () => {}, 137 | pid: 1111, 138 | }; 139 | const mockProcess2 = { 140 | kill: () => {}, 141 | on: () => {}, 142 | pid: 2222, 143 | }; 144 | 145 | addProcess(1111, { process: mockProcess1, startedAt: new Date() }); 146 | addProcess(2222, { process: mockProcess2, startedAt: new Date() }); 147 | 148 | expect(activeProcesses.size).toBe(2); 149 | 150 | clearAllProcesses(); 151 | 152 | expect(activeProcesses.size).toBe(0); 153 | }); 154 | 155 | it('should work on already empty map', () => { 156 | expect(activeProcesses.size).toBe(0); 157 | clearAllProcesses(); 158 | expect(activeProcesses.size).toBe(0); 159 | }); 160 | }); 161 | 162 | describe('ProcessInfo interface', () => { 163 | it('should work with complete process object', () => { 164 | const mockProcess = { 165 | kill: () => {}, 166 | on: () => {}, 167 | pid: 12345, 168 | }; 169 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 170 | const processInfo: ProcessInfo = { 171 | process: mockProcess, 172 | startedAt: startedAt, 173 | }; 174 | 175 | addProcess(12345, processInfo); 176 | const retrieved = getProcess(12345); 177 | 178 | expect(retrieved).toEqual({ 179 | process: { 180 | kill: expect.any(Function), 181 | on: expect.any(Function), 182 | pid: 12345, 183 | }, 184 | startedAt: startedAt, 185 | }); 186 | }); 187 | 188 | it('should work with minimal process object', () => { 189 | const mockProcess = { 190 | kill: () => {}, 191 | on: () => {}, 192 | }; 193 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 194 | const processInfo: ProcessInfo = { 195 | process: mockProcess, 196 | startedAt: startedAt, 197 | }; 198 | 199 | addProcess(98765, processInfo); 200 | const retrieved = getProcess(98765); 201 | 202 | expect(retrieved).toEqual({ 203 | process: { 204 | kill: expect.any(Function), 205 | on: expect.any(Function), 206 | }, 207 | startedAt: startedAt, 208 | }); 209 | }); 210 | }); 211 | }); 212 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for launch_app_logs_sim plugin (session-aware version) 3 | * Follows CLAUDE.md guidance with literal validation and DI. 4 | */ 5 | 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { z } from 'zod'; 8 | import launchAppLogsSim, { 9 | launch_app_logs_simLogic, 10 | LogCaptureFunction, 11 | } from '../launch_app_logs_sim.ts'; 12 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 13 | import { sessionStore } from '../../../../utils/session-store.ts'; 14 | 15 | describe('launch_app_logs_sim tool', () => { 16 | beforeEach(() => { 17 | sessionStore.clear(); 18 | }); 19 | 20 | describe('Export Field Validation (Literal)', () => { 21 | it('should expose correct metadata', () => { 22 | expect(launchAppLogsSim.name).toBe('launch_app_logs_sim'); 23 | expect(launchAppLogsSim.description).toBe( 24 | 'Launches an app in an iOS simulator and captures its logs.', 25 | ); 26 | }); 27 | 28 | it('should expose only non-session fields in public schema', () => { 29 | const schema = z.object(launchAppLogsSim.schema); 30 | 31 | expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); 32 | expect(schema.safeParse({ bundleId: 'com.example.app', args: ['--debug'] }).success).toBe( 33 | true, 34 | ); 35 | expect(schema.safeParse({}).success).toBe(false); 36 | expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); 37 | 38 | expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args', 'bundleId'].sort()); 39 | }); 40 | }); 41 | 42 | describe('Handler Requirements', () => { 43 | it('should require simulatorId when not provided', async () => { 44 | const result = await launchAppLogsSim.handler({ bundleId: 'com.example.testapp' }); 45 | 46 | expect(result.isError).toBe(true); 47 | expect(result.content[0].text).toContain('Missing required session defaults'); 48 | expect(result.content[0].text).toContain('simulatorId is required'); 49 | expect(result.content[0].text).toContain('session-set-defaults'); 50 | }); 51 | 52 | it('should validate bundleId when simulatorId default exists', async () => { 53 | sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); 54 | 55 | const result = await launchAppLogsSim.handler({}); 56 | 57 | expect(result.isError).toBe(true); 58 | expect(result.content[0].text).toContain('Parameter validation failed'); 59 | expect(result.content[0].text).toContain('bundleId: Required'); 60 | expect(result.content[0].text).toContain( 61 | 'Tip: set session defaults via session-set-defaults', 62 | ); 63 | }); 64 | }); 65 | 66 | describe('Logic Behavior (Literal Returns)', () => { 67 | it('should handle successful app launch with log capture', async () => { 68 | let capturedParams: unknown = null; 69 | const logCaptureStub: LogCaptureFunction = async (params) => { 70 | capturedParams = params; 71 | return { 72 | sessionId: 'test-session-123', 73 | logFilePath: '/tmp/xcodemcp_sim_log_test-session-123.log', 74 | processes: [], 75 | error: undefined, 76 | }; 77 | }; 78 | 79 | const mockExecutor = createMockExecutor({ success: true, output: '' }); 80 | 81 | const result = await launch_app_logs_simLogic( 82 | { 83 | simulatorId: 'test-uuid-123', 84 | bundleId: 'com.example.testapp', 85 | }, 86 | mockExecutor, 87 | logCaptureStub, 88 | ); 89 | 90 | expect(result).toEqual({ 91 | content: [ 92 | { 93 | type: 'text', 94 | text: `App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "test-session-123" })' to stop capture and retrieve logs.`, 95 | }, 96 | ], 97 | isError: false, 98 | }); 99 | 100 | expect(capturedParams).toEqual({ 101 | simulatorUuid: 'test-uuid-123', 102 | bundleId: 'com.example.testapp', 103 | captureConsole: true, 104 | }); 105 | }); 106 | 107 | it('should ignore args for log capture setup', async () => { 108 | let capturedParams: unknown = null; 109 | const logCaptureStub: LogCaptureFunction = async (params) => { 110 | capturedParams = params; 111 | return { 112 | sessionId: 'test-session-456', 113 | logFilePath: '/tmp/xcodemcp_sim_log_test-session-456.log', 114 | processes: [], 115 | error: undefined, 116 | }; 117 | }; 118 | 119 | const mockExecutor = createMockExecutor({ success: true, output: '' }); 120 | 121 | await launch_app_logs_simLogic( 122 | { 123 | simulatorId: 'test-uuid-123', 124 | bundleId: 'com.example.testapp', 125 | args: ['--debug'], 126 | }, 127 | mockExecutor, 128 | logCaptureStub, 129 | ); 130 | 131 | expect(capturedParams).toEqual({ 132 | simulatorUuid: 'test-uuid-123', 133 | bundleId: 'com.example.testapp', 134 | captureConsole: true, 135 | args: ['--debug'], 136 | }); 137 | }); 138 | 139 | it('should surface log capture failure', async () => { 140 | const logCaptureStub: LogCaptureFunction = async () => ({ 141 | sessionId: '', 142 | logFilePath: '', 143 | processes: [], 144 | error: 'Failed to start log capture', 145 | }); 146 | 147 | const mockExecutor = createMockExecutor({ success: true, output: '' }); 148 | 149 | const result = await launch_app_logs_simLogic( 150 | { 151 | simulatorId: 'test-uuid-123', 152 | bundleId: 'com.example.testapp', 153 | }, 154 | mockExecutor, 155 | logCaptureStub, 156 | ); 157 | 158 | expect(result).toEqual({ 159 | content: [ 160 | { 161 | type: 'text', 162 | text: 'App was launched but log capture failed: Failed to start log capture', 163 | }, 164 | ], 165 | isError: true, 166 | }); 167 | }); 168 | }); 169 | }); 170 | ``` -------------------------------------------------------------------------------- /src/utils/__tests__/test-runner-env-integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration tests for TEST_RUNNER_ environment variable passing 3 | * 4 | * These tests verify that testRunnerEnv parameters are correctly processed 5 | * and passed through the execution chain. We focus on testing the core 6 | * functionality that matters most: environment variable normalization. 7 | */ 8 | 9 | import { describe, it, expect } from 'vitest'; 10 | import { normalizeTestRunnerEnv } from '../environment.ts'; 11 | 12 | describe('TEST_RUNNER_ Environment Variable Integration', () => { 13 | describe('Core normalization functionality', () => { 14 | it('should normalize environment variables correctly for real scenarios', () => { 15 | // Test the GitHub issue scenario: USE_DEV_MODE -> TEST_RUNNER_USE_DEV_MODE 16 | const gitHubIssueScenario = { USE_DEV_MODE: 'YES' }; 17 | const normalized = normalizeTestRunnerEnv(gitHubIssueScenario); 18 | 19 | expect(normalized).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES' }); 20 | }); 21 | 22 | it('should handle mixed prefixed and unprefixed variables', () => { 23 | const mixedVars = { 24 | USE_DEV_MODE: 'YES', // Should be prefixed 25 | TEST_RUNNER_SKIP_ANIMATIONS: '1', // Already prefixed, preserve 26 | DEBUG_MODE: 'true', // Should be prefixed 27 | }; 28 | 29 | const normalized = normalizeTestRunnerEnv(mixedVars); 30 | 31 | expect(normalized).toEqual({ 32 | TEST_RUNNER_USE_DEV_MODE: 'YES', 33 | TEST_RUNNER_SKIP_ANIMATIONS: '1', 34 | TEST_RUNNER_DEBUG_MODE: 'true', 35 | }); 36 | }); 37 | 38 | it('should filter out null and undefined values', () => { 39 | const varsWithNulls = { 40 | VALID_VAR: 'value1', 41 | NULL_VAR: null as any, 42 | UNDEFINED_VAR: undefined as any, 43 | ANOTHER_VALID: 'value2', 44 | }; 45 | 46 | const normalized = normalizeTestRunnerEnv(varsWithNulls); 47 | 48 | expect(normalized).toEqual({ 49 | TEST_RUNNER_VALID_VAR: 'value1', 50 | TEST_RUNNER_ANOTHER_VALID: 'value2', 51 | }); 52 | 53 | // Ensure null/undefined vars are not present 54 | expect(normalized).not.toHaveProperty('TEST_RUNNER_NULL_VAR'); 55 | expect(normalized).not.toHaveProperty('TEST_RUNNER_UNDEFINED_VAR'); 56 | }); 57 | 58 | it('should handle special characters in keys and values', () => { 59 | const specialChars = { 60 | 'VAR_WITH-DASH': 'value-with-dash', 61 | 'VAR.WITH.DOTS': 'value/with/slashes', 62 | VAR_WITH_SPACES: 'value with spaces', 63 | TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', 64 | }; 65 | 66 | const normalized = normalizeTestRunnerEnv(specialChars); 67 | 68 | expect(normalized).toEqual({ 69 | 'TEST_RUNNER_VAR_WITH-DASH': 'value-with-dash', 70 | 'TEST_RUNNER_VAR.WITH.DOTS': 'value/with/slashes', 71 | TEST_RUNNER_VAR_WITH_SPACES: 'value with spaces', 72 | TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', 73 | }); 74 | }); 75 | 76 | it('should handle empty values correctly', () => { 77 | const emptyValues = { 78 | EMPTY_STRING: '', 79 | NORMAL_VAR: 'normal_value', 80 | }; 81 | 82 | const normalized = normalizeTestRunnerEnv(emptyValues); 83 | 84 | expect(normalized).toEqual({ 85 | TEST_RUNNER_EMPTY_STRING: '', 86 | TEST_RUNNER_NORMAL_VAR: 'normal_value', 87 | }); 88 | }); 89 | 90 | it('should handle edge case prefix variations', () => { 91 | const prefixEdgeCases = { 92 | TEST_RUN: 'not_quite_prefixed', // Should get prefixed 93 | TEST_RUNNER: 'no_underscore', // Should get prefixed 94 | TEST_RUNNER_CORRECT: 'already_good', // Should stay as-is 95 | test_runner_lowercase: 'lowercase', // Should get prefixed (case sensitive) 96 | }; 97 | 98 | const normalized = normalizeTestRunnerEnv(prefixEdgeCases); 99 | 100 | expect(normalized).toEqual({ 101 | TEST_RUNNER_TEST_RUN: 'not_quite_prefixed', 102 | TEST_RUNNER_TEST_RUNNER: 'no_underscore', 103 | TEST_RUNNER_CORRECT: 'already_good', 104 | TEST_RUNNER_test_runner_lowercase: 'lowercase', 105 | }); 106 | }); 107 | 108 | it('should preserve immutability of input object', () => { 109 | const originalInput = { FOO: 'bar', BAZ: 'qux' }; 110 | const inputCopy = { ...originalInput }; 111 | 112 | const normalized = normalizeTestRunnerEnv(originalInput); 113 | 114 | // Original should be unchanged 115 | expect(originalInput).toEqual(inputCopy); 116 | 117 | // Result should be different 118 | expect(normalized).not.toEqual(originalInput); 119 | expect(normalized).toEqual({ 120 | TEST_RUNNER_FOO: 'bar', 121 | TEST_RUNNER_BAZ: 'qux', 122 | }); 123 | }); 124 | 125 | it('should handle the complete test environment workflow', () => { 126 | // Simulate a comprehensive test environment setup 127 | const fullTestEnv = { 128 | // Core testing flags 129 | USE_DEV_MODE: 'YES', 130 | SKIP_ANIMATIONS: '1', 131 | FAST_MODE: 'true', 132 | 133 | // Already prefixed variables (user might provide these) 134 | TEST_RUNNER_TIMEOUT: '30', 135 | TEST_RUNNER_RETRIES: '3', 136 | 137 | // UI testing specific 138 | UI_TESTING_MODE: 'enabled', 139 | SCREENSHOT_MODE: 'disabled', 140 | 141 | // Performance testing 142 | PERFORMANCE_TESTS: 'false', 143 | MEMORY_TESTING: 'true', 144 | 145 | // Special values 146 | EMPTY_VAR: '', 147 | PATH_VAR: '/usr/local/bin:/usr/bin', 148 | }; 149 | 150 | const normalized = normalizeTestRunnerEnv(fullTestEnv); 151 | 152 | expect(normalized).toEqual({ 153 | TEST_RUNNER_USE_DEV_MODE: 'YES', 154 | TEST_RUNNER_SKIP_ANIMATIONS: '1', 155 | TEST_RUNNER_FAST_MODE: 'true', 156 | TEST_RUNNER_TIMEOUT: '30', 157 | TEST_RUNNER_RETRIES: '3', 158 | TEST_RUNNER_UI_TESTING_MODE: 'enabled', 159 | TEST_RUNNER_SCREENSHOT_MODE: 'disabled', 160 | TEST_RUNNER_PERFORMANCE_TESTS: 'false', 161 | TEST_RUNNER_MEMORY_TESTING: 'true', 162 | TEST_RUNNER_EMPTY_VAR: '', 163 | TEST_RUNNER_PATH_VAR: '/usr/local/bin:/usr/bin', 164 | }); 165 | }); 166 | }); 167 | }); 168 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for swift_package_clean plugin 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using dependency injection for deterministic testing 5 | */ 6 | 7 | import { describe, it, expect } from 'vitest'; 8 | import { 9 | createMockExecutor, 10 | createMockFileSystemExecutor, 11 | createNoopExecutor, 12 | } from '../../../../test-utils/mock-executors.ts'; 13 | import swiftPackageClean, { swift_package_cleanLogic } from '../swift_package_clean.ts'; 14 | 15 | describe('swift_package_clean plugin', () => { 16 | describe('Export Field Validation (Literal)', () => { 17 | it('should have correct name', () => { 18 | expect(swiftPackageClean.name).toBe('swift_package_clean'); 19 | }); 20 | 21 | it('should have correct description', () => { 22 | expect(swiftPackageClean.description).toBe( 23 | 'Cleans Swift Package build artifacts and derived data', 24 | ); 25 | }); 26 | 27 | it('should have handler function', () => { 28 | expect(typeof swiftPackageClean.handler).toBe('function'); 29 | }); 30 | 31 | it('should validate schema correctly', () => { 32 | // Test required fields 33 | expect(swiftPackageClean.schema.packagePath.safeParse('/test/package').success).toBe(true); 34 | expect(swiftPackageClean.schema.packagePath.safeParse('').success).toBe(true); 35 | 36 | // Test invalid inputs 37 | expect(swiftPackageClean.schema.packagePath.safeParse(null).success).toBe(false); 38 | expect(swiftPackageClean.schema.packagePath.safeParse(undefined).success).toBe(false); 39 | }); 40 | }); 41 | 42 | describe('Command Generation Testing', () => { 43 | it('should build correct command for clean', async () => { 44 | const calls: Array<{ 45 | command: string[]; 46 | description: string; 47 | showOutput: boolean; 48 | workingDirectory: string | undefined; 49 | }> = []; 50 | 51 | const mockExecutor = async ( 52 | command: string[], 53 | description: string, 54 | showOutput: boolean, 55 | workingDirectory?: string, 56 | ) => { 57 | calls.push({ command, description, showOutput, workingDirectory }); 58 | return { 59 | success: true, 60 | output: 'Clean succeeded', 61 | error: undefined, 62 | process: { pid: 12345 }, 63 | }; 64 | }; 65 | 66 | await swift_package_cleanLogic( 67 | { 68 | packagePath: '/test/package', 69 | }, 70 | mockExecutor, 71 | ); 72 | 73 | expect(calls).toHaveLength(1); 74 | expect(calls[0]).toEqual({ 75 | command: ['swift', 'package', '--package-path', '/test/package', 'clean'], 76 | description: 'Swift Package Clean', 77 | showOutput: true, 78 | workingDirectory: undefined, 79 | }); 80 | }); 81 | }); 82 | 83 | describe('Response Logic Testing', () => { 84 | it('should handle valid params without validation errors in logic function', async () => { 85 | // Note: The logic function assumes valid params since createTypedTool handles validation 86 | const mockExecutor = createMockExecutor({ 87 | success: true, 88 | output: 'Package cleaned successfully', 89 | }); 90 | 91 | const result = await swift_package_cleanLogic( 92 | { 93 | packagePath: '/test/package', 94 | }, 95 | mockExecutor, 96 | ); 97 | 98 | expect(result.isError).toBe(false); 99 | expect(result.content[0].text).toBe('✅ Swift package cleaned successfully.'); 100 | }); 101 | 102 | it('should return successful clean response', async () => { 103 | const mockExecutor = createMockExecutor({ 104 | success: true, 105 | output: 'Package cleaned successfully', 106 | }); 107 | 108 | const result = await swift_package_cleanLogic( 109 | { 110 | packagePath: '/test/package', 111 | }, 112 | mockExecutor, 113 | ); 114 | 115 | expect(result).toEqual({ 116 | content: [ 117 | { type: 'text', text: '✅ Swift package cleaned successfully.' }, 118 | { 119 | type: 'text', 120 | text: '💡 Build artifacts and derived data removed. Ready for fresh build.', 121 | }, 122 | { type: 'text', text: 'Package cleaned successfully' }, 123 | ], 124 | isError: false, 125 | }); 126 | }); 127 | 128 | it('should return successful clean response with no output', async () => { 129 | const mockExecutor = createMockExecutor({ 130 | success: true, 131 | output: '', 132 | }); 133 | 134 | const result = await swift_package_cleanLogic( 135 | { 136 | packagePath: '/test/package', 137 | }, 138 | mockExecutor, 139 | ); 140 | 141 | expect(result).toEqual({ 142 | content: [ 143 | { type: 'text', text: '✅ Swift package cleaned successfully.' }, 144 | { 145 | type: 'text', 146 | text: '💡 Build artifacts and derived data removed. Ready for fresh build.', 147 | }, 148 | { type: 'text', text: '(clean completed silently)' }, 149 | ], 150 | isError: false, 151 | }); 152 | }); 153 | 154 | it('should return error response for clean failure', async () => { 155 | const mockExecutor = createMockExecutor({ 156 | success: false, 157 | error: 'Permission denied', 158 | }); 159 | 160 | const result = await swift_package_cleanLogic( 161 | { 162 | packagePath: '/test/package', 163 | }, 164 | mockExecutor, 165 | ); 166 | 167 | expect(result).toEqual({ 168 | content: [ 169 | { 170 | type: 'text', 171 | text: 'Error: Swift package clean failed\nDetails: Permission denied', 172 | }, 173 | ], 174 | isError: true, 175 | }); 176 | }); 177 | 178 | it('should handle spawn error', async () => { 179 | const mockExecutor = async () => { 180 | throw new Error('spawn ENOENT'); 181 | }; 182 | 183 | const result = await swift_package_cleanLogic( 184 | { 185 | packagePath: '/test/package', 186 | }, 187 | mockExecutor, 188 | ); 189 | 190 | expect(result).toEqual({ 191 | content: [ 192 | { 193 | type: 'text', 194 | text: 'Error: Failed to execute swift package clean\nDetails: spawn ENOENT', 195 | }, 196 | ], 197 | isError: true, 198 | }); 199 | }); 200 | }); 201 | }); 202 | ``` -------------------------------------------------------------------------------- /scripts/bundle-axe.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Build script for AXe artifacts 4 | # This script downloads pre-built AXe artifacts from GitHub releases and bundles them 5 | 6 | set -e 7 | 8 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 9 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 10 | BUNDLED_DIR="$PROJECT_ROOT/bundled" 11 | AXE_LOCAL_DIR="/Volumes/Developer/AXe" 12 | AXE_TEMP_DIR="/tmp/axe-download-$$" 13 | 14 | echo "🔨 Preparing AXe artifacts for bundling..." 15 | 16 | # Single source of truth for AXe version (overridable) 17 | # 1) Use $AXE_VERSION if provided in env 18 | # 2) Else, use repo-level pin from .axe-version if present 19 | # 3) Else, fall back to default below 20 | DEFAULT_AXE_VERSION="1.1.1" 21 | VERSION_FILE="$PROJECT_ROOT/.axe-version" 22 | if [ -n "${AXE_VERSION}" ]; then 23 | PINNED_AXE_VERSION="${AXE_VERSION}" 24 | elif [ -f "$VERSION_FILE" ]; then 25 | PINNED_AXE_VERSION="$(cat "$VERSION_FILE" | tr -d ' \n\r')" 26 | else 27 | PINNED_AXE_VERSION="$DEFAULT_AXE_VERSION" 28 | fi 29 | echo "📌 Using AXe version: $PINNED_AXE_VERSION" 30 | 31 | # Clean up any existing bundled directory 32 | if [ -d "$BUNDLED_DIR" ]; then 33 | echo "🧹 Cleaning existing bundled directory..." 34 | rm -rf "$BUNDLED_DIR" 35 | fi 36 | 37 | # Create bundled directory 38 | mkdir -p "$BUNDLED_DIR" 39 | 40 | # Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases 41 | if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then 42 | echo "🏠 Using local AXe source at $AXE_LOCAL_DIR" 43 | cd "$AXE_LOCAL_DIR" 44 | 45 | # Build AXe in release configuration 46 | echo "🔨 Building AXe in release configuration..." 47 | swift build --configuration release 48 | 49 | # Check if build succeeded 50 | if [ ! -f ".build/release/axe" ]; then 51 | echo "❌ AXe build failed - binary not found" 52 | exit 1 53 | fi 54 | 55 | echo "✅ AXe build completed successfully" 56 | 57 | # Copy binary to bundled directory 58 | echo "📦 Copying AXe binary..." 59 | cp ".build/release/axe" "$BUNDLED_DIR/" 60 | 61 | # Fix rpath to find frameworks in Frameworks/ subdirectory 62 | echo "🔧 Configuring AXe binary rpath for bundled frameworks..." 63 | install_name_tool -add_rpath "@executable_path/Frameworks" "$BUNDLED_DIR/axe" 64 | 65 | # Create Frameworks directory and copy frameworks 66 | echo "📦 Copying frameworks..." 67 | mkdir -p "$BUNDLED_DIR/Frameworks" 68 | 69 | # Copy frameworks with better error handling 70 | for framework in .build/release/*.framework; do 71 | if [ -d "$framework" ]; then 72 | echo "📦 Copying framework: $(basename "$framework")" 73 | cp -r "$framework" "$BUNDLED_DIR/Frameworks/" 74 | 75 | # Only copy nested frameworks if they exist 76 | if [ -d "$framework/Frameworks" ]; then 77 | echo "📦 Found nested frameworks in $(basename "$framework")" 78 | cp -r "$framework/Frameworks"/* "$BUNDLED_DIR/Frameworks/" 2>/dev/null || true 79 | fi 80 | fi 81 | done 82 | else 83 | echo "📥 Downloading latest AXe release from GitHub..." 84 | 85 | # Construct release download URL from pinned version 86 | AXE_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v${PINNED_AXE_VERSION}/AXe-macOS-v${PINNED_AXE_VERSION}.tar.gz" 87 | 88 | # Create temp directory 89 | mkdir -p "$AXE_TEMP_DIR" 90 | cd "$AXE_TEMP_DIR" 91 | 92 | # Download and extract the release 93 | echo "📥 Downloading AXe release archive ($AXE_RELEASE_URL)..." 94 | curl -L -o "axe-release.tar.gz" "$AXE_RELEASE_URL" 95 | 96 | echo "📦 Extracting AXe release archive..." 97 | tar -xzf "axe-release.tar.gz" 98 | 99 | # Find the extracted directory (might be named differently) 100 | EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1) 101 | if [ -z "$EXTRACTED_DIR" ]; then 102 | # If no AXe directory found, assume files are in current directory 103 | EXTRACTED_DIR="." 104 | fi 105 | 106 | cd "$EXTRACTED_DIR" 107 | 108 | # Copy binary 109 | if [ -f "axe" ]; then 110 | echo "📦 Copying AXe binary..." 111 | cp "axe" "$BUNDLED_DIR/" 112 | chmod +x "$BUNDLED_DIR/axe" 113 | elif [ -f "bin/axe" ]; then 114 | echo "📦 Copying AXe binary from bin/..." 115 | cp "bin/axe" "$BUNDLED_DIR/" 116 | chmod +x "$BUNDLED_DIR/axe" 117 | else 118 | echo "❌ AXe binary not found in release archive" 119 | ls -la 120 | exit 1 121 | fi 122 | 123 | # Copy frameworks if they exist 124 | echo "📦 Copying frameworks..." 125 | mkdir -p "$BUNDLED_DIR/Frameworks" 126 | 127 | if [ -d "Frameworks" ]; then 128 | cp -r Frameworks/* "$BUNDLED_DIR/Frameworks/" 129 | elif [ -d "lib" ]; then 130 | # Look for frameworks in lib directory 131 | find lib -name "*.framework" -exec cp -r {} "$BUNDLED_DIR/Frameworks/" \; 132 | else 133 | echo "⚠️ No frameworks directory found in release archive" 134 | echo "📂 Contents of release archive:" 135 | find . -type f -name "*.framework" -o -name "*.dylib" | head -10 136 | fi 137 | fi 138 | 139 | # Verify frameworks were copied 140 | FRAMEWORK_COUNT=$(find "$BUNDLED_DIR/Frameworks" -name "*.framework" | wc -l) 141 | echo "📦 Copied $FRAMEWORK_COUNT frameworks" 142 | 143 | # List the frameworks for verification 144 | echo "🔍 Bundled frameworks:" 145 | ls -la "$BUNDLED_DIR/Frameworks/" 146 | 147 | # Verify binary can run with bundled frameworks 148 | echo "🧪 Testing bundled AXe binary..." 149 | if DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version > /dev/null 2>&1; then 150 | echo "✅ Bundled AXe binary test passed" 151 | else 152 | echo "❌ Bundled AXe binary test failed" 153 | exit 1 154 | fi 155 | 156 | # Get AXe version for logging 157 | AXE_VERSION=$(DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version 2>/dev/null || echo "unknown") 158 | echo "📋 AXe version: $AXE_VERSION" 159 | 160 | # Clean up temp directory if it was used 161 | if [ -d "$AXE_TEMP_DIR" ]; then 162 | echo "🧹 Cleaning up temporary files..." 163 | rm -rf "$AXE_TEMP_DIR" 164 | fi 165 | 166 | # Show final bundle size 167 | BUNDLE_SIZE=$(du -sh "$BUNDLED_DIR" | cut -f1) 168 | echo "📊 Final bundle size: $BUNDLE_SIZE" 169 | 170 | echo "🎉 AXe bundling completed successfully!" 171 | echo "📁 Bundled artifacts location: $BUNDLED_DIR" 172 | ``` -------------------------------------------------------------------------------- /build-plugins/plugin-discovery.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Plugin } from 'esbuild'; 2 | import { readdirSync, readFileSync, existsSync } from 'fs'; 3 | import { join } from 'path'; 4 | import path from 'path'; 5 | 6 | export interface WorkflowMetadata { 7 | name: string; 8 | description: string; 9 | platforms?: string[]; 10 | targets?: string[]; 11 | projectTypes?: string[]; 12 | capabilities?: string[]; 13 | } 14 | 15 | export function createPluginDiscoveryPlugin(): Plugin { 16 | return { 17 | name: 'plugin-discovery', 18 | setup(build) { 19 | // Generate the workflow loaders file before build starts 20 | build.onStart(async () => { 21 | try { 22 | await generateWorkflowLoaders(); 23 | } catch (error) { 24 | console.error('Failed to generate workflow loaders:', error); 25 | throw error; 26 | } 27 | }); 28 | } 29 | }; 30 | } 31 | 32 | async function generateWorkflowLoaders(): Promise<void> { 33 | const pluginsDir = path.resolve(process.cwd(), 'src/plugins'); 34 | 35 | if (!existsSync(pluginsDir)) { 36 | throw new Error(`Plugins directory not found: ${pluginsDir}`); 37 | } 38 | 39 | // Scan for workflow directories 40 | const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true }) 41 | .filter(dirent => dirent.isDirectory()) 42 | .map(dirent => dirent.name); 43 | 44 | const workflowLoaders: Record<string, string> = {}; 45 | const workflowMetadata: Record<string, WorkflowMetadata> = {}; 46 | 47 | for (const dirName of workflowDirs) { 48 | const indexPath = join(pluginsDir, dirName, 'index.ts'); 49 | 50 | // Check if workflow has index.ts file 51 | if (!existsSync(indexPath)) { 52 | console.warn(`Skipping ${dirName}: no index.ts file found`); 53 | continue; 54 | } 55 | 56 | // Try to extract workflow metadata from index.ts 57 | try { 58 | const indexContent = readFileSync(indexPath, 'utf8'); 59 | const metadata = extractWorkflowMetadata(indexContent); 60 | 61 | if (metadata) { 62 | // Generate dynamic import for this workflow 63 | workflowLoaders[dirName] = `() => import('../plugins/${dirName}/index.js')`; 64 | workflowMetadata[dirName] = metadata; 65 | 66 | console.log(`✅ Discovered workflow: ${dirName} - ${metadata.name}`); 67 | } else { 68 | console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`); 69 | } 70 | } catch (error) { 71 | console.warn(`⚠️ Error processing ${dirName}:`, error); 72 | } 73 | } 74 | 75 | // Generate the content for generated-plugins.ts 76 | const generatedContent = generatePluginsFileContent(workflowLoaders, workflowMetadata); 77 | 78 | // Write to the generated file 79 | const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts'); 80 | 81 | const fs = await import('fs'); 82 | await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); 83 | 84 | console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`); 85 | } 86 | 87 | function extractWorkflowMetadata(content: string): WorkflowMetadata | null { 88 | try { 89 | // Simple regex to extract workflow export object 90 | const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/); 91 | 92 | if (!workflowMatch) { 93 | return null; 94 | } 95 | 96 | const workflowObj = workflowMatch[1]; 97 | 98 | // Extract name 99 | const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/); 100 | if (!nameMatch) return null; 101 | 102 | // Extract description 103 | const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/); 104 | if (!descMatch) return null; 105 | 106 | // Extract platforms (optional) 107 | const platformsMatch = workflowObj.match(/platforms\s*:\s*\[([^\]]*)\]/); 108 | let platforms: string[] | undefined; 109 | if (platformsMatch) { 110 | platforms = platformsMatch[1] 111 | .split(',') 112 | .map(p => p.trim().replace(/['"]/g, '')) 113 | .filter(p => p.length > 0); 114 | } 115 | 116 | // Extract targets (optional) 117 | const targetsMatch = workflowObj.match(/targets\s*:\s*\[([^\]]*)\]/); 118 | let targets: string[] | undefined; 119 | if (targetsMatch) { 120 | targets = targetsMatch[1] 121 | .split(',') 122 | .map(t => t.trim().replace(/['"]/g, '')) 123 | .filter(t => t.length > 0); 124 | } 125 | 126 | // Extract projectTypes (optional) 127 | const projectTypesMatch = workflowObj.match(/projectTypes\s*:\s*\[([^\]]*)\]/); 128 | let projectTypes: string[] | undefined; 129 | if (projectTypesMatch) { 130 | projectTypes = projectTypesMatch[1] 131 | .split(',') 132 | .map(pt => pt.trim().replace(/['"]/g, '')) 133 | .filter(pt => pt.length > 0); 134 | } 135 | 136 | // Extract capabilities (optional) 137 | const capabilitiesMatch = workflowObj.match(/capabilities\s*:\s*\[([^\]]*)\]/); 138 | let capabilities: string[] | undefined; 139 | if (capabilitiesMatch) { 140 | capabilities = capabilitiesMatch[1] 141 | .split(',') 142 | .map(c => c.trim().replace(/['"]/g, '')) 143 | .filter(c => c.length > 0); 144 | } 145 | 146 | return { 147 | name: nameMatch[1], 148 | description: descMatch[1], 149 | platforms, 150 | targets, 151 | projectTypes, 152 | capabilities 153 | }; 154 | } catch (error) { 155 | console.warn('Failed to extract workflow metadata:', error); 156 | return null; 157 | } 158 | } 159 | 160 | function generatePluginsFileContent( 161 | workflowLoaders: Record<string, string>, 162 | workflowMetadata: Record<string, WorkflowMetadata> 163 | ): string { 164 | const loaderEntries = Object.entries(workflowLoaders) 165 | .map(([key, loader]) => ` '${key}': ${loader}`) 166 | .join(',\n'); 167 | 168 | const metadataEntries = Object.entries(workflowMetadata) 169 | .map(([key, metadata]) => { 170 | const metadataJson = JSON.stringify(metadata, null, 4) 171 | .split('\n') 172 | .map(line => ` ${line}`) 173 | .join('\n'); 174 | return ` '${key}': ${metadataJson.trim()}`; 175 | }) 176 | .join(',\n'); 177 | 178 | return `// AUTO-GENERATED - DO NOT EDIT 179 | // This file is generated by the plugin discovery esbuild plugin 180 | 181 | // Generated based on filesystem scan 182 | export const WORKFLOW_LOADERS = { 183 | ${loaderEntries} 184 | }; 185 | 186 | export type WorkflowName = keyof typeof WORKFLOW_LOADERS; 187 | 188 | // Optional: Export workflow metadata for quick access 189 | export const WORKFLOW_METADATA = { 190 | ${metadataEntries} 191 | }; 192 | `; 193 | } ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/describe_ui.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import { createErrorResponse } from '../../../utils/responses/index.ts'; 5 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; 6 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 7 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 8 | import { 9 | createAxeNotAvailableResponse, 10 | getAxePath, 11 | getBundledAxeEnvironment, 12 | } from '../../../utils/axe-helpers.ts'; 13 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 14 | 15 | // Define schema as ZodObject 16 | const describeUiSchema = z.object({ 17 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 18 | }); 19 | 20 | // Use z.infer for type safety 21 | type DescribeUiParams = z.infer<typeof describeUiSchema>; 22 | 23 | export interface AxeHelpers { 24 | getAxePath: () => string | null; 25 | getBundledAxeEnvironment: () => Record<string, string>; 26 | createAxeNotAvailableResponse: () => ToolResponse; 27 | } 28 | 29 | const LOG_PREFIX = '[AXe]'; 30 | 31 | // Session tracking for describe_ui warnings (shared across UI tools) 32 | const describeUITimestamps = new Map(); 33 | 34 | function recordDescribeUICall(simulatorUuid: string): void { 35 | describeUITimestamps.set(simulatorUuid, { 36 | timestamp: Date.now(), 37 | simulatorUuid, 38 | }); 39 | } 40 | 41 | /** 42 | * Core business logic for describe_ui functionality 43 | */ 44 | export async function describe_uiLogic( 45 | params: DescribeUiParams, 46 | executor: CommandExecutor, 47 | axeHelpers: AxeHelpers = { 48 | getAxePath, 49 | getBundledAxeEnvironment, 50 | createAxeNotAvailableResponse, 51 | }, 52 | ): Promise<ToolResponse> { 53 | const toolName = 'describe_ui'; 54 | const { simulatorUuid } = params; 55 | const commandArgs = ['describe-ui']; 56 | 57 | log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorUuid}`); 58 | 59 | try { 60 | const responseText = await executeAxeCommand( 61 | commandArgs, 62 | simulatorUuid, 63 | 'describe-ui', 64 | executor, 65 | axeHelpers, 66 | ); 67 | 68 | // Record the describe_ui call for warning system 69 | recordDescribeUICall(simulatorUuid); 70 | 71 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); 72 | return { 73 | content: [ 74 | { 75 | type: 'text', 76 | text: 77 | 'Accessibility hierarchy retrieved successfully:\n```json\n' + responseText + '\n```', 78 | }, 79 | { 80 | type: 'text', 81 | text: `Next Steps: 82 | - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) 83 | - Re-run describe_ui after layout changes 84 | - Screenshots are for visual verification only`, 85 | }, 86 | ], 87 | }; 88 | } catch (error) { 89 | log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); 90 | if (error instanceof DependencyError) { 91 | return axeHelpers.createAxeNotAvailableResponse(); 92 | } else if (error instanceof AxeError) { 93 | return createErrorResponse( 94 | `Failed to get accessibility hierarchy: ${error.message}`, 95 | error.axeOutput, 96 | ); 97 | } else if (error instanceof SystemError) { 98 | return createErrorResponse( 99 | `System error executing axe: ${error.message}`, 100 | error.originalError?.stack, 101 | ); 102 | } 103 | return createErrorResponse( 104 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 105 | ); 106 | } 107 | } 108 | 109 | export default { 110 | name: 'describe_ui', 111 | description: 112 | 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.', 113 | schema: describeUiSchema.shape, // MCP SDK compatibility 114 | handler: createTypedTool( 115 | describeUiSchema, 116 | (params: DescribeUiParams, executor: CommandExecutor) => { 117 | return describe_uiLogic(params, executor, { 118 | getAxePath, 119 | getBundledAxeEnvironment, 120 | createAxeNotAvailableResponse, 121 | }); 122 | }, 123 | getDefaultCommandExecutor, 124 | ), 125 | }; 126 | 127 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) 128 | async function executeAxeCommand( 129 | commandArgs: string[], 130 | simulatorUuid: string, 131 | commandName: string, 132 | executor: CommandExecutor = getDefaultCommandExecutor(), 133 | axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, 134 | ): Promise<string> { 135 | // Get the appropriate axe binary path 136 | const axeBinary = axeHelpers.getAxePath(); 137 | if (!axeBinary) { 138 | throw new DependencyError('AXe binary not found'); 139 | } 140 | 141 | // Add --udid parameter to all commands 142 | const fullArgs = [...commandArgs, '--udid', simulatorUuid]; 143 | 144 | // Construct the full command array with the axe binary as the first element 145 | const fullCommand = [axeBinary, ...fullArgs]; 146 | 147 | try { 148 | // Determine environment variables for bundled AXe 149 | const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; 150 | 151 | const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); 152 | 153 | if (!result.success) { 154 | throw new AxeError( 155 | `axe command '${commandName}' failed.`, 156 | commandName, 157 | result.error ?? result.output, 158 | simulatorUuid, 159 | ); 160 | } 161 | 162 | // Check for stderr output in successful commands 163 | if (result.error) { 164 | log( 165 | 'warn', 166 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, 167 | ); 168 | } 169 | 170 | return result.output.trim(); 171 | } catch (error) { 172 | if (error instanceof Error) { 173 | if (error instanceof AxeError) { 174 | throw error; 175 | } 176 | 177 | // Otherwise wrap it in a SystemError 178 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error); 179 | } 180 | 181 | // For any other type of error 182 | throw new SystemError(`Failed to execute axe command: ${String(error)}`); 183 | } 184 | } 185 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/stop_mac_app.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Pure dependency injection test for stop_mac_app plugin 3 | * 4 | * Tests plugin structure and macOS app stopping functionality including parameter validation, 5 | * command generation, and response formatting. 6 | * 7 | * Uses manual call tracking instead of vitest mocking. 8 | * NO VITEST MOCKING ALLOWED - Only manual stubs 9 | */ 10 | 11 | import { describe, it, expect } from 'vitest'; 12 | import { z } from 'zod'; 13 | 14 | import stopMacApp, { stop_mac_appLogic } from '../stop_mac_app.ts'; 15 | 16 | describe('stop_mac_app plugin', () => { 17 | describe('Export Field Validation (Literal)', () => { 18 | it('should have correct name', () => { 19 | expect(stopMacApp.name).toBe('stop_mac_app'); 20 | }); 21 | 22 | it('should have correct description', () => { 23 | expect(stopMacApp.description).toBe( 24 | 'Stops a running macOS application. Can stop by app name or process ID.', 25 | ); 26 | }); 27 | 28 | it('should have handler function', () => { 29 | expect(typeof stopMacApp.handler).toBe('function'); 30 | }); 31 | 32 | it('should validate schema correctly', () => { 33 | // Test optional fields 34 | expect(stopMacApp.schema.appName.safeParse('Calculator').success).toBe(true); 35 | expect(stopMacApp.schema.appName.safeParse(undefined).success).toBe(true); 36 | expect(stopMacApp.schema.processId.safeParse(1234).success).toBe(true); 37 | expect(stopMacApp.schema.processId.safeParse(undefined).success).toBe(true); 38 | 39 | // Test invalid inputs 40 | expect(stopMacApp.schema.appName.safeParse(null).success).toBe(false); 41 | expect(stopMacApp.schema.processId.safeParse('not-number').success).toBe(false); 42 | expect(stopMacApp.schema.processId.safeParse(null).success).toBe(false); 43 | }); 44 | }); 45 | 46 | describe('Input Validation', () => { 47 | it('should return exact validation error for missing parameters', async () => { 48 | const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); 49 | const result = await stop_mac_appLogic({}, mockExecutor); 50 | 51 | expect(result).toEqual({ 52 | content: [ 53 | { 54 | type: 'text', 55 | text: 'Either appName or processId must be provided.', 56 | }, 57 | ], 58 | isError: true, 59 | }); 60 | }); 61 | }); 62 | 63 | describe('Command Generation', () => { 64 | it('should generate correct command for process ID', async () => { 65 | const calls: any[] = []; 66 | const mockExecutor = async (command: string[]) => { 67 | calls.push({ command }); 68 | return { success: true, output: '', process: {} as any }; 69 | }; 70 | 71 | await stop_mac_appLogic( 72 | { 73 | processId: 1234, 74 | }, 75 | mockExecutor, 76 | ); 77 | 78 | expect(calls).toHaveLength(1); 79 | expect(calls[0].command).toEqual(['kill', '1234']); 80 | }); 81 | 82 | it('should generate correct command for app name', async () => { 83 | const calls: any[] = []; 84 | const mockExecutor = async (command: string[]) => { 85 | calls.push({ command }); 86 | return { success: true, output: '', process: {} as any }; 87 | }; 88 | 89 | await stop_mac_appLogic( 90 | { 91 | appName: 'Calculator', 92 | }, 93 | mockExecutor, 94 | ); 95 | 96 | expect(calls).toHaveLength(1); 97 | expect(calls[0].command).toEqual([ 98 | 'sh', 99 | '-c', 100 | 'pkill -f "Calculator" || osascript -e \'tell application "Calculator" to quit\'', 101 | ]); 102 | }); 103 | 104 | it('should prioritize processId over appName', async () => { 105 | const calls: any[] = []; 106 | const mockExecutor = async (command: string[]) => { 107 | calls.push({ command }); 108 | return { success: true, output: '', process: {} as any }; 109 | }; 110 | 111 | await stop_mac_appLogic( 112 | { 113 | appName: 'Calculator', 114 | processId: 1234, 115 | }, 116 | mockExecutor, 117 | ); 118 | 119 | expect(calls).toHaveLength(1); 120 | expect(calls[0].command).toEqual(['kill', '1234']); 121 | }); 122 | }); 123 | 124 | describe('Response Processing', () => { 125 | it('should return exact successful stop response by app name', async () => { 126 | const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); 127 | 128 | const result = await stop_mac_appLogic( 129 | { 130 | appName: 'Calculator', 131 | }, 132 | mockExecutor, 133 | ); 134 | 135 | expect(result).toEqual({ 136 | content: [ 137 | { 138 | type: 'text', 139 | text: '✅ macOS app stopped successfully: Calculator', 140 | }, 141 | ], 142 | }); 143 | }); 144 | 145 | it('should return exact successful stop response by process ID', async () => { 146 | const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); 147 | 148 | const result = await stop_mac_appLogic( 149 | { 150 | processId: 1234, 151 | }, 152 | mockExecutor, 153 | ); 154 | 155 | expect(result).toEqual({ 156 | content: [ 157 | { 158 | type: 'text', 159 | text: '✅ macOS app stopped successfully: PID 1234', 160 | }, 161 | ], 162 | }); 163 | }); 164 | 165 | it('should return exact successful stop response with both parameters (processId takes precedence)', async () => { 166 | const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); 167 | 168 | const result = await stop_mac_appLogic( 169 | { 170 | appName: 'Calculator', 171 | processId: 1234, 172 | }, 173 | mockExecutor, 174 | ); 175 | 176 | expect(result).toEqual({ 177 | content: [ 178 | { 179 | type: 'text', 180 | text: '✅ macOS app stopped successfully: PID 1234', 181 | }, 182 | ], 183 | }); 184 | }); 185 | 186 | it('should handle execution errors', async () => { 187 | const mockExecutor = async () => { 188 | throw new Error('Process not found'); 189 | }; 190 | 191 | const result = await stop_mac_appLogic( 192 | { 193 | processId: 9999, 194 | }, 195 | mockExecutor, 196 | ); 197 | 198 | expect(result).toEqual({ 199 | content: [ 200 | { 201 | type: 'text', 202 | text: '❌ Stop macOS app operation failed: Process not found', 203 | }, 204 | ], 205 | isError: true, 206 | }); 207 | }); 208 | }); 209 | }); 210 | ``` -------------------------------------------------------------------------------- /src/utils/tool-registry.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer, RegisteredTool } from '@camsoft/mcp-sdk/server/mcp.js'; 2 | import { loadPlugins } from '../core/plugin-registry.ts'; 3 | import { ToolResponse } from '../types/common.ts'; 4 | import { log } from './logger.ts'; 5 | 6 | // Global registry to track registered tools for cleanup 7 | const toolRegistry = new Map<string, RegisteredTool>(); 8 | 9 | /** 10 | * Register a tool and track it for potential removal 11 | */ 12 | export function registerAndTrackTool( 13 | server: McpServer, 14 | name: string, 15 | config: Parameters<McpServer['registerTool']>[1], 16 | callback: Parameters<McpServer['registerTool']>[2], 17 | ): RegisteredTool { 18 | const registeredTool = server.registerTool(name, config, callback); 19 | toolRegistry.set(name, registeredTool); 20 | return registeredTool; 21 | } 22 | 23 | /** 24 | * Register multiple tools and track them for potential removal 25 | */ 26 | export function registerAndTrackTools( 27 | server: McpServer, 28 | tools: Parameters<McpServer['registerTools']>[0], 29 | ): RegisteredTool[] { 30 | const registeredTools = server.registerTools(tools); 31 | 32 | // Track each registered tool 33 | tools.forEach((tool, index) => { 34 | if (registeredTools[index]) { 35 | toolRegistry.set(tool.name, registeredTools[index]); 36 | } 37 | }); 38 | 39 | return registeredTools; 40 | } 41 | 42 | /** 43 | * Check if a tool is already registered 44 | */ 45 | export function isToolRegistered(name: string): boolean { 46 | return toolRegistry.has(name); 47 | } 48 | 49 | /** 50 | * Remove a specific tracked tool by name 51 | */ 52 | export function removeTrackedTool(name: string): boolean { 53 | const tool = toolRegistry.get(name); 54 | if (!tool) { 55 | return false; 56 | } 57 | 58 | try { 59 | tool.remove(); 60 | toolRegistry.delete(name); 61 | log('debug', `✅ Removed tool: ${name}`); 62 | return true; 63 | } catch (error) { 64 | log('error', `❌ Failed to remove tool ${name}: ${error}`); 65 | return false; 66 | } 67 | } 68 | 69 | /** 70 | * Remove multiple tracked tools by names 71 | */ 72 | export function removeTrackedTools(names: string[]): string[] { 73 | const removedTools: string[] = []; 74 | 75 | for (const name of names) { 76 | if (removeTrackedTool(name)) { 77 | removedTools.push(name); 78 | } 79 | } 80 | 81 | return removedTools; 82 | } 83 | 84 | /** 85 | * Remove all currently tracked tools 86 | */ 87 | export function removeAllTrackedTools(): void { 88 | const toolNames = Array.from(toolRegistry.keys()); 89 | 90 | if (toolNames.length === 0) { 91 | return; 92 | } 93 | 94 | log('info', `Removing ${toolNames.length} tracked tools...`); 95 | 96 | const removedTools = removeTrackedTools(toolNames); 97 | log('info', `✅ Removed ${removedTools.length} tracked tools`); 98 | } 99 | 100 | /** 101 | * Get the number of currently tracked tools 102 | */ 103 | export function getTrackedToolCount(): number { 104 | return toolRegistry.size; 105 | } 106 | 107 | /** 108 | * Get the names of currently tracked tools 109 | */ 110 | export function getTrackedToolNames(): string[] { 111 | return Array.from(toolRegistry.keys()); 112 | } 113 | 114 | /** 115 | * Register only discovery tools (discover_tools, discover_projs) with tracking 116 | */ 117 | export async function registerDiscoveryTools(server: McpServer): Promise<void> { 118 | const plugins = await loadPlugins(); 119 | let registeredCount = 0; 120 | 121 | // Only register discovery tools initially 122 | const discoveryTools = []; 123 | for (const plugin of plugins.values()) { 124 | // Only load discover_tools and discover_projs initially - other tools will be loaded via workflows 125 | if (plugin.name === 'discover_tools' || plugin.name === 'discover_projs') { 126 | discoveryTools.push({ 127 | name: plugin.name, 128 | config: { 129 | description: plugin.description ?? '', 130 | inputSchema: plugin.schema, 131 | }, 132 | // Adapt callback to match SDK's expected signature 133 | callback: (args: unknown): Promise<ToolResponse> => 134 | plugin.handler(args as Record<string, unknown>), 135 | }); 136 | registeredCount++; 137 | } 138 | } 139 | 140 | // Register discovery tools using bulk registration with tracking 141 | if (discoveryTools.length > 0) { 142 | registerAndTrackTools(server, discoveryTools); 143 | } 144 | 145 | log('info', `✅ Registered ${registeredCount} discovery tools in dynamic mode.`); 146 | } 147 | 148 | /** 149 | * Register selected workflows based on environment variable 150 | */ 151 | export async function registerSelectedWorkflows( 152 | server: McpServer, 153 | workflowNames: string[], 154 | ): Promise<void> { 155 | const { loadWorkflowGroups } = await import('../core/plugin-registry.js'); 156 | const workflowGroups = await loadWorkflowGroups(); 157 | const selectedTools = []; 158 | 159 | for (const workflowName of workflowNames) { 160 | const workflow = workflowGroups.get(workflowName.trim()); 161 | if (workflow) { 162 | for (const tool of workflow.tools) { 163 | selectedTools.push({ 164 | name: tool.name, 165 | config: { 166 | description: tool.description ?? '', 167 | inputSchema: tool.schema, 168 | }, 169 | callback: (args: unknown): Promise<ToolResponse> => 170 | tool.handler(args as Record<string, unknown>), 171 | }); 172 | } 173 | } 174 | } 175 | 176 | if (selectedTools.length > 0) { 177 | server.registerTools(selectedTools); 178 | } 179 | 180 | log( 181 | 'info', 182 | `✅ Registered ${selectedTools.length} tools from workflows: ${workflowNames.join(', ')}`, 183 | ); 184 | } 185 | 186 | /** 187 | * Register all tools (static mode) - no tracking needed since these won't be removed 188 | */ 189 | export async function registerAllToolsStatic(server: McpServer): Promise<void> { 190 | const plugins = await loadPlugins(); 191 | const allTools = []; 192 | 193 | for (const plugin of plugins.values()) { 194 | // Exclude discovery tools in static mode - they should only be available in dynamic mode 195 | if (plugin.name === 'discover_tools') { 196 | continue; 197 | } 198 | 199 | allTools.push({ 200 | name: plugin.name, 201 | config: { 202 | description: plugin.description ?? '', 203 | inputSchema: plugin.schema, 204 | }, 205 | // Adapt callback to match SDK's expected signature 206 | callback: (args: unknown): Promise<ToolResponse> => 207 | plugin.handler(args as Record<string, unknown>), 208 | }); 209 | } 210 | 211 | // Register all tools using bulk registration (no tracking since static tools aren't removed) 212 | if (allTools.length > 0) { 213 | server.registerTools(allTools); 214 | } 215 | 216 | log('info', `✅ Registered ${allTools.length} tools in static mode.`); 217 | } 218 | ``` -------------------------------------------------------------------------------- /src/utils/__tests__/simulator-utils.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { determineSimulatorUuid } from '../simulator-utils.ts'; 3 | import { createMockExecutor } from '../../test-utils/mock-executors.ts'; 4 | 5 | describe('determineSimulatorUuid', () => { 6 | const mockSimulatorListOutput = JSON.stringify({ 7 | devices: { 8 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 9 | { 10 | udid: 'ABC-123-UUID', 11 | name: 'iPhone 16', 12 | isAvailable: true, 13 | }, 14 | { 15 | udid: 'DEF-456-UUID', 16 | name: 'iPhone 15', 17 | isAvailable: false, 18 | }, 19 | ], 20 | 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [ 21 | { 22 | udid: 'GHI-789-UUID', 23 | name: 'iPhone 14', 24 | isAvailable: true, 25 | }, 26 | ], 27 | }, 28 | }); 29 | 30 | describe('UUID provided directly', () => { 31 | it('should return UUID when simulatorUuid is provided', async () => { 32 | const mockExecutor = createMockExecutor( 33 | new Error('Should not call executor when UUID provided'), 34 | ); 35 | 36 | const result = await determineSimulatorUuid( 37 | { simulatorUuid: 'DIRECT-UUID-123' }, 38 | mockExecutor, 39 | ); 40 | 41 | expect(result.uuid).toBe('DIRECT-UUID-123'); 42 | expect(result.warning).toBeUndefined(); 43 | expect(result.error).toBeUndefined(); 44 | }); 45 | 46 | it('should prefer simulatorUuid when both UUID and name are provided', async () => { 47 | const mockExecutor = createMockExecutor( 48 | new Error('Should not call executor when UUID provided'), 49 | ); 50 | 51 | const result = await determineSimulatorUuid( 52 | { simulatorUuid: 'DIRECT-UUID', simulatorName: 'iPhone 16' }, 53 | mockExecutor, 54 | ); 55 | 56 | expect(result.uuid).toBe('DIRECT-UUID'); 57 | }); 58 | }); 59 | 60 | describe('Name that looks like UUID', () => { 61 | it('should detect and use UUID-like name directly', async () => { 62 | const mockExecutor = createMockExecutor( 63 | new Error('Should not call executor for UUID-like name'), 64 | ); 65 | const uuidLikeName = '12345678-1234-1234-1234-123456789abc'; 66 | 67 | const result = await determineSimulatorUuid({ simulatorName: uuidLikeName }, mockExecutor); 68 | 69 | expect(result.uuid).toBe(uuidLikeName); 70 | expect(result.warning).toContain('appears to be a UUID'); 71 | expect(result.error).toBeUndefined(); 72 | }); 73 | 74 | it('should detect uppercase UUID-like name', async () => { 75 | const mockExecutor = createMockExecutor( 76 | new Error('Should not call executor for UUID-like name'), 77 | ); 78 | const uuidLikeName = '12345678-1234-1234-1234-123456789ABC'; 79 | 80 | const result = await determineSimulatorUuid({ simulatorName: uuidLikeName }, mockExecutor); 81 | 82 | expect(result.uuid).toBe(uuidLikeName); 83 | expect(result.warning).toContain('appears to be a UUID'); 84 | }); 85 | }); 86 | 87 | describe('Name resolution via simctl', () => { 88 | it('should resolve name to UUID for available simulator', async () => { 89 | const mockExecutor = createMockExecutor({ 90 | success: true, 91 | output: mockSimulatorListOutput, 92 | }); 93 | 94 | const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); 95 | 96 | expect(result.uuid).toBe('ABC-123-UUID'); 97 | expect(result.warning).toBeUndefined(); 98 | expect(result.error).toBeUndefined(); 99 | }); 100 | 101 | it('should find simulator across different runtimes', async () => { 102 | const mockExecutor = createMockExecutor({ 103 | success: true, 104 | output: mockSimulatorListOutput, 105 | }); 106 | 107 | const result = await determineSimulatorUuid({ simulatorName: 'iPhone 14' }, mockExecutor); 108 | 109 | expect(result.uuid).toBe('GHI-789-UUID'); 110 | expect(result.error).toBeUndefined(); 111 | }); 112 | 113 | it('should error for unavailable simulator', async () => { 114 | const mockExecutor = createMockExecutor({ 115 | success: true, 116 | output: mockSimulatorListOutput, 117 | }); 118 | 119 | const result = await determineSimulatorUuid({ simulatorName: 'iPhone 15' }, mockExecutor); 120 | 121 | expect(result.uuid).toBeUndefined(); 122 | expect(result.error).toBeDefined(); 123 | expect(result.error?.content[0].text).toContain('exists but is not available'); 124 | }); 125 | 126 | it('should error for non-existent simulator', async () => { 127 | const mockExecutor = createMockExecutor({ 128 | success: true, 129 | output: mockSimulatorListOutput, 130 | }); 131 | 132 | const result = await determineSimulatorUuid({ simulatorName: 'iPhone 99' }, mockExecutor); 133 | 134 | expect(result.uuid).toBeUndefined(); 135 | expect(result.error).toBeDefined(); 136 | expect(result.error?.content[0].text).toContain('not found'); 137 | }); 138 | 139 | it('should handle simctl list failure', async () => { 140 | const mockExecutor = createMockExecutor({ 141 | success: false, 142 | error: 'simctl command failed', 143 | }); 144 | 145 | const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); 146 | 147 | expect(result.uuid).toBeUndefined(); 148 | expect(result.error).toBeDefined(); 149 | expect(result.error?.content[0].text).toContain('Failed to list simulators'); 150 | }); 151 | 152 | it('should handle invalid JSON from simctl', async () => { 153 | const mockExecutor = createMockExecutor({ 154 | success: true, 155 | output: 'invalid json {', 156 | }); 157 | 158 | const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); 159 | 160 | expect(result.uuid).toBeUndefined(); 161 | expect(result.error).toBeDefined(); 162 | expect(result.error?.content[0].text).toContain('Failed to parse simulator list'); 163 | }); 164 | }); 165 | 166 | describe('No identifier provided', () => { 167 | it('should error when neither UUID nor name is provided', async () => { 168 | const mockExecutor = createMockExecutor( 169 | new Error('Should not call executor when no identifier'), 170 | ); 171 | 172 | const result = await determineSimulatorUuid({}, mockExecutor); 173 | 174 | expect(result.uuid).toBeUndefined(); 175 | expect(result.error).toBeDefined(); 176 | expect(result.error?.content[0].text).toContain('No simulator identifier provided'); 177 | }); 178 | }); 179 | }); 180 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/build_sim.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Simulator Build Plugin: Build Simulator (Unified) 3 | * 4 | * Builds an app from a project or workspace for a specific simulator by UUID or name. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | * Accepts mutually exclusive `simulatorId` or `simulatorName`. 7 | */ 8 | 9 | import { z } from 'zod'; 10 | import { log } from '../../../utils/logging/index.ts'; 11 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; 12 | import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; 13 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 14 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 15 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 16 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 17 | 18 | // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName 19 | const baseOptions = { 20 | scheme: z.string().describe('The scheme to use (Required)'), 21 | simulatorId: z 22 | .string() 23 | .optional() 24 | .describe( 25 | 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', 26 | ), 27 | simulatorName: z 28 | .string() 29 | .optional() 30 | .describe( 31 | "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", 32 | ), 33 | configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), 34 | derivedDataPath: z 35 | .string() 36 | .optional() 37 | .describe('Path where build products and other derived data will go'), 38 | extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), 39 | useLatestOS: z 40 | .boolean() 41 | .optional() 42 | .describe('Whether to use the latest OS version for the named simulator'), 43 | preferXcodebuild: z 44 | .boolean() 45 | .optional() 46 | .describe( 47 | 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', 48 | ), 49 | }; 50 | 51 | const baseSchemaObject = z.object({ 52 | projectPath: z 53 | .string() 54 | .optional() 55 | .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), 56 | workspacePath: z 57 | .string() 58 | .optional() 59 | .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), 60 | ...baseOptions, 61 | }); 62 | 63 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 64 | 65 | const buildSimulatorSchema = baseSchema 66 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 67 | message: 'Either projectPath or workspacePath is required.', 68 | }) 69 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 70 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 71 | }) 72 | .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { 73 | message: 'Either simulatorId or simulatorName is required.', 74 | }) 75 | .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { 76 | message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', 77 | }); 78 | 79 | export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>; 80 | 81 | // Internal logic for building Simulator apps. 82 | async function _handleSimulatorBuildLogic( 83 | params: BuildSimulatorParams, 84 | executor: CommandExecutor = getDefaultCommandExecutor(), 85 | ): Promise<ToolResponse> { 86 | const projectType = params.projectPath ? 'project' : 'workspace'; 87 | const filePath = params.projectPath ?? params.workspacePath; 88 | 89 | // Log warning if useLatestOS is provided with simulatorId 90 | if (params.simulatorId && params.useLatestOS !== undefined) { 91 | log( 92 | 'warning', 93 | `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, 94 | ); 95 | } 96 | 97 | log( 98 | 'info', 99 | `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, 100 | ); 101 | 102 | // Ensure configuration has a default value for SharedBuildParams compatibility 103 | const sharedBuildParams = { 104 | ...params, 105 | configuration: params.configuration ?? 'Debug', 106 | }; 107 | 108 | // executeXcodeBuildCommand handles both simulatorId and simulatorName 109 | return executeXcodeBuildCommand( 110 | sharedBuildParams, 111 | { 112 | platform: XcodePlatform.iOSSimulator, 113 | simulatorName: params.simulatorName, 114 | simulatorId: params.simulatorId, 115 | useLatestOS: params.simulatorId ? false : params.useLatestOS, // Ignore useLatestOS with ID 116 | logPrefix: 'iOS Simulator Build', 117 | }, 118 | params.preferXcodebuild ?? false, 119 | 'build', 120 | executor, 121 | ); 122 | } 123 | 124 | export async function build_simLogic( 125 | params: BuildSimulatorParams, 126 | executor: CommandExecutor, 127 | ): Promise<ToolResponse> { 128 | // Provide defaults 129 | const processedParams: BuildSimulatorParams = { 130 | ...params, 131 | configuration: params.configuration ?? 'Debug', 132 | useLatestOS: params.useLatestOS ?? true, // May be ignored if simulatorId is provided 133 | preferXcodebuild: params.preferXcodebuild ?? false, 134 | }; 135 | 136 | return _handleSimulatorBuildLogic(processedParams, executor); 137 | } 138 | 139 | // Public schema = internal minus session-managed fields 140 | const publicSchemaObject = baseSchemaObject.omit({ 141 | projectPath: true, 142 | workspacePath: true, 143 | scheme: true, 144 | configuration: true, 145 | simulatorId: true, 146 | simulatorName: true, 147 | useLatestOS: true, 148 | } as const); 149 | 150 | export default { 151 | name: 'build_sim', 152 | description: 'Builds an app for an iOS simulator.', 153 | schema: publicSchemaObject.shape, // MCP SDK compatibility (public inputs only) 154 | handler: createSessionAwareTool<BuildSimulatorParams>({ 155 | internalSchema: buildSimulatorSchema as unknown as z.ZodType<BuildSimulatorParams>, 156 | logicFunction: build_simLogic, 157 | getExecutor: getDefaultCommandExecutor, 158 | requirements: [ 159 | { allOf: ['scheme'], message: 'scheme is required' }, 160 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 161 | { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, 162 | ], 163 | exclusivePairs: [ 164 | ['projectPath', 'workspacePath'], 165 | ['simulatorId', 'simulatorName'], 166 | ], 167 | }), 168 | }; 169 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/utilities/clean.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Utilities Plugin: Clean (Unified) 3 | * 4 | * Cleans build products for either a project or workspace using xcodebuild. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 10 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 11 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 12 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; 13 | import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; 14 | import { createErrorResponse } from '../../../utils/responses/index.ts'; 15 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 16 | 17 | // Unified schema: XOR between projectPath and workspacePath, sharing common options 18 | const baseOptions = { 19 | scheme: z.string().optional().describe('Optional: The scheme to clean'), 20 | configuration: z 21 | .string() 22 | .optional() 23 | .describe('Optional: Build configuration to clean (Debug, Release, etc.)'), 24 | derivedDataPath: z 25 | .string() 26 | .optional() 27 | .describe('Optional: Path where derived data might be located'), 28 | extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), 29 | preferXcodebuild: z 30 | .boolean() 31 | .optional() 32 | .describe( 33 | 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', 34 | ), 35 | platform: z 36 | .enum([ 37 | 'macOS', 38 | 'iOS', 39 | 'iOS Simulator', 40 | 'watchOS', 41 | 'watchOS Simulator', 42 | 'tvOS', 43 | 'tvOS Simulator', 44 | 'visionOS', 45 | 'visionOS Simulator', 46 | ]) 47 | .optional() 48 | .describe( 49 | 'Optional: Platform to clean for (defaults to iOS). Choose from macOS, iOS, iOS Simulator, watchOS, watchOS Simulator, tvOS, tvOS Simulator, visionOS, visionOS Simulator', 50 | ), 51 | }; 52 | 53 | const baseSchemaObject = z.object({ 54 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'), 55 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), 56 | ...baseOptions, 57 | }); 58 | 59 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 60 | 61 | const cleanSchema = baseSchema 62 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 63 | message: 'Either projectPath or workspacePath is required.', 64 | }) 65 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 66 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 67 | }) 68 | .refine((val) => !(val.workspacePath && !val.scheme), { 69 | message: 'scheme is required when workspacePath is provided.', 70 | path: ['scheme'], 71 | }); 72 | 73 | export type CleanParams = z.infer<typeof cleanSchema>; 74 | 75 | export async function cleanLogic( 76 | params: CleanParams, 77 | executor: CommandExecutor, 78 | ): Promise<ToolResponse> { 79 | // Extra safety: ensure workspace path has a scheme (xcodebuild requires it) 80 | if (params.workspacePath && !params.scheme) { 81 | return createErrorResponse( 82 | 'Parameter validation failed', 83 | 'Invalid parameters:\nscheme: scheme is required when workspacePath is provided.', 84 | ); 85 | } 86 | 87 | // Use provided platform or default to iOS 88 | const targetPlatform = params.platform ?? 'iOS'; 89 | 90 | // Map human-friendly platform names to XcodePlatform enum values 91 | // This is safer than direct key lookup and handles the space-containing simulator names 92 | const platformMap = { 93 | macOS: XcodePlatform.macOS, 94 | iOS: XcodePlatform.iOS, 95 | 'iOS Simulator': XcodePlatform.iOSSimulator, 96 | watchOS: XcodePlatform.watchOS, 97 | 'watchOS Simulator': XcodePlatform.watchOSSimulator, 98 | tvOS: XcodePlatform.tvOS, 99 | 'tvOS Simulator': XcodePlatform.tvOSSimulator, 100 | visionOS: XcodePlatform.visionOS, 101 | 'visionOS Simulator': XcodePlatform.visionOSSimulator, 102 | }; 103 | 104 | const platformEnum = platformMap[targetPlatform]; 105 | if (!platformEnum) { 106 | return createErrorResponse( 107 | 'Parameter validation failed', 108 | `Invalid parameters:\nplatform: unsupported value "${targetPlatform}".`, 109 | ); 110 | } 111 | 112 | const hasProjectPath = typeof params.projectPath === 'string'; 113 | const typedParams: SharedBuildParams = { 114 | ...(hasProjectPath 115 | ? { projectPath: params.projectPath as string } 116 | : { workspacePath: params.workspacePath as string }), 117 | // scheme may be omitted for project; when omitted we do not pass -scheme 118 | // Provide empty string to satisfy type, executeXcodeBuildCommand only emits -scheme when non-empty 119 | scheme: params.scheme ?? '', 120 | configuration: params.configuration ?? 'Debug', 121 | derivedDataPath: params.derivedDataPath, 122 | extraArgs: params.extraArgs, 123 | }; 124 | 125 | // For clean operations, simulator platforms should be mapped to their device equivalents 126 | // since clean works at the build product level, not runtime level, and build products 127 | // are shared between device and simulator platforms 128 | const cleanPlatformMap: Partial<Record<XcodePlatform, XcodePlatform>> = { 129 | [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, 130 | [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, 131 | [XcodePlatform.tvOSSimulator]: XcodePlatform.tvOS, 132 | [XcodePlatform.visionOSSimulator]: XcodePlatform.visionOS, 133 | }; 134 | 135 | const cleanPlatform = cleanPlatformMap[platformEnum] ?? platformEnum; 136 | 137 | return executeXcodeBuildCommand( 138 | typedParams, 139 | { 140 | platform: cleanPlatform, 141 | logPrefix: 'Clean', 142 | }, 143 | false, 144 | 'clean', 145 | executor, 146 | ); 147 | } 148 | 149 | const publicSchemaObject = baseSchemaObject.omit({ 150 | projectPath: true, 151 | workspacePath: true, 152 | scheme: true, 153 | configuration: true, 154 | } as const); 155 | 156 | export default { 157 | name: 'clean', 158 | description: 'Cleans build products with xcodebuild.', 159 | schema: publicSchemaObject.shape, 160 | handler: createSessionAwareTool<CleanParams>({ 161 | internalSchema: cleanSchema as unknown as z.ZodType<CleanParams>, 162 | logicFunction: cleanLogic, 163 | getExecutor: getDefaultCommandExecutor, 164 | requirements: [ 165 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 166 | ], 167 | exclusivePairs: [['projectPath', 'workspacePath']], 168 | }), 169 | }; 170 | ``` -------------------------------------------------------------------------------- /docs/RELEASE_PROCESS.md: -------------------------------------------------------------------------------- ```markdown 1 | # Release Process 2 | 3 | ## Step-by-Step Development Workflow 4 | 5 | ### 1. Starting New Work 6 | 7 | **Always start by syncing with main:** 8 | ```bash 9 | git checkout main 10 | git pull origin main 11 | ``` 12 | 13 | **Create feature branch using standardized naming convention:** 14 | ```bash 15 | git checkout -b feature/issue-123-add-new-feature 16 | git checkout -b bugfix/issue-456-fix-simulator-crash 17 | ``` 18 | 19 | ### 2. Development & Commits 20 | 21 | **Before committing, ALWAYS run quality checks:** 22 | ```bash 23 | npm run build # Ensure code compiles 24 | npm run typecheck # MANDATORY: Fix all TypeScript errors 25 | npm run lint # Fix linting issues 26 | npm run test # Ensure tests pass 27 | ``` 28 | 29 | **🚨 CRITICAL: TypeScript errors are BLOCKING:** 30 | - **ZERO tolerance** for TypeScript errors in commits 31 | - The `npm run typecheck` command must pass with no errors 32 | - Fix all `ts(XXXX)` errors before committing 33 | - Do not ignore or suppress TypeScript errors without explicit approval 34 | 35 | **Make logical, atomic commits:** 36 | - Each commit should represent a single logical change 37 | - Write short, descriptive commit summaries 38 | - Commit frequently to your feature branch 39 | 40 | ```bash 41 | # Always run quality checks first 42 | npm run typecheck && npm run lint && npm run test 43 | 44 | # Then commit your changes 45 | git add . 46 | git commit -m "feat: add simulator boot validation logic" 47 | git commit -m "fix: handle null response in device list parser" 48 | ``` 49 | 50 | ### 3. Pushing Changes 51 | 52 | **🚨 CRITICAL: Always ask permission before pushing** 53 | - **NEVER push without explicit user permission** 54 | - **NEVER force push without explicit permission** 55 | - Pushing without permission is a fatal error resulting in termination 56 | 57 | ```bash 58 | # Only after getting permission: 59 | git push origin feature/your-branch-name 60 | ``` 61 | 62 | ### 4. Pull Request Creation 63 | 64 | **Use GitHub CLI tool exclusively:** 65 | ```bash 66 | gh pr create --title "feat: add simulator boot validation" --body "$(cat <<'EOF' 67 | ## Summary 68 | Brief description of what this PR does and why. 69 | 70 | ## Background/Details 71 | ### For New Features: 72 | - Detailed explanation of the new feature 73 | - Context and requirements that led to this implementation 74 | - Design decisions and approach taken 75 | 76 | ### For Bug Fixes: 77 | - **Root Cause Analysis**: Detailed explanation of what caused the bug 78 | - Specific conditions that trigger the issue 79 | - Why the current code fails in these scenarios 80 | 81 | ## Solution 82 | - How the root cause was addressed 83 | - Technical approach and implementation details 84 | - Key changes made to resolve the issue 85 | 86 | ## Testing 87 | - **Reproduction Steps**: How to reproduce the original issue (for bugs) 88 | - **Validation Method**: How you verified the fix works 89 | - **Test Coverage**: What tests were added or modified 90 | - **Manual Testing**: Steps taken to validate the solution 91 | - **Edge Cases**: Additional scenarios tested 92 | 93 | ## Notes 94 | - Any important considerations for reviewers 95 | - Potential impacts or side effects 96 | - Future improvements or technical debt 97 | - Deployment considerations 98 | EOF 99 | )" 100 | ``` 101 | 102 | **After PR creation, add automated review trigger:** 103 | ```bash 104 | gh pr comment --body "Cursor review" 105 | ``` 106 | 107 | ### 5. Branch Management & Rebasing 108 | 109 | **Keep branch up to date with main:** 110 | ```bash 111 | git checkout main 112 | git pull origin main 113 | git checkout your-feature-branch 114 | git rebase main 115 | ``` 116 | 117 | **If rebase creates conflicts:** 118 | - Resolve conflicts manually 119 | - `git add .` resolved files 120 | - `git rebase --continue` 121 | - **Ask permission before force pushing rebased branch** 122 | 123 | ### 6. Merge Process 124 | 125 | **Only merge via Pull Requests:** 126 | - No direct merges to `main` 127 | - Maintain linear commit history through rebasing 128 | - Use "Squash and merge" or "Rebase and merge" as appropriate 129 | - Delete feature branch after successful merge 130 | 131 | ## Pull Request Template Structure 132 | 133 | Every PR must include these sections in order: 134 | 135 | 1. **Summary**: Brief overview of changes and purpose 136 | 2. **Background/Details**: 137 | - New Feature: Requirements, context, design decisions 138 | - Bug Fix: Detailed root cause analysis 139 | 3. **Solution**: Technical approach and implementation details 140 | 4. **Testing**: Reproduction steps, validation methods, test coverage 141 | 5. **Notes**: Additional considerations, impacts, future work 142 | 143 | ## Critical Rules 144 | 145 | ### ❌ FATAL ERRORS (Result in Termination) 146 | - **NEVER push to `main` directly** 147 | - **NEVER push without explicit user permission** 148 | - **NEVER force push without explicit permission** 149 | - **NEVER commit code with TypeScript errors** 150 | 151 | ### ✅ Required Practices 152 | - Always pull from `main` before creating branches 153 | - **MANDATORY: Run `npm run typecheck` before every commit** 154 | - **MANDATORY: Fix all TypeScript errors before committing** 155 | - Use `gh` CLI tool for all PR operations 156 | - Add "Cursor review" comment after PR creation 157 | - Maintain linear commit history via rebasing 158 | - Ask permission before any push operation 159 | - Use standardized branch naming conventions 160 | 161 | ## Branch Naming Conventions 162 | 163 | - `feature/issue-xxx-description` - New features 164 | - `bugfix/issue-xxx-description` - Bug fixes 165 | - `hotfix/critical-issue-description` - Critical production fixes 166 | - `docs/update-readme` - Documentation updates 167 | - `refactor/improve-error-handling` - Code refactoring 168 | 169 | ## Automated Quality Gates 170 | 171 | ### CI/CD Pipeline 172 | Our GitHub Actions CI pipeline automatically enforces these quality checks: 173 | 1. `npm run build` - Compilation check 174 | 2. `npm run lint` - ESLint validation 175 | 3. `npm run format:check` - Prettier formatting check 176 | 4. `npm run typecheck` - **TypeScript error validation** 177 | 5. `npm run test` - Test suite execution 178 | 179 | **All checks must pass before PR merge is allowed.** 180 | 181 | ### Optional: Pre-commit Hook Setup 182 | To catch TypeScript errors before committing locally: 183 | 184 | ```bash 185 | # Create pre-commit hook 186 | cat > .git/hooks/pre-commit << 'EOF' 187 | #!/bin/sh 188 | echo "🔍 Running pre-commit checks..." 189 | 190 | # Run TypeScript type checking 191 | echo "📝 Checking TypeScript..." 192 | npm run typecheck 193 | if [ $? -ne 0 ]; then 194 | echo "❌ TypeScript errors found. Please fix before committing." 195 | exit 1 196 | fi 197 | 198 | # Run linting 199 | echo "🧹 Running linter..." 200 | npm run lint 201 | if [ $? -ne 0 ]; then 202 | echo "❌ Linting errors found. Please fix before committing." 203 | exit 1 204 | fi 205 | 206 | echo "✅ Pre-commit checks passed!" 207 | EOF 208 | 209 | # Make it executable 210 | chmod +x .git/hooks/pre-commit 211 | ``` 212 | 213 | This hook will automatically run `typecheck` and `lint` before every commit, preventing TypeScript errors from being committed. ``` -------------------------------------------------------------------------------- /src/mcp/resources/__tests__/simulators.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { z } from 'zod'; 3 | 4 | import simulatorsResource, { simulatorsResourceLogic } from '../simulators.ts'; 5 | import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; 6 | 7 | describe('simulators resource', () => { 8 | describe('Export Field Validation', () => { 9 | it('should export correct uri', () => { 10 | expect(simulatorsResource.uri).toBe('xcodebuildmcp://simulators'); 11 | }); 12 | 13 | it('should export correct description', () => { 14 | expect(simulatorsResource.description).toBe( 15 | 'Available iOS simulators with their UUIDs and states', 16 | ); 17 | }); 18 | 19 | it('should export correct mimeType', () => { 20 | expect(simulatorsResource.mimeType).toBe('text/plain'); 21 | }); 22 | 23 | it('should export handler function', () => { 24 | expect(typeof simulatorsResource.handler).toBe('function'); 25 | }); 26 | }); 27 | 28 | describe('Handler Functionality', () => { 29 | it('should handle successful simulator data retrieval', async () => { 30 | const mockExecutor = createMockExecutor({ 31 | success: true, 32 | output: JSON.stringify({ 33 | devices: { 34 | 'iOS 17.0': [ 35 | { 36 | name: 'iPhone 15 Pro', 37 | udid: 'ABC123-DEF456-GHI789', 38 | state: 'Shutdown', 39 | isAvailable: true, 40 | }, 41 | ], 42 | }, 43 | }), 44 | }); 45 | 46 | const result = await simulatorsResourceLogic(mockExecutor); 47 | 48 | expect(result.contents).toHaveLength(1); 49 | expect(result.contents[0].text).toContain('Available iOS Simulators:'); 50 | expect(result.contents[0].text).toContain('iPhone 15 Pro'); 51 | expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); 52 | }); 53 | 54 | it('should handle command execution failure', async () => { 55 | const mockExecutor = createMockExecutor({ 56 | success: false, 57 | output: '', 58 | error: 'Command failed', 59 | }); 60 | 61 | const result = await simulatorsResourceLogic(mockExecutor); 62 | 63 | expect(result.contents).toHaveLength(1); 64 | expect(result.contents[0].text).toContain('Failed to list simulators'); 65 | expect(result.contents[0].text).toContain('Command failed'); 66 | }); 67 | 68 | it('should handle JSON parsing errors and fall back to text parsing', async () => { 69 | const mockTextOutput = `== Devices == 70 | -- iOS 17.0 -- 71 | iPhone 15 (test-uuid-123) (Shutdown)`; 72 | 73 | const mockExecutor = async (command: string[]) => { 74 | // JSON command returns invalid JSON 75 | if (command.includes('--json')) { 76 | return { 77 | success: true, 78 | output: 'invalid json', 79 | error: undefined, 80 | process: { pid: 12345 }, 81 | }; 82 | } 83 | 84 | // Text command returns valid text output 85 | return { 86 | success: true, 87 | output: mockTextOutput, 88 | error: undefined, 89 | process: { pid: 12345 }, 90 | }; 91 | }; 92 | 93 | const result = await simulatorsResourceLogic(mockExecutor); 94 | 95 | expect(result.contents).toHaveLength(1); 96 | expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)'); 97 | expect(result.contents[0].text).toContain('iOS 17.0'); 98 | }); 99 | 100 | it('should handle spawn errors', async () => { 101 | const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); 102 | 103 | const result = await simulatorsResourceLogic(mockExecutor); 104 | 105 | expect(result.contents).toHaveLength(1); 106 | expect(result.contents[0].text).toContain('Failed to list simulators'); 107 | expect(result.contents[0].text).toContain('spawn xcrun ENOENT'); 108 | }); 109 | 110 | it('should handle empty simulator data', async () => { 111 | const mockExecutor = createMockExecutor({ 112 | success: true, 113 | output: JSON.stringify({ devices: {} }), 114 | }); 115 | 116 | const result = await simulatorsResourceLogic(mockExecutor); 117 | 118 | expect(result.contents).toHaveLength(1); 119 | expect(result.contents[0].text).toContain('Available iOS Simulators:'); 120 | }); 121 | 122 | it('should handle booted simulators correctly', async () => { 123 | const mockExecutor = createMockExecutor({ 124 | success: true, 125 | output: JSON.stringify({ 126 | devices: { 127 | 'iOS 17.0': [ 128 | { 129 | name: 'iPhone 15 Pro', 130 | udid: 'ABC123-DEF456-GHI789', 131 | state: 'Booted', 132 | isAvailable: true, 133 | }, 134 | ], 135 | }, 136 | }), 137 | }); 138 | 139 | const result = await simulatorsResourceLogic(mockExecutor); 140 | 141 | expect(result.contents[0].text).toContain('[Booted]'); 142 | }); 143 | 144 | it('should filter out unavailable simulators', async () => { 145 | const mockExecutor = createMockExecutor({ 146 | success: true, 147 | output: JSON.stringify({ 148 | devices: { 149 | 'iOS 17.0': [ 150 | { 151 | name: 'iPhone 15 Pro', 152 | udid: 'ABC123-DEF456-GHI789', 153 | state: 'Shutdown', 154 | isAvailable: true, 155 | }, 156 | { 157 | name: 'iPhone 14', 158 | udid: 'XYZ789-UVW456-RST123', 159 | state: 'Shutdown', 160 | isAvailable: false, 161 | }, 162 | ], 163 | }, 164 | }), 165 | }); 166 | 167 | const result = await simulatorsResourceLogic(mockExecutor); 168 | 169 | expect(result.contents[0].text).toContain('iPhone 15 Pro'); 170 | expect(result.contents[0].text).not.toContain('iPhone 14'); 171 | }); 172 | 173 | it('should include next steps guidance', async () => { 174 | const mockExecutor = createMockExecutor({ 175 | success: true, 176 | output: JSON.stringify({ 177 | devices: { 178 | 'iOS 17.0': [ 179 | { 180 | name: 'iPhone 15 Pro', 181 | udid: 'ABC123-DEF456-GHI789', 182 | state: 'Shutdown', 183 | isAvailable: true, 184 | }, 185 | ], 186 | }, 187 | }), 188 | }); 189 | 190 | const result = await simulatorsResourceLogic(mockExecutor); 191 | 192 | expect(result.contents[0].text).toContain('Next Steps:'); 193 | expect(result.contents[0].text).toContain('boot_sim'); 194 | expect(result.contents[0].text).toContain('open_sim'); 195 | expect(result.contents[0].text).toContain('build_sim'); 196 | expect(result.contents[0].text).toContain('get_sim_app_path'); 197 | }); 198 | }); 199 | }); 200 | ``` -------------------------------------------------------------------------------- /example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift: -------------------------------------------------------------------------------- ```swift 1 | import Foundation 2 | 3 | // MARK: - Calculator Business Logic Service 4 | 5 | /// Handles all calculator operations and state management 6 | /// Separated from UI concerns for better testability and modularity 7 | @Observable 8 | public final class CalculatorService { 9 | // MARK: - Public Properties 10 | 11 | public private(set) var display: String = "0" 12 | public private(set) var expressionDisplay: String = "" 13 | public private(set) var hasError: Bool = false 14 | 15 | // MARK: - Private State 16 | 17 | private var currentNumber: Double = 0 18 | private var previousNumber: Double = 0 19 | private var operation: Operation? 20 | private var shouldResetDisplay = false 21 | private var isNewCalculation = true 22 | private var lastOperation: Operation? 23 | private var lastOperand: Double = 0 24 | 25 | // MARK: - Operations 26 | 27 | public enum Operation: String, CaseIterable, Sendable { 28 | case add = "+" 29 | case subtract = "-" 30 | case multiply = "×" 31 | case divide = "÷" 32 | 33 | public func calculate(_ a: Double, _ b: Double) -> Double { 34 | switch self { 35 | case .add: return a + b 36 | case .subtract: return a - b 37 | case .multiply: return a * b 38 | case .divide: return b != 0 ? a / b : 0 39 | } 40 | } 41 | } 42 | 43 | public init() {} 44 | 45 | // MARK: - Public Interface 46 | 47 | public func inputNumber(_ digit: String) { 48 | guard !hasError else { clear(); return } 49 | 50 | if shouldResetDisplay || isNewCalculation { 51 | display = digit 52 | shouldResetDisplay = false 53 | isNewCalculation = false 54 | } else if display.count < 12 { 55 | display = display == "0" ? digit : display + digit 56 | } 57 | 58 | currentNumber = Double(display) ?? 0 59 | updateExpressionDisplay() 60 | } 61 | 62 | /// Inputs a decimal point into the display 63 | public func inputDecimal() { 64 | guard !hasError else { 65 | clear(); return 66 | } 67 | 68 | if shouldResetDisplay || isNewCalculation { 69 | display = "0." 70 | shouldResetDisplay = false 71 | isNewCalculation = false 72 | } else if !display.contains("."), display.count < 11 { 73 | display += "." 74 | } 75 | updateExpressionDisplay() 76 | } 77 | 78 | public func setOperation(_ op: Operation) { 79 | guard !hasError else { return } 80 | 81 | if operation != nil, !shouldResetDisplay { 82 | calculate() 83 | if hasError { return } 84 | } 85 | 86 | previousNumber = currentNumber 87 | operation = op 88 | shouldResetDisplay = true 89 | isNewCalculation = false 90 | updateExpressionDisplay() 91 | } 92 | 93 | public func calculate() { 94 | guard let op = operation ?? lastOperation else { return } 95 | let operand = (operation != nil) ? currentNumber : lastOperand 96 | 97 | let result = op.calculate(previousNumber, operand) 98 | 99 | // Error handling 100 | if result.isNaN || result.isInfinite { 101 | setError("Cannot divide by zero") 102 | return 103 | } 104 | 105 | if abs(result) > 1e12 { 106 | setError("Number too large") 107 | return 108 | } 109 | 110 | // Success path 111 | let prevFormatted = formatNumber(previousNumber) 112 | let currFormatted = formatNumber(operand) 113 | display = formatNumber(result) 114 | expressionDisplay = "\(prevFormatted) \(op.rawValue) \(currFormatted) =" 115 | 116 | previousNumber = result 117 | if operation != nil { 118 | lastOperand = currentNumber 119 | } 120 | 121 | lastOperation = op 122 | operation = nil 123 | currentNumber = result 124 | shouldResetDisplay = true 125 | isNewCalculation = false 126 | } 127 | 128 | public func toggleSign() { 129 | guard !hasError, currentNumber != 0 else { return } 130 | currentNumber *= -1 131 | display = formatNumber(currentNumber) 132 | updateExpressionDisplay() 133 | } 134 | 135 | public func percentage() { 136 | guard !hasError else { return } 137 | currentNumber /= 100 138 | display = formatNumber(currentNumber) 139 | updateExpressionDisplay() 140 | } 141 | 142 | public func clear() { 143 | display = "0" 144 | expressionDisplay = "" 145 | currentNumber = 0 146 | previousNumber = 0 147 | operation = nil 148 | shouldResetDisplay = false 149 | hasError = false 150 | isNewCalculation = true 151 | } 152 | 153 | public func deleteLastDigit() { 154 | guard !hasError else { clear(); return } 155 | 156 | if shouldResetDisplay || isNewCalculation { 157 | display = "0" 158 | shouldResetDisplay = false 159 | isNewCalculation = false 160 | } else if display.count > 1 { 161 | display.removeLast() 162 | if display == "-" { display = "0" } 163 | } else { 164 | display = "0" 165 | } 166 | currentNumber = Double(display) ?? 0 167 | updateExpressionDisplay() 168 | } 169 | 170 | // MARK: - Private Helpers 171 | 172 | private func setError(_ message: String) { 173 | hasError = true 174 | display = "Error" 175 | expressionDisplay = message 176 | } 177 | 178 | private func updateExpressionDisplay() { 179 | guard !hasError else { return } 180 | 181 | if let op = operation { 182 | let prevFormatted = formatNumber(previousNumber) 183 | expressionDisplay = "\(prevFormatted) \(op.rawValue)" 184 | } else if isNewCalculation { 185 | expressionDisplay = "" 186 | } 187 | } 188 | 189 | private func formatNumber(_ number: Double) -> String { 190 | guard !number.isNaN && !number.isInfinite else { return "Error" } 191 | 192 | let formatter = NumberFormatter() 193 | formatter.numberStyle = .decimal 194 | formatter.maximumFractionDigits = 8 195 | formatter.minimumFractionDigits = 0 196 | 197 | // For integers, don't show decimal places 198 | if number == floor(number) && abs(number) < 1e10 { 199 | formatter.maximumFractionDigits = 0 200 | } 201 | 202 | // For very small decimals, use scientific notation 203 | if abs(number) < 0.000001 && number != 0 { 204 | formatter.numberStyle = .scientific 205 | formatter.maximumFractionDigits = 2 206 | } 207 | 208 | return formatter.string(from: NSNumber(value: number)) ?? "0" 209 | } 210 | } 211 | 212 | // MARK: - Testing Support 213 | 214 | public extension CalculatorService { 215 | var currentValue: Double { currentNumber } 216 | var previousValue: Double { previousNumber } 217 | var currentOperation: Operation? { operation } 218 | var willResetDisplay: Bool { shouldResetDisplay } 219 | } 220 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/device/get_device_app_path.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Device Shared Plugin: Get Device App Path (Unified) 3 | * 4 | * Gets the app bundle path for a physical device application (iOS, watchOS, tvOS, visionOS) using either a project or workspace. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; 10 | import { log } from '../../../utils/logging/index.ts'; 11 | import { createTextResponse } from '../../../utils/responses/index.ts'; 12 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 14 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 15 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 16 | 17 | // Unified schema: XOR between projectPath and workspacePath, sharing common options 18 | const baseOptions = { 19 | scheme: z.string().describe('The scheme to use'), 20 | configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), 21 | platform: z 22 | .enum(['iOS', 'watchOS', 'tvOS', 'visionOS']) 23 | .optional() 24 | .describe('Target platform (defaults to iOS)'), 25 | }; 26 | 27 | const baseSchemaObject = z.object({ 28 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'), 29 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), 30 | ...baseOptions, 31 | }); 32 | 33 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 34 | 35 | const getDeviceAppPathSchema = baseSchema 36 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 37 | message: 'Either projectPath or workspacePath is required.', 38 | }) 39 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 40 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 41 | }); 42 | 43 | // Use z.infer for type safety 44 | type GetDeviceAppPathParams = z.infer<typeof getDeviceAppPathSchema>; 45 | 46 | export async function get_device_app_pathLogic( 47 | params: GetDeviceAppPathParams, 48 | executor: CommandExecutor, 49 | ): Promise<ToolResponse> { 50 | const platformMap = { 51 | iOS: XcodePlatform.iOS, 52 | watchOS: XcodePlatform.watchOS, 53 | tvOS: XcodePlatform.tvOS, 54 | visionOS: XcodePlatform.visionOS, 55 | }; 56 | 57 | const platform = platformMap[params.platform ?? 'iOS']; 58 | const configuration = params.configuration ?? 'Debug'; 59 | 60 | log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); 61 | 62 | try { 63 | // Create the command array for xcodebuild with -showBuildSettings option 64 | const command = ['xcodebuild', '-showBuildSettings']; 65 | 66 | // Add the project or workspace 67 | if (params.projectPath) { 68 | command.push('-project', params.projectPath); 69 | } else if (params.workspacePath) { 70 | command.push('-workspace', params.workspacePath); 71 | } else { 72 | // This should never happen due to schema validation 73 | throw new Error('Either projectPath or workspacePath is required.'); 74 | } 75 | 76 | // Add the scheme and configuration 77 | command.push('-scheme', params.scheme); 78 | command.push('-configuration', configuration); 79 | 80 | // Handle destination based on platform 81 | let destinationString = ''; 82 | 83 | if (platform === XcodePlatform.iOS) { 84 | destinationString = 'generic/platform=iOS'; 85 | } else if (platform === XcodePlatform.watchOS) { 86 | destinationString = 'generic/platform=watchOS'; 87 | } else if (platform === XcodePlatform.tvOS) { 88 | destinationString = 'generic/platform=tvOS'; 89 | } else if (platform === XcodePlatform.visionOS) { 90 | destinationString = 'generic/platform=visionOS'; 91 | } else { 92 | return createTextResponse(`Unsupported platform: ${platform}`, true); 93 | } 94 | 95 | command.push('-destination', destinationString); 96 | 97 | // Execute the command directly 98 | const result = await executor(command, 'Get App Path', true); 99 | 100 | if (!result.success) { 101 | return createTextResponse(`Failed to get app path: ${result.error}`, true); 102 | } 103 | 104 | if (!result.output) { 105 | return createTextResponse('Failed to extract build settings output from the result.', true); 106 | } 107 | 108 | const buildSettingsOutput = result.output; 109 | const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); 110 | const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); 111 | 112 | if (!builtProductsDirMatch || !fullProductNameMatch) { 113 | return createTextResponse( 114 | 'Failed to extract app path from build settings. Make sure the app has been built first.', 115 | true, 116 | ); 117 | } 118 | 119 | const builtProductsDir = builtProductsDirMatch[1].trim(); 120 | const fullProductName = fullProductNameMatch[1].trim(); 121 | const appPath = `${builtProductsDir}/${fullProductName}`; 122 | 123 | const nextStepsText = `Next Steps: 124 | 1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) 125 | 2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" }) 126 | 3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`; 127 | 128 | return { 129 | content: [ 130 | { 131 | type: 'text', 132 | text: `✅ App path retrieved successfully: ${appPath}`, 133 | }, 134 | { 135 | type: 'text', 136 | text: nextStepsText, 137 | }, 138 | ], 139 | }; 140 | } catch (error) { 141 | const errorMessage = error instanceof Error ? error.message : String(error); 142 | log('error', `Error retrieving app path: ${errorMessage}`); 143 | return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); 144 | } 145 | } 146 | 147 | export default { 148 | name: 'get_device_app_path', 149 | description: 'Retrieves the built app path for a connected device.', 150 | schema: baseSchemaObject.omit({ 151 | projectPath: true, 152 | workspacePath: true, 153 | scheme: true, 154 | configuration: true, 155 | } as const).shape, 156 | handler: createSessionAwareTool<GetDeviceAppPathParams>({ 157 | internalSchema: getDeviceAppPathSchema as unknown as z.ZodType<GetDeviceAppPathParams>, 158 | logicFunction: get_device_app_pathLogic, 159 | getExecutor: getDefaultCommandExecutor, 160 | requirements: [ 161 | { allOf: ['scheme'], message: 'scheme is required' }, 162 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 163 | ], 164 | exclusivePairs: [['projectPath', 'workspacePath']], 165 | }), 166 | }; 167 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/tap.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import type { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; 5 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 6 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 7 | import { 8 | createAxeNotAvailableResponse, 9 | getAxePath, 10 | getBundledAxeEnvironment, 11 | } from '../../../utils/axe-helpers.ts'; 12 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; 13 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 14 | 15 | export interface AxeHelpers { 16 | getAxePath: () => string | null; 17 | getBundledAxeEnvironment: () => Record<string, string>; 18 | createAxeNotAvailableResponse: () => ToolResponse; 19 | } 20 | 21 | // Define schema as ZodObject 22 | const tapSchema = z.object({ 23 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 24 | x: z.number().int('X coordinate must be an integer'), 25 | y: z.number().int('Y coordinate must be an integer'), 26 | preDelay: z.number().min(0, 'Pre-delay must be non-negative').optional(), 27 | postDelay: z.number().min(0, 'Post-delay must be non-negative').optional(), 28 | }); 29 | 30 | // Use z.infer for type safety 31 | type TapParams = z.infer<typeof tapSchema>; 32 | 33 | const LOG_PREFIX = '[AXe]'; 34 | 35 | // Session tracking for describe_ui warnings (shared across UI tools) 36 | const describeUITimestamps = new Map<string, { timestamp: number }>(); 37 | const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds 38 | 39 | function getCoordinateWarning(simulatorUuid: string): string | null { 40 | const session = describeUITimestamps.get(simulatorUuid); 41 | if (!session) { 42 | return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; 43 | } 44 | 45 | const timeSinceDescribe = Date.now() - session.timestamp; 46 | if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { 47 | const secondsAgo = Math.round(timeSinceDescribe / 1000); 48 | return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; 49 | } 50 | 51 | return null; 52 | } 53 | 54 | export async function tapLogic( 55 | params: TapParams, 56 | executor: CommandExecutor, 57 | axeHelpers: AxeHelpers = { 58 | getAxePath, 59 | getBundledAxeEnvironment, 60 | createAxeNotAvailableResponse, 61 | }, 62 | ): Promise<ToolResponse> { 63 | const toolName = 'tap'; 64 | const { simulatorUuid, x, y, preDelay, postDelay } = params; 65 | const commandArgs = ['tap', '-x', String(x), '-y', String(y)]; 66 | if (preDelay !== undefined) { 67 | commandArgs.push('--pre-delay', String(preDelay)); 68 | } 69 | if (postDelay !== undefined) { 70 | commandArgs.push('--post-delay', String(postDelay)); 71 | } 72 | 73 | log('info', `${LOG_PREFIX}/${toolName}: Starting for (${x}, ${y}) on ${simulatorUuid}`); 74 | 75 | try { 76 | await executeAxeCommand(commandArgs, simulatorUuid, 'tap', executor, axeHelpers); 77 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); 78 | 79 | const warning = getCoordinateWarning(simulatorUuid); 80 | const message = `Tap at (${x}, ${y}) simulated successfully.`; 81 | 82 | if (warning) { 83 | return createTextResponse(`${message}\n\n${warning}`); 84 | } 85 | 86 | return createTextResponse(message); 87 | } catch (error) { 88 | log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); 89 | if (error instanceof DependencyError) { 90 | return axeHelpers.createAxeNotAvailableResponse(); 91 | } else if (error instanceof AxeError) { 92 | return createErrorResponse( 93 | `Failed to simulate tap at (${x}, ${y}): ${error.message}`, 94 | error.axeOutput, 95 | ); 96 | } else if (error instanceof SystemError) { 97 | return createErrorResponse( 98 | `System error executing axe: ${error.message}`, 99 | error.originalError?.stack, 100 | ); 101 | } 102 | return createErrorResponse( 103 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 104 | ); 105 | } 106 | } 107 | 108 | export default { 109 | name: 'tap', 110 | description: 111 | "Tap at specific coordinates. Use describe_ui to get precise element coordinates (don't guess from screenshots). Supports optional timing delays.", 112 | schema: tapSchema.shape, // MCP SDK compatibility 113 | handler: createTypedTool( 114 | tapSchema, 115 | (params: TapParams, executor: CommandExecutor) => { 116 | return tapLogic(params, executor, { 117 | getAxePath, 118 | getBundledAxeEnvironment, 119 | createAxeNotAvailableResponse, 120 | }); 121 | }, 122 | getDefaultCommandExecutor, 123 | ), 124 | }; 125 | 126 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) 127 | async function executeAxeCommand( 128 | commandArgs: string[], 129 | simulatorUuid: string, 130 | commandName: string, 131 | executor: CommandExecutor = getDefaultCommandExecutor(), 132 | axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, 133 | ): Promise<void> { 134 | // Get the appropriate axe binary path 135 | const axeBinary = axeHelpers.getAxePath(); 136 | if (!axeBinary) { 137 | throw new DependencyError('AXe binary not found'); 138 | } 139 | 140 | // Add --udid parameter to all commands 141 | const fullArgs = [...commandArgs, '--udid', simulatorUuid]; 142 | 143 | // Construct the full command array with the axe binary as the first element 144 | const fullCommand = [axeBinary, ...fullArgs]; 145 | 146 | try { 147 | // Determine environment variables for bundled AXe 148 | const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; 149 | 150 | const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); 151 | 152 | if (!result.success) { 153 | throw new AxeError( 154 | `axe command '${commandName}' failed.`, 155 | commandName, 156 | result.error ?? result.output, 157 | simulatorUuid, 158 | ); 159 | } 160 | 161 | // Check for stderr output in successful commands 162 | if (result.error) { 163 | log( 164 | 'warn', 165 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, 166 | ); 167 | } 168 | 169 | // Function now returns void - the calling code creates its own response 170 | } catch (error) { 171 | if (error instanceof Error) { 172 | if (error instanceof AxeError) { 173 | throw error; 174 | } 175 | 176 | // Otherwise wrap it in a SystemError 177 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error); 178 | } 179 | 180 | // For any other type of error 181 | throw new SystemError(`Failed to execute axe command: ${String(error)}`); 182 | } 183 | } 184 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for get_sim_app_path plugin (session-aware version) 3 | * Mirrors patterns from other simulator session-aware migrations. 4 | */ 5 | 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { ChildProcess } from 'child_process'; 8 | import { z } from 'zod'; 9 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 10 | import { sessionStore } from '../../../../utils/session-store.ts'; 11 | import getSimAppPath, { get_sim_app_pathLogic } from '../get_sim_app_path.ts'; 12 | import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; 13 | 14 | describe('get_sim_app_path tool', () => { 15 | beforeEach(() => { 16 | sessionStore.clear(); 17 | }); 18 | 19 | describe('Export Field Validation (Literal)', () => { 20 | it('should have correct name', () => { 21 | expect(getSimAppPath.name).toBe('get_sim_app_path'); 22 | }); 23 | 24 | it('should have concise description', () => { 25 | expect(getSimAppPath.description).toBe('Retrieves the built app path for an iOS simulator.'); 26 | }); 27 | 28 | it('should have handler function', () => { 29 | expect(typeof getSimAppPath.handler).toBe('function'); 30 | }); 31 | 32 | it('should expose only platform in public schema', () => { 33 | const schema = z.object(getSimAppPath.schema); 34 | 35 | expect(schema.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); 36 | expect(schema.safeParse({}).success).toBe(false); 37 | expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); 38 | 39 | const schemaKeys = Object.keys(getSimAppPath.schema).sort(); 40 | expect(schemaKeys).toEqual(['platform']); 41 | }); 42 | }); 43 | 44 | describe('Handler Requirements', () => { 45 | it('should require scheme when not provided', async () => { 46 | const result = await getSimAppPath.handler({ 47 | platform: 'iOS Simulator', 48 | }); 49 | 50 | expect(result.isError).toBe(true); 51 | expect(result.content[0].text).toContain('scheme is required'); 52 | }); 53 | 54 | it('should require project or workspace when scheme default exists', async () => { 55 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 56 | 57 | const result = await getSimAppPath.handler({ 58 | platform: 'iOS Simulator', 59 | }); 60 | 61 | expect(result.isError).toBe(true); 62 | expect(result.content[0].text).toContain('Provide a project or workspace'); 63 | }); 64 | 65 | it('should require simulator identifier when scheme and project defaults exist', async () => { 66 | sessionStore.setDefaults({ 67 | scheme: 'MyScheme', 68 | projectPath: '/path/to/project.xcodeproj', 69 | }); 70 | 71 | const result = await getSimAppPath.handler({ 72 | platform: 'iOS Simulator', 73 | }); 74 | 75 | expect(result.isError).toBe(true); 76 | expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); 77 | }); 78 | 79 | it('should error when both projectPath and workspacePath provided explicitly', async () => { 80 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 81 | 82 | const result = await getSimAppPath.handler({ 83 | platform: 'iOS Simulator', 84 | projectPath: '/path/project.xcodeproj', 85 | workspacePath: '/path/workspace.xcworkspace', 86 | }); 87 | 88 | expect(result.isError).toBe(true); 89 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 90 | expect(result.content[0].text).toContain('projectPath'); 91 | expect(result.content[0].text).toContain('workspacePath'); 92 | }); 93 | 94 | it('should error when both simulatorId and simulatorName provided explicitly', async () => { 95 | sessionStore.setDefaults({ 96 | scheme: 'MyScheme', 97 | workspacePath: '/path/to/workspace.xcworkspace', 98 | }); 99 | 100 | const result = await getSimAppPath.handler({ 101 | platform: 'iOS Simulator', 102 | simulatorId: 'SIM-UUID', 103 | simulatorName: 'iPhone 16', 104 | }); 105 | 106 | expect(result.isError).toBe(true); 107 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 108 | expect(result.content[0].text).toContain('simulatorId'); 109 | expect(result.content[0].text).toContain('simulatorName'); 110 | }); 111 | }); 112 | 113 | describe('Logic Behavior', () => { 114 | it('should return app path with simulator name destination', async () => { 115 | const callHistory: Array<{ 116 | command: string[]; 117 | logPrefix?: string; 118 | useShell?: boolean; 119 | opts?: unknown; 120 | }> = []; 121 | 122 | const trackingExecutor: CommandExecutor = async ( 123 | command, 124 | logPrefix, 125 | useShell, 126 | opts, 127 | ): Promise<{ 128 | success: boolean; 129 | output: string; 130 | process: ChildProcess; 131 | }> => { 132 | callHistory.push({ command, logPrefix, useShell, opts }); 133 | return { 134 | success: true, 135 | output: 136 | ' BUILT_PRODUCTS_DIR = /tmp/DerivedData/Build\n FULL_PRODUCT_NAME = MyApp.app\n', 137 | process: { pid: 12345 } as unknown as ChildProcess, 138 | }; 139 | }; 140 | 141 | const result = await get_sim_app_pathLogic( 142 | { 143 | workspacePath: '/path/to/workspace.xcworkspace', 144 | scheme: 'MyScheme', 145 | platform: 'iOS Simulator', 146 | simulatorName: 'iPhone 16', 147 | useLatestOS: true, 148 | }, 149 | trackingExecutor, 150 | ); 151 | 152 | expect(callHistory).toHaveLength(1); 153 | expect(callHistory[0].logPrefix).toBe('Get App Path'); 154 | expect(callHistory[0].useShell).toBe(true); 155 | expect(callHistory[0].command).toEqual([ 156 | 'xcodebuild', 157 | '-showBuildSettings', 158 | '-workspace', 159 | '/path/to/workspace.xcworkspace', 160 | '-scheme', 161 | 'MyScheme', 162 | '-configuration', 163 | 'Debug', 164 | '-destination', 165 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 166 | ]); 167 | 168 | expect(result.isError).toBe(false); 169 | expect(result.content[0].text).toContain( 170 | '✅ App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app', 171 | ); 172 | }); 173 | 174 | it('should surface executor failures when build settings cannot be retrieved', async () => { 175 | const mockExecutor = createMockExecutor({ 176 | success: false, 177 | error: 'Failed to run xcodebuild', 178 | }); 179 | 180 | const result = await get_sim_app_pathLogic( 181 | { 182 | projectPath: '/path/to/project.xcodeproj', 183 | scheme: 'MyScheme', 184 | platform: 'iOS Simulator', 185 | simulatorId: 'SIM-UUID', 186 | }, 187 | mockExecutor, 188 | ); 189 | 190 | expect(result.isError).toBe(true); 191 | expect(result.content[0].text).toContain('Failed to get app path'); 192 | expect(result.content[0].text).toContain('Failed to run xcodebuild'); 193 | }); 194 | }); 195 | }); 196 | ``` -------------------------------------------------------------------------------- /src/utils/video_capture.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Video capture utility for simulator recordings using AXe. 3 | * 4 | * Manages long-running AXe "record-video" processes keyed by simulator UUID. 5 | * It aggregates stdout/stderr to parse the generated MP4 path on stop. 6 | */ 7 | 8 | import type { ChildProcess } from 'child_process'; 9 | import { log } from './logging/index.ts'; 10 | import { getAxePath, getBundledAxeEnvironment } from './axe-helpers.ts'; 11 | import type { CommandExecutor } from './execution/index.ts'; 12 | 13 | type Session = { 14 | process: unknown; 15 | sessionId: string; 16 | startedAt: number; 17 | buffer: string; 18 | ended: boolean; 19 | }; 20 | 21 | const sessions = new Map<string, Session>(); 22 | let signalHandlersAttached = false; 23 | 24 | export interface AxeHelpers { 25 | getAxePath: () => string | null; 26 | getBundledAxeEnvironment: () => Record<string, string>; 27 | } 28 | 29 | function ensureSignalHandlersAttached(): void { 30 | if (signalHandlersAttached) return; 31 | signalHandlersAttached = true; 32 | 33 | const stopAll = (): void => { 34 | for (const [simulatorUuid, sess] of sessions) { 35 | try { 36 | const child = sess.process as ChildProcess | undefined; 37 | child?.kill?.('SIGINT'); 38 | } catch { 39 | // ignore 40 | } finally { 41 | sessions.delete(simulatorUuid); 42 | } 43 | } 44 | }; 45 | 46 | try { 47 | process.on('SIGINT', stopAll); 48 | process.on('SIGTERM', stopAll); 49 | process.on('exit', stopAll); 50 | } catch { 51 | // Non-Node environments may not support process signals; ignore 52 | } 53 | } 54 | 55 | function parseLastAbsoluteMp4Path(buffer: string | undefined): string | null { 56 | if (!buffer) return null; 57 | const matches = [...buffer.matchAll(/(\s|^)(\/[^\s'"]+\.mp4)\b/gi)]; 58 | if (matches.length === 0) return null; 59 | const last = matches[matches.length - 1]; 60 | return last?.[2] ?? null; 61 | } 62 | 63 | function createSessionId(simulatorUuid: string): string { 64 | return `${simulatorUuid}:${Date.now()}`; 65 | } 66 | 67 | /** 68 | * Start recording video for a simulator using AXe. 69 | */ 70 | export async function startSimulatorVideoCapture( 71 | params: { simulatorUuid: string; fps?: number }, 72 | executor: CommandExecutor, 73 | axeHelpers?: AxeHelpers, 74 | ): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> { 75 | const simulatorUuid = params.simulatorUuid; 76 | if (!simulatorUuid) { 77 | return { started: false, error: 'simulatorUuid is required' }; 78 | } 79 | 80 | if (sessions.has(simulatorUuid)) { 81 | return { 82 | started: false, 83 | error: 'A video recording session is already active for this simulator. Stop it first.', 84 | }; 85 | } 86 | 87 | const helpers = axeHelpers ?? { 88 | getAxePath, 89 | getBundledAxeEnvironment, 90 | }; 91 | 92 | const axeBinary = helpers.getAxePath(); 93 | if (!axeBinary) { 94 | return { started: false, error: 'Bundled AXe binary not found' }; 95 | } 96 | 97 | const fps = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; 98 | const command = [axeBinary, 'record-video', '--udid', simulatorUuid, '--fps', String(fps)]; 99 | const env = helpers.getBundledAxeEnvironment?.() ?? {}; 100 | 101 | log('info', `Starting AXe video recording for simulator ${simulatorUuid} at ${fps} fps`); 102 | 103 | const result = await executor(command, 'Start Simulator Video Capture', true, { env }, true); 104 | 105 | if (!result.success || !result.process) { 106 | return { 107 | started: false, 108 | error: result.error ?? 'Failed to start video capture process', 109 | }; 110 | } 111 | 112 | const child = result.process as ChildProcess; 113 | const session: Session = { 114 | process: child, 115 | sessionId: createSessionId(simulatorUuid), 116 | startedAt: Date.now(), 117 | buffer: '', 118 | ended: false, 119 | }; 120 | 121 | try { 122 | child.stdout?.on('data', (d: unknown) => { 123 | try { 124 | session.buffer += String(d ?? ''); 125 | } catch { 126 | // ignore 127 | } 128 | }); 129 | child.stderr?.on('data', (d: unknown) => { 130 | try { 131 | session.buffer += String(d ?? ''); 132 | } catch { 133 | // ignore 134 | } 135 | }); 136 | } catch { 137 | // ignore stream listener setup failures 138 | } 139 | 140 | // Track when the child process naturally ends, so stop can short-circuit 141 | try { 142 | child.once?.('exit', () => { 143 | session.ended = true; 144 | }); 145 | child.once?.('close', () => { 146 | session.ended = true; 147 | }); 148 | } catch { 149 | // ignore 150 | } 151 | 152 | sessions.set(simulatorUuid, session); 153 | ensureSignalHandlersAttached(); 154 | 155 | return { 156 | started: true, 157 | sessionId: session.sessionId, 158 | warning: fps !== (params.fps ?? 30) ? `FPS coerced to ${fps}` : undefined, 159 | }; 160 | } 161 | 162 | /** 163 | * Stop recording video for a simulator. Returns aggregated output and parsed MP4 path if found. 164 | */ 165 | export async function stopSimulatorVideoCapture( 166 | params: { simulatorUuid: string }, 167 | executor: CommandExecutor, 168 | ): Promise<{ 169 | stopped: boolean; 170 | sessionId?: string; 171 | stdout?: string; 172 | parsedPath?: string; 173 | error?: string; 174 | }> { 175 | // Mark executor as used to satisfy lint rule 176 | void executor; 177 | 178 | const simulatorUuid = params.simulatorUuid; 179 | if (!simulatorUuid) { 180 | return { stopped: false, error: 'simulatorUuid is required' }; 181 | } 182 | 183 | const session = sessions.get(simulatorUuid); 184 | if (!session) { 185 | return { stopped: false, error: 'No active video recording session for this simulator' }; 186 | } 187 | 188 | const child = session.process as ChildProcess | undefined; 189 | 190 | // Attempt graceful shutdown 191 | try { 192 | child?.kill?.('SIGINT'); 193 | } catch { 194 | try { 195 | child?.kill?.(); 196 | } catch { 197 | // ignore 198 | } 199 | } 200 | 201 | // Wait for process to close (avoid hanging if it already exited) 202 | await new Promise<void>((resolve): void => { 203 | if (!child) return resolve(); 204 | 205 | // If process has already ended, resolve immediately 206 | const alreadyEnded = (session as Session).ended === true; 207 | const hasExitCode = (child as ChildProcess).exitCode !== null; 208 | const hasSignal = (child as unknown as { signalCode?: string | null }).signalCode != null; 209 | if (alreadyEnded || hasExitCode || hasSignal) { 210 | return resolve(); 211 | } 212 | 213 | let resolved = false; 214 | const finish = (): void => { 215 | if (!resolved) { 216 | resolved = true; 217 | resolve(); 218 | } 219 | }; 220 | try { 221 | child.once('close', finish); 222 | child.once('exit', finish); 223 | } catch { 224 | return finish(); 225 | } 226 | // Safety timeout to prevent indefinite hangs 227 | setTimeout(finish, 5000); 228 | }); 229 | 230 | const combinedOutput = session.buffer; 231 | const parsedPath = parseLastAbsoluteMp4Path(combinedOutput) ?? undefined; 232 | 233 | sessions.delete(simulatorUuid); 234 | 235 | log( 236 | 'info', 237 | `Stopped AXe video recording for simulator ${simulatorUuid}. ${parsedPath ? `Detected file: ${parsedPath}` : 'No file detected in output.'}`, 238 | ); 239 | 240 | return { 241 | stopped: true, 242 | sessionId: session.sessionId, 243 | stdout: combinedOutput, 244 | parsedPath, 245 | }; 246 | } 247 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/launch_app_sim.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 5 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 6 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 7 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 8 | 9 | const baseSchemaObject = z.object({ 10 | simulatorId: z 11 | .string() 12 | .optional() 13 | .describe( 14 | 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', 15 | ), 16 | simulatorName: z 17 | .string() 18 | .optional() 19 | .describe( 20 | "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", 21 | ), 22 | bundleId: z 23 | .string() 24 | .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), 25 | args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), 26 | }); 27 | 28 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 29 | 30 | const launchAppSimSchema = baseSchema 31 | .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { 32 | message: 'Either simulatorId or simulatorName is required.', 33 | }) 34 | .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { 35 | message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', 36 | }); 37 | 38 | export type LaunchAppSimParams = z.infer<typeof launchAppSimSchema>; 39 | 40 | export async function launch_app_simLogic( 41 | params: LaunchAppSimParams, 42 | executor: CommandExecutor, 43 | ): Promise<ToolResponse> { 44 | let simulatorId = params.simulatorId; 45 | let simulatorDisplayName = simulatorId ?? ''; 46 | 47 | if (params.simulatorName && !simulatorId) { 48 | log('info', `Looking up simulator by name: ${params.simulatorName}`); 49 | 50 | const simulatorListResult = await executor( 51 | ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 52 | 'List Simulators', 53 | true, 54 | ); 55 | if (!simulatorListResult.success) { 56 | return { 57 | content: [ 58 | { 59 | type: 'text', 60 | text: `Failed to list simulators: ${simulatorListResult.error}`, 61 | }, 62 | ], 63 | isError: true, 64 | }; 65 | } 66 | 67 | const simulatorsData = JSON.parse(simulatorListResult.output) as { 68 | devices: Record<string, Array<{ udid: string; name: string }>>; 69 | }; 70 | 71 | let foundSimulator: { udid: string; name: string } | null = null; 72 | for (const runtime in simulatorsData.devices) { 73 | const devices = simulatorsData.devices[runtime]; 74 | const simulator = devices.find((device) => device.name === params.simulatorName); 75 | if (simulator) { 76 | foundSimulator = simulator; 77 | break; 78 | } 79 | } 80 | 81 | if (!foundSimulator) { 82 | return { 83 | content: [ 84 | { 85 | type: 'text', 86 | text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, 87 | }, 88 | ], 89 | isError: true, 90 | }; 91 | } 92 | 93 | simulatorId = foundSimulator.udid; 94 | simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; 95 | } 96 | 97 | if (!simulatorId) { 98 | return { 99 | content: [ 100 | { 101 | type: 'text', 102 | text: 'No simulator identifier provided', 103 | }, 104 | ], 105 | isError: true, 106 | }; 107 | } 108 | 109 | log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); 110 | 111 | try { 112 | const getAppContainerCmd = [ 113 | 'xcrun', 114 | 'simctl', 115 | 'get_app_container', 116 | simulatorId, 117 | params.bundleId, 118 | 'app', 119 | ]; 120 | const getAppContainerResult = await executor( 121 | getAppContainerCmd, 122 | 'Check App Installed', 123 | true, 124 | undefined, 125 | ); 126 | if (!getAppContainerResult.success) { 127 | return { 128 | content: [ 129 | { 130 | type: 'text', 131 | text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, 132 | }, 133 | ], 134 | isError: true, 135 | }; 136 | } 137 | } catch { 138 | return { 139 | content: [ 140 | { 141 | type: 'text', 142 | text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, 143 | }, 144 | ], 145 | isError: true, 146 | }; 147 | } 148 | 149 | try { 150 | const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId]; 151 | if (params.args && params.args.length > 0) { 152 | command.push(...params.args); 153 | } 154 | 155 | const result = await executor(command, 'Launch App in Simulator', true, undefined); 156 | 157 | if (!result.success) { 158 | return { 159 | content: [ 160 | { 161 | type: 'text', 162 | text: `Launch app in simulator operation failed: ${result.error}`, 163 | }, 164 | ], 165 | }; 166 | } 167 | 168 | const userParamName = params.simulatorName ? 'simulatorName' : 'simulatorUuid'; 169 | const userParamValue = params.simulatorName ?? simulatorId; 170 | 171 | return { 172 | content: [ 173 | { 174 | type: 'text', 175 | text: `✅ App launched successfully in simulator ${simulatorDisplayName || simulatorId}. 176 | 177 | Next Steps: 178 | 1. To see simulator: open_sim() 179 | 2. Log capture: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}" }) 180 | With console: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}", captureConsole: true }) 181 | 3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, 182 | }, 183 | ], 184 | }; 185 | } catch (error) { 186 | const errorMessage = error instanceof Error ? error.message : String(error); 187 | log('error', `Error during launch app in simulator operation: ${errorMessage}`); 188 | return { 189 | content: [ 190 | { 191 | type: 'text', 192 | text: `Launch app in simulator operation failed: ${errorMessage}`, 193 | }, 194 | ], 195 | }; 196 | } 197 | } 198 | 199 | const publicSchemaObject = baseSchemaObject.omit({ 200 | simulatorId: true, 201 | simulatorName: true, 202 | } as const); 203 | 204 | export default { 205 | name: 'launch_app_sim', 206 | description: 'Launches an app in an iOS simulator.', 207 | schema: publicSchemaObject.shape, 208 | handler: createSessionAwareTool<LaunchAppSimParams>({ 209 | internalSchema: launchAppSimSchema as unknown as z.ZodType<LaunchAppSimParams>, 210 | logicFunction: launch_app_simLogic, 211 | getExecutor: getDefaultCommandExecutor, 212 | requirements: [ 213 | { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, 214 | ], 215 | exclusivePairs: [['simulatorId', 'simulatorName']], 216 | }), 217 | }; 218 | ```