This is page 12 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/project-scaffolding/scaffold_ios_project.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Utilities Plugin: Scaffold iOS Project 3 | * 4 | * Scaffold a new iOS project from templates. 5 | */ 6 | 7 | import { z } from 'zod'; 8 | import { join, dirname, basename } from 'path'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import { ValidationError } from '../../../utils/responses/index.ts'; 11 | import { TemplateManager } from '../../../utils/template/index.ts'; 12 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; 13 | import { 14 | getDefaultCommandExecutor, 15 | getDefaultFileSystemExecutor, 16 | } from '../../../utils/execution/index.ts'; 17 | import { ToolResponse } from '../../../types/common.ts'; 18 | 19 | // Common base schema for both iOS and macOS 20 | const BaseScaffoldSchema = z.object({ 21 | projectName: z.string().min(1).describe('Name of the new project'), 22 | outputPath: z.string().describe('Path where the project should be created'), 23 | bundleIdentifier: z 24 | .string() 25 | .optional() 26 | .describe( 27 | 'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname', 28 | ), 29 | displayName: z 30 | .string() 31 | .optional() 32 | .describe( 33 | 'App display name (shown on home screen/dock). If not provided, will use projectName', 34 | ), 35 | marketingVersion: z 36 | .string() 37 | .optional() 38 | .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'), 39 | currentProjectVersion: z 40 | .string() 41 | .optional() 42 | .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'), 43 | customizeNames: z 44 | .boolean() 45 | .default(true) 46 | .describe('Whether to customize project names and identifiers. Default is true.'), 47 | }); 48 | 49 | // iOS-specific schema 50 | const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({ 51 | deploymentTarget: z 52 | .string() 53 | .optional() 54 | .describe('iOS deployment target (e.g., 18.4, 17.0). If not provided, will use 18.4'), 55 | targetedDeviceFamily: z 56 | .array(z.enum(['iphone', 'ipad', 'universal'])) 57 | .optional() 58 | .describe('Targeted device families'), 59 | supportedOrientations: z 60 | .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down'])) 61 | .optional() 62 | .describe('Supported orientations for iPhone'), 63 | supportedOrientationsIpad: z 64 | .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down'])) 65 | .optional() 66 | .describe('Supported orientations for iPad'), 67 | }); 68 | 69 | /** 70 | * Convert orientation enum to iOS constant 71 | */ 72 | function orientationToIOSConstant(orientation: string): string { 73 | switch (orientation) { 74 | case 'Portrait': 75 | return 'UIInterfaceOrientationPortrait'; 76 | case 'PortraitUpsideDown': 77 | return 'UIInterfaceOrientationPortraitUpsideDown'; 78 | case 'LandscapeLeft': 79 | return 'UIInterfaceOrientationLandscapeLeft'; 80 | case 'LandscapeRight': 81 | return 'UIInterfaceOrientationLandscapeRight'; 82 | default: 83 | return orientation; 84 | } 85 | } 86 | 87 | /** 88 | * Convert device family enum to numeric value 89 | */ 90 | function deviceFamilyToNumeric(family: string): string { 91 | switch (family) { 92 | case 'iPhone': 93 | return '1'; 94 | case 'iPad': 95 | return '2'; 96 | case 'iPhone+iPad': 97 | return '1,2'; 98 | default: 99 | return '1,2'; 100 | } 101 | } 102 | 103 | /** 104 | * Update Package.swift file with deployment target 105 | */ 106 | function updatePackageSwiftFile(content: string, params: Record<string, unknown>): string { 107 | let result = content; 108 | 109 | const projectName = params.projectName as string; 110 | const platform = params.platform as string; 111 | const deploymentTarget = params.deploymentTarget as string | undefined; 112 | 113 | // Update ALL target name references in Package.swift 114 | const featureName = `${projectName}Feature`; 115 | const testName = `${projectName}FeatureTests`; 116 | 117 | // Replace ALL occurrences of MyProjectFeatureTests first (more specific) 118 | result = result.replace(/MyProjectFeatureTests/g, testName); 119 | // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after) 120 | result = result.replace(/MyProjectFeature/g, featureName); 121 | 122 | // Update deployment targets based on platform 123 | if (platform === 'iOS') { 124 | if (deploymentTarget) { 125 | // Extract major version (e.g., "17.0" -> "17") 126 | const majorVersion = deploymentTarget.split('.')[0]; 127 | result = result.replace(/\.iOS\(\.v\d+\)/, `.iOS(.v${majorVersion})`); 128 | } 129 | } 130 | 131 | return result; 132 | } 133 | 134 | /** 135 | * Update XCConfig file with scaffold parameters 136 | */ 137 | function updateXCConfigFile(content: string, params: Record<string, unknown>): string { 138 | let result = content; 139 | 140 | const projectName = params.projectName as string; 141 | const displayName = params.displayName as string | undefined; 142 | const bundleIdentifier = params.bundleIdentifier as string | undefined; 143 | const marketingVersion = params.marketingVersion as string | undefined; 144 | const currentProjectVersion = params.currentProjectVersion as string | undefined; 145 | const platform = params.platform as string; 146 | const deploymentTarget = params.deploymentTarget as string | undefined; 147 | const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined; 148 | const supportedOrientations = params.supportedOrientations as string[] | undefined; 149 | const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined; 150 | 151 | // Update project identity settings 152 | result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`); 153 | result = result.replace( 154 | /PRODUCT_DISPLAY_NAME = .+/g, 155 | `PRODUCT_DISPLAY_NAME = ${displayName ?? projectName}`, 156 | ); 157 | result = result.replace( 158 | /PRODUCT_BUNDLE_IDENTIFIER = .+/g, 159 | `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`, 160 | ); 161 | result = result.replace( 162 | /MARKETING_VERSION = .+/g, 163 | `MARKETING_VERSION = ${marketingVersion ?? '1.0'}`, 164 | ); 165 | result = result.replace( 166 | /CURRENT_PROJECT_VERSION = .+/g, 167 | `CURRENT_PROJECT_VERSION = ${currentProjectVersion ?? '1'}`, 168 | ); 169 | 170 | // Platform-specific updates 171 | if (platform === 'iOS') { 172 | // iOS deployment target 173 | if (deploymentTarget) { 174 | result = result.replace( 175 | /IPHONEOS_DEPLOYMENT_TARGET = .+/g, 176 | `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}`, 177 | ); 178 | } 179 | 180 | // Device family 181 | if (targetedDeviceFamily) { 182 | const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily); 183 | result = result.replace( 184 | /TARGETED_DEVICE_FAMILY = .+/g, 185 | `TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`, 186 | ); 187 | } 188 | 189 | // iPhone orientations 190 | if (supportedOrientations && supportedOrientations.length > 0) { 191 | // Filter out any empty strings and validate 192 | const validOrientations = supportedOrientations.filter((o: string) => o && o.trim() !== ''); 193 | if (validOrientations.length > 0) { 194 | const orientations = validOrientations.map(orientationToIOSConstant).join(' '); 195 | result = result.replace( 196 | /INFOPLIST_KEY_UISupportedInterfaceOrientations = .+/g, 197 | `INFOPLIST_KEY_UISupportedInterfaceOrientations = ${orientations}`, 198 | ); 199 | } 200 | } 201 | 202 | // iPad orientations 203 | if (supportedOrientationsIpad && supportedOrientationsIpad.length > 0) { 204 | // Filter out any empty strings and validate 205 | const validOrientations = supportedOrientationsIpad.filter( 206 | (o: string) => o && o.trim() !== '', 207 | ); 208 | if (validOrientations.length > 0) { 209 | const orientations = validOrientations.map(orientationToIOSConstant).join(' '); 210 | result = result.replace( 211 | /INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = .+/g, 212 | `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = ${orientations}`, 213 | ); 214 | } 215 | } 216 | 217 | // Update entitlements path for iOS 218 | result = result.replace( 219 | /CODE_SIGN_ENTITLEMENTS = .+/g, 220 | `CODE_SIGN_ENTITLEMENTS = Config/${projectName}.entitlements`, 221 | ); 222 | } 223 | 224 | // Update test bundle identifier and target name 225 | result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${projectName}`); 226 | 227 | // Update comments that reference MyProject in entitlements paths 228 | result = result.replace(/Config\/MyProject\.entitlements/g, `Config/${projectName}.entitlements`); 229 | 230 | return result; 231 | } 232 | 233 | /** 234 | * Replace placeholders in a string (for non-XCConfig files) 235 | */ 236 | function replacePlaceholders( 237 | content: string, 238 | projectName: string, 239 | bundleIdentifier: string, 240 | ): string { 241 | let result = content; 242 | 243 | // Replace project name 244 | result = result.replace(/MyProject/g, projectName); 245 | 246 | // Replace bundle identifier - check for both patterns used in templates 247 | if (bundleIdentifier) { 248 | result = result.replace(/com\.example\.MyProject/g, bundleIdentifier); 249 | result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier); 250 | } 251 | 252 | return result; 253 | } 254 | 255 | /** 256 | * Process a single file, replacing placeholders if it's a text file 257 | */ 258 | async function processFile( 259 | sourcePath: string, 260 | destPath: string, 261 | params: Record<string, unknown>, 262 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), 263 | ): Promise<void> { 264 | const projectName = params.projectName as string; 265 | const bundleIdentifierParam = params.bundleIdentifier as string | undefined; 266 | const customizeNames = params.customizeNames as boolean | undefined; 267 | 268 | // Determine the destination file path 269 | let finalDestPath = destPath; 270 | if (customizeNames) { 271 | // Replace MyProject in file/directory names 272 | const fileName = basename(destPath); 273 | const dirName = dirname(destPath); 274 | const newFileName = fileName.replace(/MyProject/g, projectName); 275 | finalDestPath = join(dirName, newFileName); 276 | } 277 | 278 | // Text file extensions that should be processed 279 | const textExtensions = [ 280 | '.swift', 281 | '.h', 282 | '.m', 283 | '.mm', 284 | '.cpp', 285 | '.c', 286 | '.pbxproj', 287 | '.plist', 288 | '.xcscheme', 289 | '.xctestplan', 290 | '.xcworkspacedata', 291 | '.xcconfig', 292 | '.json', 293 | '.xml', 294 | '.entitlements', 295 | '.storyboard', 296 | '.xib', 297 | '.md', 298 | ]; 299 | 300 | const ext = sourcePath.toLowerCase(); 301 | const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt)); 302 | const isXCConfig = sourcePath.endsWith('.xcconfig'); 303 | const isPackageSwift = sourcePath.endsWith('Package.swift'); 304 | 305 | if (isTextFile && customizeNames) { 306 | // Read the file content 307 | const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8'); 308 | 309 | let processedContent; 310 | 311 | if (isXCConfig) { 312 | // Use special XCConfig processing 313 | processedContent = updateXCConfigFile(content, params); 314 | } else if (isPackageSwift) { 315 | // Use special Package.swift processing 316 | processedContent = updatePackageSwiftFile(content, params); 317 | } else { 318 | // Use standard placeholder replacement 319 | const bundleIdentifier = 320 | bundleIdentifierParam ?? 321 | `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; 322 | processedContent = replacePlaceholders(content, projectName, bundleIdentifier); 323 | } 324 | 325 | await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); 326 | await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8'); 327 | } else { 328 | // Copy binary files as-is 329 | await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); 330 | await fileSystemExecutor.cp(sourcePath, finalDestPath); 331 | } 332 | } 333 | 334 | /** 335 | * Recursively process a directory 336 | */ 337 | async function processDirectory( 338 | sourceDir: string, 339 | destDir: string, 340 | params: Record<string, unknown>, 341 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), 342 | ): Promise<void> { 343 | const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true }); 344 | 345 | for (const entry of entries) { 346 | const entryTyped = entry as { name: string; isDirectory: () => boolean; isFile: () => boolean }; 347 | const sourcePath = join(sourceDir, entryTyped.name); 348 | let destName = entryTyped.name; 349 | 350 | if (params.customizeNames) { 351 | // Replace MyProject in directory names 352 | destName = destName.replace(/MyProject/g, params.projectName as string); 353 | } 354 | 355 | const destPath = join(destDir, destName); 356 | 357 | if (entryTyped.isDirectory()) { 358 | // Skip certain directories 359 | if (entryTyped.name === '.git' || entryTyped.name === 'xcuserdata') { 360 | continue; 361 | } 362 | await fileSystemExecutor.mkdir(destPath, { recursive: true }); 363 | await processDirectory(sourcePath, destPath, params, fileSystemExecutor); 364 | } else if (entryTyped.isFile()) { 365 | // Skip certain files 366 | if (entryTyped.name === '.DS_Store' || entryTyped.name.endsWith('.xcuserstate')) { 367 | continue; 368 | } 369 | await processFile(sourcePath, destPath, params, fileSystemExecutor); 370 | } 371 | } 372 | } 373 | 374 | // Use z.infer for type safety 375 | type ScaffoldIOSProjectParams = z.infer<typeof ScaffoldiOSProjectSchema>; 376 | 377 | /** 378 | * Logic function for scaffolding iOS projects 379 | */ 380 | export async function scaffold_ios_projectLogic( 381 | params: ScaffoldIOSProjectParams, 382 | commandExecutor: CommandExecutor, 383 | fileSystemExecutor: FileSystemExecutor, 384 | ): Promise<ToolResponse> { 385 | try { 386 | const projectParams = { ...params, platform: 'iOS' }; 387 | const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); 388 | 389 | const response = { 390 | success: true, 391 | projectPath, 392 | platform: 'iOS', 393 | message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`, 394 | nextSteps: [ 395 | `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, 396 | `Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, 397 | `Build and run on simulator: build_run_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, 398 | ], 399 | }; 400 | 401 | return { 402 | content: [ 403 | { 404 | type: 'text', 405 | text: JSON.stringify(response, null, 2), 406 | }, 407 | ], 408 | }; 409 | } catch (error) { 410 | log( 411 | 'error', 412 | `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`, 413 | ); 414 | 415 | return { 416 | content: [ 417 | { 418 | type: 'text', 419 | text: JSON.stringify( 420 | { 421 | success: false, 422 | error: error instanceof Error ? error.message : 'Unknown error occurred', 423 | }, 424 | null, 425 | 2, 426 | ), 427 | }, 428 | ], 429 | isError: true, 430 | }; 431 | } 432 | } 433 | 434 | /** 435 | * Scaffold a new iOS or macOS project 436 | */ 437 | async function scaffoldProject( 438 | params: Record<string, unknown>, 439 | commandExecutor?: CommandExecutor, 440 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), 441 | ): Promise<string> { 442 | const projectName = params.projectName as string; 443 | const outputPath = params.outputPath as string; 444 | const platform = params.platform as 'iOS' | 'macOS'; 445 | const customizeNames = (params.customizeNames as boolean | undefined) ?? true; 446 | 447 | log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`); 448 | 449 | // Validate project name 450 | if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) { 451 | throw new ValidationError( 452 | 'Project name must start with a letter and contain only letters, numbers, and underscores', 453 | ); 454 | } 455 | 456 | // Get template path from TemplateManager 457 | let templatePath; 458 | try { 459 | // Use the default command executor if not provided 460 | commandExecutor ??= getDefaultCommandExecutor(); 461 | 462 | templatePath = await TemplateManager.getTemplatePath( 463 | platform, 464 | commandExecutor, 465 | fileSystemExecutor, 466 | ); 467 | } catch (error) { 468 | throw new ValidationError( 469 | `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`, 470 | ); 471 | } 472 | 473 | // Use outputPath directly as the destination 474 | const projectPath = outputPath; 475 | 476 | // Check if the output directory already has Xcode project files 477 | const xcworkspaceExists = fileSystemExecutor.existsSync( 478 | join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`), 479 | ); 480 | const xcodeprojExists = fileSystemExecutor.existsSync( 481 | join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`), 482 | ); 483 | 484 | if (xcworkspaceExists || xcodeprojExists) { 485 | throw new ValidationError(`Xcode project files already exist in ${projectPath}`); 486 | } 487 | 488 | try { 489 | // Process the template directly into the output path 490 | await processDirectory(templatePath, projectPath, params, fileSystemExecutor); 491 | 492 | return projectPath; 493 | } finally { 494 | // Clean up downloaded template if needed 495 | await TemplateManager.cleanup(templatePath, fileSystemExecutor); 496 | } 497 | } 498 | 499 | export default { 500 | name: 'scaffold_ios_project', 501 | description: 502 | 'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.', 503 | schema: ScaffoldiOSProjectSchema.shape, 504 | async handler(args: Record<string, unknown>): Promise<ToolResponse> { 505 | const params = ScaffoldiOSProjectSchema.parse(args); 506 | return scaffold_ios_projectLogic( 507 | params, 508 | getDefaultCommandExecutor(), 509 | getDefaultFileSystemExecutor(), 510 | ); 511 | }, 512 | }; 513 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/test_macos.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for test_macos plugin (unified project/workspace) 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using dependency injection for deterministic testing 5 | */ 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { z } from 'zod'; 8 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 9 | import { sessionStore } from '../../../../utils/session-store.ts'; 10 | import testMacos, { testMacosLogic } from '../test_macos.ts'; 11 | 12 | describe('test_macos plugin (unified)', () => { 13 | beforeEach(() => { 14 | sessionStore.clear(); 15 | }); 16 | 17 | describe('Export Field Validation (Literal)', () => { 18 | it('should have correct name', () => { 19 | expect(testMacos.name).toBe('test_macos'); 20 | }); 21 | 22 | it('should have correct description', () => { 23 | expect(testMacos.description).toBe('Runs tests for a macOS target.'); 24 | }); 25 | 26 | it('should have handler function', () => { 27 | expect(typeof testMacos.handler).toBe('function'); 28 | }); 29 | 30 | it('should validate schema correctly', () => { 31 | const schema = z.object(testMacos.schema); 32 | 33 | expect(schema.safeParse({}).success).toBe(true); 34 | expect( 35 | schema.safeParse({ 36 | derivedDataPath: '/path/to/derived-data', 37 | extraArgs: ['--arg1', '--arg2'], 38 | preferXcodebuild: true, 39 | testRunnerEnv: { FOO: 'BAR' }, 40 | }).success, 41 | ).toBe(true); 42 | 43 | expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); 44 | expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); 45 | expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); 46 | expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); 47 | 48 | const schemaKeys = Object.keys(testMacos.schema).sort(); 49 | expect(schemaKeys).toEqual( 50 | ['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(), 51 | ); 52 | }); 53 | }); 54 | 55 | describe('Handler Requirements', () => { 56 | it('should require scheme before running', async () => { 57 | const result = await testMacos.handler({}); 58 | 59 | expect(result.isError).toBe(true); 60 | expect(result.content[0].text).toContain('scheme is required'); 61 | }); 62 | 63 | it('should require project or workspace when scheme default exists', async () => { 64 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 65 | 66 | const result = await testMacos.handler({}); 67 | 68 | expect(result.isError).toBe(true); 69 | expect(result.content[0].text).toContain('Provide a project or workspace'); 70 | }); 71 | 72 | it('should reject when both projectPath and workspacePath provided explicitly', async () => { 73 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 74 | 75 | const result = await testMacos.handler({ 76 | projectPath: '/path/to/project.xcodeproj', 77 | workspacePath: '/path/to/workspace.xcworkspace', 78 | }); 79 | 80 | expect(result.isError).toBe(true); 81 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 82 | }); 83 | }); 84 | 85 | describe('XOR Parameter Validation', () => { 86 | it('should validate that either projectPath or workspacePath is provided', async () => { 87 | // Should return error response when neither is provided 88 | const result = await testMacos.handler({ 89 | scheme: 'MyScheme', 90 | }); 91 | 92 | expect(result.isError).toBe(true); 93 | expect(result.content[0].text).toContain('Provide a project or workspace'); 94 | }); 95 | 96 | it('should validate that both projectPath and workspacePath cannot be provided', async () => { 97 | // Should return error response when both are provided 98 | const result = await testMacos.handler({ 99 | projectPath: '/path/to/project.xcodeproj', 100 | workspacePath: '/path/to/workspace.xcworkspace', 101 | scheme: 'MyScheme', 102 | }); 103 | 104 | expect(result.isError).toBe(true); 105 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 106 | }); 107 | 108 | it('should allow only projectPath', async () => { 109 | const mockExecutor = createMockExecutor({ 110 | success: true, 111 | output: 'Test Suite All Tests passed', 112 | }); 113 | 114 | const mockFileSystemExecutor = { 115 | mkdtemp: async () => '/tmp/test-123', 116 | rm: async () => {}, 117 | tmpdir: () => '/tmp', 118 | stat: async () => ({ isDirectory: () => true }), 119 | }; 120 | 121 | const result = await testMacosLogic( 122 | { 123 | projectPath: '/path/to/project.xcodeproj', 124 | scheme: 'MyScheme', 125 | }, 126 | mockExecutor, 127 | mockFileSystemExecutor, 128 | ); 129 | 130 | expect(result.content).toBeDefined(); 131 | expect(Array.isArray(result.content)).toBe(true); 132 | expect(result.isError).toBeUndefined(); 133 | }); 134 | 135 | it('should allow only workspacePath', async () => { 136 | const mockExecutor = createMockExecutor({ 137 | success: true, 138 | output: 'Test Suite All Tests passed', 139 | }); 140 | 141 | const mockFileSystemExecutor = { 142 | mkdtemp: async () => '/tmp/test-123', 143 | rm: async () => {}, 144 | tmpdir: () => '/tmp', 145 | stat: async () => ({ isDirectory: () => true }), 146 | }; 147 | 148 | const result = await testMacosLogic( 149 | { 150 | workspacePath: '/path/to/workspace.xcworkspace', 151 | scheme: 'MyScheme', 152 | }, 153 | mockExecutor, 154 | mockFileSystemExecutor, 155 | ); 156 | 157 | expect(result.content).toBeDefined(); 158 | expect(Array.isArray(result.content)).toBe(true); 159 | expect(result.isError).toBeUndefined(); 160 | }); 161 | }); 162 | 163 | describe('Handler Behavior (Complete Literal Returns)', () => { 164 | it('should return successful test response with workspace when xcodebuild succeeds', async () => { 165 | const mockExecutor = createMockExecutor({ 166 | success: true, 167 | output: 'Test Suite All Tests passed', 168 | }); 169 | 170 | // Mock file system dependencies 171 | const mockFileSystemExecutor = { 172 | mkdtemp: async () => '/tmp/test-123', 173 | rm: async () => {}, 174 | tmpdir: () => '/tmp', 175 | stat: async () => ({ isDirectory: () => true }), 176 | }; 177 | 178 | const result = await testMacosLogic( 179 | { 180 | workspacePath: '/path/to/workspace.xcworkspace', 181 | scheme: 'MyScheme', 182 | configuration: 'Debug', 183 | }, 184 | mockExecutor, 185 | mockFileSystemExecutor, 186 | ); 187 | 188 | expect(result.content).toBeDefined(); 189 | expect(Array.isArray(result.content)).toBe(true); 190 | expect(result.isError).toBeUndefined(); 191 | }); 192 | 193 | it('should return successful test response with project when xcodebuild succeeds', async () => { 194 | const mockExecutor = createMockExecutor({ 195 | success: true, 196 | output: 'Test Suite All Tests passed', 197 | }); 198 | 199 | // Mock file system dependencies 200 | const mockFileSystemExecutor = { 201 | mkdtemp: async () => '/tmp/test-123', 202 | rm: async () => {}, 203 | tmpdir: () => '/tmp', 204 | stat: async () => ({ isDirectory: () => true }), 205 | }; 206 | 207 | const result = await testMacosLogic( 208 | { 209 | projectPath: '/path/to/project.xcodeproj', 210 | scheme: 'MyScheme', 211 | configuration: 'Debug', 212 | }, 213 | mockExecutor, 214 | mockFileSystemExecutor, 215 | ); 216 | 217 | expect(result.content).toBeDefined(); 218 | expect(Array.isArray(result.content)).toBe(true); 219 | expect(result.isError).toBeUndefined(); 220 | }); 221 | 222 | it('should use default configuration when not provided', async () => { 223 | const mockExecutor = createMockExecutor({ 224 | success: true, 225 | output: 'Test Suite All Tests passed', 226 | }); 227 | 228 | // Mock file system dependencies 229 | const mockFileSystemExecutor = { 230 | mkdtemp: async () => '/tmp/test-123', 231 | rm: async () => {}, 232 | tmpdir: () => '/tmp', 233 | stat: async () => ({ isDirectory: () => true }), 234 | }; 235 | 236 | const result = await testMacosLogic( 237 | { 238 | workspacePath: '/path/to/workspace.xcworkspace', 239 | scheme: 'MyScheme', 240 | }, 241 | mockExecutor, 242 | mockFileSystemExecutor, 243 | ); 244 | 245 | expect(result.content).toBeDefined(); 246 | expect(Array.isArray(result.content)).toBe(true); 247 | expect(result.isError).toBeUndefined(); 248 | }); 249 | 250 | it('should handle optional parameters correctly', async () => { 251 | const mockExecutor = createMockExecutor({ 252 | success: true, 253 | output: 'Test Suite All Tests passed', 254 | }); 255 | 256 | // Mock file system dependencies 257 | const mockFileSystemExecutor = { 258 | mkdtemp: async () => '/tmp/test-123', 259 | rm: async () => {}, 260 | tmpdir: () => '/tmp', 261 | stat: async () => ({ isDirectory: () => true }), 262 | }; 263 | 264 | const result = await testMacosLogic( 265 | { 266 | workspacePath: '/path/to/workspace.xcworkspace', 267 | scheme: 'MyScheme', 268 | configuration: 'Release', 269 | derivedDataPath: '/custom/derived', 270 | extraArgs: ['--verbose'], 271 | preferXcodebuild: true, 272 | }, 273 | mockExecutor, 274 | mockFileSystemExecutor, 275 | ); 276 | 277 | expect(result.content).toBeDefined(); 278 | expect(Array.isArray(result.content)).toBe(true); 279 | expect(result.isError).toBeUndefined(); 280 | }); 281 | 282 | it('should handle successful test execution with minimal parameters', async () => { 283 | const mockExecutor = createMockExecutor({ 284 | success: true, 285 | output: 'Test Suite All Tests passed', 286 | }); 287 | 288 | // Mock file system dependencies 289 | const mockFileSystemExecutor = { 290 | mkdtemp: async () => '/tmp/test-123', 291 | rm: async () => {}, 292 | tmpdir: () => '/tmp', 293 | stat: async () => ({ isDirectory: () => true }), 294 | }; 295 | 296 | const result = await testMacosLogic( 297 | { 298 | workspacePath: '/path/to/MyProject.xcworkspace', 299 | scheme: 'MyApp', 300 | }, 301 | mockExecutor, 302 | mockFileSystemExecutor, 303 | ); 304 | 305 | expect(result.content).toBeDefined(); 306 | expect(Array.isArray(result.content)).toBe(true); 307 | expect(result.isError).toBeUndefined(); 308 | }); 309 | 310 | it('should return exact successful test response', async () => { 311 | // Track command execution calls 312 | const commandCalls: any[] = []; 313 | 314 | // Mock executor for successful test 315 | const mockExecutor = async ( 316 | command: string[], 317 | logPrefix?: string, 318 | useShell?: boolean, 319 | env?: Record<string, string>, 320 | ) => { 321 | commandCalls.push({ command, logPrefix, useShell, env }); 322 | 323 | // Handle xcresulttool command 324 | if (command.includes('xcresulttool')) { 325 | return { 326 | success: true, 327 | output: JSON.stringify({ 328 | title: 'Test Results', 329 | result: 'SUCCEEDED', 330 | totalTestCount: 5, 331 | passedTests: 5, 332 | failedTests: 0, 333 | skippedTests: 0, 334 | expectedFailures: 0, 335 | }), 336 | error: undefined, 337 | }; 338 | } 339 | 340 | return { 341 | success: true, 342 | output: 'Test Succeeded', 343 | error: undefined, 344 | process: { pid: 12345 }, 345 | }; 346 | }; 347 | 348 | // Mock file system dependencies using approved utility 349 | const mockFileSystemExecutor = { 350 | mkdtemp: async () => '/tmp/xcodebuild-test-abc123', 351 | rm: async () => {}, 352 | tmpdir: () => '/tmp', 353 | stat: async () => ({ isDirectory: () => true }), 354 | }; 355 | 356 | const result = await testMacosLogic( 357 | { 358 | workspacePath: '/path/to/MyProject.xcworkspace', 359 | scheme: 'MyScheme', 360 | }, 361 | mockExecutor, 362 | mockFileSystemExecutor, 363 | ); 364 | 365 | // Verify commands were called with correct parameters 366 | expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool 367 | expect(commandCalls[0].command).toEqual([ 368 | 'xcodebuild', 369 | '-workspace', 370 | '/path/to/MyProject.xcworkspace', 371 | '-scheme', 372 | 'MyScheme', 373 | '-configuration', 374 | 'Debug', 375 | '-skipMacroValidation', 376 | '-destination', 377 | 'platform=macOS', 378 | '-resultBundlePath', 379 | '/tmp/xcodebuild-test-abc123/TestResults.xcresult', 380 | 'test', 381 | ]); 382 | expect(commandCalls[0].logPrefix).toBe('Test Run'); 383 | expect(commandCalls[0].useShell).toBe(true); 384 | 385 | // Verify xcresulttool was called 386 | expect(commandCalls[1].command).toEqual([ 387 | 'xcrun', 388 | 'xcresulttool', 389 | 'get', 390 | 'test-results', 391 | 'summary', 392 | '--path', 393 | '/tmp/xcodebuild-test-abc123/TestResults.xcresult', 394 | ]); 395 | expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle'); 396 | 397 | expect(result.content).toEqual( 398 | expect.arrayContaining([ 399 | expect.objectContaining({ 400 | type: 'text', 401 | text: '✅ Test Run test succeeded for scheme MyScheme.', 402 | }), 403 | ]), 404 | ); 405 | }); 406 | 407 | it('should return exact test failure response', async () => { 408 | // Track command execution calls 409 | let callCount = 0; 410 | const mockExecutor = async ( 411 | command: string[], 412 | logPrefix?: string, 413 | useShell?: boolean, 414 | env?: Record<string, string>, 415 | ) => { 416 | callCount++; 417 | 418 | // First call is xcodebuild test - fails 419 | if (callCount === 1) { 420 | return { 421 | success: false, 422 | output: '', 423 | error: 'error: Test failed', 424 | process: { pid: 12345 }, 425 | }; 426 | } 427 | 428 | // Second call is xcresulttool 429 | if (command.includes('xcresulttool')) { 430 | return { 431 | success: true, 432 | output: JSON.stringify({ 433 | title: 'Test Results', 434 | result: 'FAILED', 435 | totalTestCount: 5, 436 | passedTests: 3, 437 | failedTests: 2, 438 | skippedTests: 0, 439 | expectedFailures: 0, 440 | }), 441 | error: undefined, 442 | }; 443 | } 444 | 445 | return { success: true, output: '', error: undefined }; 446 | }; 447 | 448 | // Mock file system dependencies 449 | const mockFileSystemExecutor = { 450 | mkdtemp: async () => '/tmp/xcodebuild-test-abc123', 451 | rm: async () => {}, 452 | tmpdir: () => '/tmp', 453 | stat: async () => ({ isDirectory: () => true }), 454 | }; 455 | 456 | const result = await testMacosLogic( 457 | { 458 | workspacePath: '/path/to/MyProject.xcworkspace', 459 | scheme: 'MyScheme', 460 | }, 461 | mockExecutor, 462 | mockFileSystemExecutor, 463 | ); 464 | 465 | expect(result.content).toEqual( 466 | expect.arrayContaining([ 467 | expect.objectContaining({ 468 | type: 'text', 469 | text: '❌ Test Run test failed for scheme MyScheme.', 470 | }), 471 | ]), 472 | ); 473 | expect(result.isError).toBe(true); 474 | }); 475 | 476 | it('should return exact successful test response with optional parameters', async () => { 477 | // Track command execution calls 478 | const commandCalls: any[] = []; 479 | 480 | // Mock executor for successful test with optional parameters 481 | const mockExecutor = async ( 482 | command: string[], 483 | logPrefix?: string, 484 | useShell?: boolean, 485 | env?: Record<string, string>, 486 | ) => { 487 | commandCalls.push({ command, logPrefix, useShell, env }); 488 | 489 | // Handle xcresulttool command 490 | if (command.includes('xcresulttool')) { 491 | return { 492 | success: true, 493 | output: JSON.stringify({ 494 | title: 'Test Results', 495 | result: 'SUCCEEDED', 496 | totalTestCount: 5, 497 | passedTests: 5, 498 | failedTests: 0, 499 | skippedTests: 0, 500 | expectedFailures: 0, 501 | }), 502 | error: undefined, 503 | }; 504 | } 505 | 506 | return { 507 | success: true, 508 | output: 'Test Succeeded', 509 | error: undefined, 510 | process: { pid: 12345 }, 511 | }; 512 | }; 513 | 514 | // Mock file system dependencies 515 | const mockFileSystemExecutor = { 516 | mkdtemp: async () => '/tmp/xcodebuild-test-abc123', 517 | rm: async () => {}, 518 | tmpdir: () => '/tmp', 519 | stat: async () => ({ isDirectory: () => true }), 520 | }; 521 | 522 | const result = await testMacosLogic( 523 | { 524 | workspacePath: '/path/to/MyProject.xcworkspace', 525 | scheme: 'MyScheme', 526 | configuration: 'Release', 527 | derivedDataPath: '/path/to/derived-data', 528 | extraArgs: ['--verbose'], 529 | preferXcodebuild: true, 530 | }, 531 | mockExecutor, 532 | mockFileSystemExecutor, 533 | ); 534 | 535 | expect(result.content).toEqual( 536 | expect.arrayContaining([ 537 | expect.objectContaining({ 538 | type: 'text', 539 | text: '✅ Test Run test succeeded for scheme MyScheme.', 540 | }), 541 | ]), 542 | ); 543 | }); 544 | 545 | it('should return exact exception handling response', async () => { 546 | // Mock executor (won't be called due to mkdtemp failure) 547 | const mockExecutor = createMockExecutor({ 548 | success: true, 549 | output: 'Test Succeeded', 550 | }); 551 | 552 | // Mock file system dependencies - mkdtemp fails 553 | const mockFileSystemExecutor = { 554 | mkdtemp: async () => { 555 | throw new Error('Network error'); 556 | }, 557 | rm: async () => {}, 558 | tmpdir: () => '/tmp', 559 | stat: async () => ({ isDirectory: () => true }), 560 | }; 561 | 562 | const result = await testMacosLogic( 563 | { 564 | workspacePath: '/path/to/MyProject.xcworkspace', 565 | scheme: 'MyScheme', 566 | }, 567 | mockExecutor, 568 | mockFileSystemExecutor, 569 | ); 570 | 571 | expect(result).toEqual({ 572 | content: [ 573 | { 574 | type: 'text', 575 | text: 'Error during test run: Network error', 576 | }, 577 | ], 578 | isError: true, 579 | }); 580 | }); 581 | }); 582 | }); 583 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/build_run_sim.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for build_run_sim plugin (unified) 3 | * Following CLAUDE.md testing standards with dependency injection and literal validation 4 | */ 5 | 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { z } from 'zod'; 8 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 9 | import { sessionStore } from '../../../../utils/session-store.ts'; 10 | import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts'; 11 | 12 | describe('build_run_sim tool', () => { 13 | beforeEach(() => { 14 | sessionStore.clear(); 15 | }); 16 | 17 | describe('Export Field Validation (Literal)', () => { 18 | it('should have correct name', () => { 19 | expect(buildRunSim.name).toBe('build_run_sim'); 20 | }); 21 | 22 | it('should have correct description', () => { 23 | expect(buildRunSim.description).toBe('Builds and runs an app on an iOS simulator.'); 24 | }); 25 | 26 | it('should have handler function', () => { 27 | expect(typeof buildRunSim.handler).toBe('function'); 28 | }); 29 | 30 | it('should expose only non-session fields in public schema', () => { 31 | const schema = z.object(buildRunSim.schema); 32 | 33 | expect(schema.safeParse({}).success).toBe(true); 34 | 35 | expect( 36 | schema.safeParse({ 37 | derivedDataPath: '/path/to/derived', 38 | extraArgs: ['--verbose'], 39 | preferXcodebuild: false, 40 | }).success, 41 | ).toBe(true); 42 | 43 | expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); 44 | expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); 45 | expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); 46 | 47 | const schemaKeys = Object.keys(buildRunSim.schema).sort(); 48 | expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); 49 | expect(schemaKeys).not.toContain('scheme'); 50 | expect(schemaKeys).not.toContain('simulatorName'); 51 | expect(schemaKeys).not.toContain('projectPath'); 52 | }); 53 | }); 54 | 55 | describe('Handler Behavior (Complete Literal Returns)', () => { 56 | // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema 57 | // The logic function receives validated parameters, so these tests focus on business logic 58 | 59 | it('should handle simulator not found', async () => { 60 | let callCount = 0; 61 | const mockExecutor = async (command: string[]) => { 62 | callCount++; 63 | if (callCount === 1) { 64 | // First call: build succeeds 65 | return { 66 | success: true, 67 | output: 'BUILD SUCCEEDED', 68 | process: { pid: 12345 }, 69 | }; 70 | } else if (callCount === 2) { 71 | // Second call: showBuildSettings fails to get app path 72 | return { 73 | success: false, 74 | error: 'Could not get build settings', 75 | process: { pid: 12345 }, 76 | }; 77 | } 78 | return { 79 | success: false, 80 | error: 'Unexpected call', 81 | process: { pid: 12345 }, 82 | }; 83 | }; 84 | 85 | const result = await build_run_simLogic( 86 | { 87 | workspacePath: '/path/to/workspace', 88 | scheme: 'MyScheme', 89 | simulatorName: 'iPhone 16', 90 | }, 91 | mockExecutor, 92 | ); 93 | 94 | expect(result).toEqual({ 95 | content: [ 96 | { 97 | type: 'text', 98 | text: 'Build succeeded, but failed to get app path: Could not get build settings', 99 | }, 100 | ], 101 | isError: true, 102 | }); 103 | }); 104 | 105 | it('should handle build failure', async () => { 106 | const mockExecutor = createMockExecutor({ 107 | success: false, 108 | error: 'Build failed with error', 109 | }); 110 | 111 | const result = await build_run_simLogic( 112 | { 113 | workspacePath: '/path/to/workspace', 114 | scheme: 'MyScheme', 115 | simulatorName: 'iPhone 16', 116 | }, 117 | mockExecutor, 118 | ); 119 | 120 | expect(result.isError).toBe(true); 121 | expect(result.content).toBeDefined(); 122 | expect(Array.isArray(result.content)).toBe(true); 123 | }); 124 | 125 | it('should handle successful build and run', async () => { 126 | // Create a mock executor that simulates full successful flow 127 | let callCount = 0; 128 | const mockExecutor = async (command: string[], logPrefix?: string) => { 129 | callCount++; 130 | 131 | if (command.includes('xcodebuild') && command.includes('build')) { 132 | // First call: build succeeds 133 | return { 134 | success: true, 135 | output: 'BUILD SUCCEEDED', 136 | process: { pid: 12345 }, 137 | }; 138 | } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { 139 | // Second call: build settings to get app path 140 | return { 141 | success: true, 142 | output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', 143 | process: { pid: 12345 }, 144 | }; 145 | } else if (command.includes('simctl') && command.includes('list')) { 146 | // Find simulator calls 147 | return { 148 | success: true, 149 | output: JSON.stringify({ 150 | devices: { 151 | 'iOS 16.0': [ 152 | { 153 | udid: 'test-uuid-123', 154 | name: 'iPhone 16', 155 | state: 'Booted', 156 | isAvailable: true, 157 | }, 158 | ], 159 | }, 160 | }), 161 | process: { pid: 12345 }, 162 | }; 163 | } else if ( 164 | command.includes('plutil') || 165 | command.includes('PlistBuddy') || 166 | command.includes('defaults') 167 | ) { 168 | // Bundle ID extraction 169 | return { 170 | success: true, 171 | output: 'com.example.MyApp', 172 | process: { pid: 12345 }, 173 | }; 174 | } else { 175 | // All other commands (boot, open, install, launch) succeed 176 | return { 177 | success: true, 178 | output: 'Success', 179 | process: { pid: 12345 }, 180 | }; 181 | } 182 | }; 183 | 184 | const result = await build_run_simLogic( 185 | { 186 | workspacePath: '/path/to/workspace', 187 | scheme: 'MyScheme', 188 | simulatorName: 'iPhone 16', 189 | }, 190 | mockExecutor, 191 | ); 192 | 193 | expect(result.content).toBeDefined(); 194 | expect(Array.isArray(result.content)).toBe(true); 195 | expect(result.isError).toBe(false); 196 | }); 197 | 198 | it('should handle exception with Error object', async () => { 199 | const mockExecutor = createMockExecutor({ 200 | success: false, 201 | error: 'Command failed', 202 | }); 203 | 204 | const result = await build_run_simLogic( 205 | { 206 | workspacePath: '/path/to/workspace', 207 | scheme: 'MyScheme', 208 | simulatorName: 'iPhone 16', 209 | }, 210 | mockExecutor, 211 | ); 212 | 213 | expect(result.isError).toBe(true); 214 | expect(result.content).toBeDefined(); 215 | expect(Array.isArray(result.content)).toBe(true); 216 | }); 217 | 218 | it('should handle exception with string error', async () => { 219 | const mockExecutor = createMockExecutor({ 220 | success: false, 221 | error: 'String error', 222 | }); 223 | 224 | const result = await build_run_simLogic( 225 | { 226 | workspacePath: '/path/to/workspace', 227 | scheme: 'MyScheme', 228 | simulatorName: 'iPhone 16', 229 | }, 230 | mockExecutor, 231 | ); 232 | 233 | expect(result.isError).toBe(true); 234 | expect(result.content).toBeDefined(); 235 | expect(Array.isArray(result.content)).toBe(true); 236 | }); 237 | }); 238 | 239 | describe('Command Generation', () => { 240 | it('should generate correct simctl list command with minimal parameters', async () => { 241 | const callHistory: Array<{ 242 | command: string[]; 243 | logPrefix?: string; 244 | useShell?: boolean; 245 | env?: any; 246 | }> = []; 247 | 248 | // Create tracking executor 249 | const trackingExecutor = async ( 250 | command: string[], 251 | logPrefix?: string, 252 | useShell?: boolean, 253 | env?: Record<string, string>, 254 | ) => { 255 | callHistory.push({ command, logPrefix, useShell, env }); 256 | return { 257 | success: false, 258 | output: '', 259 | error: 'Test error to stop execution early', 260 | process: { pid: 12345 }, 261 | }; 262 | }; 263 | 264 | const result = await build_run_simLogic( 265 | { 266 | workspacePath: '/path/to/MyProject.xcworkspace', 267 | scheme: 'MyScheme', 268 | simulatorName: 'iPhone 16', 269 | }, 270 | trackingExecutor, 271 | ); 272 | 273 | // Should generate the initial build command 274 | expect(callHistory).toHaveLength(1); 275 | expect(callHistory[0].command).toEqual([ 276 | 'xcodebuild', 277 | '-workspace', 278 | '/path/to/MyProject.xcworkspace', 279 | '-scheme', 280 | 'MyScheme', 281 | '-configuration', 282 | 'Debug', 283 | '-skipMacroValidation', 284 | '-destination', 285 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 286 | 'build', 287 | ]); 288 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 289 | }); 290 | 291 | it('should generate correct build command after finding simulator', async () => { 292 | const callHistory: Array<{ 293 | command: string[]; 294 | logPrefix?: string; 295 | useShell?: boolean; 296 | env?: any; 297 | }> = []; 298 | 299 | let callCount = 0; 300 | // Create tracking executor that succeeds on first call (list) and fails on second 301 | const trackingExecutor = async ( 302 | command: string[], 303 | logPrefix?: string, 304 | useShell?: boolean, 305 | env?: Record<string, string>, 306 | ) => { 307 | callHistory.push({ command, logPrefix, useShell, env }); 308 | callCount++; 309 | 310 | if (callCount === 1) { 311 | // First call: simulator list succeeds 312 | return { 313 | success: true, 314 | output: JSON.stringify({ 315 | devices: { 316 | 'iOS 16.0': [ 317 | { 318 | udid: 'test-uuid-123', 319 | name: 'iPhone 16', 320 | state: 'Booted', 321 | }, 322 | ], 323 | }, 324 | }), 325 | error: undefined, 326 | process: { pid: 12345 }, 327 | }; 328 | } else { 329 | // Second call: build command fails to stop execution 330 | return { 331 | success: false, 332 | output: '', 333 | error: 'Test error to stop execution', 334 | process: { pid: 12345 }, 335 | }; 336 | } 337 | }; 338 | 339 | const result = await build_run_simLogic( 340 | { 341 | workspacePath: '/path/to/MyProject.xcworkspace', 342 | scheme: 'MyScheme', 343 | simulatorName: 'iPhone 16', 344 | }, 345 | trackingExecutor, 346 | ); 347 | 348 | // Should generate build command and then build settings command 349 | expect(callHistory).toHaveLength(2); 350 | 351 | // First call: build command 352 | expect(callHistory[0].command).toEqual([ 353 | 'xcodebuild', 354 | '-workspace', 355 | '/path/to/MyProject.xcworkspace', 356 | '-scheme', 357 | 'MyScheme', 358 | '-configuration', 359 | 'Debug', 360 | '-skipMacroValidation', 361 | '-destination', 362 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 363 | 'build', 364 | ]); 365 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 366 | 367 | // Second call: build settings command to get app path 368 | expect(callHistory[1].command).toEqual([ 369 | 'xcodebuild', 370 | '-showBuildSettings', 371 | '-workspace', 372 | '/path/to/MyProject.xcworkspace', 373 | '-scheme', 374 | 'MyScheme', 375 | '-configuration', 376 | 'Debug', 377 | '-destination', 378 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 379 | ]); 380 | expect(callHistory[1].logPrefix).toBe('Get App Path'); 381 | }); 382 | 383 | it('should generate correct build settings command after successful build', async () => { 384 | const callHistory: Array<{ 385 | command: string[]; 386 | logPrefix?: string; 387 | useShell?: boolean; 388 | env?: any; 389 | }> = []; 390 | 391 | let callCount = 0; 392 | // Create tracking executor that succeeds on first two calls and fails on third 393 | const trackingExecutor = async ( 394 | command: string[], 395 | logPrefix?: string, 396 | useShell?: boolean, 397 | env?: Record<string, string>, 398 | ) => { 399 | callHistory.push({ command, logPrefix, useShell, env }); 400 | callCount++; 401 | 402 | if (callCount === 1) { 403 | // First call: simulator list succeeds 404 | return { 405 | success: true, 406 | output: JSON.stringify({ 407 | devices: { 408 | 'iOS 16.0': [ 409 | { 410 | udid: 'test-uuid-123', 411 | name: 'iPhone 16', 412 | state: 'Booted', 413 | }, 414 | ], 415 | }, 416 | }), 417 | error: undefined, 418 | process: { pid: 12345 }, 419 | }; 420 | } else if (callCount === 2) { 421 | // Second call: build command succeeds 422 | return { 423 | success: true, 424 | output: 'BUILD SUCCEEDED', 425 | error: undefined, 426 | process: { pid: 12345 }, 427 | }; 428 | } else { 429 | // Third call: build settings command fails to stop execution 430 | return { 431 | success: false, 432 | output: '', 433 | error: 'Test error to stop execution', 434 | process: { pid: 12345 }, 435 | }; 436 | } 437 | }; 438 | 439 | const result = await build_run_simLogic( 440 | { 441 | workspacePath: '/path/to/MyProject.xcworkspace', 442 | scheme: 'MyScheme', 443 | simulatorName: 'iPhone 16', 444 | configuration: 'Release', 445 | useLatestOS: false, 446 | }, 447 | trackingExecutor, 448 | ); 449 | 450 | // Should generate build command and build settings command 451 | expect(callHistory).toHaveLength(2); 452 | 453 | // First call: build command 454 | expect(callHistory[0].command).toEqual([ 455 | 'xcodebuild', 456 | '-workspace', 457 | '/path/to/MyProject.xcworkspace', 458 | '-scheme', 459 | 'MyScheme', 460 | '-configuration', 461 | 'Release', 462 | '-skipMacroValidation', 463 | '-destination', 464 | 'platform=iOS Simulator,name=iPhone 16', 465 | 'build', 466 | ]); 467 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 468 | 469 | // Second call: build settings command 470 | expect(callHistory[1].command).toEqual([ 471 | 'xcodebuild', 472 | '-showBuildSettings', 473 | '-workspace', 474 | '/path/to/MyProject.xcworkspace', 475 | '-scheme', 476 | 'MyScheme', 477 | '-configuration', 478 | 'Release', 479 | '-destination', 480 | 'platform=iOS Simulator,name=iPhone 16', 481 | ]); 482 | expect(callHistory[1].logPrefix).toBe('Get App Path'); 483 | }); 484 | 485 | it('should handle paths with spaces in command generation', async () => { 486 | const callHistory: Array<{ 487 | command: string[]; 488 | logPrefix?: string; 489 | useShell?: boolean; 490 | env?: any; 491 | }> = []; 492 | 493 | // Create tracking executor 494 | const trackingExecutor = async ( 495 | command: string[], 496 | logPrefix?: string, 497 | useShell?: boolean, 498 | env?: Record<string, string>, 499 | ) => { 500 | callHistory.push({ command, logPrefix, useShell, env }); 501 | return { 502 | success: false, 503 | output: '', 504 | error: 'Test error to stop execution early', 505 | process: { pid: 12345 }, 506 | }; 507 | }; 508 | 509 | const result = await build_run_simLogic( 510 | { 511 | workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', 512 | scheme: 'My Scheme', 513 | simulatorName: 'iPhone 16 Pro', 514 | }, 515 | trackingExecutor, 516 | ); 517 | 518 | // Should generate build command first 519 | expect(callHistory).toHaveLength(1); 520 | expect(callHistory[0].command).toEqual([ 521 | 'xcodebuild', 522 | '-workspace', 523 | '/Users/dev/My Project/MyProject.xcworkspace', 524 | '-scheme', 525 | 'My Scheme', 526 | '-configuration', 527 | 'Debug', 528 | '-skipMacroValidation', 529 | '-destination', 530 | 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', 531 | 'build', 532 | ]); 533 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 534 | }); 535 | }); 536 | 537 | describe('XOR Validation', () => { 538 | it('should error when neither projectPath nor workspacePath provided', async () => { 539 | const result = await buildRunSim.handler({ 540 | scheme: 'MyScheme', 541 | simulatorName: 'iPhone 16', 542 | }); 543 | expect(result.isError).toBe(true); 544 | expect(result.content[0].text).toContain('Missing required session defaults'); 545 | expect(result.content[0].text).toContain('Provide a project or workspace'); 546 | }); 547 | 548 | it('should error when both projectPath and workspacePath provided', async () => { 549 | const result = await buildRunSim.handler({ 550 | projectPath: '/path/project.xcodeproj', 551 | workspacePath: '/path/workspace.xcworkspace', 552 | scheme: 'MyScheme', 553 | simulatorName: 'iPhone 16', 554 | }); 555 | expect(result.isError).toBe(true); 556 | expect(result.content[0].text).toContain('Parameter validation failed'); 557 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 558 | expect(result.content[0].text).toContain('projectPath'); 559 | expect(result.content[0].text).toContain('workspacePath'); 560 | }); 561 | 562 | it('should succeed with only projectPath', async () => { 563 | // This test fails early due to build failure, which is expected behavior 564 | const mockExecutor = createMockExecutor({ 565 | success: false, 566 | error: 'Build failed', 567 | }); 568 | 569 | const result = await build_run_simLogic( 570 | { 571 | projectPath: '/path/project.xcodeproj', 572 | scheme: 'MyScheme', 573 | simulatorName: 'iPhone 16', 574 | }, 575 | mockExecutor, 576 | ); 577 | // The test succeeds if the logic function accepts the parameters and attempts to build 578 | expect(result.isError).toBe(true); 579 | expect(result.content[0].text).toContain('Build failed'); 580 | }); 581 | 582 | it('should succeed with only workspacePath', async () => { 583 | // This test fails early due to build failure, which is expected behavior 584 | const mockExecutor = createMockExecutor({ 585 | success: false, 586 | error: 'Build failed', 587 | }); 588 | 589 | const result = await build_run_simLogic( 590 | { 591 | workspacePath: '/path/workspace.xcworkspace', 592 | scheme: 'MyScheme', 593 | simulatorName: 'iPhone 16', 594 | }, 595 | mockExecutor, 596 | ); 597 | // The test succeeds if the logic function accepts the parameters and attempts to build 598 | expect(result.isError).toBe(true); 599 | expect(result.content[0].text).toContain('Build failed'); 600 | }); 601 | }); 602 | }); 603 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/build_run_sim.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Simulator Build & Run Plugin: Build Run Simulator (Unified) 3 | * 4 | * Builds and runs an app from a project or workspace on 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 { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; 11 | import { log } from '../../../utils/logging/index.ts'; 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 14 | import { createTextResponse } from '../../../utils/responses/index.ts'; 15 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; 16 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 17 | import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; 18 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 19 | 20 | // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName 21 | const baseOptions = { 22 | scheme: z.string().describe('The scheme to use (Required)'), 23 | simulatorId: z 24 | .string() 25 | .optional() 26 | .describe( 27 | 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', 28 | ), 29 | simulatorName: z 30 | .string() 31 | .optional() 32 | .describe( 33 | "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", 34 | ), 35 | configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), 36 | derivedDataPath: z 37 | .string() 38 | .optional() 39 | .describe('Path where build products and other derived data will go'), 40 | extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), 41 | useLatestOS: z 42 | .boolean() 43 | .optional() 44 | .describe('Whether to use the latest OS version for the named simulator'), 45 | preferXcodebuild: z 46 | .boolean() 47 | .optional() 48 | .describe( 49 | 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', 50 | ), 51 | }; 52 | 53 | const baseSchemaObject = z.object({ 54 | projectPath: z 55 | .string() 56 | .optional() 57 | .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), 58 | workspacePath: z 59 | .string() 60 | .optional() 61 | .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), 62 | ...baseOptions, 63 | }); 64 | 65 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 66 | 67 | const buildRunSimulatorSchema = baseSchema 68 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 69 | message: 'Either projectPath or workspacePath is required.', 70 | }) 71 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 72 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 73 | }) 74 | .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { 75 | message: 'Either simulatorId or simulatorName is required.', 76 | }) 77 | .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { 78 | message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', 79 | }); 80 | 81 | export type BuildRunSimulatorParams = z.infer<typeof buildRunSimulatorSchema>; 82 | 83 | // Internal logic for building Simulator apps. 84 | async function _handleSimulatorBuildLogic( 85 | params: BuildRunSimulatorParams, 86 | executor: CommandExecutor, 87 | executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, 88 | ): Promise<ToolResponse> { 89 | const projectType = params.projectPath ? 'project' : 'workspace'; 90 | const filePath = params.projectPath ?? params.workspacePath; 91 | 92 | // Log warning if useLatestOS is provided with simulatorId 93 | if (params.simulatorId && params.useLatestOS !== undefined) { 94 | log( 95 | 'warning', 96 | `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, 97 | ); 98 | } 99 | 100 | log( 101 | 'info', 102 | `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, 103 | ); 104 | 105 | // Create SharedBuildParams object with required configuration property 106 | const sharedBuildParams: SharedBuildParams = { 107 | workspacePath: params.workspacePath, 108 | projectPath: params.projectPath, 109 | scheme: params.scheme, 110 | configuration: params.configuration ?? 'Debug', 111 | derivedDataPath: params.derivedDataPath, 112 | extraArgs: params.extraArgs, 113 | }; 114 | 115 | return executeXcodeBuildCommandFn( 116 | sharedBuildParams, 117 | { 118 | platform: XcodePlatform.iOSSimulator, 119 | simulatorId: params.simulatorId, 120 | simulatorName: params.simulatorName, 121 | useLatestOS: params.simulatorId ? false : params.useLatestOS, 122 | logPrefix: 'iOS Simulator Build', 123 | }, 124 | params.preferXcodebuild as boolean, 125 | 'build', 126 | executor, 127 | ); 128 | } 129 | 130 | // Exported business logic function for building and running iOS Simulator apps. 131 | export async function build_run_simLogic( 132 | params: BuildRunSimulatorParams, 133 | executor: CommandExecutor, 134 | executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, 135 | ): Promise<ToolResponse> { 136 | const projectType = params.projectPath ? 'project' : 'workspace'; 137 | const filePath = params.projectPath ?? params.workspacePath; 138 | 139 | log( 140 | 'info', 141 | `Starting iOS Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, 142 | ); 143 | 144 | try { 145 | // --- Build Step --- 146 | const buildResult = await _handleSimulatorBuildLogic( 147 | params, 148 | executor, 149 | executeXcodeBuildCommandFn, 150 | ); 151 | 152 | if (buildResult.isError) { 153 | return buildResult; // Return the build error 154 | } 155 | 156 | // --- Get App Path Step --- 157 | // Create the command array for xcodebuild with -showBuildSettings option 158 | const command = ['xcodebuild', '-showBuildSettings']; 159 | 160 | // Add the workspace or project 161 | if (params.workspacePath) { 162 | command.push('-workspace', params.workspacePath); 163 | } else if (params.projectPath) { 164 | command.push('-project', params.projectPath); 165 | } 166 | 167 | // Add the scheme and configuration 168 | command.push('-scheme', params.scheme); 169 | command.push('-configuration', params.configuration ?? 'Debug'); 170 | 171 | // Handle destination for simulator 172 | let destinationString: string; 173 | if (params.simulatorId) { 174 | destinationString = `platform=iOS Simulator,id=${params.simulatorId}`; 175 | } else if (params.simulatorName) { 176 | destinationString = `platform=iOS Simulator,name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; 177 | } else { 178 | // This shouldn't happen due to validation, but handle it 179 | destinationString = 'platform=iOS Simulator'; 180 | } 181 | command.push('-destination', destinationString); 182 | 183 | // Add derived data path if provided 184 | if (params.derivedDataPath) { 185 | command.push('-derivedDataPath', params.derivedDataPath); 186 | } 187 | 188 | // Add extra args if provided 189 | if (params.extraArgs && params.extraArgs.length > 0) { 190 | command.push(...params.extraArgs); 191 | } 192 | 193 | // Execute the command directly 194 | const result = await executor(command, 'Get App Path', true, undefined); 195 | 196 | // If there was an error with the command execution, return it 197 | if (!result.success) { 198 | return createTextResponse( 199 | `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`, 200 | true, 201 | ); 202 | } 203 | 204 | // Parse the output to extract the app path 205 | const buildSettingsOutput = result.output; 206 | 207 | // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH) 208 | let appBundlePath: string | null = null; 209 | 210 | // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path 211 | const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/); 212 | if (appPathMatch?.[1]) { 213 | appBundlePath = appPathMatch[1].trim(); 214 | } else { 215 | // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME 216 | const builtProductsDirMatch = buildSettingsOutput.match( 217 | /^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m, 218 | ); 219 | const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); 220 | 221 | if (builtProductsDirMatch && fullProductNameMatch) { 222 | const builtProductsDir = builtProductsDirMatch[1].trim(); 223 | const fullProductName = fullProductNameMatch[1].trim(); 224 | appBundlePath = `${builtProductsDir}/${fullProductName}`; 225 | } 226 | } 227 | 228 | if (!appBundlePath) { 229 | return createTextResponse( 230 | `Build succeeded, but could not find app path in build settings.`, 231 | true, 232 | ); 233 | } 234 | 235 | log('info', `App bundle path for run: ${appBundlePath}`); 236 | 237 | // --- Find/Boot Simulator Step --- 238 | // Use our helper to determine the simulator UUID 239 | const uuidResult = await determineSimulatorUuid( 240 | { simulatorUuid: params.simulatorId, simulatorName: params.simulatorName }, 241 | executor, 242 | ); 243 | 244 | if (uuidResult.error) { 245 | return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true); 246 | } 247 | 248 | if (uuidResult.warning) { 249 | log('warning', uuidResult.warning); 250 | } 251 | 252 | const simulatorUuid = uuidResult.uuid; 253 | 254 | if (!simulatorUuid) { 255 | return createTextResponse( 256 | 'Build succeeded, but no simulator specified and failed to find a suitable one.', 257 | true, 258 | ); 259 | } 260 | 261 | // Check simulator state and boot if needed 262 | try { 263 | log('info', `Checking simulator state for UUID: ${simulatorUuid}`); 264 | const simulatorListResult = await executor( 265 | ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 266 | 'List Simulators', 267 | ); 268 | if (!simulatorListResult.success) { 269 | throw new Error(simulatorListResult.error ?? 'Failed to list simulators'); 270 | } 271 | 272 | const simulatorsData = JSON.parse(simulatorListResult.output) as { 273 | devices: Record<string, unknown[]>; 274 | }; 275 | let targetSimulator: { udid: string; name: string; state: string } | null = null; 276 | 277 | // Find the target simulator 278 | for (const runtime in simulatorsData.devices) { 279 | const devices = simulatorsData.devices[runtime]; 280 | if (Array.isArray(devices)) { 281 | for (const device of devices) { 282 | if ( 283 | typeof device === 'object' && 284 | device !== null && 285 | 'udid' in device && 286 | 'name' in device && 287 | 'state' in device && 288 | typeof device.udid === 'string' && 289 | typeof device.name === 'string' && 290 | typeof device.state === 'string' && 291 | device.udid === simulatorUuid 292 | ) { 293 | targetSimulator = { 294 | udid: device.udid, 295 | name: device.name, 296 | state: device.state, 297 | }; 298 | break; 299 | } 300 | } 301 | if (targetSimulator) break; 302 | } 303 | } 304 | 305 | if (!targetSimulator) { 306 | return createTextResponse( 307 | `Build succeeded, but could not find simulator with UUID: ${simulatorUuid}`, 308 | true, 309 | ); 310 | } 311 | 312 | // Boot if needed 313 | if (targetSimulator.state !== 'Booted') { 314 | log('info', `Booting simulator ${targetSimulator.name}...`); 315 | const bootResult = await executor( 316 | ['xcrun', 'simctl', 'boot', simulatorUuid], 317 | 'Boot Simulator', 318 | ); 319 | if (!bootResult.success) { 320 | throw new Error(bootResult.error ?? 'Failed to boot simulator'); 321 | } 322 | } else { 323 | log('info', `Simulator ${simulatorUuid} is already booted`); 324 | } 325 | } catch (error) { 326 | const errorMessage = error instanceof Error ? error.message : String(error); 327 | log('error', `Error checking/booting simulator: ${errorMessage}`); 328 | return createTextResponse( 329 | `Build succeeded, but error checking/booting simulator: ${errorMessage}`, 330 | true, 331 | ); 332 | } 333 | 334 | // --- Open Simulator UI Step --- 335 | try { 336 | log('info', 'Opening Simulator app'); 337 | const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App'); 338 | if (!openResult.success) { 339 | throw new Error(openResult.error ?? 'Failed to open Simulator app'); 340 | } 341 | } catch (error) { 342 | const errorMessage = error instanceof Error ? error.message : String(error); 343 | log('warning', `Warning: Could not open Simulator app: ${errorMessage}`); 344 | // Don't fail the whole operation for this 345 | } 346 | 347 | // --- Install App Step --- 348 | try { 349 | log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorUuid}`); 350 | const installResult = await executor( 351 | ['xcrun', 'simctl', 'install', simulatorUuid, appBundlePath], 352 | 'Install App', 353 | ); 354 | if (!installResult.success) { 355 | throw new Error(installResult.error ?? 'Failed to install app'); 356 | } 357 | } catch (error) { 358 | const errorMessage = error instanceof Error ? error.message : String(error); 359 | log('error', `Error installing app: ${errorMessage}`); 360 | return createTextResponse( 361 | `Build succeeded, but error installing app on simulator: ${errorMessage}`, 362 | true, 363 | ); 364 | } 365 | 366 | // --- Get Bundle ID Step --- 367 | let bundleId; 368 | try { 369 | log('info', `Extracting bundle ID from app: ${appBundlePath}`); 370 | 371 | // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults 372 | let bundleIdResult = null; 373 | 374 | // Method 1: PlistBuddy (most reliable) 375 | try { 376 | bundleIdResult = await executor( 377 | [ 378 | '/usr/libexec/PlistBuddy', 379 | '-c', 380 | 'Print :CFBundleIdentifier', 381 | `${appBundlePath}/Info.plist`, 382 | ], 383 | 'Get Bundle ID with PlistBuddy', 384 | ); 385 | if (bundleIdResult.success) { 386 | bundleId = bundleIdResult.output.trim(); 387 | } 388 | } catch { 389 | // Continue to next method 390 | } 391 | 392 | // Method 2: plutil (workspace approach) 393 | if (!bundleId) { 394 | try { 395 | bundleIdResult = await executor( 396 | ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`], 397 | 'Get Bundle ID with plutil', 398 | ); 399 | if (bundleIdResult?.success) { 400 | bundleId = bundleIdResult.output?.trim(); 401 | } 402 | } catch { 403 | // Continue to next method 404 | } 405 | } 406 | 407 | // Method 3: defaults (fallback) 408 | if (!bundleId) { 409 | try { 410 | bundleIdResult = await executor( 411 | ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'], 412 | 'Get Bundle ID with defaults', 413 | ); 414 | if (bundleIdResult?.success) { 415 | bundleId = bundleIdResult.output?.trim(); 416 | } 417 | } catch { 418 | // All methods failed 419 | } 420 | } 421 | 422 | if (!bundleId) { 423 | throw new Error('Could not extract bundle ID from Info.plist using any method'); 424 | } 425 | 426 | log('info', `Bundle ID for run: ${bundleId}`); 427 | } catch (error) { 428 | const errorMessage = error instanceof Error ? error.message : String(error); 429 | log('error', `Error getting bundle ID: ${errorMessage}`); 430 | return createTextResponse( 431 | `Build and install succeeded, but error getting bundle ID: ${errorMessage}`, 432 | true, 433 | ); 434 | } 435 | 436 | // --- Launch App Step --- 437 | try { 438 | log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorUuid}`); 439 | const launchResult = await executor( 440 | ['xcrun', 'simctl', 'launch', simulatorUuid, bundleId], 441 | 'Launch App', 442 | ); 443 | if (!launchResult.success) { 444 | throw new Error(launchResult.error ?? 'Failed to launch app'); 445 | } 446 | } catch (error) { 447 | const errorMessage = error instanceof Error ? error.message : String(error); 448 | log('error', `Error launching app: ${errorMessage}`); 449 | return createTextResponse( 450 | `Build and install succeeded, but error launching app on simulator: ${errorMessage}`, 451 | true, 452 | ); 453 | } 454 | 455 | // --- Success --- 456 | log('info', '✅ iOS simulator build & run succeeded.'); 457 | 458 | const target = params.simulatorId 459 | ? `simulator UUID '${params.simulatorId}'` 460 | : `simulator name '${params.simulatorName}'`; 461 | const sourceType = params.projectPath ? 'project' : 'workspace'; 462 | const sourcePath = params.projectPath ?? params.workspacePath; 463 | 464 | return { 465 | content: [ 466 | { 467 | type: 'text', 468 | text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}. 469 | 470 | The app (${bundleId}) is now running in the iOS Simulator. 471 | If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open. 472 | 473 | Next Steps: 474 | - Option 1: Capture structured logs only (app continues running): 475 | start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' }) 476 | - Option 2: Capture both console and structured logs (app will restart): 477 | start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}', captureConsole: true }) 478 | - Option 3: Launch app with logs in one step (for a fresh start): 479 | launch_app_with_logs_in_simulator({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' }) 480 | 481 | When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, 482 | }, 483 | ], 484 | isError: false, 485 | }; 486 | } catch (error) { 487 | const errorMessage = error instanceof Error ? error.message : String(error); 488 | log('error', `Error in iOS Simulator build and run: ${errorMessage}`); 489 | return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true); 490 | } 491 | } 492 | 493 | const publicSchemaObject = baseSchemaObject.omit({ 494 | projectPath: true, 495 | workspacePath: true, 496 | scheme: true, 497 | configuration: true, 498 | simulatorId: true, 499 | simulatorName: true, 500 | useLatestOS: true, 501 | } as const); 502 | 503 | export default { 504 | name: 'build_run_sim', 505 | description: 'Builds and runs an app on an iOS simulator.', 506 | schema: publicSchemaObject.shape, 507 | handler: createSessionAwareTool<BuildRunSimulatorParams>({ 508 | internalSchema: buildRunSimulatorSchema as unknown as z.ZodType<BuildRunSimulatorParams>, 509 | logicFunction: build_run_simLogic, 510 | getExecutor: getDefaultCommandExecutor, 511 | requirements: [ 512 | { allOf: ['scheme'], message: 'scheme is required' }, 513 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 514 | { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, 515 | ], 516 | exclusivePairs: [ 517 | ['projectPath', 'workspacePath'], 518 | ['simulatorId', 'simulatorName'], 519 | ], 520 | }), 521 | }; 522 | ``` -------------------------------------------------------------------------------- /docs/session_management_plan.md: -------------------------------------------------------------------------------- ```markdown 1 | # Stateful Session Defaults for MCP Tools — Design, Middleware, and Plan 2 | 3 | Below is a concise architecture and implementation plan to introduce a session-aware defaults layer that removes repeated tool parameters from public schemas, while keeping all tool logic and tests unchanged. 4 | 5 | ## Architecture Overview 6 | 7 | - **Core idea**: keep logic functions and tests untouched; move argument consolidation into a session-aware interop layer and expose minimal public schemas. 8 | - **Data flow**: 9 | - Client calls a tool with zero or few args → session middleware merges session defaults → validates with the internal schema → calls the existing logic function. 10 | - **Components**: 11 | - `SessionStore` (singleton, in-memory): set/get/clear/show defaults. 12 | - Session-aware tool factory: merges defaults, performs preflight requirement checks (allOf/oneOf), then validates with the tool's internal zod schema. 13 | - Public vs internal schema: plugins register a minimal "public" input schema; handlers validate with the unchanged "internal" schema. 14 | 15 | ## Core Types 16 | 17 | ```typescript 18 | // src/utils/session-store.ts 19 | export type SessionDefaults = { 20 | projectPath?: string; 21 | workspacePath?: string; 22 | scheme?: string; 23 | configuration?: string; 24 | simulatorName?: string; 25 | simulatorId?: string; 26 | deviceId?: string; 27 | useLatestOS?: boolean; 28 | arch?: 'arm64' | 'x86_64'; 29 | }; 30 | ``` 31 | 32 | ## Session Store (singleton) 33 | 34 | ```typescript 35 | // src/utils/session-store.ts 36 | import { log } from './logger.ts'; 37 | 38 | class SessionStore { 39 | private defaults: SessionDefaults = {}; 40 | 41 | setDefaults(partial: Partial<SessionDefaults>): void { 42 | this.defaults = { ...this.defaults, ...partial }; 43 | log('info', '[Session] Defaults set', { keys: Object.keys(partial) }); 44 | } 45 | 46 | clear(keys?: (keyof SessionDefaults)[]): void { 47 | if (!keys || keys.length === 0) { 48 | this.defaults = {}; 49 | log('info', '[Session] All defaults cleared'); 50 | return; 51 | } 52 | for (const k of keys) delete this.defaults[k]; 53 | log('info', '[Session] Defaults cleared', { keys }); 54 | } 55 | 56 | get<K extends keyof SessionDefaults>(key: K): SessionDefaults[K] { 57 | return this.defaults[key]; 58 | } 59 | 60 | getAll(): SessionDefaults { 61 | return { ...this.defaults }; 62 | } 63 | } 64 | 65 | export const sessionStore = new SessionStore(); 66 | ``` 67 | 68 | ## Session-Aware Tool Factory 69 | 70 | ```typescript 71 | // src/utils/typed-tool-factory.ts (add new helper, keep createTypedTool as-is) 72 | import { z } from 'zod'; 73 | import { sessionStore, type SessionDefaults } from './session-store.ts'; 74 | import type { CommandExecutor } from './execution/index.ts'; 75 | import { createErrorResponse } from './responses/index.ts'; 76 | import type { ToolResponse } from '../types/common.ts'; 77 | 78 | export type SessionRequirement = 79 | | { allOf: (keyof SessionDefaults)[]; message?: string } 80 | | { oneOf: (keyof SessionDefaults)[]; message?: string }; 81 | 82 | function missingFromArgsAndSession( 83 | keys: (keyof SessionDefaults)[], 84 | args: Record<string, unknown>, 85 | ): string[] { 86 | return keys.filter((k) => args[k] == null && sessionStore.get(k) == null); 87 | } 88 | 89 | export function createSessionAwareTool<TParams>(opts: { 90 | internalSchema: z.ZodType<TParams>; 91 | logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>; 92 | getExecutor: () => CommandExecutor; 93 | // Optional extras to improve UX and ergonomics 94 | sessionKeys?: (keyof SessionDefaults)[]; 95 | requirements?: SessionRequirement[]; // preflight, friendlier than raw zod errors 96 | }) { 97 | const { internalSchema, logicFunction, getExecutor, sessionKeys = [], requirements = [] } = opts; 98 | 99 | return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => { 100 | try { 101 | // Merge: explicit args take precedence over session defaults 102 | const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...rawArgs }; 103 | 104 | // Preflight requirement checks (clear message how to fix) 105 | for (const req of requirements) { 106 | if ('allOf' in req) { 107 | const missing = missingFromArgsAndSession(req.allOf, rawArgs); 108 | if (missing.length > 0) { 109 | return createErrorResponse( 110 | 'Missing required session defaults', 111 | `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` + 112 | `Set with: session-set-defaults { ${missing.map((k) => `"${k}": "..."`).join(', ')} }`, 113 | ); 114 | } 115 | } else if ('oneOf' in req) { 116 | const missing = missingFromArgsAndSession(req.oneOf, rawArgs); 117 | // oneOf satisfied if at least one is present in merged 118 | const satisfied = req.oneOf.some((k) => merged[k] != null); 119 | if (!satisfied) { 120 | return createErrorResponse( 121 | 'Missing required session defaults', 122 | `${req.message ?? `Provide one of: ${req.oneOf.join(', ')}`}\n` + 123 | `Set with: session-set-defaults { "${req.oneOf[0]}": "..." }`, 124 | ); 125 | } 126 | } 127 | } 128 | 129 | // Validate against unchanged internal schema (logic/api untouched) 130 | const validated = internalSchema.parse(merged); 131 | return await logicFunction(validated, getExecutor()); 132 | } catch (error) { 133 | if (error instanceof z.ZodError) { 134 | const msgs = error.errors.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`); 135 | return createErrorResponse( 136 | 'Parameter validation failed', 137 | `Invalid parameters:\n${msgs.join('\n')}\n` + 138 | `Tip: set session defaults via session-set-defaults`, 139 | ); 140 | } 141 | throw error; 142 | } 143 | }; 144 | } 145 | ``` 146 | 147 | ## Plugin Migration Pattern (Example: build_sim) 148 | 149 | Public schema hides session fields; handler uses session-aware factory with internal schema and requirements; logic function unchanged. 150 | 151 | ```typescript 152 | // src/mcp/tools/simulator/build_sim.ts (key parts only) 153 | import { z } from 'zod'; 154 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 155 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 156 | 157 | // Existing internal schema (unchanged)… 158 | const baseOptions = { /* as-is (scheme, simulatorId, simulatorName, configuration, …) */ }; 159 | const baseSchemaObject = z.object({ 160 | projectPath: z.string().optional(), 161 | workspacePath: z.string().optional(), 162 | ...baseOptions, 163 | }); 164 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 165 | const buildSimulatorSchema = baseSchema 166 | .refine(/* as-is: projectPath XOR workspacePath */) 167 | .refine(/* as-is: simulatorId XOR simulatorName */); 168 | 169 | export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>; 170 | 171 | // Public schema = internal minus session-managed fields 172 | const sessionManaged = [ 173 | 'projectPath', 174 | 'workspacePath', 175 | 'scheme', 176 | 'configuration', 177 | 'simulatorId', 178 | 'simulatorName', 179 | 'useLatestOS', 180 | ] as const; 181 | 182 | const publicSchemaObject = baseSchemaObject.omit( 183 | Object.fromEntries(sessionManaged.map((k) => [k, true])) as Record<string, true>, 184 | ); 185 | 186 | export default { 187 | name: 'build_sim', 188 | description: 'Builds an app for an iOS simulator.', 189 | schema: publicSchemaObject.shape, // what the MCP client sees 190 | handler: createSessionAwareTool<BuildSimulatorParams>({ 191 | internalSchema: buildSimulatorSchema, 192 | logicFunction: build_simLogic, 193 | getExecutor: getDefaultCommandExecutor, 194 | sessionKeys: sessionManaged, 195 | requirements: [ 196 | { allOf: ['scheme'], message: 'scheme is required' }, 197 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 198 | { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, 199 | ], 200 | }), 201 | }; 202 | ``` 203 | 204 | This same pattern applies to `build_run_sim`, `test_sim`, device/macos tools, etc. Public schemas become minimal, while internal schemas and logic remain unchanged. 205 | 206 | ## New Tool Group: session-management 207 | 208 | ### session_set_defaults.ts 209 | 210 | ```typescript 211 | // src/mcp/tools/session-management/session_set_defaults.ts 212 | import { z } from 'zod'; 213 | import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; 214 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 215 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 216 | 217 | const schemaObj = z.object({ 218 | projectPath: z.string().optional(), 219 | workspacePath: z.string().optional(), 220 | scheme: z.string().optional(), 221 | configuration: z.string().optional(), 222 | simulatorName: z.string().optional(), 223 | simulatorId: z.string().optional(), 224 | deviceId: z.string().optional(), 225 | useLatestOS: z.boolean().optional(), 226 | arch: z.enum(['arm64', 'x86_64']).optional(), 227 | }); 228 | type Params = z.infer<typeof schemaObj>; 229 | 230 | async function logic(params: Params): Promise<import('../../../types/common.ts').ToolResponse> { 231 | sessionStore.setDefaults(params as Partial<SessionDefaults>); 232 | const current = sessionStore.getAll(); 233 | return { content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }] }; 234 | } 235 | 236 | export default { 237 | name: 'session-set-defaults', 238 | description: 'Set session defaults used by other tools.', 239 | schema: schemaObj.shape, 240 | handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor), 241 | }; 242 | ``` 243 | 244 | ### session_clear_defaults.ts 245 | 246 | ```typescript 247 | // src/mcp/tools/session-management/session_clear_defaults.ts 248 | import { z } from 'zod'; 249 | import { sessionStore } from '../../../utils/session-store.ts'; 250 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 251 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 252 | 253 | const keys = [ 254 | 'projectPath','workspacePath','scheme','configuration', 255 | 'simulatorName','simulatorId','deviceId','useLatestOS','arch', 256 | ] as const; 257 | const schemaObj = z.object({ 258 | keys: z.array(z.enum(keys)).optional(), 259 | all: z.boolean().optional(), 260 | }); 261 | 262 | async function logic(params: z.infer<typeof schemaObj>) { 263 | if (params.all || !params.keys) sessionStore.clear(); 264 | else sessionStore.clear(params.keys); 265 | return { content: [{ type: 'text', text: 'Session defaults cleared' }] }; 266 | } 267 | 268 | export default { 269 | name: 'session-clear-defaults', 270 | description: 'Clear selected or all session defaults.', 271 | schema: schemaObj.shape, 272 | handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor), 273 | }; 274 | ``` 275 | 276 | ### session_show_defaults.ts 277 | 278 | ```typescript 279 | // src/mcp/tools/session-management/session_show_defaults.ts 280 | import { sessionStore } from '../../../utils/session-store.ts'; 281 | 282 | export default { 283 | name: 'session-show-defaults', 284 | description: 'Show current session defaults.', 285 | schema: {}, // no args 286 | handler: async () => { 287 | const current = sessionStore.getAll(); 288 | return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }] }; 289 | }, 290 | }; 291 | ``` 292 | 293 | ## Step-by-Step Implementation Plan (Incremental, buildable at each step) 294 | 295 | 1. **Add SessionStore** ✅ **DONE** 296 | - New file: `src/utils/session-store.ts`. 297 | - No existing code changes; run: `npm run build`, `lint`, `test`. 298 | - Commit checkpoint (after review): see Commit & Review Protocol below. 299 | 300 | 2. **Add session-management tools** ✅ **DONE** 301 | - New folder: `src/mcp/tools/session-management` with the three tools above. 302 | - Register via existing plugin discovery (same pattern as others). 303 | - Build and test. 304 | - Commit checkpoint (after review). 305 | 306 | 3. **Add session-aware tool factory** ✅ **DONE** 307 | - Add `createSessionAwareTool` to `src/utils/typed-tool-factory.ts` (keep `createTypedTool` intact). 308 | - Unit tests for requirement preflight and merge precedence. 309 | - Commit checkpoint (after review). 310 | 311 | 4. **Migrate 2-3 representative tools** 312 | - Example: `simulator/build_sim`, `macos/build_macos`, `device/build_device`. 313 | - Create `publicSchemaObject` (omit session fields), switch handler to `createSessionAwareTool` with requirements. 314 | - Keep internal schema and logic unchanged. Build and test. 315 | - Commit checkpoint (after review). 316 | 317 | 5. **Migrate remaining tools in small batches** 318 | - Apply the same pattern across simulator/device/macos/test utilities. 319 | - After each batch: `npm run typecheck`, `lint`, `test`. 320 | - Commit checkpoint (after review). 321 | 322 | 6. **Final polish** 323 | - Add tests for session tools and session-aware preflight error messages. 324 | - Ensure public schemas no longer expose session parameters globally. 325 | - Commit checkpoint (after review). 326 | 327 | ## Standard Testing & DI Checklist (Mandatory) 328 | 329 | - Handlers must use dependency injection; tests must never call real executors. 330 | - For validation-only tests, calling the handler is acceptable because Zod validation occurs before executor acquisition. 331 | - For logic tests that would otherwise trigger `getDefaultCommandExecutor`, export the logic function and test it directly (no executor needed if logic doesn’t use one): 332 | 333 | ```ts 334 | // Example: src/mcp/tools/session-management/session_clear_defaults.ts 335 | export async function sessionClearDefaultsLogic(params: Params): Promise<ToolResponse> { /* ... */ } 336 | export default { 337 | name: 'session-clear-defaults', 338 | handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor), 339 | }; 340 | 341 | // Test: import logic and call directly to avoid real executor 342 | import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; 343 | ``` 344 | 345 | - Add tests for the new group and tools: 346 | - Group metadata test: `src/mcp/tools/session-management/__tests__/index.test.ts` 347 | - Tool tests: `session_set_defaults.test.ts`, `session_clear_defaults.test.ts`, `session_show_defaults.test.ts` 348 | - Utils tests: `src/utils/__tests__/session-store.test.ts` 349 | - Factory tests: `src/utils/__tests__/session-aware-tool-factory.test.ts` covering: 350 | - Preflight requirements (allOf/oneOf) 351 | - Merge precedence (explicit args override session defaults) 352 | - Zod error reporting with helpful tips 353 | 354 | - Always run locally before requesting review: 355 | - `npm run typecheck` 356 | - `npm run lint` 357 | - `npm run format:check` 358 | - `npm run build` 359 | - `npm run test` 360 | - Perform a quick manual CLI check (mcpli or reloaderoo) per the Manual Testing section 361 | 362 | ### Minimal Changes Policy for Tests (Enforced) 363 | 364 | - Only make material, essential edits to tests required by the code change (e.g., new preflight error messages or added/removed fields). 365 | - Do not change sample input values or defaults in tests (e.g., flipping a boolean like `preferXcodebuild`) unless strictly necessary to validate behavior. 366 | - Preserve the original intent and coverage of logic-function tests; keep handler vs logic boundaries intact. 367 | - When session-awareness is added, prefer setting/clearing session defaults around tests rather than altering existing assertions or sample inputs. 368 | 369 | ### Tool Description Policy (Enforced) 370 | 371 | - Keep tool descriptions concise (maximum one short sentence). 372 | - Do not mention session defaults, setup steps, examples, or parameter relationships in descriptions. 373 | - Use clear, imperative phrasing (e.g., "Builds an app for an iOS simulator."). 374 | - Apply consistently across all migrated tools; update any tests that assert `description` to match the concise string only. 375 | 376 | ## Commit & Review Protocol (Enforced) 377 | 378 | At the end of each numbered step above: 379 | 380 | 1. Ensure all checks pass: `typecheck`, `lint`, `format:check`, `build`, `test`; then perform a quick manual CLI test (mcpli or reloaderoo) per the Manual Testing section. 381 | - Verify tool descriptions comply with the Tool Description Policy (concise, no session-defaults mention). 382 | 2. Stage only the files for that step. 383 | 3. Prepare a concise commit message focused on the “why”. 384 | 4. Request manual review and approval before committing. Do not push. 385 | 386 | Example messages per step: 387 | 388 | - Step 1 (SessionStore) 389 | - `chore(utils): add in-memory SessionStore for session defaults` 390 | - Body: “Introduces singleton SessionStore with set/get/clear/show for session defaults; no behavior changes.” 391 | 392 | - Step 2 (session-management tools) 393 | - `feat(session-management): add set/clear/show session defaults tools and workflow metadata` 394 | - Body: “Adds tools to manage session defaults and exposes workflow metadata; minimal schemas via typed factory.” 395 | 396 | - Step 3 (middleware) 397 | - `feat(utils): add createSessionAwareTool with preflight requirements and args>session merge` 398 | - Body: “Session-aware interop layer performing requirements checks and Zod validation against internal schema.” 399 | 400 | - Step 6 (tests/final polish) 401 | - `test(session-management): add tool, store, and middleware tests; export logic for DI` 402 | - Body: “Covers group metadata, tools, SessionStore, and factory (requirements/merge/errors). No production behavior changes.” 403 | 404 | Approval flow: 405 | - After preparing messages and confirming checks, request maintainer approval. 406 | - On approval: commit locally (no push). 407 | - On rejection: revise and re-run checks. 408 | 409 | Note on commit hooks and selective commits: 410 | - The pre-commit hook runs format/lint/build and can auto-add or modify files, causing additional files to be included in the commit. If you must commit a minimal subset, skip hooks with: `git commit --no-verify` (use sparingly and run `npm run typecheck && npm run lint && npm run test` manually first). 411 | 412 | ## Safety, Buildability, Testability 413 | 414 | - Logic functions and their types remain unchanged; existing unit tests that import logic directly continue to pass. 415 | - Public schemas shrink; MCP clients see smaller input schemas without session fields. 416 | - Handlers validate with internal schemas after session-defaults merge, preserving runtime guarantees. 417 | - Preflight requirement checks return clear guidance, e.g., "Provide one of: projectPath or workspacePath" + "Set with: session-set-defaults { "projectPath": "..." }". 418 | 419 | ## Developer Usage 420 | 421 | - **Set defaults once**: 422 | - `session-set-defaults { "workspacePath": "...", "scheme": "App", "simulatorName": "iPhone 16" }` 423 | - **Run tools without args**: 424 | - `build_sim {}` 425 | - **Inspect/reset**: 426 | - `session-show-defaults {}` 427 | - `session-clear-defaults { "all": true }` 428 | 429 | ## Manual Testing with mcpli (CLI) 430 | 431 | The following commands exercise the session workflow end‑to‑end using the built server. 432 | 433 | 1) Build the server (required after code changes): 434 | 435 | ```bash 436 | npm run build 437 | ``` 438 | 439 | 2) Discover a scheme (optional helper): 440 | 441 | ```bash 442 | mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js 443 | ``` 444 | 445 | 3) Set the session defaults (project/workspace, scheme, and simulator): 446 | 447 | ```bash 448 | mcpli --raw session-set-defaults \ 449 | --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \ 450 | --scheme MCPTest \ 451 | --simulatorName "iPhone 16" \ 452 | -- node build/index.js 453 | ``` 454 | 455 | 4) Verify defaults are stored: 456 | 457 | ```bash 458 | mcpli --raw session-show-defaults -- node build/index.js 459 | ``` 460 | 461 | 5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically): 462 | 463 | ```bash 464 | # Optionally provide a scratch derived data path and a short timeout 465 | mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js 466 | ``` 467 | 468 | Troubleshooting: 469 | 470 | - If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys. 471 | - If you see connect ECONNREFUSED or the daemon appears flaky: 472 | - Check logs: `mcpli daemon log --since=10m -- node build/index.js` 473 | - Restart daemon: `mcpli daemon restart -- node build/index.js` 474 | - Clean daemon state: `mcpli daemon clean -- node build/index.js` then `mcpli daemon start -- node build/index.js` 475 | - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js` 476 | 477 | Notes: 478 | 479 | - Public schemas for session‑aware tools intentionally omit session fields (e.g., `scheme`, `projectPath`, `simulatorName`). Provide them once via `session-set-defaults` and then call the tool with zero/minimal flags. 480 | - Use `--tool-timeout=<seconds>` to cap long‑running builds during manual testing. 481 | - mcpli CLI normalizes tool names: tools exported with underscores (e.g., `build_sim`) can be invoked with hyphens (e.g., `build-sim`). Copy/paste samples using hyphens are valid because mcpli converts underscores to dashes. 482 | 483 | ## Next Steps 484 | 485 | Would you like me to proceed with Phase 1–3 implementation (store + session tools + middleware), then migrate a first tool (build_sim) and run the test suite? ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/build_sim.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { z } from 'zod'; 3 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 4 | import { sessionStore } from '../../../../utils/session-store.ts'; 5 | 6 | // Import the plugin and logic function 7 | import buildSim, { build_simLogic } from '../build_sim.ts'; 8 | 9 | describe('build_sim tool', () => { 10 | beforeEach(() => { 11 | sessionStore.clear(); 12 | }); 13 | 14 | describe('Export Field Validation (Literal)', () => { 15 | it('should have correct name', () => { 16 | expect(buildSim.name).toBe('build_sim'); 17 | }); 18 | 19 | it('should have correct description', () => { 20 | expect(buildSim.description).toBe('Builds an app for an iOS simulator.'); 21 | }); 22 | 23 | it('should have handler function', () => { 24 | expect(typeof buildSim.handler).toBe('function'); 25 | }); 26 | 27 | it('should have correct public schema (only non-session fields)', () => { 28 | const schema = z.object(buildSim.schema); 29 | 30 | // Public schema should allow empty input 31 | expect(schema.safeParse({}).success).toBe(true); 32 | 33 | // Valid public inputs 34 | expect( 35 | schema.safeParse({ 36 | derivedDataPath: '/path/to/derived', 37 | extraArgs: ['--verbose'], 38 | preferXcodebuild: false, 39 | }).success, 40 | ).toBe(true); 41 | 42 | // Invalid types on public inputs 43 | expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); 44 | expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); 45 | expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); 46 | }); 47 | }); 48 | 49 | describe('Parameter Validation', () => { 50 | it('should handle missing both projectPath and workspacePath', async () => { 51 | const result = await buildSim.handler({ 52 | scheme: 'MyScheme', 53 | simulatorName: 'iPhone 16', 54 | }); 55 | 56 | expect(result.isError).toBe(true); 57 | expect(result.content[0].text).toContain('Missing required session defaults'); 58 | expect(result.content[0].text).toContain('Provide a project or workspace'); 59 | }); 60 | 61 | it('should handle both projectPath and workspacePath provided', async () => { 62 | const result = await buildSim.handler({ 63 | projectPath: '/path/to/project.xcodeproj', 64 | workspacePath: '/path/to/workspace', 65 | scheme: 'MyScheme', 66 | simulatorName: 'iPhone 16', 67 | }); 68 | 69 | expect(result.isError).toBe(true); 70 | expect(result.content[0].text).toContain('Parameter validation failed'); 71 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 72 | expect(result.content[0].text).toContain('projectPath'); 73 | expect(result.content[0].text).toContain('workspacePath'); 74 | }); 75 | 76 | it('should handle empty workspacePath parameter', async () => { 77 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 78 | 79 | const result = await build_simLogic( 80 | { 81 | workspacePath: '', 82 | scheme: 'MyScheme', 83 | simulatorName: 'iPhone 16', 84 | }, 85 | mockExecutor, 86 | ); 87 | 88 | // Empty string passes validation but may cause build issues 89 | expect(result.content).toEqual([ 90 | { 91 | type: 'text', 92 | text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', 93 | }, 94 | { 95 | type: 'text', 96 | text: expect.stringContaining('Next Steps:'), 97 | }, 98 | ]); 99 | }); 100 | 101 | it('should handle missing scheme parameter', async () => { 102 | const result = await buildSim.handler({ 103 | workspacePath: '/path/to/workspace', 104 | simulatorName: 'iPhone 16', 105 | }); 106 | 107 | expect(result.isError).toBe(true); 108 | expect(result.content[0].text).toContain('Missing required session defaults'); 109 | expect(result.content[0].text).toContain('scheme is required'); 110 | }); 111 | 112 | it('should handle empty scheme parameter', async () => { 113 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 114 | 115 | const result = await build_simLogic( 116 | { 117 | workspacePath: '/path/to/workspace', 118 | scheme: '', 119 | simulatorName: 'iPhone 16', 120 | }, 121 | mockExecutor, 122 | ); 123 | 124 | // Empty string passes validation but may cause build issues 125 | expect(result.content).toEqual([ 126 | { 127 | type: 'text', 128 | text: '✅ iOS Simulator Build build succeeded for scheme .', 129 | }, 130 | { 131 | type: 'text', 132 | text: expect.stringContaining('Next Steps:'), 133 | }, 134 | ]); 135 | }); 136 | 137 | it('should handle missing both simulatorId and simulatorName', async () => { 138 | const result = await buildSim.handler({ 139 | workspacePath: '/path/to/workspace', 140 | scheme: 'MyScheme', 141 | }); 142 | 143 | expect(result.isError).toBe(true); 144 | expect(result.content[0].text).toContain('Missing required session defaults'); 145 | expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); 146 | }); 147 | 148 | it('should handle both simulatorId and simulatorName provided', async () => { 149 | const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' }); 150 | 151 | // Should fail with XOR validation 152 | const result = await buildSim.handler({ 153 | workspacePath: '/path/to/workspace', 154 | scheme: 'MyScheme', 155 | simulatorId: 'ABC-123', 156 | simulatorName: 'iPhone 16', 157 | }); 158 | 159 | expect(result.isError).toBe(true); 160 | expect(result.content[0].text).toContain('Parameter validation failed'); 161 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 162 | expect(result.content[0].text).toContain('simulatorId'); 163 | expect(result.content[0].text).toContain('simulatorName'); 164 | }); 165 | 166 | it('should handle empty simulatorName parameter', async () => { 167 | const mockExecutor = createMockExecutor({ 168 | success: false, 169 | output: '', 170 | error: 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', 171 | }); 172 | 173 | const result = await build_simLogic( 174 | { 175 | workspacePath: '/path/to/workspace', 176 | scheme: 'MyScheme', 177 | simulatorName: '', 178 | }, 179 | mockExecutor, 180 | ); 181 | 182 | // Empty simulatorName passes validation but causes early failure in destination construction 183 | expect(result.isError).toBe(true); 184 | expect(result.content[0].text).toBe( 185 | 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', 186 | ); 187 | }); 188 | }); 189 | 190 | describe('Command Generation', () => { 191 | it('should generate correct build command with minimal parameters (workspace)', async () => { 192 | const callHistory: Array<{ 193 | command: string[]; 194 | logPrefix?: string; 195 | useShell?: boolean; 196 | env?: any; 197 | }> = []; 198 | 199 | // Create tracking executor 200 | const trackingExecutor = async ( 201 | command: string[], 202 | logPrefix?: string, 203 | useShell?: boolean, 204 | env?: Record<string, string>, 205 | ) => { 206 | callHistory.push({ command, logPrefix, useShell, env }); 207 | return { 208 | success: false, 209 | output: '', 210 | error: 'Test error to stop execution early', 211 | process: { pid: 12345 }, 212 | }; 213 | }; 214 | 215 | const result = await build_simLogic( 216 | { 217 | workspacePath: '/path/to/MyProject.xcworkspace', 218 | scheme: 'MyScheme', 219 | simulatorName: 'iPhone 16', 220 | }, 221 | trackingExecutor, 222 | ); 223 | 224 | // Should generate one build command 225 | expect(callHistory).toHaveLength(1); 226 | expect(callHistory[0].command).toEqual([ 227 | 'xcodebuild', 228 | '-workspace', 229 | '/path/to/MyProject.xcworkspace', 230 | '-scheme', 231 | 'MyScheme', 232 | '-configuration', 233 | 'Debug', 234 | '-skipMacroValidation', 235 | '-destination', 236 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 237 | 'build', 238 | ]); 239 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 240 | }); 241 | 242 | it('should generate correct build command with minimal parameters (project)', async () => { 243 | const callHistory: Array<{ 244 | command: string[]; 245 | logPrefix?: string; 246 | useShell?: boolean; 247 | env?: any; 248 | }> = []; 249 | 250 | // Create tracking executor 251 | const trackingExecutor = async ( 252 | command: string[], 253 | logPrefix?: string, 254 | useShell?: boolean, 255 | env?: Record<string, string>, 256 | ) => { 257 | callHistory.push({ command, logPrefix, useShell, env }); 258 | return { 259 | success: false, 260 | output: '', 261 | error: 'Test error to stop execution early', 262 | process: { pid: 12345 }, 263 | }; 264 | }; 265 | 266 | const result = await build_simLogic( 267 | { 268 | projectPath: '/path/to/MyProject.xcodeproj', 269 | scheme: 'MyScheme', 270 | simulatorName: 'iPhone 16', 271 | }, 272 | trackingExecutor, 273 | ); 274 | 275 | // Should generate one build command 276 | expect(callHistory).toHaveLength(1); 277 | expect(callHistory[0].command).toEqual([ 278 | 'xcodebuild', 279 | '-project', 280 | '/path/to/MyProject.xcodeproj', 281 | '-scheme', 282 | 'MyScheme', 283 | '-configuration', 284 | 'Debug', 285 | '-skipMacroValidation', 286 | '-destination', 287 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 288 | 'build', 289 | ]); 290 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 291 | }); 292 | 293 | it('should generate correct build command with all optional parameters', async () => { 294 | const callHistory: Array<{ 295 | command: string[]; 296 | logPrefix?: string; 297 | useShell?: boolean; 298 | env?: any; 299 | }> = []; 300 | 301 | // Create tracking executor 302 | const trackingExecutor = async ( 303 | command: string[], 304 | logPrefix?: string, 305 | useShell?: boolean, 306 | env?: Record<string, string>, 307 | ) => { 308 | callHistory.push({ command, logPrefix, useShell, env }); 309 | return { 310 | success: false, 311 | output: '', 312 | error: 'Test error to stop execution early', 313 | process: { pid: 12345 }, 314 | }; 315 | }; 316 | 317 | const result = await build_simLogic( 318 | { 319 | workspacePath: '/path/to/MyProject.xcworkspace', 320 | scheme: 'MyScheme', 321 | simulatorName: 'iPhone 16', 322 | configuration: 'Release', 323 | derivedDataPath: '/custom/derived/path', 324 | extraArgs: ['--verbose'], 325 | useLatestOS: false, 326 | }, 327 | trackingExecutor, 328 | ); 329 | 330 | // Should generate one build command with all parameters 331 | expect(callHistory).toHaveLength(1); 332 | expect(callHistory[0].command).toEqual([ 333 | 'xcodebuild', 334 | '-workspace', 335 | '/path/to/MyProject.xcworkspace', 336 | '-scheme', 337 | 'MyScheme', 338 | '-configuration', 339 | 'Release', 340 | '-skipMacroValidation', 341 | '-destination', 342 | 'platform=iOS Simulator,name=iPhone 16', 343 | '-derivedDataPath', 344 | '/custom/derived/path', 345 | '--verbose', 346 | 'build', 347 | ]); 348 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 349 | }); 350 | 351 | it('should handle paths with spaces in command generation', async () => { 352 | const callHistory: Array<{ 353 | command: string[]; 354 | logPrefix?: string; 355 | useShell?: boolean; 356 | env?: any; 357 | }> = []; 358 | 359 | // Create tracking executor 360 | const trackingExecutor = async ( 361 | command: string[], 362 | logPrefix?: string, 363 | useShell?: boolean, 364 | env?: Record<string, string>, 365 | ) => { 366 | callHistory.push({ command, logPrefix, useShell, env }); 367 | return { 368 | success: false, 369 | output: '', 370 | error: 'Test error to stop execution early', 371 | process: { pid: 12345 }, 372 | }; 373 | }; 374 | 375 | const result = await build_simLogic( 376 | { 377 | workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', 378 | scheme: 'My Scheme', 379 | simulatorName: 'iPhone 16 Pro', 380 | }, 381 | trackingExecutor, 382 | ); 383 | 384 | // Should generate one build command with paths containing spaces 385 | expect(callHistory).toHaveLength(1); 386 | expect(callHistory[0].command).toEqual([ 387 | 'xcodebuild', 388 | '-workspace', 389 | '/Users/dev/My Project/MyProject.xcworkspace', 390 | '-scheme', 391 | 'My Scheme', 392 | '-configuration', 393 | 'Debug', 394 | '-skipMacroValidation', 395 | '-destination', 396 | 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', 397 | 'build', 398 | ]); 399 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 400 | }); 401 | 402 | it('should generate correct build command with useLatestOS set to true', async () => { 403 | const callHistory: Array<{ 404 | command: string[]; 405 | logPrefix?: string; 406 | useShell?: boolean; 407 | env?: any; 408 | }> = []; 409 | 410 | // Create tracking executor 411 | const trackingExecutor = async ( 412 | command: string[], 413 | logPrefix?: string, 414 | useShell?: boolean, 415 | env?: Record<string, string>, 416 | ) => { 417 | callHistory.push({ command, logPrefix, useShell, env }); 418 | return { 419 | success: false, 420 | output: '', 421 | error: 'Test error to stop execution early', 422 | process: { pid: 12345 }, 423 | }; 424 | }; 425 | 426 | const result = await build_simLogic( 427 | { 428 | workspacePath: '/path/to/MyProject.xcworkspace', 429 | scheme: 'MyScheme', 430 | simulatorName: 'iPhone 16', 431 | useLatestOS: true, 432 | }, 433 | trackingExecutor, 434 | ); 435 | 436 | // Should generate one build command with OS=latest 437 | expect(callHistory).toHaveLength(1); 438 | expect(callHistory[0].command).toEqual([ 439 | 'xcodebuild', 440 | '-workspace', 441 | '/path/to/MyProject.xcworkspace', 442 | '-scheme', 443 | 'MyScheme', 444 | '-configuration', 445 | 'Debug', 446 | '-skipMacroValidation', 447 | '-destination', 448 | 'platform=iOS Simulator,name=iPhone 16,OS=latest', 449 | 'build', 450 | ]); 451 | expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); 452 | }); 453 | }); 454 | 455 | describe('Response Processing', () => { 456 | it('should handle successful build', async () => { 457 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 458 | 459 | const result = await build_simLogic( 460 | { 461 | workspacePath: '/path/to/workspace', 462 | scheme: 'MyScheme', 463 | simulatorName: 'iPhone 16', 464 | }, 465 | mockExecutor, 466 | ); 467 | 468 | expect(result.content).toEqual([ 469 | { 470 | type: 'text', 471 | text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', 472 | }, 473 | { 474 | type: 'text', 475 | text: expect.stringContaining('Next Steps:'), 476 | }, 477 | ]); 478 | }); 479 | 480 | it('should handle successful build with all optional parameters', async () => { 481 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 482 | 483 | const result = await build_simLogic( 484 | { 485 | workspacePath: '/path/to/workspace', 486 | scheme: 'MyScheme', 487 | simulatorName: 'iPhone 16', 488 | configuration: 'Release', 489 | derivedDataPath: '/path/to/derived', 490 | extraArgs: ['--verbose'], 491 | useLatestOS: false, 492 | preferXcodebuild: true, 493 | }, 494 | mockExecutor, 495 | ); 496 | 497 | expect(result.content).toEqual([ 498 | { 499 | type: 'text', 500 | text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', 501 | }, 502 | { 503 | type: 'text', 504 | text: expect.stringContaining('Next Steps:'), 505 | }, 506 | ]); 507 | }); 508 | 509 | it('should handle build failure', async () => { 510 | const mockExecutor = createMockExecutor({ 511 | success: false, 512 | output: '', 513 | error: 'Build failed: Compilation error', 514 | }); 515 | 516 | const result = await build_simLogic( 517 | { 518 | workspacePath: '/path/to/workspace', 519 | scheme: 'MyScheme', 520 | simulatorName: 'iPhone 16', 521 | }, 522 | mockExecutor, 523 | ); 524 | 525 | expect(result).toEqual({ 526 | content: [ 527 | { 528 | type: 'text', 529 | text: '❌ [stderr] Build failed: Compilation error', 530 | }, 531 | { 532 | type: 'text', 533 | text: '❌ iOS Simulator Build build failed for scheme MyScheme.', 534 | }, 535 | ], 536 | isError: true, 537 | }); 538 | }); 539 | 540 | it('should handle build warnings', async () => { 541 | const mockExecutor = createMockExecutor({ 542 | success: true, 543 | output: 'warning: deprecated method used\nBUILD SUCCEEDED', 544 | }); 545 | 546 | const result = await build_simLogic( 547 | { 548 | workspacePath: '/path/to/workspace', 549 | scheme: 'MyScheme', 550 | simulatorName: 'iPhone 16', 551 | }, 552 | mockExecutor, 553 | ); 554 | 555 | expect(result.content).toEqual( 556 | expect.arrayContaining([ 557 | { 558 | type: 'text', 559 | text: expect.stringContaining('⚠️'), 560 | }, 561 | { 562 | type: 'text', 563 | text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', 564 | }, 565 | { 566 | type: 'text', 567 | text: expect.stringContaining('Next Steps:'), 568 | }, 569 | ]), 570 | ); 571 | }); 572 | 573 | it('should handle command executor errors', async () => { 574 | const mockExecutor = createMockExecutor({ 575 | success: false, 576 | error: 'spawn xcodebuild ENOENT', 577 | }); 578 | 579 | const result = await build_simLogic( 580 | { 581 | workspacePath: '/path/to/workspace', 582 | scheme: 'MyScheme', 583 | simulatorName: 'iPhone 16', 584 | }, 585 | mockExecutor, 586 | ); 587 | 588 | expect(result.isError).toBe(true); 589 | expect(result.content[0].text).toBe('❌ [stderr] spawn xcodebuild ENOENT'); 590 | }); 591 | 592 | it('should handle mixed warning and error output', async () => { 593 | const mockExecutor = createMockExecutor({ 594 | success: false, 595 | output: 'warning: deprecated method\nerror: undefined symbol', 596 | error: 'Build failed', 597 | }); 598 | 599 | const result = await build_simLogic( 600 | { 601 | workspacePath: '/path/to/workspace', 602 | scheme: 'MyScheme', 603 | simulatorName: 'iPhone 16', 604 | }, 605 | mockExecutor, 606 | ); 607 | 608 | expect(result.isError).toBe(true); 609 | expect(result.content).toEqual([ 610 | { 611 | type: 'text', 612 | text: '⚠️ Warning: warning: deprecated method', 613 | }, 614 | { 615 | type: 'text', 616 | text: '❌ Error: error: undefined symbol', 617 | }, 618 | { 619 | type: 'text', 620 | text: '❌ [stderr] Build failed', 621 | }, 622 | { 623 | type: 'text', 624 | text: '❌ iOS Simulator Build build failed for scheme MyScheme.', 625 | }, 626 | ]); 627 | }); 628 | 629 | it('should use default configuration when not provided', async () => { 630 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 631 | 632 | const result = await build_simLogic( 633 | { 634 | workspacePath: '/path/to/workspace', 635 | scheme: 'MyScheme', 636 | simulatorName: 'iPhone 16', 637 | // configuration intentionally omitted - should default to Debug 638 | }, 639 | mockExecutor, 640 | ); 641 | 642 | expect(result.content).toEqual([ 643 | { 644 | type: 'text', 645 | text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', 646 | }, 647 | { 648 | type: 'text', 649 | text: expect.stringContaining('Next Steps:'), 650 | }, 651 | ]); 652 | }); 653 | }); 654 | 655 | describe('Error Handling', () => { 656 | it('should handle catch block exceptions', async () => { 657 | // Create a mock that throws an error when called 658 | const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); 659 | 660 | // Mock the handler to throw an error by passing invalid parameters to internal functions 661 | const result = await build_simLogic( 662 | { 663 | workspacePath: '/path/to/workspace', 664 | scheme: 'MyScheme', 665 | simulatorName: 'iPhone 16', 666 | }, 667 | mockExecutor, 668 | ); 669 | 670 | // Should handle the build successfully 671 | expect(result.content).toEqual([ 672 | { 673 | type: 'text', 674 | text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', 675 | }, 676 | { 677 | type: 'text', 678 | text: expect.stringContaining('Next Steps:'), 679 | }, 680 | ]); 681 | }); 682 | }); 683 | }); 684 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/logging/start_device_log_cap.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Logging Plugin: Start Device Log Capture 3 | * 4 | * Starts capturing logs from a specified Apple device by launching the app with console output. 5 | */ 6 | 7 | import * as fs from 'fs'; 8 | import * as path from 'path'; 9 | import * as os from 'os'; 10 | import type { ChildProcess } from 'child_process'; 11 | import { v4 as uuidv4 } from 'uuid'; 12 | import { z } from 'zod'; 13 | import { log } from '../../../utils/logging/index.ts'; 14 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; 15 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 16 | import { ToolResponse } from '../../../types/common.ts'; 17 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 18 | 19 | /** 20 | * Log file retention policy for device logs: 21 | * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory 22 | * - Cleanup runs on every new log capture start 23 | */ 24 | const LOG_RETENTION_DAYS = 3; 25 | const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; 26 | 27 | // Note: Device and simulator logging use different approaches due to platform constraints: 28 | // - Simulators use 'xcrun simctl' with console-pty and OSLog stream capabilities 29 | // - Devices use 'xcrun devicectl' with console output only (no OSLog streaming) 30 | // The different command structures and output formats make sharing infrastructure complex. 31 | // However, both follow similar patterns for session management and log retention. 32 | export interface DeviceLogSession { 33 | process: ChildProcess; 34 | logFilePath: string; 35 | deviceUuid: string; 36 | bundleId: string; 37 | logStream?: fs.WriteStream; 38 | hasEnded: boolean; 39 | } 40 | 41 | export const activeDeviceLogSessions = new Map<string, DeviceLogSession>(); 42 | 43 | const EARLY_FAILURE_WINDOW_MS = 5000; 44 | const INITIAL_OUTPUT_LIMIT = 8_192; 45 | const DEFAULT_JSON_RESULT_WAIT_MS = 8000; 46 | 47 | const FAILURE_PATTERNS = [ 48 | /The application failed to launch/i, 49 | /Provide a valid bundle identifier/i, 50 | /The requested application .* is not installed/i, 51 | /NSOSStatusErrorDomain/i, 52 | /NSLocalizedFailureReason/i, 53 | /ERROR:/i, 54 | ]; 55 | 56 | type JsonOutcome = { 57 | errorMessage?: string; 58 | pid?: number; 59 | }; 60 | 61 | type DevicectlLaunchJson = { 62 | result?: { 63 | process?: { 64 | processIdentifier?: unknown; 65 | }; 66 | }; 67 | error?: { 68 | code?: unknown; 69 | domain?: unknown; 70 | localizedDescription?: unknown; 71 | userInfo?: Record<string, unknown> | undefined; 72 | }; 73 | }; 74 | 75 | function getJsonResultWaitMs(): number { 76 | const raw = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; 77 | if (raw === undefined) { 78 | return DEFAULT_JSON_RESULT_WAIT_MS; 79 | } 80 | 81 | const parsed = Number(raw); 82 | if (!Number.isFinite(parsed) || parsed < 0) { 83 | return DEFAULT_JSON_RESULT_WAIT_MS; 84 | } 85 | 86 | return parsed; 87 | } 88 | 89 | function safeParseJson(text: string): DevicectlLaunchJson | null { 90 | try { 91 | const parsed = JSON.parse(text) as unknown; 92 | if (!parsed || typeof parsed !== 'object') { 93 | return null; 94 | } 95 | return parsed as DevicectlLaunchJson; 96 | } catch { 97 | return null; 98 | } 99 | } 100 | 101 | function extractJsonOutcome(json: DevicectlLaunchJson | null): JsonOutcome | null { 102 | if (!json) { 103 | return null; 104 | } 105 | 106 | const resultProcess = json.result?.process; 107 | const pidValue = resultProcess?.processIdentifier; 108 | if (typeof pidValue === 'number' && Number.isFinite(pidValue)) { 109 | return { pid: pidValue }; 110 | } 111 | 112 | const error = json.error; 113 | if (!error) { 114 | return null; 115 | } 116 | 117 | const parts: string[] = []; 118 | 119 | if (typeof error.localizedDescription === 'string' && error.localizedDescription.length > 0) { 120 | parts.push(error.localizedDescription); 121 | } 122 | 123 | const userInfo = error.userInfo ?? {}; 124 | const recovery = userInfo?.NSLocalizedRecoverySuggestion; 125 | const failureReason = userInfo?.NSLocalizedFailureReason; 126 | const bundleIdentifier = userInfo?.BundleIdentifier; 127 | 128 | if (typeof failureReason === 'string' && failureReason.length > 0) { 129 | parts.push(failureReason); 130 | } 131 | 132 | if (typeof recovery === 'string' && recovery.length > 0) { 133 | parts.push(recovery); 134 | } 135 | 136 | if (typeof bundleIdentifier === 'string' && bundleIdentifier.length > 0) { 137 | parts.push(`BundleIdentifier = ${bundleIdentifier}`); 138 | } 139 | 140 | const domain = error.domain; 141 | const code = error.code; 142 | const domainPart = typeof domain === 'string' && domain.length > 0 ? domain : undefined; 143 | const codePart = typeof code === 'number' && Number.isFinite(code) ? code : undefined; 144 | 145 | if (domainPart || codePart !== undefined) { 146 | parts.push(`(${domainPart ?? 'UnknownDomain'} code ${codePart ?? 'unknown'})`); 147 | } 148 | 149 | if (parts.length === 0) { 150 | return { errorMessage: 'Launch failed' }; 151 | } 152 | 153 | return { errorMessage: parts.join('\n') }; 154 | } 155 | 156 | async function removeFileIfExists( 157 | targetPath: string, 158 | fileExecutor?: FileSystemExecutor, 159 | ): Promise<void> { 160 | try { 161 | if (fileExecutor) { 162 | if (fileExecutor.existsSync(targetPath)) { 163 | await fileExecutor.rm(targetPath, { force: true }); 164 | } 165 | return; 166 | } 167 | 168 | if (fs.existsSync(targetPath)) { 169 | await fs.promises.rm(targetPath, { force: true }); 170 | } 171 | } catch { 172 | // Best-effort cleanup only 173 | } 174 | } 175 | 176 | async function pollJsonOutcome( 177 | jsonPath: string, 178 | fileExecutor: FileSystemExecutor | undefined, 179 | timeoutMs: number, 180 | ): Promise<JsonOutcome | null> { 181 | const start = Date.now(); 182 | 183 | const readOnce = async (): Promise<JsonOutcome | null> => { 184 | try { 185 | const exists = fileExecutor?.existsSync(jsonPath) ?? fs.existsSync(jsonPath); 186 | 187 | if (!exists) { 188 | return null; 189 | } 190 | 191 | const content = fileExecutor 192 | ? await fileExecutor.readFile(jsonPath, 'utf8') 193 | : await fs.promises.readFile(jsonPath, 'utf8'); 194 | 195 | const outcome = extractJsonOutcome(safeParseJson(content)); 196 | if (outcome) { 197 | await removeFileIfExists(jsonPath, fileExecutor); 198 | return outcome; 199 | } 200 | } catch { 201 | // File may still be written; try again later 202 | } 203 | 204 | return null; 205 | }; 206 | 207 | const immediate = await readOnce(); 208 | if (immediate) { 209 | return immediate; 210 | } 211 | 212 | if (timeoutMs <= 0) { 213 | return null; 214 | } 215 | 216 | let delay = Math.min(100, Math.max(10, Math.floor(timeoutMs / 4) || 10)); 217 | 218 | while (Date.now() - start < timeoutMs) { 219 | await new Promise((resolve) => setTimeout(resolve, delay)); 220 | const result = await readOnce(); 221 | if (result) { 222 | return result; 223 | } 224 | delay = Math.min(400, delay + 50); 225 | } 226 | 227 | return null; 228 | } 229 | 230 | type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean }; 231 | 232 | /** 233 | * Start a log capture session for an iOS device by launching the app with console output. 234 | * Uses the devicectl command to launch the app and capture console logs. 235 | * Returns { sessionId, error? } 236 | */ 237 | export async function startDeviceLogCapture( 238 | params: { 239 | deviceUuid: string; 240 | bundleId: string; 241 | }, 242 | executor: CommandExecutor = getDefaultCommandExecutor(), 243 | fileSystemExecutor?: FileSystemExecutor, 244 | ): Promise<{ sessionId: string; error?: string }> { 245 | // Clean up old logs before starting a new session 246 | await cleanOldDeviceLogs(); 247 | 248 | const { deviceUuid, bundleId } = params; 249 | const logSessionId = uuidv4(); 250 | const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`; 251 | const tempDir = fileSystemExecutor ? fileSystemExecutor.tmpdir() : os.tmpdir(); 252 | const logFilePath = path.join(tempDir, logFileName); 253 | const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`); 254 | 255 | let logStream: fs.WriteStream | undefined; 256 | 257 | try { 258 | // Use injected file system executor or default 259 | if (fileSystemExecutor) { 260 | await fileSystemExecutor.mkdir(tempDir, { recursive: true }); 261 | await fileSystemExecutor.writeFile(logFilePath, ''); 262 | } else { 263 | await fs.promises.mkdir(tempDir, { recursive: true }); 264 | await fs.promises.writeFile(logFilePath, ''); 265 | } 266 | 267 | logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); 268 | 269 | logStream.write( 270 | `\n--- Device log capture for bundle ID: ${bundleId} on device: ${deviceUuid} ---\n`, 271 | ); 272 | 273 | // Use executor with dependency injection instead of spawn directly 274 | const result = await executor( 275 | [ 276 | 'xcrun', 277 | 'devicectl', 278 | 'device', 279 | 'process', 280 | 'launch', 281 | '--console', 282 | '--terminate-existing', 283 | '--device', 284 | deviceUuid, 285 | '--json-output', 286 | launchJsonPath, 287 | bundleId, 288 | ], 289 | 'Device Log Capture', 290 | true, 291 | undefined, 292 | true, 293 | ); 294 | 295 | if (!result.success) { 296 | log( 297 | 'error', 298 | `Device log capture process reported failure: ${result.error ?? 'unknown error'}`, 299 | ); 300 | if (logStream && !logStream.destroyed) { 301 | logStream.write( 302 | `\n--- Device log capture failed to start ---\n${result.error ?? 'Unknown error'}\n`, 303 | ); 304 | logStream.end(); 305 | } 306 | return { 307 | sessionId: '', 308 | error: result.error ?? 'Failed to start device log capture', 309 | }; 310 | } 311 | 312 | const childProcess = result.process; 313 | if (!childProcess) { 314 | throw new Error('Device log capture process handle was not returned'); 315 | } 316 | 317 | const session: DeviceLogSession = { 318 | process: childProcess, 319 | logFilePath, 320 | deviceUuid, 321 | bundleId, 322 | logStream, 323 | hasEnded: false, 324 | }; 325 | 326 | let bufferedOutput = ''; 327 | const appendBufferedOutput = (text: string): void => { 328 | bufferedOutput += text; 329 | if (bufferedOutput.length > INITIAL_OUTPUT_LIMIT) { 330 | bufferedOutput = bufferedOutput.slice(bufferedOutput.length - INITIAL_OUTPUT_LIMIT); 331 | } 332 | }; 333 | 334 | let triggerImmediateFailure: ((message: string) => void) | undefined; 335 | 336 | const handleOutput = (chunk: unknown): void => { 337 | if (!logStream || logStream.destroyed) return; 338 | const text = 339 | typeof chunk === 'string' 340 | ? chunk 341 | : chunk instanceof Buffer 342 | ? chunk.toString('utf8') 343 | : String(chunk ?? ''); 344 | if (text.length > 0) { 345 | appendBufferedOutput(text); 346 | const extracted = extractFailureMessage(bufferedOutput); 347 | if (extracted) { 348 | triggerImmediateFailure?.(extracted); 349 | } 350 | logStream.write(text); 351 | } 352 | }; 353 | 354 | childProcess.stdout?.setEncoding?.('utf8'); 355 | childProcess.stdout?.on?.('data', handleOutput); 356 | childProcess.stderr?.setEncoding?.('utf8'); 357 | childProcess.stderr?.on?.('data', handleOutput); 358 | 359 | const cleanupStreams = (): void => { 360 | childProcess.stdout?.off?.('data', handleOutput); 361 | childProcess.stderr?.off?.('data', handleOutput); 362 | }; 363 | 364 | const earlyFailure = await detectEarlyLaunchFailure( 365 | childProcess, 366 | EARLY_FAILURE_WINDOW_MS, 367 | () => bufferedOutput, 368 | (handler) => { 369 | triggerImmediateFailure = handler; 370 | }, 371 | ); 372 | 373 | if (earlyFailure) { 374 | cleanupStreams(); 375 | session.hasEnded = true; 376 | 377 | const failureMessage = 378 | earlyFailure.errorMessage && earlyFailure.errorMessage.length > 0 379 | ? earlyFailure.errorMessage 380 | : `Device log capture process exited immediately (exit code: ${ 381 | earlyFailure.exitCode ?? 'unknown' 382 | })`; 383 | 384 | log('error', `Device log capture failed to start: ${failureMessage}`); 385 | if (logStream && !logStream.destroyed) { 386 | try { 387 | logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); 388 | } catch { 389 | // best-effort logging 390 | } 391 | logStream.end(); 392 | } 393 | 394 | await removeFileIfExists(launchJsonPath, fileSystemExecutor); 395 | 396 | childProcess.kill?.('SIGTERM'); 397 | return { sessionId: '', error: failureMessage }; 398 | } 399 | 400 | const jsonOutcome = await pollJsonOutcome( 401 | launchJsonPath, 402 | fileSystemExecutor, 403 | getJsonResultWaitMs(), 404 | ); 405 | 406 | if (jsonOutcome?.errorMessage) { 407 | cleanupStreams(); 408 | session.hasEnded = true; 409 | 410 | const failureMessage = jsonOutcome.errorMessage; 411 | 412 | log('error', `Device log capture failed to start (JSON): ${failureMessage}`); 413 | 414 | if (logStream && !logStream.destroyed) { 415 | try { 416 | logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); 417 | } catch { 418 | // ignore secondary logging failures 419 | } 420 | logStream.end(); 421 | } 422 | 423 | childProcess.kill?.('SIGTERM'); 424 | return { sessionId: '', error: failureMessage }; 425 | } 426 | 427 | if (jsonOutcome?.pid && logStream && !logStream.destroyed) { 428 | try { 429 | logStream.write(`Process ID: ${jsonOutcome.pid}\n`); 430 | } catch { 431 | // best-effort logging only 432 | } 433 | } 434 | 435 | childProcess.once?.('error', (err) => { 436 | log( 437 | 'error', 438 | `Device log capture process error (session ${logSessionId}): ${ 439 | err instanceof Error ? err.message : String(err) 440 | }`, 441 | ); 442 | }); 443 | 444 | childProcess.once?.('close', (code) => { 445 | cleanupStreams(); 446 | session.hasEnded = true; 447 | if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { 448 | logStream.write(`\n--- Device log capture ended (exit code: ${code ?? 'unknown'}) ---\n`); 449 | logStream.end(); 450 | } 451 | void removeFileIfExists(launchJsonPath, fileSystemExecutor); 452 | }); 453 | 454 | // For testing purposes, we'll simulate process management 455 | // In actual usage, the process would be managed by the executor result 456 | activeDeviceLogSessions.set(logSessionId, session); 457 | 458 | log('info', `Device log capture started with session ID: ${logSessionId}`); 459 | return { sessionId: logSessionId }; 460 | } catch (error) { 461 | const message = error instanceof Error ? error.message : String(error); 462 | log('error', `Failed to start device log capture: ${message}`); 463 | if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { 464 | try { 465 | logStream.write(`\n--- Device log capture failed: ${message} ---\n`); 466 | } catch { 467 | // ignore secondary stream write failures 468 | } 469 | logStream.end(); 470 | } 471 | await removeFileIfExists(launchJsonPath, fileSystemExecutor); 472 | return { sessionId: '', error: message }; 473 | } 474 | } 475 | 476 | type EarlyFailureResult = { 477 | exitCode: number | null; 478 | errorMessage?: string; 479 | }; 480 | 481 | function detectEarlyLaunchFailure( 482 | process: ChildProcess, 483 | timeoutMs: number, 484 | getBufferedOutput?: () => string, 485 | registerImmediateFailure?: (handler: (message: string) => void) => void, 486 | ): Promise<EarlyFailureResult | null> { 487 | if (process.exitCode != null) { 488 | if (process.exitCode === 0) { 489 | const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); 490 | return Promise.resolve( 491 | failureFromOutput ? { exitCode: process.exitCode, errorMessage: failureFromOutput } : null, 492 | ); 493 | } 494 | const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); 495 | return Promise.resolve({ exitCode: process.exitCode, errorMessage: failureFromOutput }); 496 | } 497 | 498 | return new Promise<EarlyFailureResult | null>((resolve) => { 499 | let settled = false; 500 | 501 | const finalize = (result: EarlyFailureResult | null): void => { 502 | if (settled) return; 503 | settled = true; 504 | process.removeListener('close', onClose); 505 | process.removeListener('error', onError); 506 | clearTimeout(timer); 507 | resolve(result); 508 | }; 509 | 510 | registerImmediateFailure?.((message) => { 511 | finalize({ exitCode: process.exitCode ?? null, errorMessage: message }); 512 | }); 513 | 514 | const onClose = (code: number | null): void => { 515 | const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); 516 | if (code === 0 && failureFromOutput) { 517 | finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); 518 | return; 519 | } 520 | if (code === 0) { 521 | finalize(null); 522 | } else { 523 | finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); 524 | } 525 | }; 526 | 527 | const onError = (error: Error): void => { 528 | finalize({ exitCode: null, errorMessage: error.message }); 529 | }; 530 | 531 | const timer = setTimeout(() => { 532 | const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); 533 | if (failureFromOutput) { 534 | process.kill?.('SIGTERM'); 535 | finalize({ exitCode: process.exitCode ?? null, errorMessage: failureFromOutput }); 536 | return; 537 | } 538 | finalize(null); 539 | }, timeoutMs); 540 | 541 | process.once('close', onClose); 542 | process.once('error', onError); 543 | }); 544 | } 545 | 546 | function extractFailureMessage(output?: string): string | undefined { 547 | if (!output) { 548 | return undefined; 549 | } 550 | const normalized = output.replace(/\r/g, ''); 551 | const lines = normalized 552 | .split('\n') 553 | .map((line) => line.trim()) 554 | .filter(Boolean); 555 | 556 | const shouldInclude = (line?: string): boolean => { 557 | if (!line) return false; 558 | return ( 559 | line.startsWith('NS') || 560 | line.startsWith('BundleIdentifier') || 561 | line.startsWith('Provide ') || 562 | line.startsWith('The application') || 563 | line.startsWith('ERROR:') 564 | ); 565 | }; 566 | 567 | for (const pattern of FAILURE_PATTERNS) { 568 | const matchIndex = lines.findIndex((line) => pattern.test(line)); 569 | if (matchIndex === -1) { 570 | continue; 571 | } 572 | 573 | const snippet: string[] = [lines[matchIndex]]; 574 | const nextLine = lines[matchIndex + 1]; 575 | const thirdLine = lines[matchIndex + 2]; 576 | if (shouldInclude(nextLine)) snippet.push(nextLine); 577 | if (shouldInclude(thirdLine)) snippet.push(thirdLine); 578 | const message = snippet.join('\n').trim(); 579 | if (message.length > 0) { 580 | return message; 581 | } 582 | return lines[matchIndex]; 583 | } 584 | 585 | return undefined; 586 | } 587 | 588 | /** 589 | * Deletes device log files older than LOG_RETENTION_DAYS from the temp directory. 590 | * Runs quietly; errors are logged but do not throw. 591 | */ 592 | // Device logs follow the same retention policy as simulator logs but use a different prefix 593 | // to avoid conflicts. Both clean up logs older than LOG_RETENTION_DAYS automatically. 594 | async function cleanOldDeviceLogs(): Promise<void> { 595 | const tempDir = os.tmpdir(); 596 | let files; 597 | try { 598 | files = await fs.promises.readdir(tempDir); 599 | } catch (err) { 600 | log( 601 | 'warn', 602 | `Could not read temp dir for device log cleanup: ${err instanceof Error ? err.message : String(err)}`, 603 | ); 604 | return; 605 | } 606 | const now = Date.now(); 607 | const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; 608 | await Promise.all( 609 | files 610 | .filter((f) => f.startsWith(DEVICE_LOG_FILE_PREFIX) && f.endsWith('.log')) 611 | .map(async (f) => { 612 | const filePath = path.join(tempDir, f); 613 | try { 614 | const stat = await fs.promises.stat(filePath); 615 | if (now - stat.mtimeMs > retentionMs) { 616 | await fs.promises.unlink(filePath); 617 | log('info', `Deleted old device log file: ${filePath}`); 618 | } 619 | } catch (err) { 620 | log( 621 | 'warn', 622 | `Error during device log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`, 623 | ); 624 | } 625 | }), 626 | ); 627 | } 628 | 629 | // Define schema as ZodObject 630 | const startDeviceLogCapSchema = z.object({ 631 | deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), 632 | bundleId: z.string().describe('Bundle identifier of the app to launch and capture logs for.'), 633 | }); 634 | 635 | // Use z.infer for type safety 636 | type StartDeviceLogCapParams = z.infer<typeof startDeviceLogCapSchema>; 637 | 638 | /** 639 | * Core business logic for starting device log capture. 640 | */ 641 | export async function start_device_log_capLogic( 642 | params: StartDeviceLogCapParams, 643 | executor: CommandExecutor, 644 | fileSystemExecutor?: FileSystemExecutor, 645 | ): Promise<ToolResponse> { 646 | const { deviceId, bundleId } = params; 647 | 648 | const { sessionId, error } = await startDeviceLogCapture( 649 | { 650 | deviceUuid: deviceId, 651 | bundleId: bundleId, 652 | }, 653 | executor, 654 | fileSystemExecutor, 655 | ); 656 | 657 | if (error) { 658 | return { 659 | content: [ 660 | { 661 | type: 'text', 662 | text: `Failed to start device log capture: ${error}`, 663 | }, 664 | ], 665 | isError: true, 666 | }; 667 | } 668 | 669 | return { 670 | content: [ 671 | { 672 | type: 'text', 673 | text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nNext Steps:\n1. Interact with your app on the device\n2. Use stop_device_log_cap({ logSessionId: '${sessionId}' }) to stop capture and retrieve logs`, 674 | }, 675 | ], 676 | }; 677 | } 678 | 679 | export default { 680 | name: 'start_device_log_cap', 681 | description: 'Starts log capture on a connected device.', 682 | schema: startDeviceLogCapSchema.omit({ deviceId: true } as const).shape, 683 | handler: createSessionAwareTool<StartDeviceLogCapParams>({ 684 | internalSchema: startDeviceLogCapSchema as unknown as z.ZodType<StartDeviceLogCapParams>, 685 | logicFunction: start_device_log_capLogic, 686 | getExecutor: getDefaultCommandExecutor, 687 | requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], 688 | }), 689 | }; 690 | ```