This is page 3 of 11. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=false&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 -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettierPlugin from 'eslint-plugin-prettier'; export default [ eslint.configs.recommended, ...tseslint.configs.recommended, { ignores: ['node_modules/**', 'build/**', 'dist/**', 'coverage/**', 'src/core/generated-plugins.ts', 'src/core/generated-resources.ts'], }, { // TypeScript files in src/ directory (covered by tsconfig.json) files: ['src/**/*.ts'], languageOptions: { ecmaVersion: 2020, sourceType: 'module', parser: tseslint.parser, parserOptions: { project: ['./tsconfig.json'], }, }, plugins: { '@typescript-eslint': tseslint.plugin, 'prettier': prettierPlugin, }, rules: { 'prettier/prettier': 'error', '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: 'never', varsIgnorePattern: 'never' }], 'no-console': ['warn', { allow: ['error'] }], // Prevent dangerous type casting anti-patterns (errors) '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'as', objectLiteralTypeAssertions: 'never' }], '@typescript-eslint/no-unsafe-argument': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-unsafe-call': 'error', '@typescript-eslint/no-unsafe-member-access': 'error', '@typescript-eslint/no-unsafe-return': 'error', // Prevent specific anti-patterns we found '@typescript-eslint/ban-ts-comment': ['error', { 'ts-expect-error': 'allow-with-description', 'ts-ignore': true, 'ts-nocheck': true, 'ts-check': false, }], // Encourage best practices (warnings - can be gradually fixed) '@typescript-eslint/prefer-as-const': 'warn', '@typescript-eslint/prefer-nullish-coalescing': 'warn', '@typescript-eslint/prefer-optional-chain': 'warn', // Prevent barrel imports to maintain architectural improvements 'no-restricted-imports': ['error', { patterns: [ { group: ['**/utils/index.js', '../utils/index.js', '../../utils/index.js', '../../../utils/index.js', '**/utils/index.ts', '../utils/index.ts', '../../utils/index.ts', '../../../utils/index.ts'], message: 'Barrel imports from utils/index are prohibited. Use focused facade imports instead (e.g., utils/logging/index.ts, utils/execution/index.ts).' }, { group: ['./**/*.js', '../**/*.js'], message: 'Import TypeScript files with .ts extension, not .js. This ensures compatibility with native TypeScript runtimes like Bun and Deno. Change .js to .ts in your import path.' } ] }], }, }, { // JavaScript and TypeScript files outside the main project (scripts/, etc.) files: ['**/*.{js,ts}'], ignores: ['src/**/*', '**/*.test.ts'], languageOptions: { ecmaVersion: 2020, sourceType: 'module', parser: tseslint.parser, // No project reference for scripts - use standalone parsing }, plugins: { '@typescript-eslint': tseslint.plugin, 'prettier': prettierPlugin, }, rules: { 'prettier/prettier': 'error', // Relaxed TypeScript rules for scripts since they're not in the main project '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: 'never', varsIgnorePattern: 'never' }], 'no-console': 'off', // Scripts are allowed to use console // Disable project-dependent rules for scripts '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/prefer-optional-chain': 'off', }, }, { files: ['**/*.test.ts'], languageOptions: { parser: tseslint.parser, parserOptions: { project: './tsconfig.test.json', }, }, rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/explicit-function-return-type': 'off', 'prefer-const': 'off', // Relax unsafe rules for tests - tests often need more flexibility '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', }, }, ]; ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/stop_app_sim.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; const baseSchemaObject = z.object({ simulatorId: z .string() .optional() .describe( 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), bundleId: z.string().describe("Bundle identifier of the app to stop (e.g., 'com.example.MyApp')"), }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const stopAppSimSchema = baseSchema .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { message: 'Either simulatorId or simulatorName is required.', }) .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', }); export type StopAppSimParams = z.infer<typeof stopAppSimSchema>; export async function stop_app_simLogic( params: StopAppSimParams, executor: CommandExecutor, ): Promise<ToolResponse> { let simulatorId = params.simulatorId; let simulatorDisplayName = simulatorId ?? ''; if (params.simulatorName && !simulatorId) { log('info', `Looking up simulator by name: ${params.simulatorName}`); const simulatorListResult = await executor( ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 'List Simulators', true, ); if (!simulatorListResult.success) { return { content: [ { type: 'text', text: `Failed to list simulators: ${simulatorListResult.error}`, }, ], isError: true, }; } const simulatorsData = JSON.parse(simulatorListResult.output) as { devices: Record<string, Array<{ udid: string; name: string }>>; }; let foundSimulator: { udid: string; name: string } | null = null; for (const runtime in simulatorsData.devices) { const devices = simulatorsData.devices[runtime]; const simulator = devices.find((device) => device.name === params.simulatorName); if (simulator) { foundSimulator = simulator; break; } } if (!foundSimulator) { return { content: [ { type: 'text', text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, }, ], isError: true, }; } simulatorId = foundSimulator.udid; simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; } if (!simulatorId) { return { content: [ { type: 'text', text: 'No simulator identifier provided', }, ], isError: true, }; } log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); try { const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; const result = await executor(command, 'Stop App in Simulator', true, undefined); if (!result.success) { return { content: [ { type: 'text', text: `Stop app in simulator operation failed: ${result.error}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `✅ App ${params.bundleId} stopped successfully in simulator ${ simulatorDisplayName || simulatorId }`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error stopping app in simulator: ${errorMessage}`); return { content: [ { type: 'text', text: `Stop app in simulator operation failed: ${errorMessage}`, }, ], isError: true, }; } } const publicSchemaObject = baseSchemaObject.omit({ simulatorId: true, simulatorName: true, } as const); export default { name: 'stop_app_sim', description: 'Stops an app running in an iOS simulator.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<StopAppSimParams>({ internalSchema: stopAppSimSchema as unknown as z.ZodType<StopAppSimParams>, logicFunction: stop_app_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], exclusivePairs: [['simulatorId', 'simulatorName']], }), }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/button.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe-helpers.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const buttonSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), buttonType: z.enum(['apple-pay', 'home', 'lock', 'side-button', 'siri']), duration: z.number().min(0, 'Duration must be non-negative').optional(), }); // Use z.infer for type safety type ButtonParams = z.infer<typeof buttonSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; export async function buttonLogic( params: ButtonParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'button'; const { simulatorUuid, buttonType, duration } = params; const commandArgs = ['button', buttonType]; if (duration !== undefined) { commandArgs.push('--duration', String(duration)); } log('info', `${LOG_PREFIX}/${toolName}: Starting ${buttonType} button press on ${simulatorUuid}`); try { await executeAxeCommand(commandArgs, simulatorUuid, 'button', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); return createTextResponse(`Hardware button '${buttonType}' pressed successfully.`); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to press button '${buttonType}': ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'button', description: 'Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri', schema: buttonSchema.shape, // MCP SDK compatibility handler: createTypedTool( buttonSchema, (params: ButtonParams, executor: CommandExecutor) => { return buttonLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/key_press.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse, DependencyError, AxeError, SystemError, } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const keyPressSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), keyCode: z.number().int('HID keycode to press (0-255)').min(0).max(255), duration: z.number().min(0, 'Duration must be non-negative').optional(), }); // Use z.infer for type safety type KeyPressParams = z.infer<typeof keyPressSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; export async function key_pressLogic( params: KeyPressParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'key_press'; const { simulatorUuid, keyCode, duration } = params; const commandArgs = ['key', String(keyCode)]; if (duration !== undefined) { commandArgs.push('--duration', String(duration)); } log('info', `${LOG_PREFIX}/${toolName}: Starting key press ${keyCode} on ${simulatorUuid}`); try { await executeAxeCommand(commandArgs, simulatorUuid, 'key', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); return createTextResponse(`Key press (code: ${keyCode}) simulated successfully.`); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to simulate key press (code: ${keyCode}): ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'key_press', description: 'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.', schema: keyPressSchema.shape, // MCP SDK compatibility handler: createTypedTool( keyPressSchema, (params: KeyPressParams, executor: CommandExecutor) => { return key_pressLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/utils/template-manager.ts: -------------------------------------------------------------------------------- ```typescript import { join } from 'path'; import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; import { log } from './logger.ts'; import { iOSTemplateVersion, macOSTemplateVersion } from '../version.ts'; import { CommandExecutor } from './command.ts'; import { FileSystemExecutor } from './FileSystemExecutor.ts'; /** * Template manager for downloading and managing project templates */ export class TemplateManager { private static readonly GITHUB_ORG = 'cameroncooke'; private static readonly IOS_TEMPLATE_REPO = 'XcodeBuildMCP-iOS-Template'; private static readonly MACOS_TEMPLATE_REPO = 'XcodeBuildMCP-macOS-Template'; /** * Get the template path for a specific platform * Checks for local override via environment variable first */ static async getTemplatePath( platform: 'iOS' | 'macOS', commandExecutor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, ): Promise<string> { // Check for local override const envVar = platform === 'iOS' ? 'XCODEBUILDMCP_IOS_TEMPLATE_PATH' : 'XCODEBUILDMCP_MACOS_TEMPLATE_PATH'; const localPath = process.env[envVar]; log('debug', `[TemplateManager] Checking env var '${envVar}'. Value: '${localPath}'`); if (localPath) { const pathExists = fileSystemExecutor.existsSync(localPath); log('debug', `[TemplateManager] Env var set. Path '${localPath}' exists? ${pathExists}`); if (pathExists) { const templateSubdir = join(localPath, 'template'); const subdirExists = fileSystemExecutor.existsSync(templateSubdir); log( 'debug', `[TemplateManager] Checking for subdir '${templateSubdir}'. Exists? ${subdirExists}`, ); if (subdirExists) { log('info', `Using local ${platform} template from: ${templateSubdir}`); return templateSubdir; } else { log('info', `Template directory not found in ${localPath}, using GitHub release`); } } } log('debug', '[TemplateManager] Env var not set or path invalid, proceeding to download.'); // Download from GitHub release return await this.downloadTemplate(platform, commandExecutor, fileSystemExecutor); } /** * Download template from GitHub release */ private static async downloadTemplate( platform: 'iOS' | 'macOS', commandExecutor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, ): Promise<string> { const repo = platform === 'iOS' ? this.IOS_TEMPLATE_REPO : this.MACOS_TEMPLATE_REPO; const defaultVersion = platform === 'iOS' ? iOSTemplateVersion : macOSTemplateVersion; const envVarName = platform === 'iOS' ? 'XCODEBUILD_MCP_IOS_TEMPLATE_VERSION' : 'XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION'; const version = String( process.env[envVarName] ?? process.env.XCODEBUILD_MCP_TEMPLATE_VERSION ?? defaultVersion, ); // Create temp directory for download const tempDir = join(tmpdir(), `xcodebuild-mcp-template-${randomUUID()}`); await fileSystemExecutor.mkdir(tempDir, { recursive: true }); try { const downloadUrl = `https://github.com/${this.GITHUB_ORG}/${repo}/releases/download/${version}/${repo}-${version.substring(1)}.zip`; const zipPath = join(tempDir, 'template.zip'); log('info', `Downloading ${platform} template ${version} from GitHub...`); log('info', `Download URL: ${downloadUrl}`); // Download the release artifact const curlResult = await commandExecutor( ['curl', '-L', '-f', '-o', zipPath, downloadUrl], 'Download Template', true, undefined, ); if (!curlResult.success) { throw new Error(`Failed to download template: ${curlResult.error}`); } // Extract the zip file // Temporarily change to temp directory for extraction const originalCwd = process.cwd(); try { process.chdir(tempDir); const unzipResult = await commandExecutor( ['unzip', '-q', zipPath], 'Extract Template', true, undefined, ); if (!unzipResult.success) { throw new Error(`Failed to extract template: ${unzipResult.error}`); } } finally { process.chdir(originalCwd); } // Find the extracted directory and return the template subdirectory const extractedDir = join(tempDir, `${repo}-${version.substring(1)}`); if (!fileSystemExecutor.existsSync(extractedDir)) { throw new Error(`Expected template directory not found: ${extractedDir}`); } log('info', `Successfully downloaded ${platform} template ${version}`); return extractedDir; } catch (error) { // Clean up on error log('error', `Failed to download ${platform} template ${version}: ${error}`); await this.cleanup(tempDir, fileSystemExecutor); throw error; } } /** * Clean up downloaded template directory */ static async cleanup( templatePath: string, fileSystemExecutor: FileSystemExecutor, ): Promise<void> { // Only clean up if it's in temp directory if (templatePath.startsWith(tmpdir())) { await fileSystemExecutor.rm(templatePath, { recursive: true, force: true }); } } } ``` -------------------------------------------------------------------------------- /docs/ESLINT_TYPE_SAFETY.md: -------------------------------------------------------------------------------- ```markdown # ESLint Type Safety Rules This document explains the ESLint rules added to prevent TypeScript anti-patterns and improve type safety. ## Rules Added ### Error-Level Rules (Block CI/Deployment) These rules prevent dangerous type casting patterns that can lead to runtime errors: #### `@typescript-eslint/consistent-type-assertions` - **Purpose**: Prevents dangerous object literal type assertions - **Example**: Prevents `{ foo: 'bar' } as ComplexType` - **Rationale**: Object literal assertions can hide missing properties #### `@typescript-eslint/no-unsafe-*` (5 rules) - **no-unsafe-argument**: Prevents passing `any` to typed parameters - **no-unsafe-assignment**: Prevents assigning `any` to typed variables - **no-unsafe-call**: Prevents calling `any` as a function - **no-unsafe-member-access**: Prevents accessing properties on `any` - **no-unsafe-return**: Prevents returning `any` from typed functions **Example of prevented anti-pattern:** ```typescript // ❌ BAD - This would now be an ESLint error function handleParams(args: Record<string, unknown>) { const typedParams = args as MyToolParams; // Unsafe casting return typedParams.someProperty as string; // Unsafe member access } // ✅ GOOD - Proper validation approach function handleParams(args: Record<string, unknown>) { const typedParams = MyToolParamsSchema.parse(args); // Runtime validation return typedParams.someProperty; // Type-safe access } ``` #### `@typescript-eslint/ban-ts-comment` - **Purpose**: Prevents unsafe TypeScript comments - **Blocks**: `@ts-ignore`, `@ts-nocheck` - **Allows**: `@ts-expect-error` (with description) ### Warning-Level Rules (Encourage Best Practices) These rules encourage modern TypeScript patterns but don't block builds: #### `@typescript-eslint/prefer-nullish-coalescing` - **Purpose**: Prefer `??` over `||` for default values - **Example**: `value ?? 'default'` instead of `value || 'default'` - **Rationale**: More precise handling of falsy values (0, '', false) #### `@typescript-eslint/prefer-optional-chain` - **Purpose**: Prefer `?.` for safe property access - **Example**: `obj?.prop` instead of `obj && obj.prop` - **Rationale**: More concise and readable #### `@typescript-eslint/prefer-as-const` - **Purpose**: Prefer `as const` for literal types - **Example**: `['a', 'b'] as const` instead of `['a', 'b'] as string[]` ## Test File Exceptions Test files (`.test.ts`) have relaxed rules for flexibility: - All `no-unsafe-*` rules are disabled - `no-explicit-any` is disabled - Tests often need to test error conditions and edge cases ## Impact on Codebase ### Current Status (Post-Implementation) - **387 total issues detected** - **207 errors**: Require fixing for type safety - **180 warnings**: Can be gradually improved ### Gradual Migration Strategy 1. **Phase 1** (Immediate): Error-level rules prevent new anti-patterns 2. **Phase 2** (Ongoing): Gradually fix warning-level violations 3. **Phase 3** (Future): Consider promoting warnings to errors ### Benefits 1. **Prevents Regression**: New code can't introduce the anti-patterns we just fixed 2. **Runtime Safety**: Catches potential runtime errors at compile time 3. **Code Quality**: Encourages modern TypeScript best practices 4. **Developer Experience**: Better IDE support and autocomplete ## Related Issues Fixed These rules prevent the specific anti-patterns identified in PR review: 1. **✅ Type Casting in Parameters**: `args as SomeType` patterns now flagged 2. **✅ Unsafe Property Access**: `params.field as string` patterns prevented 3. **✅ Missing Validation**: Encourages schema validation over casting 4. **✅ Return Type Mismatches**: Function signature inconsistencies caught 5. **✅ Nullish Coalescing**: Promotes safer default value handling ## Agent Orchestration for ESLint Fixes ### Parallel Agent Strategy When fixing ESLint issues across the codebase: 1. **Deploy Multiple Agents**: Run agents in parallel on different files 2. **Single File Focus**: Each agent works on ONE tool file at a time 3. **Individual Linting**: Agents run `npm run lint path/to/single/file.ts` only 4. **Immediate Commits**: Commit each agent's work as soon as they complete 5. **Never Wait**: Don't wait for all agents to finish before committing 6. **Avoid Full Linting**: Never run `npm run lint` without a file path (eats context) 7. **Progress Tracking**: Update todo list and periodically check overall status 8. **Loop Until Done**: Keep deploying agents until all issues are resolved ### Example Commands for Agents ```bash # Single file linting (what agents should run) npm run lint src/mcp/tools/device-project/test_device_proj.ts # NOT this (too much context) npm run lint ``` ### Commit Strategy - **Individual commits**: One commit per agent completion - **Clear messages**: `fix: resolve ESLint errors in tool_name.ts` - **Never batch**: Don't wait to commit multiple files together - **Progress preservation**: Each fix is immediately saved ## Future Improvements Consider adding these rules in future iterations: - `@typescript-eslint/strict-boolean-expressions`: Stricter boolean logic - `@typescript-eslint/prefer-reduce-type-parameter`: Better generic usage - `@typescript-eslint/switch-exhaustiveness-check`: Complete switch statements ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/key_sequence.ts: -------------------------------------------------------------------------------- ```typescript /** * UI Testing Plugin: Key Sequence * * Press key sequence using HID keycodes on iOS simulator with configurable delay. */ import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse, DependencyError, AxeError, SystemError, } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const keySequenceSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), keyCodes: z.array(z.number().int().min(0).max(255)).min(1, 'At least one key code required'), delay: z.number().min(0, 'Delay must be non-negative').optional(), }); // Use z.infer for type safety type KeySequenceParams = z.infer<typeof keySequenceSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; export async function key_sequenceLogic( params: KeySequenceParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'key_sequence'; const { simulatorUuid, keyCodes, delay } = params; const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')]; if (delay !== undefined) { commandArgs.push('--delay', String(delay)); } log( 'info', `${LOG_PREFIX}/${toolName}: Starting key sequence [${keyCodes.join(',')}] on ${simulatorUuid}`, ); try { await executeAxeCommand(commandArgs, simulatorUuid, 'key-sequence', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); return createTextResponse(`Key sequence [${keyCodes.join(',')}] executed successfully.`); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to execute key sequence: ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'key_sequence', description: 'Press key sequence using HID keycodes on iOS simulator with configurable delay', schema: keySequenceSchema.shape, // MCP SDK compatibility handler: createTypedTool( keySequenceSchema, (params: KeySequenceParams, executor: CommandExecutor) => { return key_sequenceLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/record_sim_video.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, afterEach } from 'vitest'; // Import the tool and logic import tool, { record_sim_videoLogic } from '../record_sim_video.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub const VALID_SIM_ID = '00000000-0000-0000-0000-000000000000'; afterEach(() => { vi.restoreAllMocks(); }); describe('record_sim_video tool - validation', () => { it('errors when start and stop are both true (mutually exclusive)', async () => { const res = await tool.handler({ simulatorId: VALID_SIM_ID, start: true, stop: true, } as any); expect(res.isError).toBe(true); const text = (res.content?.[0] as any)?.text ?? ''; expect(text.toLowerCase()).toContain('mutually exclusive'); }); it('errors when stop=true but outputFile is missing', async () => { const res = await tool.handler({ simulatorId: VALID_SIM_ID, stop: true, } as any); expect(res.isError).toBe(true); const text = (res.content?.[0] as any)?.text ?? ''; expect(text.toLowerCase()).toContain('outputfile is required'); }); }); describe('record_sim_video logic - start behavior', () => { it('starts with default fps (30) and warns when outputFile is provided on start (ignored)', async () => { const video: any = { startSimulatorVideoCapture: async () => ({ started: true, sessionId: 'sess-123', }), stopSimulatorVideoCapture: async () => ({ stopped: false, }), }; // DI for AXe helpers: available and version OK const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, createAxeNotAvailableResponse: () => ({ content: [{ type: 'text', text: 'AXe not available' }], isError: true, }), }; const fs = createMockFileSystemExecutor(); const res = await record_sim_videoLogic( { simulatorId: VALID_SIM_ID, start: true, // fps omitted to hit default 30 outputFile: '/tmp/ignored.mp4', // should be ignored with a note } as any, DUMMY_EXECUTOR, axe, video, fs, ); expect(res.isError).toBe(false); const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); expect(texts).toContain('🎥'); expect(texts).toMatch(/30\s*fps/i); expect(texts.toLowerCase()).toContain('outputfile is ignored'); expect(texts).toContain('Next Steps'); expect(texts).toContain('stop: true'); expect(texts).toContain('outputFile'); }); }); describe('record_sim_video logic - end-to-end stop with rename', () => { it('stops, parses stdout path, and renames to outputFile', async () => { const video: any = { startSimulatorVideoCapture: async () => ({ started: true, sessionId: 'sess-abc', }), stopSimulatorVideoCapture: async () => ({ stopped: true, parsedPath: '/tmp/recorded.mp4', stdout: 'Saved to /tmp/recorded.mp4', }), }; const fs = createMockFileSystemExecutor(); const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, createAxeNotAvailableResponse: () => ({ content: [{ type: 'text', text: 'AXe not available' }], isError: true, }), }; // Start (not strictly required for stop path, but included to mimic flow) const startRes = await record_sim_videoLogic( { simulatorId: VALID_SIM_ID, start: true, } as any, DUMMY_EXECUTOR, axe, video, fs, ); expect(startRes.isError).toBe(false); // Stop and rename const outputFile = '/var/videos/final.mp4'; const stopRes = await record_sim_videoLogic( { simulatorId: VALID_SIM_ID, stop: true, outputFile, } as any, DUMMY_EXECUTOR, axe, video, fs, ); expect(stopRes.isError).toBe(false); const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); expect(texts).toContain('Original file: /tmp/recorded.mp4'); expect(texts).toContain(`Saved to: ${outputFile}`); // _meta should include final saved path expect((stopRes as any)._meta?.outputFile).toBe(outputFile); }); }); describe('record_sim_video logic - version gate', () => { it('errors when AXe version is below 1.1.0', async () => { const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => false, createAxeNotAvailableResponse: () => ({ content: [{ type: 'text', text: 'AXe not available' }], isError: true, }), }; const video: any = { startSimulatorVideoCapture: async () => ({ started: true, sessionId: 'sess-xyz', }), stopSimulatorVideoCapture: async () => ({ stopped: true, }), }; const fs = createMockFileSystemExecutor(); const res = await record_sim_videoLogic( { simulatorId: VALID_SIM_ID, start: true, } as any, DUMMY_EXECUTOR, axe, video, fs, ); expect(res.isError).toBe(true); const text = (res.content?.[0] as any)?.text ?? ''; expect(text).toContain('AXe v1.1.0'); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/screenshot.ts: -------------------------------------------------------------------------------- ```typescript /** * Screenshot tool plugin - Capture screenshots from iOS Simulator */ import * as path from 'path'; import { tmpdir } from 'os'; import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; import { ToolResponse, createImageContent } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createErrorResponse, SystemError } from '../../../utils/responses/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor, } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; const LOG_PREFIX = '[Screenshot]'; // Define schema as ZodObject const screenshotSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), }); // Use z.infer for type safety type ScreenshotParams = z.infer<typeof screenshotSchema>; export async function screenshotLogic( params: ScreenshotParams, executor: CommandExecutor, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), pathUtils: { tmpdir: () => string; join: (...paths: string[]) => string } = { ...path, tmpdir }, uuidUtils: { v4: () => string } = { v4: uuidv4 }, ): Promise<ToolResponse> { const { simulatorUuid } = params; const tempDir = pathUtils.tmpdir(); const screenshotFilename = `screenshot_${uuidUtils.v4()}.png`; const screenshotPath = pathUtils.join(tempDir, screenshotFilename); const optimizedFilename = `screenshot_optimized_${uuidUtils.v4()}.jpg`; const optimizedPath = pathUtils.join(tempDir, optimizedFilename); // Use xcrun simctl to take screenshot const commandArgs: string[] = [ 'xcrun', 'simctl', 'io', simulatorUuid, 'screenshot', screenshotPath, ]; log( 'info', `${LOG_PREFIX}/screenshot: Starting capture to ${screenshotPath} on ${simulatorUuid}`, ); try { // Execute the screenshot command const result = await executor(commandArgs, `${LOG_PREFIX}: screenshot`, false); if (!result.success) { throw new SystemError(`Failed to capture screenshot: ${result.error ?? result.output}`); } log('info', `${LOG_PREFIX}/screenshot: Success for ${simulatorUuid}`); try { // Optimize the image for LLM consumption: resize to max 800px width and convert to JPEG const optimizeArgs = [ 'sips', '-Z', '800', // Resize to max 800px (maintains aspect ratio) '-s', 'format', 'jpeg', // Convert to JPEG '-s', 'formatOptions', '75', // 75% quality compression screenshotPath, '--out', optimizedPath, ]; const optimizeResult = await executor(optimizeArgs, `${LOG_PREFIX}: optimize image`, false); if (!optimizeResult.success) { log('warning', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`); // Fallback to original PNG if optimization fails const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64'); // Clean up try { await fileSystemExecutor.rm(screenshotPath); } catch (err) { log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); } return { content: [createImageContent(base64Image, 'image/png')], isError: false, }; } log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`); // Read the optimized image file as base64 const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64'); log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`); // Clean up both temporary files try { await fileSystemExecutor.rm(screenshotPath); await fileSystemExecutor.rm(optimizedPath); } catch (err) { log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`); } // Return the optimized image (JPEG format, smaller size) return { content: [createImageContent(base64Image, 'image/jpeg')], isError: false, }; } catch (fileError) { log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`); return createErrorResponse( `Screenshot captured but failed to process image file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, ); } } catch (_error) { log('error', `${LOG_PREFIX}/screenshot: Failed - ${_error}`); if (_error instanceof SystemError) { return createErrorResponse( `System error executing screenshot: ${_error.message}`, _error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, ); } } export default { name: 'screenshot', description: "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", schema: screenshotSchema.shape, // MCP SDK compatibility handler: createTypedTool( screenshotSchema, (params: ScreenshotParams, executor: CommandExecutor) => { return screenshotLogic(params, executor); }, getDefaultCommandExecutor, ), }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/test_sim.ts: -------------------------------------------------------------------------------- ```typescript /** * Simulator Test Plugin: Test Simulator (Unified) * * Runs tests for a project or workspace on a simulator by UUID or name. * Accepts mutually exclusive `projectPath` or `workspacePath`. * Accepts mutually exclusive `simulatorId` or `simulatorName`. */ import { z } from 'zod'; import { handleTestLogic } from '../../../utils/test/index.ts'; import { log } from '../../../utils/logging/index.ts'; import { XcodePlatform } from '../../../types/common.ts'; import { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; // Define base schema object with all fields const baseSchemaObject = z.object({ projectPath: z .string() .optional() .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), workspacePath: z .string() .optional() .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z .string() .optional() .describe( 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z .string() .optional() .describe('Path where build products and other derived data will go'), extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), useLatestOS: z .boolean() .optional() .describe('Whether to use the latest OS version for the named simulator'), preferXcodebuild: z .boolean() .optional() .describe( 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', ), testRunnerEnv: z .record(z.string(), z.string()) .optional() .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), }); // Apply preprocessor to handle empty strings const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); // Apply XOR validation: exactly one of projectPath OR workspacePath, and exactly one of simulatorId OR simulatorName required const testSimulatorSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }) .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { message: 'Either simulatorId or simulatorName is required.', }) .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', }); // Use z.infer for type safety type TestSimulatorParams = z.infer<typeof testSimulatorSchema>; export async function test_simLogic( params: TestSimulatorParams, executor: CommandExecutor, ): Promise<ToolResponse> { // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warning', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } return handleTestLogic( { projectPath: params.projectPath, workspacePath: params.workspacePath, scheme: params.scheme, simulatorId: params.simulatorId, simulatorName: params.simulatorName, configuration: params.configuration ?? 'Debug', derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), preferXcodebuild: params.preferXcodebuild ?? false, platform: XcodePlatform.iOSSimulator, testRunnerEnv: params.testRunnerEnv, }, executor, ); } const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, simulatorId: true, simulatorName: true, configuration: true, useLatestOS: true, } as const); export default { name: 'test_sim', description: 'Runs tests on an iOS simulator.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<TestSimulatorParams>({ internalSchema: testSimulatorSchema as unknown as z.ZodType<TestSimulatorParams>, logicFunction: test_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], exclusivePairs: [ ['projectPath', 'workspacePath'], ['simulatorId', 'simulatorName'], ], }), }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/utilities/__tests__/clean.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import tool, { cleanLogic } from '../clean.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('clean (unified) tool', () => { beforeEach(() => { sessionStore.clear(); }); it('exports correct name/description/schema/handler', () => { expect(tool.name).toBe('clean'); expect(tool.description).toBe('Cleans build products with xcodebuild.'); expect(typeof tool.handler).toBe('function'); const schema = z.object(tool.schema).strict(); expect(schema.safeParse({}).success).toBe(true); expect( schema.safeParse({ derivedDataPath: '/tmp/Derived', extraArgs: ['--quiet'], preferXcodebuild: true, platform: 'iOS Simulator', }).success, ).toBe(true); expect(schema.safeParse({ configuration: 'Debug' }).success).toBe(false); const schemaKeys = Object.keys(tool.schema).sort(); expect(schemaKeys).toEqual( ['derivedDataPath', 'extraArgs', 'platform', 'preferXcodebuild'].sort(), ); }); it('handler validation: error when neither projectPath nor workspacePath provided', async () => { const result = await (tool as any).handler({}); expect(result.isError).toBe(true); const text = String(result.content?.[0]?.text ?? ''); expect(text).toContain('Missing required session defaults'); expect(text).toContain('Provide a project or workspace'); }); it('handler validation: error when both projectPath and workspacePath provided', async () => { const result = await (tool as any).handler({ projectPath: '/p.xcodeproj', workspacePath: '/w.xcworkspace', }); expect(result.isError).toBe(true); const text = String(result.content?.[0]?.text ?? ''); expect(text).toContain('Mutually exclusive parameters provided'); }); it('runs project-path flow via logic', async () => { const mock = createMockExecutor({ success: true, output: 'ok' }); const result = await cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mock); expect(result.isError).not.toBe(true); }); it('runs workspace-path flow via logic', async () => { const mock = createMockExecutor({ success: true, output: 'ok' }); const result = await cleanLogic( { workspacePath: '/w.xcworkspace', scheme: 'App' } as any, mock, ); expect(result.isError).not.toBe(true); }); it('handler validation: requires scheme when workspacePath is provided', async () => { const result = await (tool as any).handler({ workspacePath: '/w.xcworkspace' }); expect(result.isError).toBe(true); const text = String(result.content?.[0]?.text ?? ''); expect(text).toContain('Parameter validation failed'); expect(text).toContain('scheme is required when workspacePath is provided'); }); it('uses iOS platform by default', async () => { let capturedCommand: string[] = []; const mockExecutor = async (command: string[]) => { capturedCommand = command; return { success: true, output: 'clean success' }; }; const result = await cleanLogic( { projectPath: '/p.xcodeproj', scheme: 'App' } as any, mockExecutor, ); expect(result.isError).not.toBe(true); // Check that the command contains iOS platform destination const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=iOS'); }); it('accepts custom platform parameter', async () => { let capturedCommand: string[] = []; const mockExecutor = async (command: string[]) => { capturedCommand = command; return { success: true, output: 'clean success' }; }; const result = await cleanLogic( { projectPath: '/p.xcodeproj', scheme: 'App', platform: 'macOS', } as any, mockExecutor, ); expect(result.isError).not.toBe(true); // Check that the command contains macOS platform destination const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=macOS'); }); it('accepts iOS Simulator platform parameter (maps to iOS for clean)', async () => { let capturedCommand: string[] = []; const mockExecutor = async (command: string[]) => { capturedCommand = command; return { success: true, output: 'clean success' }; }; const result = await cleanLogic( { projectPath: '/p.xcodeproj', scheme: 'App', platform: 'iOS Simulator', } as any, mockExecutor, ); expect(result.isError).not.toBe(true); // For clean operations, iOS Simulator should be mapped to iOS platform const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=iOS'); }); it('handler validation: rejects invalid platform values', async () => { const result = await (tool as any).handler({ projectPath: '/p.xcodeproj', scheme: 'App', platform: 'InvalidPlatform', }); expect(result.isError).toBe(true); const text = String(result.content?.[0]?.text ?? ''); expect(text).toContain('Parameter validation failed'); expect(text).toContain('platform'); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/swift-package/__tests__/active-processes.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for active-processes module * Following CLAUDE.md testing standards with literal validation */ import { describe, it, expect, beforeEach } from 'vitest'; import { activeProcesses, getProcess, addProcess, removeProcess, clearAllProcesses, type ProcessInfo, } from '../active-processes.ts'; describe('active-processes module', () => { // Clear the map before each test beforeEach(() => { clearAllProcesses(); }); describe('activeProcesses Map', () => { it('should be a Map instance', () => { expect(activeProcesses instanceof Map).toBe(true); }); it('should start empty after clearing', () => { expect(activeProcesses.size).toBe(0); }); }); describe('getProcess function', () => { it('should return undefined for non-existent process', () => { const result = getProcess(12345); expect(result).toBe(undefined); }); it('should return process info for existing process', () => { const mockProcess = { kill: () => {}, on: () => {}, pid: 12345, }; const startedAt = new Date('2023-01-01T10:00:00.000Z'); const processInfo: ProcessInfo = { process: mockProcess, startedAt: startedAt, }; activeProcesses.set(12345, processInfo); const result = getProcess(12345); expect(result).toEqual({ process: mockProcess, startedAt: startedAt, }); }); }); describe('addProcess function', () => { it('should add process to the map', () => { const mockProcess = { kill: () => {}, on: () => {}, pid: 67890, }; const startedAt = new Date('2023-02-15T14:30:00.000Z'); const processInfo: ProcessInfo = { process: mockProcess, startedAt: startedAt, }; addProcess(67890, processInfo); expect(activeProcesses.size).toBe(1); expect(activeProcesses.get(67890)).toEqual(processInfo); }); it('should overwrite existing process with same pid', () => { const mockProcess1 = { kill: () => {}, on: () => {}, pid: 11111, }; const mockProcess2 = { kill: () => {}, on: () => {}, pid: 11111, }; const startedAt1 = new Date('2023-01-01T10:00:00.000Z'); const startedAt2 = new Date('2023-01-01T11:00:00.000Z'); addProcess(11111, { process: mockProcess1, startedAt: startedAt1 }); addProcess(11111, { process: mockProcess2, startedAt: startedAt2 }); expect(activeProcesses.size).toBe(1); expect(activeProcesses.get(11111)).toEqual({ process: mockProcess2, startedAt: startedAt2, }); }); }); describe('removeProcess function', () => { it('should return false for non-existent process', () => { const result = removeProcess(99999); expect(result).toBe(false); }); it('should return true and remove existing process', () => { const mockProcess = { kill: () => {}, on: () => {}, pid: 54321, }; const processInfo: ProcessInfo = { process: mockProcess, startedAt: new Date('2023-03-20T09:15:00.000Z'), }; addProcess(54321, processInfo); expect(activeProcesses.size).toBe(1); const result = removeProcess(54321); expect(result).toBe(true); expect(activeProcesses.size).toBe(0); expect(activeProcesses.get(54321)).toBe(undefined); }); }); describe('clearAllProcesses function', () => { it('should clear all processes from the map', () => { const mockProcess1 = { kill: () => {}, on: () => {}, pid: 1111, }; const mockProcess2 = { kill: () => {}, on: () => {}, pid: 2222, }; addProcess(1111, { process: mockProcess1, startedAt: new Date() }); addProcess(2222, { process: mockProcess2, startedAt: new Date() }); expect(activeProcesses.size).toBe(2); clearAllProcesses(); expect(activeProcesses.size).toBe(0); }); it('should work on already empty map', () => { expect(activeProcesses.size).toBe(0); clearAllProcesses(); expect(activeProcesses.size).toBe(0); }); }); describe('ProcessInfo interface', () => { it('should work with complete process object', () => { const mockProcess = { kill: () => {}, on: () => {}, pid: 12345, }; const startedAt = new Date('2023-01-01T10:00:00.000Z'); const processInfo: ProcessInfo = { process: mockProcess, startedAt: startedAt, }; addProcess(12345, processInfo); const retrieved = getProcess(12345); expect(retrieved).toEqual({ process: { kill: expect.any(Function), on: expect.any(Function), pid: 12345, }, startedAt: startedAt, }); }); it('should work with minimal process object', () => { const mockProcess = { kill: () => {}, on: () => {}, }; const startedAt = new Date('2023-01-01T10:00:00.000Z'); const processInfo: ProcessInfo = { process: mockProcess, startedAt: startedAt, }; addProcess(98765, processInfo); const retrieved = getProcess(98765); expect(retrieved).toEqual({ process: { kill: expect.any(Function), on: expect.any(Function), }, startedAt: startedAt, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for launch_app_logs_sim plugin (session-aware version) * Follows CLAUDE.md guidance with literal validation and DI. */ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import launchAppLogsSim, { launch_app_logs_simLogic, LogCaptureFunction, } from '../launch_app_logs_sim.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('launch_app_logs_sim tool', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should expose correct metadata', () => { expect(launchAppLogsSim.name).toBe('launch_app_logs_sim'); expect(launchAppLogsSim.description).toBe( 'Launches an app in an iOS simulator and captures its logs.', ); }); it('should expose only non-session fields in public schema', () => { const schema = z.object(launchAppLogsSim.schema); expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); expect(schema.safeParse({ bundleId: 'com.example.app', args: ['--debug'] }).success).toBe( true, ); expect(schema.safeParse({}).success).toBe(false); expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args', 'bundleId'].sort()); }); }); describe('Handler Requirements', () => { it('should require simulatorId when not provided', async () => { const result = await launchAppLogsSim.handler({ bundleId: 'com.example.testapp' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('simulatorId is required'); expect(result.content[0].text).toContain('session-set-defaults'); }); it('should validate bundleId when simulatorId default exists', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); const result = await launchAppLogsSim.handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('bundleId: Required'); expect(result.content[0].text).toContain( 'Tip: set session defaults via session-set-defaults', ); }); }); describe('Logic Behavior (Literal Returns)', () => { it('should handle successful app launch with log capture', async () => { let capturedParams: unknown = null; const logCaptureStub: LogCaptureFunction = async (params) => { capturedParams = params; return { sessionId: 'test-session-123', logFilePath: '/tmp/xcodemcp_sim_log_test-session-123.log', processes: [], error: undefined, }; }; const mockExecutor = createMockExecutor({ success: true, output: '' }); const result = await launch_app_logs_simLogic( { simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, mockExecutor, logCaptureStub, ); expect(result).toEqual({ content: [ { type: 'text', text: `App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "test-session-123" })' to stop capture and retrieve logs.`, }, ], isError: false, }); expect(capturedParams).toEqual({ simulatorUuid: 'test-uuid-123', bundleId: 'com.example.testapp', captureConsole: true, }); }); it('should ignore args for log capture setup', async () => { let capturedParams: unknown = null; const logCaptureStub: LogCaptureFunction = async (params) => { capturedParams = params; return { sessionId: 'test-session-456', logFilePath: '/tmp/xcodemcp_sim_log_test-session-456.log', processes: [], error: undefined, }; }; const mockExecutor = createMockExecutor({ success: true, output: '' }); await launch_app_logs_simLogic( { simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', args: ['--debug'], }, mockExecutor, logCaptureStub, ); expect(capturedParams).toEqual({ simulatorUuid: 'test-uuid-123', bundleId: 'com.example.testapp', captureConsole: true, args: ['--debug'], }); }); it('should surface log capture failure', async () => { const logCaptureStub: LogCaptureFunction = async () => ({ sessionId: '', logFilePath: '', processes: [], error: 'Failed to start log capture', }); const mockExecutor = createMockExecutor({ success: true, output: '' }); const result = await launch_app_logs_simLogic( { simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, mockExecutor, logCaptureStub, ); expect(result).toEqual({ content: [ { type: 'text', text: 'App was launched but log capture failed: Failed to start log capture', }, ], isError: true, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/__tests__/test-runner-env-integration.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration tests for TEST_RUNNER_ environment variable passing * * These tests verify that testRunnerEnv parameters are correctly processed * and passed through the execution chain. We focus on testing the core * functionality that matters most: environment variable normalization. */ import { describe, it, expect } from 'vitest'; import { normalizeTestRunnerEnv } from '../environment.ts'; describe('TEST_RUNNER_ Environment Variable Integration', () => { describe('Core normalization functionality', () => { it('should normalize environment variables correctly for real scenarios', () => { // Test the GitHub issue scenario: USE_DEV_MODE -> TEST_RUNNER_USE_DEV_MODE const gitHubIssueScenario = { USE_DEV_MODE: 'YES' }; const normalized = normalizeTestRunnerEnv(gitHubIssueScenario); expect(normalized).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES' }); }); it('should handle mixed prefixed and unprefixed variables', () => { const mixedVars = { USE_DEV_MODE: 'YES', // Should be prefixed TEST_RUNNER_SKIP_ANIMATIONS: '1', // Already prefixed, preserve DEBUG_MODE: 'true', // Should be prefixed }; const normalized = normalizeTestRunnerEnv(mixedVars); expect(normalized).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES', TEST_RUNNER_SKIP_ANIMATIONS: '1', TEST_RUNNER_DEBUG_MODE: 'true', }); }); it('should filter out null and undefined values', () => { const varsWithNulls = { VALID_VAR: 'value1', NULL_VAR: null as any, UNDEFINED_VAR: undefined as any, ANOTHER_VALID: 'value2', }; const normalized = normalizeTestRunnerEnv(varsWithNulls); expect(normalized).toEqual({ TEST_RUNNER_VALID_VAR: 'value1', TEST_RUNNER_ANOTHER_VALID: 'value2', }); // Ensure null/undefined vars are not present expect(normalized).not.toHaveProperty('TEST_RUNNER_NULL_VAR'); expect(normalized).not.toHaveProperty('TEST_RUNNER_UNDEFINED_VAR'); }); it('should handle special characters in keys and values', () => { const specialChars = { 'VAR_WITH-DASH': 'value-with-dash', 'VAR.WITH.DOTS': 'value/with/slashes', VAR_WITH_SPACES: 'value with spaces', TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', }; const normalized = normalizeTestRunnerEnv(specialChars); expect(normalized).toEqual({ 'TEST_RUNNER_VAR_WITH-DASH': 'value-with-dash', 'TEST_RUNNER_VAR.WITH.DOTS': 'value/with/slashes', TEST_RUNNER_VAR_WITH_SPACES: 'value with spaces', TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', }); }); it('should handle empty values correctly', () => { const emptyValues = { EMPTY_STRING: '', NORMAL_VAR: 'normal_value', }; const normalized = normalizeTestRunnerEnv(emptyValues); expect(normalized).toEqual({ TEST_RUNNER_EMPTY_STRING: '', TEST_RUNNER_NORMAL_VAR: 'normal_value', }); }); it('should handle edge case prefix variations', () => { const prefixEdgeCases = { TEST_RUN: 'not_quite_prefixed', // Should get prefixed TEST_RUNNER: 'no_underscore', // Should get prefixed TEST_RUNNER_CORRECT: 'already_good', // Should stay as-is test_runner_lowercase: 'lowercase', // Should get prefixed (case sensitive) }; const normalized = normalizeTestRunnerEnv(prefixEdgeCases); expect(normalized).toEqual({ TEST_RUNNER_TEST_RUN: 'not_quite_prefixed', TEST_RUNNER_TEST_RUNNER: 'no_underscore', TEST_RUNNER_CORRECT: 'already_good', TEST_RUNNER_test_runner_lowercase: 'lowercase', }); }); it('should preserve immutability of input object', () => { const originalInput = { FOO: 'bar', BAZ: 'qux' }; const inputCopy = { ...originalInput }; const normalized = normalizeTestRunnerEnv(originalInput); // Original should be unchanged expect(originalInput).toEqual(inputCopy); // Result should be different expect(normalized).not.toEqual(originalInput); expect(normalized).toEqual({ TEST_RUNNER_FOO: 'bar', TEST_RUNNER_BAZ: 'qux', }); }); it('should handle the complete test environment workflow', () => { // Simulate a comprehensive test environment setup const fullTestEnv = { // Core testing flags USE_DEV_MODE: 'YES', SKIP_ANIMATIONS: '1', FAST_MODE: 'true', // Already prefixed variables (user might provide these) TEST_RUNNER_TIMEOUT: '30', TEST_RUNNER_RETRIES: '3', // UI testing specific UI_TESTING_MODE: 'enabled', SCREENSHOT_MODE: 'disabled', // Performance testing PERFORMANCE_TESTS: 'false', MEMORY_TESTING: 'true', // Special values EMPTY_VAR: '', PATH_VAR: '/usr/local/bin:/usr/bin', }; const normalized = normalizeTestRunnerEnv(fullTestEnv); expect(normalized).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES', TEST_RUNNER_SKIP_ANIMATIONS: '1', TEST_RUNNER_FAST_MODE: 'true', TEST_RUNNER_TIMEOUT: '30', TEST_RUNNER_RETRIES: '3', TEST_RUNNER_UI_TESTING_MODE: 'enabled', TEST_RUNNER_SCREENSHOT_MODE: 'disabled', TEST_RUNNER_PERFORMANCE_TESTS: 'false', TEST_RUNNER_MEMORY_TESTING: 'true', TEST_RUNNER_EMPTY_VAR: '', TEST_RUNNER_PATH_VAR: '/usr/local/bin:/usr/bin', }); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for swift_package_clean plugin * Following CLAUDE.md testing standards with literal validation * Using dependency injection for deterministic testing */ import { describe, it, expect } from 'vitest'; import { createMockExecutor, createMockFileSystemExecutor, createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; import swiftPackageClean, { swift_package_cleanLogic } from '../swift_package_clean.ts'; describe('swift_package_clean plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(swiftPackageClean.name).toBe('swift_package_clean'); }); it('should have correct description', () => { expect(swiftPackageClean.description).toBe( 'Cleans Swift Package build artifacts and derived data', ); }); it('should have handler function', () => { expect(typeof swiftPackageClean.handler).toBe('function'); }); it('should validate schema correctly', () => { // Test required fields expect(swiftPackageClean.schema.packagePath.safeParse('/test/package').success).toBe(true); expect(swiftPackageClean.schema.packagePath.safeParse('').success).toBe(true); // Test invalid inputs expect(swiftPackageClean.schema.packagePath.safeParse(null).success).toBe(false); expect(swiftPackageClean.schema.packagePath.safeParse(undefined).success).toBe(false); }); }); describe('Command Generation Testing', () => { it('should build correct command for clean', async () => { const calls: Array<{ command: string[]; description: string; showOutput: boolean; workingDirectory: string | undefined; }> = []; const mockExecutor = async ( command: string[], description: string, showOutput: boolean, workingDirectory?: string, ) => { calls.push({ command, description, showOutput, workingDirectory }); return { success: true, output: 'Clean succeeded', error: undefined, process: { pid: 12345 }, }; }; await swift_package_cleanLogic( { packagePath: '/test/package', }, mockExecutor, ); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ command: ['swift', 'package', '--package-path', '/test/package', 'clean'], description: 'Swift Package Clean', showOutput: true, workingDirectory: undefined, }); }); }); describe('Response Logic Testing', () => { it('should handle valid params without validation errors in logic function', async () => { // Note: The logic function assumes valid params since createTypedTool handles validation const mockExecutor = createMockExecutor({ success: true, output: 'Package cleaned successfully', }); const result = await swift_package_cleanLogic( { packagePath: '/test/package', }, mockExecutor, ); expect(result.isError).toBe(false); expect(result.content[0].text).toBe('✅ Swift package cleaned successfully.'); }); it('should return successful clean response', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Package cleaned successfully', }); const result = await swift_package_cleanLogic( { packagePath: '/test/package', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ Swift package cleaned successfully.' }, { type: 'text', text: '💡 Build artifacts and derived data removed. Ready for fresh build.', }, { type: 'text', text: 'Package cleaned successfully' }, ], isError: false, }); }); it('should return successful clean response with no output', async () => { const mockExecutor = createMockExecutor({ success: true, output: '', }); const result = await swift_package_cleanLogic( { packagePath: '/test/package', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ Swift package cleaned successfully.' }, { type: 'text', text: '💡 Build artifacts and derived data removed. Ready for fresh build.', }, { type: 'text', text: '(clean completed silently)' }, ], isError: false, }); }); it('should return error response for clean failure', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Permission denied', }); const result = await swift_package_cleanLogic( { packagePath: '/test/package', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Swift package clean failed\nDetails: Permission denied', }, ], isError: true, }); }); it('should handle spawn error', async () => { const mockExecutor = async () => { throw new Error('spawn ENOENT'); }; const result = await swift_package_cleanLogic( { packagePath: '/test/package', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Failed to execute swift package clean\nDetails: spawn ENOENT', }, ], isError: true, }); }); }); }); ``` -------------------------------------------------------------------------------- /scripts/bundle-axe.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # Build script for AXe artifacts # This script downloads pre-built AXe artifacts from GitHub releases and bundles them set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" BUNDLED_DIR="$PROJECT_ROOT/bundled" AXE_LOCAL_DIR="/Volumes/Developer/AXe" AXE_TEMP_DIR="/tmp/axe-download-$$" echo "🔨 Preparing AXe artifacts for bundling..." # Single source of truth for AXe version (overridable) # 1) Use $AXE_VERSION if provided in env # 2) Else, use repo-level pin from .axe-version if present # 3) Else, fall back to default below DEFAULT_AXE_VERSION="1.1.1" VERSION_FILE="$PROJECT_ROOT/.axe-version" if [ -n "${AXE_VERSION}" ]; then PINNED_AXE_VERSION="${AXE_VERSION}" elif [ -f "$VERSION_FILE" ]; then PINNED_AXE_VERSION="$(cat "$VERSION_FILE" | tr -d ' \n\r')" else PINNED_AXE_VERSION="$DEFAULT_AXE_VERSION" fi echo "📌 Using AXe version: $PINNED_AXE_VERSION" # Clean up any existing bundled directory if [ -d "$BUNDLED_DIR" ]; then echo "🧹 Cleaning existing bundled directory..." rm -rf "$BUNDLED_DIR" fi # Create bundled directory mkdir -p "$BUNDLED_DIR" # Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then echo "🏠 Using local AXe source at $AXE_LOCAL_DIR" cd "$AXE_LOCAL_DIR" # Build AXe in release configuration echo "🔨 Building AXe in release configuration..." swift build --configuration release # Check if build succeeded if [ ! -f ".build/release/axe" ]; then echo "❌ AXe build failed - binary not found" exit 1 fi echo "✅ AXe build completed successfully" # Copy binary to bundled directory echo "📦 Copying AXe binary..." cp ".build/release/axe" "$BUNDLED_DIR/" # Fix rpath to find frameworks in Frameworks/ subdirectory echo "🔧 Configuring AXe binary rpath for bundled frameworks..." install_name_tool -add_rpath "@executable_path/Frameworks" "$BUNDLED_DIR/axe" # Create Frameworks directory and copy frameworks echo "📦 Copying frameworks..." mkdir -p "$BUNDLED_DIR/Frameworks" # Copy frameworks with better error handling for framework in .build/release/*.framework; do if [ -d "$framework" ]; then echo "📦 Copying framework: $(basename "$framework")" cp -r "$framework" "$BUNDLED_DIR/Frameworks/" # Only copy nested frameworks if they exist if [ -d "$framework/Frameworks" ]; then echo "📦 Found nested frameworks in $(basename "$framework")" cp -r "$framework/Frameworks"/* "$BUNDLED_DIR/Frameworks/" 2>/dev/null || true fi fi done else echo "📥 Downloading latest AXe release from GitHub..." # Construct release download URL from pinned version AXE_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v${PINNED_AXE_VERSION}/AXe-macOS-v${PINNED_AXE_VERSION}.tar.gz" # Create temp directory mkdir -p "$AXE_TEMP_DIR" cd "$AXE_TEMP_DIR" # Download and extract the release echo "📥 Downloading AXe release archive ($AXE_RELEASE_URL)..." curl -L -o "axe-release.tar.gz" "$AXE_RELEASE_URL" echo "📦 Extracting AXe release archive..." tar -xzf "axe-release.tar.gz" # Find the extracted directory (might be named differently) EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1) if [ -z "$EXTRACTED_DIR" ]; then # If no AXe directory found, assume files are in current directory EXTRACTED_DIR="." fi cd "$EXTRACTED_DIR" # Copy binary if [ -f "axe" ]; then echo "📦 Copying AXe binary..." cp "axe" "$BUNDLED_DIR/" chmod +x "$BUNDLED_DIR/axe" elif [ -f "bin/axe" ]; then echo "📦 Copying AXe binary from bin/..." cp "bin/axe" "$BUNDLED_DIR/" chmod +x "$BUNDLED_DIR/axe" else echo "❌ AXe binary not found in release archive" ls -la exit 1 fi # Copy frameworks if they exist echo "📦 Copying frameworks..." mkdir -p "$BUNDLED_DIR/Frameworks" if [ -d "Frameworks" ]; then cp -r Frameworks/* "$BUNDLED_DIR/Frameworks/" elif [ -d "lib" ]; then # Look for frameworks in lib directory find lib -name "*.framework" -exec cp -r {} "$BUNDLED_DIR/Frameworks/" \; else echo "⚠️ No frameworks directory found in release archive" echo "📂 Contents of release archive:" find . -type f -name "*.framework" -o -name "*.dylib" | head -10 fi fi # Verify frameworks were copied FRAMEWORK_COUNT=$(find "$BUNDLED_DIR/Frameworks" -name "*.framework" | wc -l) echo "📦 Copied $FRAMEWORK_COUNT frameworks" # List the frameworks for verification echo "🔍 Bundled frameworks:" ls -la "$BUNDLED_DIR/Frameworks/" # Verify binary can run with bundled frameworks echo "🧪 Testing bundled AXe binary..." if DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version > /dev/null 2>&1; then echo "✅ Bundled AXe binary test passed" else echo "❌ Bundled AXe binary test failed" exit 1 fi # Get AXe version for logging AXE_VERSION=$(DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version 2>/dev/null || echo "unknown") echo "📋 AXe version: $AXE_VERSION" # Clean up temp directory if it was used if [ -d "$AXE_TEMP_DIR" ]; then echo "🧹 Cleaning up temporary files..." rm -rf "$AXE_TEMP_DIR" fi # Show final bundle size BUNDLE_SIZE=$(du -sh "$BUNDLED_DIR" | cut -f1) echo "📊 Final bundle size: $BUNDLE_SIZE" echo "🎉 AXe bundling completed successfully!" echo "📁 Bundled artifacts location: $BUNDLED_DIR" ``` -------------------------------------------------------------------------------- /build-plugins/plugin-discovery.ts: -------------------------------------------------------------------------------- ```typescript import { Plugin } from 'esbuild'; import { readdirSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import path from 'path'; export interface WorkflowMetadata { name: string; description: string; platforms?: string[]; targets?: string[]; projectTypes?: string[]; capabilities?: string[]; } export function createPluginDiscoveryPlugin(): Plugin { return { name: 'plugin-discovery', setup(build) { // Generate the workflow loaders file before build starts build.onStart(async () => { try { await generateWorkflowLoaders(); } catch (error) { console.error('Failed to generate workflow loaders:', error); throw error; } }); } }; } async function generateWorkflowLoaders(): Promise<void> { const pluginsDir = path.resolve(process.cwd(), 'src/plugins'); if (!existsSync(pluginsDir)) { throw new Error(`Plugins directory not found: ${pluginsDir}`); } // Scan for workflow directories const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); const workflowLoaders: Record<string, string> = {}; const workflowMetadata: Record<string, WorkflowMetadata> = {}; for (const dirName of workflowDirs) { const indexPath = join(pluginsDir, dirName, 'index.ts'); // Check if workflow has index.ts file if (!existsSync(indexPath)) { console.warn(`Skipping ${dirName}: no index.ts file found`); continue; } // Try to extract workflow metadata from index.ts try { const indexContent = readFileSync(indexPath, 'utf8'); const metadata = extractWorkflowMetadata(indexContent); if (metadata) { // Generate dynamic import for this workflow workflowLoaders[dirName] = `() => import('../plugins/${dirName}/index.js')`; workflowMetadata[dirName] = metadata; console.log(`✅ Discovered workflow: ${dirName} - ${metadata.name}`); } else { console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`); } } catch (error) { console.warn(`⚠️ Error processing ${dirName}:`, error); } } // Generate the content for generated-plugins.ts const generatedContent = generatePluginsFileContent(workflowLoaders, workflowMetadata); // Write to the generated file const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts'); const fs = await import('fs'); await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`); } function extractWorkflowMetadata(content: string): WorkflowMetadata | null { try { // Simple regex to extract workflow export object const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/); if (!workflowMatch) { return null; } const workflowObj = workflowMatch[1]; // Extract name const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/); if (!nameMatch) return null; // Extract description const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/); if (!descMatch) return null; // Extract platforms (optional) const platformsMatch = workflowObj.match(/platforms\s*:\s*\[([^\]]*)\]/); let platforms: string[] | undefined; if (platformsMatch) { platforms = platformsMatch[1] .split(',') .map(p => p.trim().replace(/['"]/g, '')) .filter(p => p.length > 0); } // Extract targets (optional) const targetsMatch = workflowObj.match(/targets\s*:\s*\[([^\]]*)\]/); let targets: string[] | undefined; if (targetsMatch) { targets = targetsMatch[1] .split(',') .map(t => t.trim().replace(/['"]/g, '')) .filter(t => t.length > 0); } // Extract projectTypes (optional) const projectTypesMatch = workflowObj.match(/projectTypes\s*:\s*\[([^\]]*)\]/); let projectTypes: string[] | undefined; if (projectTypesMatch) { projectTypes = projectTypesMatch[1] .split(',') .map(pt => pt.trim().replace(/['"]/g, '')) .filter(pt => pt.length > 0); } // Extract capabilities (optional) const capabilitiesMatch = workflowObj.match(/capabilities\s*:\s*\[([^\]]*)\]/); let capabilities: string[] | undefined; if (capabilitiesMatch) { capabilities = capabilitiesMatch[1] .split(',') .map(c => c.trim().replace(/['"]/g, '')) .filter(c => c.length > 0); } return { name: nameMatch[1], description: descMatch[1], platforms, targets, projectTypes, capabilities }; } catch (error) { console.warn('Failed to extract workflow metadata:', error); return null; } } function generatePluginsFileContent( workflowLoaders: Record<string, string>, workflowMetadata: Record<string, WorkflowMetadata> ): string { const loaderEntries = Object.entries(workflowLoaders) .map(([key, loader]) => ` '${key}': ${loader}`) .join(',\n'); const metadataEntries = Object.entries(workflowMetadata) .map(([key, metadata]) => { const metadataJson = JSON.stringify(metadata, null, 4) .split('\n') .map(line => ` ${line}`) .join('\n'); return ` '${key}': ${metadataJson.trim()}`; }) .join(',\n'); return `// AUTO-GENERATED - DO NOT EDIT // This file is generated by the plugin discovery esbuild plugin // Generated based on filesystem scan export const WORKFLOW_LOADERS = { ${loaderEntries} }; export type WorkflowName = keyof typeof WORKFLOW_LOADERS; // Optional: Export workflow metadata for quick access export const WORKFLOW_METADATA = { ${metadataEntries} }; `; } ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/describe_ui.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe-helpers.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const describeUiSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), }); // Use z.infer for type safety type DescribeUiParams = z.infer<typeof describeUiSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; // Session tracking for describe_ui warnings (shared across UI tools) const describeUITimestamps = new Map(); function recordDescribeUICall(simulatorUuid: string): void { describeUITimestamps.set(simulatorUuid, { timestamp: Date.now(), simulatorUuid, }); } /** * Core business logic for describe_ui functionality */ export async function describe_uiLogic( params: DescribeUiParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'describe_ui'; const { simulatorUuid } = params; const commandArgs = ['describe-ui']; log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorUuid}`); try { const responseText = await executeAxeCommand( commandArgs, simulatorUuid, 'describe-ui', executor, axeHelpers, ); // Record the describe_ui call for warning system recordDescribeUICall(simulatorUuid); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); return { content: [ { type: 'text', text: 'Accessibility hierarchy retrieved successfully:\n```json\n' + responseText + '\n```', }, { type: 'text', text: `Next Steps: - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) - Re-run describe_ui after layout changes - Screenshots are for visual verification only`, }, ], }; } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to get accessibility hierarchy: ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'describe_ui', description: 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.', schema: describeUiSchema.shape, // MCP SDK compatibility handler: createTypedTool( describeUiSchema, (params: DescribeUiParams, executor: CommandExecutor) => { return describe_uiLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<string> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } return result.output.trim(); } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/stop_mac_app.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Pure dependency injection test for stop_mac_app plugin * * Tests plugin structure and macOS app stopping functionality including parameter validation, * command generation, and response formatting. * * Uses manual call tracking instead of vitest mocking. * NO VITEST MOCKING ALLOWED - Only manual stubs */ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import stopMacApp, { stop_mac_appLogic } from '../stop_mac_app.ts'; describe('stop_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(stopMacApp.name).toBe('stop_mac_app'); }); it('should have correct description', () => { expect(stopMacApp.description).toBe( 'Stops a running macOS application. Can stop by app name or process ID.', ); }); it('should have handler function', () => { expect(typeof stopMacApp.handler).toBe('function'); }); it('should validate schema correctly', () => { // Test optional fields expect(stopMacApp.schema.appName.safeParse('Calculator').success).toBe(true); expect(stopMacApp.schema.appName.safeParse(undefined).success).toBe(true); expect(stopMacApp.schema.processId.safeParse(1234).success).toBe(true); expect(stopMacApp.schema.processId.safeParse(undefined).success).toBe(true); // Test invalid inputs expect(stopMacApp.schema.appName.safeParse(null).success).toBe(false); expect(stopMacApp.schema.processId.safeParse('not-number').success).toBe(false); expect(stopMacApp.schema.processId.safeParse(null).success).toBe(false); }); }); describe('Input Validation', () => { it('should return exact validation error for missing parameters', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); const result = await stop_mac_appLogic({}, mockExecutor); expect(result).toEqual({ content: [ { type: 'text', text: 'Either appName or processId must be provided.', }, ], isError: true, }); }); }); describe('Command Generation', () => { it('should generate correct command for process ID', async () => { const calls: any[] = []; const mockExecutor = async (command: string[]) => { calls.push({ command }); return { success: true, output: '', process: {} as any }; }; await stop_mac_appLogic( { processId: 1234, }, mockExecutor, ); expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['kill', '1234']); }); it('should generate correct command for app name', async () => { const calls: any[] = []; const mockExecutor = async (command: string[]) => { calls.push({ command }); return { success: true, output: '', process: {} as any }; }; await stop_mac_appLogic( { appName: 'Calculator', }, mockExecutor, ); expect(calls).toHaveLength(1); expect(calls[0].command).toEqual([ 'sh', '-c', 'pkill -f "Calculator" || osascript -e \'tell application "Calculator" to quit\'', ]); }); it('should prioritize processId over appName', async () => { const calls: any[] = []; const mockExecutor = async (command: string[]) => { calls.push({ command }); return { success: true, output: '', process: {} as any }; }; await stop_mac_appLogic( { appName: 'Calculator', processId: 1234, }, mockExecutor, ); expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['kill', '1234']); }); }); describe('Response Processing', () => { it('should return exact successful stop response by app name', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); const result = await stop_mac_appLogic( { appName: 'Calculator', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ macOS app stopped successfully: Calculator', }, ], }); }); it('should return exact successful stop response by process ID', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); const result = await stop_mac_appLogic( { processId: 1234, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ macOS app stopped successfully: PID 1234', }, ], }); }); it('should return exact successful stop response with both parameters (processId takes precedence)', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); const result = await stop_mac_appLogic( { appName: 'Calculator', processId: 1234, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ macOS app stopped successfully: PID 1234', }, ], }); }); it('should handle execution errors', async () => { const mockExecutor = async () => { throw new Error('Process not found'); }; const result = await stop_mac_appLogic( { processId: 9999, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '❌ Stop macOS app operation failed: Process not found', }, ], isError: true, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/tool-registry.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer, RegisteredTool } from '@camsoft/mcp-sdk/server/mcp.js'; import { loadPlugins } from '../core/plugin-registry.ts'; import { ToolResponse } from '../types/common.ts'; import { log } from './logger.ts'; // Global registry to track registered tools for cleanup const toolRegistry = new Map<string, RegisteredTool>(); /** * Register a tool and track it for potential removal */ export function registerAndTrackTool( server: McpServer, name: string, config: Parameters<McpServer['registerTool']>[1], callback: Parameters<McpServer['registerTool']>[2], ): RegisteredTool { const registeredTool = server.registerTool(name, config, callback); toolRegistry.set(name, registeredTool); return registeredTool; } /** * Register multiple tools and track them for potential removal */ export function registerAndTrackTools( server: McpServer, tools: Parameters<McpServer['registerTools']>[0], ): RegisteredTool[] { const registeredTools = server.registerTools(tools); // Track each registered tool tools.forEach((tool, index) => { if (registeredTools[index]) { toolRegistry.set(tool.name, registeredTools[index]); } }); return registeredTools; } /** * Check if a tool is already registered */ export function isToolRegistered(name: string): boolean { return toolRegistry.has(name); } /** * Remove a specific tracked tool by name */ export function removeTrackedTool(name: string): boolean { const tool = toolRegistry.get(name); if (!tool) { return false; } try { tool.remove(); toolRegistry.delete(name); log('debug', `✅ Removed tool: ${name}`); return true; } catch (error) { log('error', `❌ Failed to remove tool ${name}: ${error}`); return false; } } /** * Remove multiple tracked tools by names */ export function removeTrackedTools(names: string[]): string[] { const removedTools: string[] = []; for (const name of names) { if (removeTrackedTool(name)) { removedTools.push(name); } } return removedTools; } /** * Remove all currently tracked tools */ export function removeAllTrackedTools(): void { const toolNames = Array.from(toolRegistry.keys()); if (toolNames.length === 0) { return; } log('info', `Removing ${toolNames.length} tracked tools...`); const removedTools = removeTrackedTools(toolNames); log('info', `✅ Removed ${removedTools.length} tracked tools`); } /** * Get the number of currently tracked tools */ export function getTrackedToolCount(): number { return toolRegistry.size; } /** * Get the names of currently tracked tools */ export function getTrackedToolNames(): string[] { return Array.from(toolRegistry.keys()); } /** * Register only discovery tools (discover_tools, discover_projs) with tracking */ export async function registerDiscoveryTools(server: McpServer): Promise<void> { const plugins = await loadPlugins(); let registeredCount = 0; // Only register discovery tools initially const discoveryTools = []; for (const plugin of plugins.values()) { // Only load discover_tools and discover_projs initially - other tools will be loaded via workflows if (plugin.name === 'discover_tools' || plugin.name === 'discover_projs') { discoveryTools.push({ name: plugin.name, config: { description: plugin.description ?? '', inputSchema: plugin.schema, }, // Adapt callback to match SDK's expected signature callback: (args: unknown): Promise<ToolResponse> => plugin.handler(args as Record<string, unknown>), }); registeredCount++; } } // Register discovery tools using bulk registration with tracking if (discoveryTools.length > 0) { registerAndTrackTools(server, discoveryTools); } log('info', `✅ Registered ${registeredCount} discovery tools in dynamic mode.`); } /** * Register selected workflows based on environment variable */ export async function registerSelectedWorkflows( server: McpServer, workflowNames: string[], ): Promise<void> { const { loadWorkflowGroups } = await import('../core/plugin-registry.js'); const workflowGroups = await loadWorkflowGroups(); const selectedTools = []; for (const workflowName of workflowNames) { const workflow = workflowGroups.get(workflowName.trim()); if (workflow) { for (const tool of workflow.tools) { selectedTools.push({ name: tool.name, config: { description: tool.description ?? '', inputSchema: tool.schema, }, callback: (args: unknown): Promise<ToolResponse> => tool.handler(args as Record<string, unknown>), }); } } } if (selectedTools.length > 0) { server.registerTools(selectedTools); } log( 'info', `✅ Registered ${selectedTools.length} tools from workflows: ${workflowNames.join(', ')}`, ); } /** * Register all tools (static mode) - no tracking needed since these won't be removed */ export async function registerAllToolsStatic(server: McpServer): Promise<void> { const plugins = await loadPlugins(); const allTools = []; for (const plugin of plugins.values()) { // Exclude discovery tools in static mode - they should only be available in dynamic mode if (plugin.name === 'discover_tools') { continue; } allTools.push({ name: plugin.name, config: { description: plugin.description ?? '', inputSchema: plugin.schema, }, // Adapt callback to match SDK's expected signature callback: (args: unknown): Promise<ToolResponse> => plugin.handler(args as Record<string, unknown>), }); } // Register all tools using bulk registration (no tracking since static tools aren't removed) if (allTools.length > 0) { server.registerTools(allTools); } log('info', `✅ Registered ${allTools.length} tools in static mode.`); } ``` -------------------------------------------------------------------------------- /src/utils/__tests__/simulator-utils.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { determineSimulatorUuid } from '../simulator-utils.ts'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; describe('determineSimulatorUuid', () => { const mockSimulatorListOutput = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC-123-UUID', name: 'iPhone 16', isAvailable: true, }, { udid: 'DEF-456-UUID', name: 'iPhone 15', isAvailable: false, }, ], 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [ { udid: 'GHI-789-UUID', name: 'iPhone 14', isAvailable: true, }, ], }, }); describe('UUID provided directly', () => { it('should return UUID when simulatorUuid is provided', async () => { const mockExecutor = createMockExecutor( new Error('Should not call executor when UUID provided'), ); const result = await determineSimulatorUuid( { simulatorUuid: 'DIRECT-UUID-123' }, mockExecutor, ); expect(result.uuid).toBe('DIRECT-UUID-123'); expect(result.warning).toBeUndefined(); expect(result.error).toBeUndefined(); }); it('should prefer simulatorUuid when both UUID and name are provided', async () => { const mockExecutor = createMockExecutor( new Error('Should not call executor when UUID provided'), ); const result = await determineSimulatorUuid( { simulatorUuid: 'DIRECT-UUID', simulatorName: 'iPhone 16' }, mockExecutor, ); expect(result.uuid).toBe('DIRECT-UUID'); }); }); describe('Name that looks like UUID', () => { it('should detect and use UUID-like name directly', async () => { const mockExecutor = createMockExecutor( new Error('Should not call executor for UUID-like name'), ); const uuidLikeName = '12345678-1234-1234-1234-123456789abc'; const result = await determineSimulatorUuid({ simulatorName: uuidLikeName }, mockExecutor); expect(result.uuid).toBe(uuidLikeName); expect(result.warning).toContain('appears to be a UUID'); expect(result.error).toBeUndefined(); }); it('should detect uppercase UUID-like name', async () => { const mockExecutor = createMockExecutor( new Error('Should not call executor for UUID-like name'), ); const uuidLikeName = '12345678-1234-1234-1234-123456789ABC'; const result = await determineSimulatorUuid({ simulatorName: uuidLikeName }, mockExecutor); expect(result.uuid).toBe(uuidLikeName); expect(result.warning).toContain('appears to be a UUID'); }); }); describe('Name resolution via simctl', () => { it('should resolve name to UUID for available simulator', async () => { const mockExecutor = createMockExecutor({ success: true, output: mockSimulatorListOutput, }); const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); expect(result.uuid).toBe('ABC-123-UUID'); expect(result.warning).toBeUndefined(); expect(result.error).toBeUndefined(); }); it('should find simulator across different runtimes', async () => { const mockExecutor = createMockExecutor({ success: true, output: mockSimulatorListOutput, }); const result = await determineSimulatorUuid({ simulatorName: 'iPhone 14' }, mockExecutor); expect(result.uuid).toBe('GHI-789-UUID'); expect(result.error).toBeUndefined(); }); it('should error for unavailable simulator', async () => { const mockExecutor = createMockExecutor({ success: true, output: mockSimulatorListOutput, }); const result = await determineSimulatorUuid({ simulatorName: 'iPhone 15' }, mockExecutor); expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); expect(result.error?.content[0].text).toContain('exists but is not available'); }); it('should error for non-existent simulator', async () => { const mockExecutor = createMockExecutor({ success: true, output: mockSimulatorListOutput, }); const result = await determineSimulatorUuid({ simulatorName: 'iPhone 99' }, mockExecutor); expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); expect(result.error?.content[0].text).toContain('not found'); }); it('should handle simctl list failure', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'simctl command failed', }); const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); expect(result.error?.content[0].text).toContain('Failed to list simulators'); }); it('should handle invalid JSON from simctl', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'invalid json {', }); const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); expect(result.error?.content[0].text).toContain('Failed to parse simulator list'); }); }); describe('No identifier provided', () => { it('should error when neither UUID nor name is provided', async () => { const mockExecutor = createMockExecutor( new Error('Should not call executor when no identifier'), ); const result = await determineSimulatorUuid({}, mockExecutor); expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); expect(result.error?.content[0].text).toContain('No simulator identifier provided'); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/build_sim.ts: -------------------------------------------------------------------------------- ```typescript /** * Simulator Build Plugin: Build Simulator (Unified) * * Builds an app from a project or workspace for a specific simulator by UUID or name. * Accepts mutually exclusive `projectPath` or `workspacePath`. * Accepts mutually exclusive `simulatorId` or `simulatorName`. */ import { z } from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z .string() .optional() .describe( 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z .string() .optional() .describe('Path where build products and other derived data will go'), extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), useLatestOS: z .boolean() .optional() .describe('Whether to use the latest OS version for the named simulator'), preferXcodebuild: z .boolean() .optional() .describe( 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', ), }; const baseSchemaObject = z.object({ projectPath: z .string() .optional() .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), workspacePath: z .string() .optional() .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), ...baseOptions, }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const buildSimulatorSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }) .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { message: 'Either simulatorId or simulatorName is required.', }) .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', }); export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>; // Internal logic for building Simulator apps. async function _handleSimulatorBuildLogic( params: BuildSimulatorParams, executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise<ToolResponse> { const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warning', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } log( 'info', `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, ); // Ensure configuration has a default value for SharedBuildParams compatibility const sharedBuildParams = { ...params, configuration: params.configuration ?? 'Debug', }; // executeXcodeBuildCommand handles both simulatorId and simulatorName return executeXcodeBuildCommand( sharedBuildParams, { platform: XcodePlatform.iOSSimulator, simulatorName: params.simulatorName, simulatorId: params.simulatorId, useLatestOS: params.simulatorId ? false : params.useLatestOS, // Ignore useLatestOS with ID logPrefix: 'iOS Simulator Build', }, params.preferXcodebuild ?? false, 'build', executor, ); } export async function build_simLogic( params: BuildSimulatorParams, executor: CommandExecutor, ): Promise<ToolResponse> { // Provide defaults const processedParams: BuildSimulatorParams = { ...params, configuration: params.configuration ?? 'Debug', useLatestOS: params.useLatestOS ?? true, // May be ignored if simulatorId is provided preferXcodebuild: params.preferXcodebuild ?? false, }; return _handleSimulatorBuildLogic(processedParams, executor); } // Public schema = internal minus session-managed fields const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, configuration: true, simulatorId: true, simulatorName: true, useLatestOS: true, } as const); export default { name: 'build_sim', description: 'Builds an app for an iOS simulator.', schema: publicSchemaObject.shape, // MCP SDK compatibility (public inputs only) handler: createSessionAwareTool<BuildSimulatorParams>({ internalSchema: buildSimulatorSchema as unknown as z.ZodType<BuildSimulatorParams>, logicFunction: build_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], exclusivePairs: [ ['projectPath', 'workspacePath'], ['simulatorId', 'simulatorName'], ], }), }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/utilities/clean.ts: -------------------------------------------------------------------------------- ```typescript /** * Utilities Plugin: Clean (Unified) * * Cleans build products for either a project or workspace using xcodebuild. * Accepts mutually exclusive `projectPath` or `workspacePath`. */ import { z } from 'zod'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { scheme: z.string().optional().describe('Optional: The scheme to clean'), configuration: z .string() .optional() .describe('Optional: Build configuration to clean (Debug, Release, etc.)'), derivedDataPath: z .string() .optional() .describe('Optional: Path where derived data might be located'), extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), preferXcodebuild: z .boolean() .optional() .describe( 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', ), platform: z .enum([ 'macOS', 'iOS', 'iOS Simulator', 'watchOS', 'watchOS Simulator', 'tvOS', 'tvOS Simulator', 'visionOS', 'visionOS Simulator', ]) .optional() .describe( 'Optional: Platform to clean for (defaults to iOS). Choose from macOS, iOS, iOS Simulator, watchOS, watchOS Simulator, tvOS, tvOS Simulator, visionOS, visionOS Simulator', ), }; const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), ...baseOptions, }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const cleanSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }) .refine((val) => !(val.workspacePath && !val.scheme), { message: 'scheme is required when workspacePath is provided.', path: ['scheme'], }); export type CleanParams = z.infer<typeof cleanSchema>; export async function cleanLogic( params: CleanParams, executor: CommandExecutor, ): Promise<ToolResponse> { // Extra safety: ensure workspace path has a scheme (xcodebuild requires it) if (params.workspacePath && !params.scheme) { return createErrorResponse( 'Parameter validation failed', 'Invalid parameters:\nscheme: scheme is required when workspacePath is provided.', ); } // Use provided platform or default to iOS const targetPlatform = params.platform ?? 'iOS'; // Map human-friendly platform names to XcodePlatform enum values // This is safer than direct key lookup and handles the space-containing simulator names const platformMap = { macOS: XcodePlatform.macOS, iOS: XcodePlatform.iOS, 'iOS Simulator': XcodePlatform.iOSSimulator, watchOS: XcodePlatform.watchOS, 'watchOS Simulator': XcodePlatform.watchOSSimulator, tvOS: XcodePlatform.tvOS, 'tvOS Simulator': XcodePlatform.tvOSSimulator, visionOS: XcodePlatform.visionOS, 'visionOS Simulator': XcodePlatform.visionOSSimulator, }; const platformEnum = platformMap[targetPlatform]; if (!platformEnum) { return createErrorResponse( 'Parameter validation failed', `Invalid parameters:\nplatform: unsupported value "${targetPlatform}".`, ); } const hasProjectPath = typeof params.projectPath === 'string'; const typedParams: SharedBuildParams = { ...(hasProjectPath ? { projectPath: params.projectPath as string } : { workspacePath: params.workspacePath as string }), // scheme may be omitted for project; when omitted we do not pass -scheme // Provide empty string to satisfy type, executeXcodeBuildCommand only emits -scheme when non-empty scheme: params.scheme ?? '', configuration: params.configuration ?? 'Debug', derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, }; // For clean operations, simulator platforms should be mapped to their device equivalents // since clean works at the build product level, not runtime level, and build products // are shared between device and simulator platforms const cleanPlatformMap: Partial<Record<XcodePlatform, XcodePlatform>> = { [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, [XcodePlatform.tvOSSimulator]: XcodePlatform.tvOS, [XcodePlatform.visionOSSimulator]: XcodePlatform.visionOS, }; const cleanPlatform = cleanPlatformMap[platformEnum] ?? platformEnum; return executeXcodeBuildCommand( typedParams, { platform: cleanPlatform, logPrefix: 'Clean', }, false, 'clean', executor, ); } const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, configuration: true, } as const); export default { name: 'clean', description: 'Cleans build products with xcodebuild.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<CleanParams>({ internalSchema: cleanSchema as unknown as z.ZodType<CleanParams>, logicFunction: cleanLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, ], exclusivePairs: [['projectPath', 'workspacePath']], }), }; ``` -------------------------------------------------------------------------------- /docs/RELEASE_PROCESS.md: -------------------------------------------------------------------------------- ```markdown # Release Process ## Step-by-Step Development Workflow ### 1. Starting New Work **Always start by syncing with main:** ```bash git checkout main git pull origin main ``` **Create feature branch using standardized naming convention:** ```bash git checkout -b feature/issue-123-add-new-feature git checkout -b bugfix/issue-456-fix-simulator-crash ``` ### 2. Development & Commits **Before committing, ALWAYS run quality checks:** ```bash npm run build # Ensure code compiles npm run typecheck # MANDATORY: Fix all TypeScript errors npm run lint # Fix linting issues npm run test # Ensure tests pass ``` **🚨 CRITICAL: TypeScript errors are BLOCKING:** - **ZERO tolerance** for TypeScript errors in commits - The `npm run typecheck` command must pass with no errors - Fix all `ts(XXXX)` errors before committing - Do not ignore or suppress TypeScript errors without explicit approval **Make logical, atomic commits:** - Each commit should represent a single logical change - Write short, descriptive commit summaries - Commit frequently to your feature branch ```bash # Always run quality checks first npm run typecheck && npm run lint && npm run test # Then commit your changes git add . git commit -m "feat: add simulator boot validation logic" git commit -m "fix: handle null response in device list parser" ``` ### 3. Pushing Changes **🚨 CRITICAL: Always ask permission before pushing** - **NEVER push without explicit user permission** - **NEVER force push without explicit permission** - Pushing without permission is a fatal error resulting in termination ```bash # Only after getting permission: git push origin feature/your-branch-name ``` ### 4. Pull Request Creation **Use GitHub CLI tool exclusively:** ```bash gh pr create --title "feat: add simulator boot validation" --body "$(cat <<'EOF' ## Summary Brief description of what this PR does and why. ## Background/Details ### For New Features: - Detailed explanation of the new feature - Context and requirements that led to this implementation - Design decisions and approach taken ### For Bug Fixes: - **Root Cause Analysis**: Detailed explanation of what caused the bug - Specific conditions that trigger the issue - Why the current code fails in these scenarios ## Solution - How the root cause was addressed - Technical approach and implementation details - Key changes made to resolve the issue ## Testing - **Reproduction Steps**: How to reproduce the original issue (for bugs) - **Validation Method**: How you verified the fix works - **Test Coverage**: What tests were added or modified - **Manual Testing**: Steps taken to validate the solution - **Edge Cases**: Additional scenarios tested ## Notes - Any important considerations for reviewers - Potential impacts or side effects - Future improvements or technical debt - Deployment considerations EOF )" ``` **After PR creation, add automated review trigger:** ```bash gh pr comment --body "Cursor review" ``` ### 5. Branch Management & Rebasing **Keep branch up to date with main:** ```bash git checkout main git pull origin main git checkout your-feature-branch git rebase main ``` **If rebase creates conflicts:** - Resolve conflicts manually - `git add .` resolved files - `git rebase --continue` - **Ask permission before force pushing rebased branch** ### 6. Merge Process **Only merge via Pull Requests:** - No direct merges to `main` - Maintain linear commit history through rebasing - Use "Squash and merge" or "Rebase and merge" as appropriate - Delete feature branch after successful merge ## Pull Request Template Structure Every PR must include these sections in order: 1. **Summary**: Brief overview of changes and purpose 2. **Background/Details**: - New Feature: Requirements, context, design decisions - Bug Fix: Detailed root cause analysis 3. **Solution**: Technical approach and implementation details 4. **Testing**: Reproduction steps, validation methods, test coverage 5. **Notes**: Additional considerations, impacts, future work ## Critical Rules ### ❌ FATAL ERRORS (Result in Termination) - **NEVER push to `main` directly** - **NEVER push without explicit user permission** - **NEVER force push without explicit permission** - **NEVER commit code with TypeScript errors** ### ✅ Required Practices - Always pull from `main` before creating branches - **MANDATORY: Run `npm run typecheck` before every commit** - **MANDATORY: Fix all TypeScript errors before committing** - Use `gh` CLI tool for all PR operations - Add "Cursor review" comment after PR creation - Maintain linear commit history via rebasing - Ask permission before any push operation - Use standardized branch naming conventions ## Branch Naming Conventions - `feature/issue-xxx-description` - New features - `bugfix/issue-xxx-description` - Bug fixes - `hotfix/critical-issue-description` - Critical production fixes - `docs/update-readme` - Documentation updates - `refactor/improve-error-handling` - Code refactoring ## Automated Quality Gates ### CI/CD Pipeline Our GitHub Actions CI pipeline automatically enforces these quality checks: 1. `npm run build` - Compilation check 2. `npm run lint` - ESLint validation 3. `npm run format:check` - Prettier formatting check 4. `npm run typecheck` - **TypeScript error validation** 5. `npm run test` - Test suite execution **All checks must pass before PR merge is allowed.** ### Optional: Pre-commit Hook Setup To catch TypeScript errors before committing locally: ```bash # Create pre-commit hook cat > .git/hooks/pre-commit << 'EOF' #!/bin/sh echo "🔍 Running pre-commit checks..." # Run TypeScript type checking echo "📝 Checking TypeScript..." npm run typecheck if [ $? -ne 0 ]; then echo "❌ TypeScript errors found. Please fix before committing." exit 1 fi # Run linting echo "🧹 Running linter..." npm run lint if [ $? -ne 0 ]; then echo "❌ Linting errors found. Please fix before committing." exit 1 fi echo "✅ Pre-commit checks passed!" EOF # Make it executable chmod +x .git/hooks/pre-commit ``` This hook will automatically run `typecheck` and `lint` before every commit, preventing TypeScript errors from being committed. ``` -------------------------------------------------------------------------------- /src/mcp/resources/__tests__/simulators.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import simulatorsResource, { simulatorsResourceLogic } from '../simulators.ts'; import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; describe('simulators resource', () => { describe('Export Field Validation', () => { it('should export correct uri', () => { expect(simulatorsResource.uri).toBe('xcodebuildmcp://simulators'); }); it('should export correct description', () => { expect(simulatorsResource.description).toBe( 'Available iOS simulators with their UUIDs and states', ); }); it('should export correct mimeType', () => { expect(simulatorsResource.mimeType).toBe('text/plain'); }); it('should export handler function', () => { expect(typeof simulatorsResource.handler).toBe('function'); }); }); describe('Handler Functionality', () => { it('should handle successful simulator data retrieval', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify({ devices: { 'iOS 17.0': [ { name: 'iPhone 15 Pro', udid: 'ABC123-DEF456-GHI789', state: 'Shutdown', isAvailable: true, }, ], }, }), }); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Available iOS Simulators:'); expect(result.contents[0].text).toContain('iPhone 15 Pro'); expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); }); it('should handle command execution failure', async () => { const mockExecutor = createMockExecutor({ success: false, output: '', error: 'Command failed', }); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Failed to list simulators'); expect(result.contents[0].text).toContain('Command failed'); }); it('should handle JSON parsing errors and fall back to text parsing', async () => { const mockTextOutput = `== Devices == -- iOS 17.0 -- iPhone 15 (test-uuid-123) (Shutdown)`; const mockExecutor = async (command: string[]) => { // JSON command returns invalid JSON if (command.includes('--json')) { return { success: true, output: 'invalid json', error: undefined, process: { pid: 12345 }, }; } // Text command returns valid text output return { success: true, output: mockTextOutput, error: undefined, process: { pid: 12345 }, }; }; const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)'); expect(result.contents[0].text).toContain('iOS 17.0'); }); it('should handle spawn errors', async () => { const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Failed to list simulators'); expect(result.contents[0].text).toContain('spawn xcrun ENOENT'); }); it('should handle empty simulator data', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify({ devices: {} }), }); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Available iOS Simulators:'); }); it('should handle booted simulators correctly', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify({ devices: { 'iOS 17.0': [ { name: 'iPhone 15 Pro', udid: 'ABC123-DEF456-GHI789', state: 'Booted', isAvailable: true, }, ], }, }), }); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents[0].text).toContain('[Booted]'); }); it('should filter out unavailable simulators', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify({ devices: { 'iOS 17.0': [ { name: 'iPhone 15 Pro', udid: 'ABC123-DEF456-GHI789', state: 'Shutdown', isAvailable: true, }, { name: 'iPhone 14', udid: 'XYZ789-UVW456-RST123', state: 'Shutdown', isAvailable: false, }, ], }, }), }); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents[0].text).toContain('iPhone 15 Pro'); expect(result.contents[0].text).not.toContain('iPhone 14'); }); it('should include next steps guidance', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify({ devices: { 'iOS 17.0': [ { name: 'iPhone 15 Pro', udid: 'ABC123-DEF456-GHI789', state: 'Shutdown', isAvailable: true, }, ], }, }), }); const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents[0].text).toContain('Next Steps:'); expect(result.contents[0].text).toContain('boot_sim'); expect(result.contents[0].text).toContain('open_sim'); expect(result.contents[0].text).toContain('build_sim'); expect(result.contents[0].text).toContain('get_sim_app_path'); }); }); }); ``` -------------------------------------------------------------------------------- /example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift: -------------------------------------------------------------------------------- ```swift import Foundation // MARK: - Calculator Business Logic Service /// Handles all calculator operations and state management /// Separated from UI concerns for better testability and modularity @Observable public final class CalculatorService { // MARK: - Public Properties public private(set) var display: String = "0" public private(set) var expressionDisplay: String = "" public private(set) var hasError: Bool = false // MARK: - Private State private var currentNumber: Double = 0 private var previousNumber: Double = 0 private var operation: Operation? private var shouldResetDisplay = false private var isNewCalculation = true private var lastOperation: Operation? private var lastOperand: Double = 0 // MARK: - Operations public enum Operation: String, CaseIterable, Sendable { case add = "+" case subtract = "-" case multiply = "×" case divide = "÷" public func calculate(_ a: Double, _ b: Double) -> Double { switch self { case .add: return a + b case .subtract: return a - b case .multiply: return a * b case .divide: return b != 0 ? a / b : 0 } } } public init() {} // MARK: - Public Interface public func inputNumber(_ digit: String) { guard !hasError else { clear(); return } if shouldResetDisplay || isNewCalculation { display = digit shouldResetDisplay = false isNewCalculation = false } else if display.count < 12 { display = display == "0" ? digit : display + digit } currentNumber = Double(display) ?? 0 updateExpressionDisplay() } /// Inputs a decimal point into the display public func inputDecimal() { guard !hasError else { clear(); return } if shouldResetDisplay || isNewCalculation { display = "0." shouldResetDisplay = false isNewCalculation = false } else if !display.contains("."), display.count < 11 { display += "." } updateExpressionDisplay() } public func setOperation(_ op: Operation) { guard !hasError else { return } if operation != nil, !shouldResetDisplay { calculate() if hasError { return } } previousNumber = currentNumber operation = op shouldResetDisplay = true isNewCalculation = false updateExpressionDisplay() } public func calculate() { guard let op = operation ?? lastOperation else { return } let operand = (operation != nil) ? currentNumber : lastOperand let result = op.calculate(previousNumber, operand) // Error handling if result.isNaN || result.isInfinite { setError("Cannot divide by zero") return } if abs(result) > 1e12 { setError("Number too large") return } // Success path let prevFormatted = formatNumber(previousNumber) let currFormatted = formatNumber(operand) display = formatNumber(result) expressionDisplay = "\(prevFormatted) \(op.rawValue) \(currFormatted) =" previousNumber = result if operation != nil { lastOperand = currentNumber } lastOperation = op operation = nil currentNumber = result shouldResetDisplay = true isNewCalculation = false } public func toggleSign() { guard !hasError, currentNumber != 0 else { return } currentNumber *= -1 display = formatNumber(currentNumber) updateExpressionDisplay() } public func percentage() { guard !hasError else { return } currentNumber /= 100 display = formatNumber(currentNumber) updateExpressionDisplay() } public func clear() { display = "0" expressionDisplay = "" currentNumber = 0 previousNumber = 0 operation = nil shouldResetDisplay = false hasError = false isNewCalculation = true } public func deleteLastDigit() { guard !hasError else { clear(); return } if shouldResetDisplay || isNewCalculation { display = "0" shouldResetDisplay = false isNewCalculation = false } else if display.count > 1 { display.removeLast() if display == "-" { display = "0" } } else { display = "0" } currentNumber = Double(display) ?? 0 updateExpressionDisplay() } // MARK: - Private Helpers private func setError(_ message: String) { hasError = true display = "Error" expressionDisplay = message } private func updateExpressionDisplay() { guard !hasError else { return } if let op = operation { let prevFormatted = formatNumber(previousNumber) expressionDisplay = "\(prevFormatted) \(op.rawValue)" } else if isNewCalculation { expressionDisplay = "" } } private func formatNumber(_ number: Double) -> String { guard !number.isNaN && !number.isInfinite else { return "Error" } let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 8 formatter.minimumFractionDigits = 0 // For integers, don't show decimal places if number == floor(number) && abs(number) < 1e10 { formatter.maximumFractionDigits = 0 } // For very small decimals, use scientific notation if abs(number) < 0.000001 && number != 0 { formatter.numberStyle = .scientific formatter.maximumFractionDigits = 2 } return formatter.string(from: NSNumber(value: number)) ?? "0" } } // MARK: - Testing Support public extension CalculatorService { var currentValue: Double { currentNumber } var previousValue: Double { previousNumber } var currentOperation: Operation? { operation } var willResetDisplay: Bool { shouldResetDisplay } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/device/get_device_app_path.ts: -------------------------------------------------------------------------------- ```typescript /** * Device Shared Plugin: Get Device App Path (Unified) * * Gets the app bundle path for a physical device application (iOS, watchOS, tvOS, visionOS) using either a project or workspace. * Accepts mutually exclusive `projectPath` or `workspacePath`. */ import { z } from 'zod'; import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { scheme: z.string().describe('The scheme to use'), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), platform: z .enum(['iOS', 'watchOS', 'tvOS', 'visionOS']) .optional() .describe('Target platform (defaults to iOS)'), }; const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), ...baseOptions, }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const getDeviceAppPathSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }); // Use z.infer for type safety type GetDeviceAppPathParams = z.infer<typeof getDeviceAppPathSchema>; export async function get_device_app_pathLogic( params: GetDeviceAppPathParams, executor: CommandExecutor, ): Promise<ToolResponse> { const platformMap = { iOS: XcodePlatform.iOS, watchOS: XcodePlatform.watchOS, tvOS: XcodePlatform.tvOS, visionOS: XcodePlatform.visionOS, }; const platform = platformMap[params.platform ?? 'iOS']; const configuration = params.configuration ?? 'Debug'; log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); try { // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; // Add the project or workspace if (params.projectPath) { command.push('-project', params.projectPath); } else if (params.workspacePath) { command.push('-workspace', params.workspacePath); } else { // This should never happen due to schema validation throw new Error('Either projectPath or workspacePath is required.'); } // Add the scheme and configuration command.push('-scheme', params.scheme); command.push('-configuration', configuration); // Handle destination based on platform let destinationString = ''; if (platform === XcodePlatform.iOS) { destinationString = 'generic/platform=iOS'; } else if (platform === XcodePlatform.watchOS) { destinationString = 'generic/platform=watchOS'; } else if (platform === XcodePlatform.tvOS) { destinationString = 'generic/platform=tvOS'; } else if (platform === XcodePlatform.visionOS) { destinationString = 'generic/platform=visionOS'; } else { return createTextResponse(`Unsupported platform: ${platform}`, true); } command.push('-destination', destinationString); // Execute the command directly const result = await executor(command, 'Get App Path', true); if (!result.success) { return createTextResponse(`Failed to get app path: ${result.error}`, true); } if (!result.output) { return createTextResponse('Failed to extract build settings output from the result.', true); } const buildSettingsOutput = result.output; const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { return createTextResponse( 'Failed to extract app path from build settings. Make sure the app has been built first.', true, ); } const builtProductsDir = builtProductsDirMatch[1].trim(); const fullProductName = fullProductNameMatch[1].trim(); const appPath = `${builtProductsDir}/${fullProductName}`; const nextStepsText = `Next Steps: 1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) 2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" }) 3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`; return { content: [ { type: 'text', text: `✅ App path retrieved successfully: ${appPath}`, }, { type: 'text', text: nextStepsText, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); } } export default { name: 'get_device_app_path', description: 'Retrieves the built app path for a connected device.', schema: baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, configuration: true, } as const).shape, handler: createSessionAwareTool<GetDeviceAppPathParams>({ internalSchema: getDeviceAppPathSchema as unknown as z.ZodType<GetDeviceAppPathParams>, logicFunction: get_device_app_pathLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, ], exclusivePairs: [['projectPath', 'workspacePath']], }), }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/tap.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe-helpers.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } // Define schema as ZodObject const tapSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), x: z.number().int('X coordinate must be an integer'), y: z.number().int('Y coordinate must be an integer'), preDelay: z.number().min(0, 'Pre-delay must be non-negative').optional(), postDelay: z.number().min(0, 'Post-delay must be non-negative').optional(), }); // Use z.infer for type safety type TapParams = z.infer<typeof tapSchema>; const LOG_PREFIX = '[AXe]'; // Session tracking for describe_ui warnings (shared across UI tools) const describeUITimestamps = new Map<string, { timestamp: number }>(); const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds function getCoordinateWarning(simulatorUuid: string): string | null { const session = describeUITimestamps.get(simulatorUuid); if (!session) { return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; } const timeSinceDescribe = Date.now() - session.timestamp; if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { const secondsAgo = Math.round(timeSinceDescribe / 1000); return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; } return null; } export async function tapLogic( params: TapParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'tap'; const { simulatorUuid, x, y, preDelay, postDelay } = params; const commandArgs = ['tap', '-x', String(x), '-y', String(y)]; if (preDelay !== undefined) { commandArgs.push('--pre-delay', String(preDelay)); } if (postDelay !== undefined) { commandArgs.push('--post-delay', String(postDelay)); } log('info', `${LOG_PREFIX}/${toolName}: Starting for (${x}, ${y}) on ${simulatorUuid}`); try { await executeAxeCommand(commandArgs, simulatorUuid, 'tap', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); const warning = getCoordinateWarning(simulatorUuid); const message = `Tap at (${x}, ${y}) simulated successfully.`; if (warning) { return createTextResponse(`${message}\n\n${warning}`); } return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to simulate tap at (${x}, ${y}): ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'tap', description: "Tap at specific coordinates. Use describe_ui to get precise element coordinates (don't guess from screenshots). Supports optional timing delays.", schema: tapSchema.shape, // MCP SDK compatibility handler: createTypedTool( tapSchema, (params: TapParams, executor: CommandExecutor) => { return tapLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for get_sim_app_path plugin (session-aware version) * Mirrors patterns from other simulator session-aware migrations. */ import { describe, it, expect, beforeEach } from 'vitest'; import { ChildProcess } from 'child_process'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import getSimAppPath, { get_sim_app_pathLogic } from '../get_sim_app_path.ts'; import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; describe('get_sim_app_path tool', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(getSimAppPath.name).toBe('get_sim_app_path'); }); it('should have concise description', () => { expect(getSimAppPath.description).toBe('Retrieves the built app path for an iOS simulator.'); }); it('should have handler function', () => { expect(typeof getSimAppPath.handler).toBe('function'); }); it('should expose only platform in public schema', () => { const schema = z.object(getSimAppPath.schema); expect(schema.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); expect(schema.safeParse({}).success).toBe(false); expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); const schemaKeys = Object.keys(getSimAppPath.schema).sort(); expect(schemaKeys).toEqual(['platform']); }); }); describe('Handler Requirements', () => { it('should require scheme when not provided', async () => { const result = await getSimAppPath.handler({ platform: 'iOS Simulator', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); }); it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); const result = await getSimAppPath.handler({ platform: 'iOS Simulator', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should require simulator identifier when scheme and project defaults exist', async () => { sessionStore.setDefaults({ scheme: 'MyScheme', projectPath: '/path/to/project.xcodeproj', }); const result = await getSimAppPath.handler({ platform: 'iOS Simulator', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); }); it('should error when both projectPath and workspacePath provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); const result = await getSimAppPath.handler({ platform: 'iOS Simulator', projectPath: '/path/project.xcodeproj', workspacePath: '/path/workspace.xcworkspace', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); expect(result.content[0].text).toContain('projectPath'); expect(result.content[0].text).toContain('workspacePath'); }); it('should error when both simulatorId and simulatorName provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme', workspacePath: '/path/to/workspace.xcworkspace', }); const result = await getSimAppPath.handler({ platform: 'iOS Simulator', simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); expect(result.content[0].text).toContain('simulatorId'); expect(result.content[0].text).toContain('simulatorName'); }); }); describe('Logic Behavior', () => { it('should return app path with simulator name destination', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; opts?: unknown; }> = []; const trackingExecutor: CommandExecutor = async ( command, logPrefix, useShell, opts, ): Promise<{ success: boolean; output: string; process: ChildProcess; }> => { callHistory.push({ command, logPrefix, useShell, opts }); return { success: true, output: ' BUILT_PRODUCTS_DIR = /tmp/DerivedData/Build\n FULL_PRODUCT_NAME = MyApp.app\n', process: { pid: 12345 } as unknown as ChildProcess, }; }; const result = await get_sim_app_pathLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorName: 'iPhone 16', useLatestOS: true, }, trackingExecutor, ); expect(callHistory).toHaveLength(1); expect(callHistory[0].logPrefix).toBe('Get App Path'); expect(callHistory[0].useShell).toBe(true); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-showBuildSettings', '-workspace', '/path/to/workspace.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', ]); expect(result.isError).toBe(false); expect(result.content[0].text).toContain( '✅ App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app', ); }); it('should surface executor failures when build settings cannot be retrieved', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Failed to run xcodebuild', }); const result = await get_sim_app_pathLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorId: 'SIM-UUID', }, mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Failed to get app path'); expect(result.content[0].text).toContain('Failed to run xcodebuild'); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/video_capture.ts: -------------------------------------------------------------------------------- ```typescript /** * Video capture utility for simulator recordings using AXe. * * Manages long-running AXe "record-video" processes keyed by simulator UUID. * It aggregates stdout/stderr to parse the generated MP4 path on stop. */ import type { ChildProcess } from 'child_process'; import { log } from './logging/index.ts'; import { getAxePath, getBundledAxeEnvironment } from './axe-helpers.ts'; import type { CommandExecutor } from './execution/index.ts'; type Session = { process: unknown; sessionId: string; startedAt: number; buffer: string; ended: boolean; }; const sessions = new Map<string, Session>(); let signalHandlersAttached = false; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; } function ensureSignalHandlersAttached(): void { if (signalHandlersAttached) return; signalHandlersAttached = true; const stopAll = (): void => { for (const [simulatorUuid, sess] of sessions) { try { const child = sess.process as ChildProcess | undefined; child?.kill?.('SIGINT'); } catch { // ignore } finally { sessions.delete(simulatorUuid); } } }; try { process.on('SIGINT', stopAll); process.on('SIGTERM', stopAll); process.on('exit', stopAll); } catch { // Non-Node environments may not support process signals; ignore } } function parseLastAbsoluteMp4Path(buffer: string | undefined): string | null { if (!buffer) return null; const matches = [...buffer.matchAll(/(\s|^)(\/[^\s'"]+\.mp4)\b/gi)]; if (matches.length === 0) return null; const last = matches[matches.length - 1]; return last?.[2] ?? null; } function createSessionId(simulatorUuid: string): string { return `${simulatorUuid}:${Date.now()}`; } /** * Start recording video for a simulator using AXe. */ export async function startSimulatorVideoCapture( params: { simulatorUuid: string; fps?: number }, executor: CommandExecutor, axeHelpers?: AxeHelpers, ): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> { const simulatorUuid = params.simulatorUuid; if (!simulatorUuid) { return { started: false, error: 'simulatorUuid is required' }; } if (sessions.has(simulatorUuid)) { return { started: false, error: 'A video recording session is already active for this simulator. Stop it first.', }; } const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment, }; const axeBinary = helpers.getAxePath(); if (!axeBinary) { return { started: false, error: 'Bundled AXe binary not found' }; } const fps = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; const command = [axeBinary, 'record-video', '--udid', simulatorUuid, '--fps', String(fps)]; const env = helpers.getBundledAxeEnvironment?.() ?? {}; log('info', `Starting AXe video recording for simulator ${simulatorUuid} at ${fps} fps`); const result = await executor(command, 'Start Simulator Video Capture', true, { env }, true); if (!result.success || !result.process) { return { started: false, error: result.error ?? 'Failed to start video capture process', }; } const child = result.process as ChildProcess; const session: Session = { process: child, sessionId: createSessionId(simulatorUuid), startedAt: Date.now(), buffer: '', ended: false, }; try { child.stdout?.on('data', (d: unknown) => { try { session.buffer += String(d ?? ''); } catch { // ignore } }); child.stderr?.on('data', (d: unknown) => { try { session.buffer += String(d ?? ''); } catch { // ignore } }); } catch { // ignore stream listener setup failures } // Track when the child process naturally ends, so stop can short-circuit try { child.once?.('exit', () => { session.ended = true; }); child.once?.('close', () => { session.ended = true; }); } catch { // ignore } sessions.set(simulatorUuid, session); ensureSignalHandlersAttached(); return { started: true, sessionId: session.sessionId, warning: fps !== (params.fps ?? 30) ? `FPS coerced to ${fps}` : undefined, }; } /** * Stop recording video for a simulator. Returns aggregated output and parsed MP4 path if found. */ export async function stopSimulatorVideoCapture( params: { simulatorUuid: string }, executor: CommandExecutor, ): Promise<{ stopped: boolean; sessionId?: string; stdout?: string; parsedPath?: string; error?: string; }> { // Mark executor as used to satisfy lint rule void executor; const simulatorUuid = params.simulatorUuid; if (!simulatorUuid) { return { stopped: false, error: 'simulatorUuid is required' }; } const session = sessions.get(simulatorUuid); if (!session) { return { stopped: false, error: 'No active video recording session for this simulator' }; } const child = session.process as ChildProcess | undefined; // Attempt graceful shutdown try { child?.kill?.('SIGINT'); } catch { try { child?.kill?.(); } catch { // ignore } } // Wait for process to close (avoid hanging if it already exited) await new Promise<void>((resolve): void => { if (!child) return resolve(); // If process has already ended, resolve immediately const alreadyEnded = (session as Session).ended === true; const hasExitCode = (child as ChildProcess).exitCode !== null; const hasSignal = (child as unknown as { signalCode?: string | null }).signalCode != null; if (alreadyEnded || hasExitCode || hasSignal) { return resolve(); } let resolved = false; const finish = (): void => { if (!resolved) { resolved = true; resolve(); } }; try { child.once('close', finish); child.once('exit', finish); } catch { return finish(); } // Safety timeout to prevent indefinite hangs setTimeout(finish, 5000); }); const combinedOutput = session.buffer; const parsedPath = parseLastAbsoluteMp4Path(combinedOutput) ?? undefined; sessions.delete(simulatorUuid); log( 'info', `Stopped AXe video recording for simulator ${simulatorUuid}. ${parsedPath ? `Detected file: ${parsedPath}` : 'No file detected in output.'}`, ); return { stopped: true, sessionId: session.sessionId, stdout: combinedOutput, parsedPath, }; } ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/launch_app_sim.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; const baseSchemaObject = z.object({ simulatorId: z .string() .optional() .describe( 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), bundleId: z .string() .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const launchAppSimSchema = baseSchema .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { message: 'Either simulatorId or simulatorName is required.', }) .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', }); export type LaunchAppSimParams = z.infer<typeof launchAppSimSchema>; export async function launch_app_simLogic( params: LaunchAppSimParams, executor: CommandExecutor, ): Promise<ToolResponse> { let simulatorId = params.simulatorId; let simulatorDisplayName = simulatorId ?? ''; if (params.simulatorName && !simulatorId) { log('info', `Looking up simulator by name: ${params.simulatorName}`); const simulatorListResult = await executor( ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 'List Simulators', true, ); if (!simulatorListResult.success) { return { content: [ { type: 'text', text: `Failed to list simulators: ${simulatorListResult.error}`, }, ], isError: true, }; } const simulatorsData = JSON.parse(simulatorListResult.output) as { devices: Record<string, Array<{ udid: string; name: string }>>; }; let foundSimulator: { udid: string; name: string } | null = null; for (const runtime in simulatorsData.devices) { const devices = simulatorsData.devices[runtime]; const simulator = devices.find((device) => device.name === params.simulatorName); if (simulator) { foundSimulator = simulator; break; } } if (!foundSimulator) { return { content: [ { type: 'text', text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, }, ], isError: true, }; } simulatorId = foundSimulator.udid; simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; } if (!simulatorId) { return { content: [ { type: 'text', text: 'No simulator identifier provided', }, ], isError: true, }; } log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); try { const getAppContainerCmd = [ 'xcrun', 'simctl', 'get_app_container', simulatorId, params.bundleId, 'app', ]; const getAppContainerResult = await executor( getAppContainerCmd, 'Check App Installed', true, undefined, ); if (!getAppContainerResult.success) { return { content: [ { type: 'text', text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, }, ], isError: true, }; } } catch { return { content: [ { type: 'text', text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, }, ], isError: true, }; } try { const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId]; if (params.args && params.args.length > 0) { command.push(...params.args); } const result = await executor(command, 'Launch App in Simulator', true, undefined); if (!result.success) { return { content: [ { type: 'text', text: `Launch app in simulator operation failed: ${result.error}`, }, ], }; } const userParamName = params.simulatorName ? 'simulatorName' : 'simulatorUuid'; const userParamValue = params.simulatorName ?? simulatorId; return { content: [ { type: 'text', text: `✅ App launched successfully in simulator ${simulatorDisplayName || simulatorId}. Next Steps: 1. To see simulator: open_sim() 2. Log capture: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}" }) With console: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}", captureConsole: true }) 3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during launch app in simulator operation: ${errorMessage}`); return { content: [ { type: 'text', text: `Launch app in simulator operation failed: ${errorMessage}`, }, ], }; } } const publicSchemaObject = baseSchemaObject.omit({ simulatorId: true, simulatorName: true, } as const); export default { name: 'launch_app_sim', description: 'Launches an app in an iOS simulator.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<LaunchAppSimParams>({ internalSchema: launchAppSimSchema as unknown as z.ZodType<LaunchAppSimParams>, logicFunction: launch_app_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], exclusivePairs: [['simulatorId', 'simulatorName']], }), }; ``` -------------------------------------------------------------------------------- /src/utils/log_capture.ts: -------------------------------------------------------------------------------- ```typescript import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { ChildProcess } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import { log } from '../utils/logger.ts'; import { CommandExecutor, getDefaultCommandExecutor } from './command.ts'; /** * Log file retention policy: * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory * - Cleanup runs on every new log capture start */ const LOG_RETENTION_DAYS = 3; const LOG_FILE_PREFIX = 'xcodemcp_sim_log_'; export interface LogSession { processes: ChildProcess[]; logFilePath: string; simulatorUuid: string; bundleId: string; } export const activeLogSessions: Map<string, LogSession> = new Map(); /** * Start a log capture session for an iOS simulator. * Returns { sessionId, logFilePath, processes, error? } */ export async function startLogCapture( params: { simulatorUuid: string; bundleId: string; captureConsole?: boolean; args?: string[]; }, executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> { // Clean up old logs before starting a new session await cleanOldLogs(); const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params; const logSessionId = uuidv4(); const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`; const logFilePath = path.join(os.tmpdir(), logFileName); try { await fs.promises.mkdir(os.tmpdir(), { recursive: true }); await fs.promises.writeFile(logFilePath, ''); const logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); const processes: ChildProcess[] = []; logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n'); if (captureConsole) { const launchCommand = [ 'xcrun', 'simctl', 'launch', '--console-pty', '--terminate-running-process', simulatorUuid, bundleId, ]; if (args.length > 0) { launchCommand.push(...args); } const stdoutLogResult = await executor( launchCommand, 'Console Log Capture', true, // useShell undefined, // env true, // detached - don't wait for this streaming process to complete ); if (!stdoutLogResult.success) { return { sessionId: '', logFilePath: '', processes: [], error: stdoutLogResult.error ?? 'Failed to start console log capture', }; } stdoutLogResult.process.stdout?.pipe(logStream); stdoutLogResult.process.stderr?.pipe(logStream); processes.push(stdoutLogResult.process); } const osLogResult = await executor( [ 'xcrun', 'simctl', 'spawn', simulatorUuid, 'log', 'stream', '--level=debug', '--predicate', `subsystem == "${bundleId}"`, ], 'OS Log Capture', true, // useShell undefined, // env true, // detached - don't wait for this streaming process to complete ); if (!osLogResult.success) { return { sessionId: '', logFilePath: '', processes: [], error: osLogResult.error ?? 'Failed to start OS log capture', }; } osLogResult.process.stdout?.pipe(logStream); osLogResult.process.stderr?.pipe(logStream); processes.push(osLogResult.process); for (const process of processes) { process.on('close', (code) => { log('info', `A log capture process for session ${logSessionId} exited with code ${code}.`); }); } activeLogSessions.set(logSessionId, { processes, logFilePath, simulatorUuid, bundleId, }); log('info', `Log capture started with session ID: ${logSessionId}`); return { sessionId: logSessionId, logFilePath, processes }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Failed to start log capture: ${message}`); return { sessionId: '', logFilePath: '', processes: [], error: message }; } } /** * Stop a log capture session and retrieve the log content. */ export async function stopLogCapture( logSessionId: string, ): Promise<{ logContent: string; error?: string }> { const session = activeLogSessions.get(logSessionId); if (!session) { log('warning', `Log session not found: ${logSessionId}`); return { logContent: '', error: `Log capture session not found: ${logSessionId}` }; } try { log('info', `Attempting to stop log capture session: ${logSessionId}`); const logFilePath = session.logFilePath; for (const process of session.processes) { if (!process.killed && process.exitCode === null) { process.kill('SIGTERM'); } } activeLogSessions.delete(logSessionId); log( 'info', `Log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`, ); await fs.promises.access(logFilePath, fs.constants.R_OK); const fileContent = await fs.promises.readFile(logFilePath, 'utf-8'); log('info', `Successfully read log content from ${logFilePath}`); return { logContent: fileContent }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Failed to stop log capture session ${logSessionId}: ${message}`); return { logContent: '', error: message }; } } /** * Deletes log files older than LOG_RETENTION_DAYS from the temp directory. * Runs quietly; errors are logged but do not throw. */ async function cleanOldLogs(): Promise<void> { const tempDir = os.tmpdir(); let files: string[]; try { files = await fs.promises.readdir(tempDir); } catch (err) { log( 'warn', `Could not read temp dir for log cleanup: ${err instanceof Error ? err.message : String(err)}`, ); return; } const now = Date.now(); const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; await Promise.all( files .filter((f) => f.startsWith(LOG_FILE_PREFIX) && f.endsWith('.log')) .map(async (f) => { const filePath = path.join(tempDir, f); try { const stat = await fs.promises.stat(filePath); if (now - stat.mtimeMs > retentionMs) { await fs.promises.unlink(filePath); log('info', `Deleted old log file: ${filePath}`); } } catch (err) { log( 'warn', `Error during log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`, ); } }), ); } ```