This is page 9 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .axe-version ├── .claude │ └── agents │ └── xcodebuild-mcp-qa-tester.md ├── .cursor │ ├── BUGBOT.md │ └── environment.json ├── .cursorrules ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude-dispatch.yml │ ├── claude.yml │ ├── droid-code-review.yml │ ├── README.md │ ├── release.yml │ └── sentry.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── mcp.json │ ├── settings.json │ └── tasks.json ├── AGENTS.md ├── banner.png ├── build-plugins │ ├── plugin-discovery.js │ ├── plugin-discovery.ts │ └── tsconfig.json ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── docs │ ├── ARCHITECTURE.md │ ├── CODE_QUALITY.md │ ├── CONTRIBUTING.md │ ├── ESLINT_TYPE_SAFETY.md │ ├── MANUAL_TESTING.md │ ├── NODEJS_2025.md │ ├── PLUGIN_DEVELOPMENT.md │ ├── RELEASE_PROCESS.md │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md │ ├── RELOADEROO.md │ ├── session_management_plan.md │ ├── session-aware-migration-todo.md │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md │ ├── TESTING.md │ └── TOOLS.md ├── eslint.config.js ├── example_projects │ ├── .vscode │ │ └── launch.json │ ├── iOS │ │ ├── .cursor │ │ │ └── rules │ │ │ └── errors.mdc │ │ ├── .vscode │ │ │ └── settings.json │ │ ├── Makefile │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ ├── MCPTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── MCPTest.xcscheme │ │ └── MCPTestUITests │ │ └── MCPTestUITests.swift │ ├── iOS_Calculator │ │ ├── CalculatorApp │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── CalculatorApp.swift │ │ │ └── CalculatorApp.xctestplan │ │ ├── CalculatorApp.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── CalculatorApp.xcscheme │ │ ├── CalculatorApp.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── CalculatorAppPackage │ │ │ ├── .gitignore │ │ │ ├── Package.swift │ │ │ ├── Sources │ │ │ │ └── CalculatorAppFeature │ │ │ │ ├── BackgroundEffect.swift │ │ │ │ ├── CalculatorButton.swift │ │ │ │ ├── CalculatorDisplay.swift │ │ │ │ ├── CalculatorInputHandler.swift │ │ │ │ ├── CalculatorService.swift │ │ │ │ └── ContentView.swift │ │ │ └── Tests │ │ │ └── CalculatorAppFeatureTests │ │ │ └── CalculatorServiceTests.swift │ │ ├── CalculatorAppTests │ │ │ └── CalculatorAppTests.swift │ │ └── Config │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ ├── Shared.xcconfig │ │ └── Tests.xcconfig │ ├── macOS │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTest.entitlements │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── MCPTest.xcodeproj │ │ ├── project.pbxproj │ │ └── xcshareddata │ │ └── xcschemes │ │ └── MCPTest.xcscheme │ └── spm │ ├── .gitignore │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ │ ├── long-server │ │ │ └── main.swift │ │ ├── quick-task │ │ │ └── main.swift │ │ ├── spm │ │ │ └── main.swift │ │ └── TestLib │ │ └── TaskManager.swift │ └── Tests │ └── TestLibTests │ └── SimpleTests.swift ├── LICENSE ├── mcp-install-dark.png ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── analysis │ │ └── tools-analysis.ts │ ├── bundle-axe.sh │ ├── check-code-patterns.js │ ├── release.sh │ ├── tools-cli.ts │ └── update-tools-docs.ts ├── server.json ├── smithery.yaml ├── src │ ├── core │ │ ├── __tests__ │ │ │ └── resources.test.ts │ │ ├── dynamic-tools.ts │ │ ├── plugin-registry.ts │ │ ├── plugin-types.ts │ │ └── resources.ts │ ├── doctor-cli.ts │ ├── index.ts │ ├── mcp │ │ ├── resources │ │ │ ├── __tests__ │ │ │ │ ├── devices.test.ts │ │ │ │ ├── doctor.test.ts │ │ │ │ └── simulators.test.ts │ │ │ ├── devices.ts │ │ │ ├── doctor.ts │ │ │ └── simulators.ts │ │ └── tools │ │ ├── device │ │ │ ├── __tests__ │ │ │ │ ├── build_device.test.ts │ │ │ │ ├── get_device_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_device.test.ts │ │ │ │ ├── launch_app_device.test.ts │ │ │ │ ├── list_devices.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_app_device.test.ts │ │ │ │ └── test_device.test.ts │ │ │ ├── build_device.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_device_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_device.ts │ │ │ ├── launch_app_device.ts │ │ │ ├── list_devices.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── stop_app_device.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── test_device.ts │ │ ├── discovery │ │ │ ├── __tests__ │ │ │ │ └── discover_tools.test.ts │ │ │ ├── discover_tools.ts │ │ │ └── index.ts │ │ ├── doctor │ │ │ ├── __tests__ │ │ │ │ ├── doctor.test.ts │ │ │ │ └── index.test.ts │ │ │ ├── doctor.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ └── doctor.deps.ts │ │ ├── logging │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── start_device_log_cap.test.ts │ │ │ │ ├── start_sim_log_cap.test.ts │ │ │ │ ├── stop_device_log_cap.test.ts │ │ │ │ └── stop_sim_log_cap.test.ts │ │ │ ├── index.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── start_sim_log_cap.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── stop_sim_log_cap.ts │ │ ├── macos │ │ │ ├── __tests__ │ │ │ │ ├── build_macos.test.ts │ │ │ │ ├── build_run_macos.test.ts │ │ │ │ ├── get_mac_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── launch_mac_app.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_mac_app.test.ts │ │ │ │ └── test_macos.test.ts │ │ │ ├── build_macos.ts │ │ │ ├── build_run_macos.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_mac_app_path.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── launch_mac_app.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_mac_app.ts │ │ │ └── test_macos.ts │ │ ├── project-discovery │ │ │ ├── __tests__ │ │ │ │ ├── discover_projs.test.ts │ │ │ │ ├── get_app_bundle_id.test.ts │ │ │ │ ├── get_mac_bundle_id.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── list_schemes.test.ts │ │ │ │ └── show_build_settings.test.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── list_schemes.ts │ │ │ └── show_build_settings.ts │ │ ├── project-scaffolding │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── scaffold_ios_project.test.ts │ │ │ │ └── scaffold_macos_project.test.ts │ │ │ ├── index.ts │ │ │ ├── scaffold_ios_project.ts │ │ │ └── scaffold_macos_project.ts │ │ ├── session-management │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── session_clear_defaults.test.ts │ │ │ │ ├── session_set_defaults.test.ts │ │ │ │ └── session_show_defaults.test.ts │ │ │ ├── index.ts │ │ │ ├── session_clear_defaults.ts │ │ │ ├── session_set_defaults.ts │ │ │ └── session_show_defaults.ts │ │ ├── simulator │ │ │ ├── __tests__ │ │ │ │ ├── boot_sim.test.ts │ │ │ │ ├── build_run_sim.test.ts │ │ │ │ ├── build_sim.test.ts │ │ │ │ ├── get_sim_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_sim.test.ts │ │ │ │ ├── launch_app_logs_sim.test.ts │ │ │ │ ├── launch_app_sim.test.ts │ │ │ │ ├── list_sims.test.ts │ │ │ │ ├── open_sim.test.ts │ │ │ │ ├── record_sim_video.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── stop_app_sim.test.ts │ │ │ │ └── test_sim.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── build_run_sim.ts │ │ │ ├── build_sim.ts │ │ │ ├── clean.ts │ │ │ ├── describe_ui.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_sim_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_sim.ts │ │ │ ├── launch_app_logs_sim.ts │ │ │ ├── launch_app_sim.ts │ │ │ ├── list_schemes.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── record_sim_video.ts │ │ │ ├── screenshot.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_app_sim.ts │ │ │ └── test_sim.ts │ │ ├── simulator-management │ │ │ ├── __tests__ │ │ │ │ ├── erase_sims.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── reset_sim_location.test.ts │ │ │ │ ├── set_sim_appearance.test.ts │ │ │ │ ├── set_sim_location.test.ts │ │ │ │ └── sim_statusbar.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── erase_sims.ts │ │ │ ├── index.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── reset_sim_location.ts │ │ │ ├── set_sim_appearance.ts │ │ │ ├── set_sim_location.ts │ │ │ └── sim_statusbar.ts │ │ ├── swift-package │ │ │ ├── __tests__ │ │ │ │ ├── active-processes.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── swift_package_build.test.ts │ │ │ │ ├── swift_package_clean.test.ts │ │ │ │ ├── swift_package_list.test.ts │ │ │ │ ├── swift_package_run.test.ts │ │ │ │ ├── swift_package_stop.test.ts │ │ │ │ └── swift_package_test.test.ts │ │ │ ├── active-processes.ts │ │ │ ├── index.ts │ │ │ ├── swift_package_build.ts │ │ │ ├── swift_package_clean.ts │ │ │ ├── swift_package_list.ts │ │ │ ├── swift_package_run.ts │ │ │ ├── swift_package_stop.ts │ │ │ └── swift_package_test.ts │ │ ├── ui-testing │ │ │ ├── __tests__ │ │ │ │ ├── button.test.ts │ │ │ │ ├── describe_ui.test.ts │ │ │ │ ├── gesture.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── key_press.test.ts │ │ │ │ ├── key_sequence.test.ts │ │ │ │ ├── long_press.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── swipe.test.ts │ │ │ │ ├── tap.test.ts │ │ │ │ ├── touch.test.ts │ │ │ │ └── type_text.test.ts │ │ │ ├── button.ts │ │ │ ├── describe_ui.ts │ │ │ ├── gesture.ts │ │ │ ├── index.ts │ │ │ ├── key_press.ts │ │ │ ├── key_sequence.ts │ │ │ ├── long_press.ts │ │ │ ├── screenshot.ts │ │ │ ├── swipe.ts │ │ │ ├── tap.ts │ │ │ ├── touch.ts │ │ │ └── type_text.ts │ │ └── utilities │ │ ├── __tests__ │ │ │ ├── clean.test.ts │ │ │ └── index.test.ts │ │ ├── clean.ts │ │ └── index.ts │ ├── server │ │ └── server.ts │ ├── test-utils │ │ └── mock-executors.ts │ ├── types │ │ └── common.ts │ └── utils │ ├── __tests__ │ │ ├── build-utils.test.ts │ │ ├── environment.test.ts │ │ ├── session-aware-tool-factory.test.ts │ │ ├── session-store.test.ts │ │ ├── simulator-utils.test.ts │ │ ├── test-runner-env-integration.test.ts │ │ └── typed-tool-factory.test.ts │ ├── axe │ │ └── index.ts │ ├── axe-helpers.ts │ ├── build │ │ └── index.ts │ ├── build-utils.ts │ ├── capabilities.ts │ ├── command.ts │ ├── CommandExecutor.ts │ ├── environment.ts │ ├── errors.ts │ ├── execution │ │ └── index.ts │ ├── FileSystemExecutor.ts │ ├── log_capture.ts │ ├── log-capture │ │ └── index.ts │ ├── logger.ts │ ├── logging │ │ └── index.ts │ ├── plugin-registry │ │ └── index.ts │ ├── responses │ │ └── index.ts │ ├── schema-helpers.ts │ ├── sentry.ts │ ├── session-store.ts │ ├── simulator-utils.ts │ ├── template │ │ └── index.ts │ ├── template-manager.ts │ ├── test │ │ └── index.ts │ ├── test-common.ts │ ├── tool-registry.ts │ ├── typed-tool-factory.ts │ ├── validation │ │ └── index.ts │ ├── validation.ts │ ├── version │ │ └── index.ts │ ├── video_capture.ts │ ├── video-capture │ │ └── index.ts │ ├── xcode.ts │ ├── xcodemake │ │ └── index.ts │ └── xcodemake.ts ├── tsconfig.json ├── tsconfig.test.json ├── tsup.config.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/gesture.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for gesture tool plugin 3 | */ 4 | 5 | import { describe, it, expect, beforeEach } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { 8 | createMockExecutor, 9 | createMockFileSystemExecutor, 10 | createNoopExecutor, 11 | } from '../../../../test-utils/mock-executors.ts'; 12 | import gesturePlugin, { gestureLogic } from '../gesture.ts'; 13 | 14 | describe('Gesture Plugin', () => { 15 | describe('Export Field Validation (Literal)', () => { 16 | it('should have correct name', () => { 17 | expect(gesturePlugin.name).toBe('gesture'); 18 | }); 19 | 20 | it('should have correct description', () => { 21 | expect(gesturePlugin.description).toBe( 22 | 'Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge', 23 | ); 24 | }); 25 | 26 | it('should have handler function', () => { 27 | expect(typeof gesturePlugin.handler).toBe('function'); 28 | }); 29 | 30 | it('should validate schema fields with safeParse', () => { 31 | const schema = z.object(gesturePlugin.schema); 32 | 33 | // Valid case 34 | expect( 35 | schema.safeParse({ 36 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 37 | preset: 'scroll-up', 38 | }).success, 39 | ).toBe(true); 40 | 41 | // Invalid simulatorUuid 42 | expect( 43 | schema.safeParse({ 44 | simulatorUuid: 'invalid-uuid', 45 | preset: 'scroll-up', 46 | }).success, 47 | ).toBe(false); 48 | 49 | // Invalid preset 50 | expect( 51 | schema.safeParse({ 52 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 53 | preset: 'invalid-preset', 54 | }).success, 55 | ).toBe(false); 56 | 57 | // Valid optional parameters 58 | expect( 59 | schema.safeParse({ 60 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 61 | preset: 'scroll-up', 62 | screenWidth: 375, 63 | screenHeight: 667, 64 | duration: 1.5, 65 | delta: 100, 66 | preDelay: 0.5, 67 | postDelay: 0.2, 68 | }).success, 69 | ).toBe(true); 70 | 71 | // Invalid optional parameters 72 | expect( 73 | schema.safeParse({ 74 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 75 | preset: 'scroll-up', 76 | screenWidth: 0, 77 | }).success, 78 | ).toBe(false); 79 | 80 | expect( 81 | schema.safeParse({ 82 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 83 | preset: 'scroll-up', 84 | duration: -1, 85 | }).success, 86 | ).toBe(false); 87 | }); 88 | }); 89 | 90 | describe('Command Generation', () => { 91 | it('should generate correct axe command for basic gesture', async () => { 92 | let capturedCommand: string[] = []; 93 | const trackingExecutor = async (command: string[]) => { 94 | capturedCommand = command; 95 | return { 96 | success: true, 97 | output: 'gesture completed', 98 | error: undefined, 99 | process: { pid: 12345 }, 100 | }; 101 | }; 102 | 103 | const mockAxeHelpers = { 104 | getAxePath: () => '/usr/local/bin/axe', 105 | getBundledAxeEnvironment: () => ({}), 106 | }; 107 | 108 | await gestureLogic( 109 | { 110 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 111 | preset: 'scroll-up', 112 | }, 113 | trackingExecutor, 114 | mockAxeHelpers, 115 | ); 116 | 117 | expect(capturedCommand).toEqual([ 118 | '/usr/local/bin/axe', 119 | 'gesture', 120 | 'scroll-up', 121 | '--udid', 122 | '12345678-1234-1234-1234-123456789012', 123 | ]); 124 | }); 125 | 126 | it('should generate correct axe command for gesture with screen dimensions', async () => { 127 | let capturedCommand: string[] = []; 128 | const trackingExecutor = async (command: string[]) => { 129 | capturedCommand = command; 130 | return { 131 | success: true, 132 | output: 'gesture completed', 133 | error: undefined, 134 | process: { pid: 12345 }, 135 | }; 136 | }; 137 | 138 | const mockAxeHelpers = { 139 | getAxePath: () => '/usr/local/bin/axe', 140 | getBundledAxeEnvironment: () => ({}), 141 | }; 142 | 143 | await gestureLogic( 144 | { 145 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 146 | preset: 'swipe-from-left-edge', 147 | screenWidth: 375, 148 | screenHeight: 667, 149 | }, 150 | trackingExecutor, 151 | mockAxeHelpers, 152 | ); 153 | 154 | expect(capturedCommand).toEqual([ 155 | '/usr/local/bin/axe', 156 | 'gesture', 157 | 'swipe-from-left-edge', 158 | '--screen-width', 159 | '375', 160 | '--screen-height', 161 | '667', 162 | '--udid', 163 | '12345678-1234-1234-1234-123456789012', 164 | ]); 165 | }); 166 | 167 | it('should generate correct axe command for gesture with all parameters', async () => { 168 | let capturedCommand: string[] = []; 169 | const trackingExecutor = async (command: string[]) => { 170 | capturedCommand = command; 171 | return { 172 | success: true, 173 | output: 'gesture completed', 174 | error: undefined, 175 | process: { pid: 12345 }, 176 | }; 177 | }; 178 | 179 | const mockAxeHelpers = { 180 | getAxePath: () => '/usr/local/bin/axe', 181 | getBundledAxeEnvironment: () => ({}), 182 | }; 183 | 184 | await gestureLogic( 185 | { 186 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 187 | preset: 'scroll-down', 188 | screenWidth: 414, 189 | screenHeight: 896, 190 | duration: 2.0, 191 | delta: 150, 192 | preDelay: 0.5, 193 | postDelay: 0.3, 194 | }, 195 | trackingExecutor, 196 | mockAxeHelpers, 197 | ); 198 | 199 | expect(capturedCommand).toEqual([ 200 | '/usr/local/bin/axe', 201 | 'gesture', 202 | 'scroll-down', 203 | '--screen-width', 204 | '414', 205 | '--screen-height', 206 | '896', 207 | '--duration', 208 | '2', 209 | '--delta', 210 | '150', 211 | '--pre-delay', 212 | '0.5', 213 | '--post-delay', 214 | '0.3', 215 | '--udid', 216 | '12345678-1234-1234-1234-123456789012', 217 | ]); 218 | }); 219 | 220 | it('should generate correct axe command with different gesture presets', async () => { 221 | let capturedCommand: string[] = []; 222 | const trackingExecutor = async (command: string[]) => { 223 | capturedCommand = command; 224 | return { 225 | success: true, 226 | output: 'gesture completed', 227 | error: undefined, 228 | process: { pid: 12345 }, 229 | }; 230 | }; 231 | 232 | const mockAxeHelpers = { 233 | getAxePath: () => '/usr/local/bin/axe', 234 | getBundledAxeEnvironment: () => ({}), 235 | }; 236 | 237 | await gestureLogic( 238 | { 239 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 240 | preset: 'swipe-from-bottom-edge', 241 | }, 242 | trackingExecutor, 243 | mockAxeHelpers, 244 | ); 245 | 246 | expect(capturedCommand).toEqual([ 247 | '/usr/local/bin/axe', 248 | 'gesture', 249 | 'swipe-from-bottom-edge', 250 | '--udid', 251 | '12345678-1234-1234-1234-123456789012', 252 | ]); 253 | }); 254 | }); 255 | 256 | describe('Handler Behavior (Complete Literal Returns)', () => { 257 | // Note: Parameter validation is now handled by Zod schema validation in createTypedTool, 258 | // so invalid parameters never reach gestureLogic. The schema validation tests above 259 | // cover parameter validation scenarios. 260 | 261 | it('should return success for valid gesture execution', async () => { 262 | const mockExecutor = createMockExecutor({ 263 | success: true, 264 | output: 'gesture completed', 265 | error: undefined, 266 | process: { pid: 12345 }, 267 | }); 268 | 269 | const mockAxeHelpers = { 270 | getAxePath: () => '/usr/local/bin/axe', 271 | getBundledAxeEnvironment: () => ({}), 272 | }; 273 | 274 | const result = await gestureLogic( 275 | { 276 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 277 | preset: 'scroll-up', 278 | }, 279 | mockExecutor, 280 | mockAxeHelpers, 281 | ); 282 | 283 | expect(result).toEqual({ 284 | content: [{ type: 'text', text: "Gesture 'scroll-up' executed successfully." }], 285 | isError: false, 286 | }); 287 | }); 288 | 289 | it('should return success for gesture execution with all optional parameters', async () => { 290 | const mockExecutor = createMockExecutor({ 291 | success: true, 292 | output: 'gesture completed', 293 | error: undefined, 294 | process: { pid: 12345 }, 295 | }); 296 | 297 | const mockAxeHelpers = { 298 | getAxePath: () => '/usr/local/bin/axe', 299 | getBundledAxeEnvironment: () => ({}), 300 | }; 301 | 302 | const result = await gestureLogic( 303 | { 304 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 305 | preset: 'swipe-from-left-edge', 306 | screenWidth: 375, 307 | screenHeight: 667, 308 | duration: 1.0, 309 | delta: 50, 310 | preDelay: 0.1, 311 | postDelay: 0.2, 312 | }, 313 | mockExecutor, 314 | mockAxeHelpers, 315 | ); 316 | 317 | expect(result).toEqual({ 318 | content: [{ type: 'text', text: "Gesture 'swipe-from-left-edge' executed successfully." }], 319 | isError: false, 320 | }); 321 | }); 322 | 323 | it('should handle DependencyError when axe is not available', async () => { 324 | const mockAxeHelpers = { 325 | getAxePath: () => null, 326 | getBundledAxeEnvironment: () => ({}), 327 | createAxeNotAvailableResponse: () => ({ 328 | content: [ 329 | { 330 | type: 'text', 331 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 332 | }, 333 | ], 334 | isError: true, 335 | }), 336 | }; 337 | 338 | const result = await gestureLogic( 339 | { 340 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 341 | preset: 'scroll-up', 342 | }, 343 | createNoopExecutor(), 344 | mockAxeHelpers, 345 | ); 346 | 347 | expect(result).toEqual({ 348 | content: [ 349 | { 350 | type: 'text', 351 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 352 | }, 353 | ], 354 | isError: true, 355 | }); 356 | }); 357 | 358 | it('should handle AxeError from failed command execution', async () => { 359 | const mockExecutor = createMockExecutor({ 360 | success: false, 361 | output: '', 362 | error: 'axe command failed', 363 | process: { pid: 12345 }, 364 | }); 365 | 366 | const mockAxeHelpers = { 367 | getAxePath: () => '/usr/local/bin/axe', 368 | getBundledAxeEnvironment: () => ({}), 369 | }; 370 | 371 | const result = await gestureLogic( 372 | { 373 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 374 | preset: 'scroll-up', 375 | }, 376 | mockExecutor, 377 | mockAxeHelpers, 378 | ); 379 | 380 | expect(result).toEqual({ 381 | content: [ 382 | { 383 | type: 'text', 384 | text: "Error: Failed to execute gesture 'scroll-up': axe command 'gesture' failed.\nDetails: axe command failed", 385 | }, 386 | ], 387 | isError: true, 388 | }); 389 | }); 390 | 391 | it('should handle SystemError from command execution', async () => { 392 | const mockExecutor = createMockExecutor(new Error('ENOENT: no such file or directory')); 393 | 394 | const mockAxeHelpers = { 395 | getAxePath: () => '/usr/local/bin/axe', 396 | getBundledAxeEnvironment: () => ({}), 397 | }; 398 | 399 | const result = await gestureLogic( 400 | { 401 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 402 | preset: 'scroll-up', 403 | }, 404 | mockExecutor, 405 | mockAxeHelpers, 406 | ); 407 | 408 | expect(result.content[0].text).toMatch( 409 | /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, 410 | ); 411 | expect(result.isError).toBe(true); 412 | }); 413 | 414 | it('should handle unexpected Error objects', async () => { 415 | const mockExecutor = createMockExecutor(new Error('Unexpected error')); 416 | 417 | const mockAxeHelpers = { 418 | getAxePath: () => '/usr/local/bin/axe', 419 | getBundledAxeEnvironment: () => ({}), 420 | }; 421 | 422 | const result = await gestureLogic( 423 | { 424 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 425 | preset: 'scroll-up', 426 | }, 427 | mockExecutor, 428 | mockAxeHelpers, 429 | ); 430 | 431 | expect(result.content[0].text).toMatch( 432 | /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, 433 | ); 434 | expect(result.isError).toBe(true); 435 | }); 436 | 437 | it('should handle unexpected string errors', async () => { 438 | const mockExecutor = createMockExecutor('String error'); 439 | 440 | const mockAxeHelpers = { 441 | getAxePath: () => '/usr/local/bin/axe', 442 | getBundledAxeEnvironment: () => ({}), 443 | }; 444 | 445 | const result = await gestureLogic( 446 | { 447 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 448 | preset: 'scroll-up', 449 | }, 450 | mockExecutor, 451 | mockAxeHelpers, 452 | ); 453 | 454 | expect(result).toEqual({ 455 | content: [ 456 | { 457 | type: 'text', 458 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 459 | }, 460 | ], 461 | isError: true, 462 | }); 463 | }); 464 | }); 465 | }); 466 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/device/__tests__/get_device_app_path.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for get_device_app_path plugin (unified) 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using dependency injection for deterministic testing 5 | */ 6 | 7 | import { describe, it, expect, beforeEach } from 'vitest'; 8 | import { z } from 'zod'; 9 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 10 | import getDeviceAppPath, { get_device_app_pathLogic } from '../get_device_app_path.ts'; 11 | import { sessionStore } from '../../../../utils/session-store.ts'; 12 | 13 | describe('get_device_app_path plugin', () => { 14 | beforeEach(() => { 15 | sessionStore.clear(); 16 | }); 17 | 18 | describe('Export Field Validation (Literal)', () => { 19 | it('should have correct name', () => { 20 | expect(getDeviceAppPath.name).toBe('get_device_app_path'); 21 | }); 22 | 23 | it('should have correct description', () => { 24 | expect(getDeviceAppPath.description).toBe( 25 | 'Retrieves the built app path for a connected device.', 26 | ); 27 | }); 28 | 29 | it('should have handler function', () => { 30 | expect(typeof getDeviceAppPath.handler).toBe('function'); 31 | }); 32 | 33 | it('should expose only platform in public schema', () => { 34 | const schema = z.object(getDeviceAppPath.schema).strict(); 35 | expect(schema.safeParse({}).success).toBe(true); 36 | expect(schema.safeParse({ platform: 'iOS' }).success).toBe(true); 37 | expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); 38 | 39 | const schemaKeys = Object.keys(getDeviceAppPath.schema).sort(); 40 | expect(schemaKeys).toEqual(['platform']); 41 | }); 42 | }); 43 | 44 | describe('XOR Validation', () => { 45 | it('should error when neither projectPath nor workspacePath provided', async () => { 46 | const result = await getDeviceAppPath.handler({ 47 | scheme: 'MyScheme', 48 | }); 49 | expect(result.isError).toBe(true); 50 | expect(result.content[0].text).toContain('Missing required session defaults'); 51 | expect(result.content[0].text).toContain('Provide a project or workspace'); 52 | }); 53 | 54 | it('should error when both projectPath and workspacePath provided', async () => { 55 | const result = await getDeviceAppPath.handler({ 56 | projectPath: '/path/to/project.xcodeproj', 57 | workspacePath: '/path/to/workspace.xcworkspace', 58 | scheme: 'MyScheme', 59 | }); 60 | expect(result.isError).toBe(true); 61 | expect(result.content[0].text).toContain('Parameter validation failed'); 62 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); 63 | }); 64 | }); 65 | 66 | describe('Handler Requirements', () => { 67 | it('should require scheme when missing', async () => { 68 | const result = await getDeviceAppPath.handler({ 69 | projectPath: '/path/to/project.xcodeproj', 70 | }); 71 | expect(result.isError).toBe(true); 72 | expect(result.content[0].text).toContain('Missing required session defaults'); 73 | expect(result.content[0].text).toContain('scheme is required'); 74 | }); 75 | 76 | it('should require project or workspace when scheme default exists', async () => { 77 | sessionStore.setDefaults({ scheme: 'MyScheme' }); 78 | 79 | const result = await getDeviceAppPath.handler({}); 80 | expect(result.isError).toBe(true); 81 | expect(result.content[0].text).toContain('Provide a project or workspace'); 82 | }); 83 | }); 84 | 85 | describe('Handler Behavior (Complete Literal Returns)', () => { 86 | // Note: Parameter validation is now handled by Zod schema validation in createTypedTool, 87 | // so invalid parameters never reach the logic function. Schema validation is tested above. 88 | 89 | it('should generate correct xcodebuild command for iOS', async () => { 90 | const calls: Array<{ 91 | args: any[]; 92 | description: string; 93 | suppressErrors: boolean; 94 | workingDirectory: string | undefined; 95 | }> = []; 96 | 97 | const mockExecutor = ( 98 | args: any[], 99 | description: string, 100 | suppressErrors: boolean, 101 | workingDirectory: string | undefined, 102 | ) => { 103 | calls.push({ args, description, suppressErrors, workingDirectory }); 104 | return Promise.resolve({ 105 | success: true, 106 | output: 107 | 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', 108 | error: undefined, 109 | process: { pid: 12345 }, 110 | }); 111 | }; 112 | 113 | await get_device_app_pathLogic( 114 | { 115 | projectPath: '/path/to/project.xcodeproj', 116 | scheme: 'MyScheme', 117 | }, 118 | mockExecutor, 119 | ); 120 | 121 | expect(calls).toHaveLength(1); 122 | expect(calls[0]).toEqual({ 123 | args: [ 124 | 'xcodebuild', 125 | '-showBuildSettings', 126 | '-project', 127 | '/path/to/project.xcodeproj', 128 | '-scheme', 129 | 'MyScheme', 130 | '-configuration', 131 | 'Debug', 132 | '-destination', 133 | 'generic/platform=iOS', 134 | ], 135 | description: 'Get App Path', 136 | suppressErrors: true, 137 | workingDirectory: undefined, 138 | }); 139 | }); 140 | 141 | it('should generate correct xcodebuild command for watchOS', async () => { 142 | const calls: Array<{ 143 | args: any[]; 144 | description: string; 145 | suppressErrors: boolean; 146 | workingDirectory: string | undefined; 147 | }> = []; 148 | 149 | const mockExecutor = ( 150 | args: any[], 151 | description: string, 152 | suppressErrors: boolean, 153 | workingDirectory: string | undefined, 154 | ) => { 155 | calls.push({ args, description, suppressErrors, workingDirectory }); 156 | return Promise.resolve({ 157 | success: true, 158 | output: 159 | 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-watchos\nFULL_PRODUCT_NAME = MyApp.app\n', 160 | error: undefined, 161 | process: { pid: 12345 }, 162 | }); 163 | }; 164 | 165 | await get_device_app_pathLogic( 166 | { 167 | projectPath: '/path/to/project.xcodeproj', 168 | scheme: 'MyScheme', 169 | platform: 'watchOS', 170 | }, 171 | mockExecutor, 172 | ); 173 | 174 | expect(calls).toHaveLength(1); 175 | expect(calls[0]).toEqual({ 176 | args: [ 177 | 'xcodebuild', 178 | '-showBuildSettings', 179 | '-project', 180 | '/path/to/project.xcodeproj', 181 | '-scheme', 182 | 'MyScheme', 183 | '-configuration', 184 | 'Debug', 185 | '-destination', 186 | 'generic/platform=watchOS', 187 | ], 188 | description: 'Get App Path', 189 | suppressErrors: true, 190 | workingDirectory: undefined, 191 | }); 192 | }); 193 | 194 | it('should generate correct xcodebuild command for workspace with iOS', async () => { 195 | const calls: Array<{ 196 | args: any[]; 197 | description: string; 198 | suppressErrors: boolean; 199 | workingDirectory: string | undefined; 200 | }> = []; 201 | 202 | const mockExecutor = ( 203 | args: any[], 204 | description: string, 205 | suppressErrors: boolean, 206 | workingDirectory: string | undefined, 207 | ) => { 208 | calls.push({ args, description, suppressErrors, workingDirectory }); 209 | return Promise.resolve({ 210 | success: true, 211 | output: 212 | 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', 213 | error: undefined, 214 | process: { pid: 12345 }, 215 | }); 216 | }; 217 | 218 | await get_device_app_pathLogic( 219 | { 220 | workspacePath: '/path/to/workspace.xcworkspace', 221 | scheme: 'MyScheme', 222 | }, 223 | mockExecutor, 224 | ); 225 | 226 | expect(calls).toHaveLength(1); 227 | expect(calls[0]).toEqual({ 228 | args: [ 229 | 'xcodebuild', 230 | '-showBuildSettings', 231 | '-workspace', 232 | '/path/to/workspace.xcworkspace', 233 | '-scheme', 234 | 'MyScheme', 235 | '-configuration', 236 | 'Debug', 237 | '-destination', 238 | 'generic/platform=iOS', 239 | ], 240 | description: 'Get App Path', 241 | suppressErrors: true, 242 | workingDirectory: undefined, 243 | }); 244 | }); 245 | 246 | it('should return exact successful app path retrieval response', async () => { 247 | const mockExecutor = createMockExecutor({ 248 | success: true, 249 | output: 250 | 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', 251 | }); 252 | 253 | const result = await get_device_app_pathLogic( 254 | { 255 | projectPath: '/path/to/project.xcodeproj', 256 | scheme: 'MyScheme', 257 | }, 258 | mockExecutor, 259 | ); 260 | 261 | expect(result).toEqual({ 262 | content: [ 263 | { 264 | type: 'text', 265 | text: '✅ App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app', 266 | }, 267 | { 268 | type: 'text', 269 | text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/path/to/build/Debug-iphoneos/MyApp.app" })\n2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "/path/to/build/Debug-iphoneos/MyApp.app" })\n3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })', 270 | }, 271 | ], 272 | }); 273 | }); 274 | 275 | it('should return exact command failure response', async () => { 276 | const mockExecutor = createMockExecutor({ 277 | success: false, 278 | error: 'xcodebuild: error: The project does not exist.', 279 | }); 280 | 281 | const result = await get_device_app_pathLogic( 282 | { 283 | projectPath: '/path/to/nonexistent.xcodeproj', 284 | scheme: 'MyScheme', 285 | }, 286 | mockExecutor, 287 | ); 288 | 289 | expect(result).toEqual({ 290 | content: [ 291 | { 292 | type: 'text', 293 | text: 'Failed to get app path: xcodebuild: error: The project does not exist.', 294 | }, 295 | ], 296 | isError: true, 297 | }); 298 | }); 299 | 300 | it('should return exact parse failure response', async () => { 301 | const mockExecutor = createMockExecutor({ 302 | success: true, 303 | output: 'Build settings without required fields', 304 | }); 305 | 306 | const result = await get_device_app_pathLogic( 307 | { 308 | projectPath: '/path/to/project.xcodeproj', 309 | scheme: 'MyScheme', 310 | }, 311 | mockExecutor, 312 | ); 313 | 314 | expect(result).toEqual({ 315 | content: [ 316 | { 317 | type: 'text', 318 | text: 'Failed to extract app path from build settings. Make sure the app has been built first.', 319 | }, 320 | ], 321 | isError: true, 322 | }); 323 | }); 324 | 325 | it('should include optional configuration parameter in command', async () => { 326 | const calls: Array<{ 327 | args: any[]; 328 | description: string; 329 | suppressErrors: boolean; 330 | workingDirectory: string | undefined; 331 | }> = []; 332 | 333 | const mockExecutor = ( 334 | args: any[], 335 | description: string, 336 | suppressErrors: boolean, 337 | workingDirectory: string | undefined, 338 | ) => { 339 | calls.push({ args, description, suppressErrors, workingDirectory }); 340 | return Promise.resolve({ 341 | success: true, 342 | output: 343 | 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Release-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', 344 | error: undefined, 345 | process: { pid: 12345 }, 346 | }); 347 | }; 348 | 349 | await get_device_app_pathLogic( 350 | { 351 | projectPath: '/path/to/project.xcodeproj', 352 | scheme: 'MyScheme', 353 | configuration: 'Release', 354 | }, 355 | mockExecutor, 356 | ); 357 | 358 | expect(calls).toHaveLength(1); 359 | expect(calls[0]).toEqual({ 360 | args: [ 361 | 'xcodebuild', 362 | '-showBuildSettings', 363 | '-project', 364 | '/path/to/project.xcodeproj', 365 | '-scheme', 366 | 'MyScheme', 367 | '-configuration', 368 | 'Release', 369 | '-destination', 370 | 'generic/platform=iOS', 371 | ], 372 | description: 'Get App Path', 373 | suppressErrors: true, 374 | workingDirectory: undefined, 375 | }); 376 | }); 377 | 378 | it('should return exact exception handling response', async () => { 379 | const mockExecutor = () => { 380 | return Promise.reject(new Error('Network error')); 381 | }; 382 | 383 | const result = await get_device_app_pathLogic( 384 | { 385 | projectPath: '/path/to/project.xcodeproj', 386 | scheme: 'MyScheme', 387 | }, 388 | mockExecutor, 389 | ); 390 | 391 | expect(result).toEqual({ 392 | content: [ 393 | { 394 | type: 'text', 395 | text: 'Error retrieving app path: Network error', 396 | }, 397 | ], 398 | isError: true, 399 | }); 400 | }); 401 | 402 | it('should return exact string error handling response', async () => { 403 | const mockExecutor = () => { 404 | return Promise.reject('String error'); 405 | }; 406 | 407 | const result = await get_device_app_pathLogic( 408 | { 409 | projectPath: '/path/to/project.xcodeproj', 410 | scheme: 'MyScheme', 411 | }, 412 | mockExecutor, 413 | ); 414 | 415 | expect(result).toEqual({ 416 | content: [ 417 | { 418 | type: 'text', 419 | text: 'Error retrieving app path: String error', 420 | }, 421 | ], 422 | isError: true, 423 | }); 424 | }); 425 | }); 426 | }); 427 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for swift_package_stop plugin 3 | * Following CLAUDE.md testing standards with pure dependency injection 4 | * No vitest mocking - using dependency injection pattern 5 | */ 6 | 7 | import { describe, it, expect } from 'vitest'; 8 | import { z } from 'zod'; 9 | import swiftPackageStop, { 10 | createMockProcessManager, 11 | swift_package_stopLogic, 12 | type ProcessManager, 13 | } from '../swift_package_stop.ts'; 14 | 15 | /** 16 | * Mock process implementation for testing 17 | */ 18 | class MockProcess { 19 | public killed = false; 20 | public killSignal: string | undefined; 21 | public exitCallback: (() => void) | undefined; 22 | public shouldThrowOnKill = false; 23 | public killError: Error | string | undefined; 24 | public pid: number; 25 | 26 | constructor(pid: number) { 27 | this.pid = pid; 28 | } 29 | 30 | kill(signal?: string): void { 31 | if (this.shouldThrowOnKill) { 32 | throw this.killError ?? new Error('Process kill failed'); 33 | } 34 | this.killed = true; 35 | this.killSignal = signal; 36 | } 37 | 38 | on(event: string, callback: () => void): void { 39 | if (event === 'exit') { 40 | this.exitCallback = callback; 41 | } 42 | } 43 | 44 | // Simulate immediate exit for test control 45 | simulateExit(): void { 46 | if (this.exitCallback) { 47 | this.exitCallback(); 48 | } 49 | } 50 | } 51 | 52 | describe('swift_package_stop plugin', () => { 53 | describe('Export Field Validation (Literal)', () => { 54 | it('should have correct name', () => { 55 | expect(swiftPackageStop.name).toBe('swift_package_stop'); 56 | }); 57 | 58 | it('should have correct description', () => { 59 | expect(swiftPackageStop.description).toBe( 60 | 'Stops a running Swift Package executable started with swift_package_run', 61 | ); 62 | }); 63 | 64 | it('should have handler function', () => { 65 | expect(typeof swiftPackageStop.handler).toBe('function'); 66 | }); 67 | 68 | it('should validate schema correctly', () => { 69 | // Test valid inputs 70 | expect(swiftPackageStop.schema.pid.safeParse(12345).success).toBe(true); 71 | expect(swiftPackageStop.schema.pid.safeParse(0).success).toBe(true); 72 | expect(swiftPackageStop.schema.pid.safeParse(-1).success).toBe(true); 73 | 74 | // Test invalid inputs 75 | expect(swiftPackageStop.schema.pid.safeParse('not-a-number').success).toBe(false); 76 | expect(swiftPackageStop.schema.pid.safeParse(null).success).toBe(false); 77 | expect(swiftPackageStop.schema.pid.safeParse(undefined).success).toBe(false); 78 | expect(swiftPackageStop.schema.pid.safeParse({}).success).toBe(false); 79 | expect(swiftPackageStop.schema.pid.safeParse([]).success).toBe(false); 80 | }); 81 | }); 82 | 83 | describe('Handler Behavior (Complete Literal Returns)', () => { 84 | it('should return exact error for process not found', async () => { 85 | const mockProcessManager = createMockProcessManager({ 86 | getProcess: () => undefined, 87 | }); 88 | 89 | const result = await swift_package_stopLogic({ pid: 99999 }, mockProcessManager); 90 | 91 | expect(result).toEqual({ 92 | content: [ 93 | { 94 | type: 'text', 95 | text: '⚠️ No running process found with PID 99999. Use swift_package_run to check active processes.', 96 | }, 97 | ], 98 | isError: true, 99 | }); 100 | }); 101 | 102 | it('should successfully stop a process that exits gracefully', async () => { 103 | const mockProcess = new MockProcess(12345); 104 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 105 | 106 | const mockProcessManager = createMockProcessManager({ 107 | getProcess: (pid: number) => 108 | pid === 12345 109 | ? { 110 | process: mockProcess, 111 | startedAt: startedAt, 112 | } 113 | : undefined, 114 | removeProcess: () => true, 115 | }); 116 | 117 | // Set up the process to exit immediately when exit handler is registered 118 | const originalOn = mockProcess.on.bind(mockProcess); 119 | mockProcess.on = (event: string, callback: () => void) => { 120 | originalOn(event, callback); 121 | if (event === 'exit') { 122 | // Simulate immediate graceful exit 123 | setImmediate(() => callback()); 124 | } 125 | }; 126 | 127 | const result = await swift_package_stopLogic( 128 | { pid: 12345 }, 129 | mockProcessManager, 130 | 10, // Very short timeout for testing 131 | ); 132 | 133 | expect(mockProcess.killed).toBe(true); 134 | expect(mockProcess.killSignal).toBe('SIGTERM'); 135 | expect(result).toEqual({ 136 | content: [ 137 | { 138 | type: 'text', 139 | text: '✅ Stopped executable (was running since 2023-01-01T10:00:00.000Z)', 140 | }, 141 | { 142 | type: 'text', 143 | text: '💡 Process terminated. You can now run swift_package_run again if needed.', 144 | }, 145 | ], 146 | }); 147 | }); 148 | 149 | it('should force kill process if graceful termination fails', async () => { 150 | const mockProcess = new MockProcess(67890); 151 | const startedAt = new Date('2023-02-15T14:30:00.000Z'); 152 | 153 | const mockProcessManager = createMockProcessManager({ 154 | getProcess: (pid: number) => 155 | pid === 67890 156 | ? { 157 | process: mockProcess, 158 | startedAt: startedAt, 159 | } 160 | : undefined, 161 | removeProcess: () => true, 162 | }); 163 | 164 | // Mock the process to NOT exit gracefully (no callback invocation) 165 | const killCalls: string[] = []; 166 | const originalKill = mockProcess.kill.bind(mockProcess); 167 | mockProcess.kill = (signal?: string) => { 168 | killCalls.push(signal ?? 'default'); 169 | originalKill(signal); 170 | }; 171 | 172 | // Set up timeout to trigger SIGKILL after SIGTERM 173 | const originalOn = mockProcess.on.bind(mockProcess); 174 | mockProcess.on = (event: string, callback: () => void) => { 175 | originalOn(event, callback); 176 | // Do NOT call the callback to simulate hanging process 177 | }; 178 | 179 | const result = await swift_package_stopLogic( 180 | { pid: 67890 }, 181 | mockProcessManager, 182 | 10, // Very short timeout for testing 183 | ); 184 | 185 | expect(killCalls).toEqual(['SIGTERM', 'SIGKILL']); 186 | expect(result).toEqual({ 187 | content: [ 188 | { 189 | type: 'text', 190 | text: '✅ Stopped executable (was running since 2023-02-15T14:30:00.000Z)', 191 | }, 192 | { 193 | type: 'text', 194 | text: '💡 Process terminated. You can now run swift_package_run again if needed.', 195 | }, 196 | ], 197 | }); 198 | }); 199 | 200 | it('should handle process kill error and return error response', async () => { 201 | const mockProcess = new MockProcess(54321); 202 | const startedAt = new Date('2023-03-20T09:15:00.000Z'); 203 | 204 | // Configure process to throw error on kill 205 | mockProcess.shouldThrowOnKill = true; 206 | mockProcess.killError = new Error('ESRCH: No such process'); 207 | 208 | const mockProcessManager = createMockProcessManager({ 209 | getProcess: (pid: number) => 210 | pid === 54321 211 | ? { 212 | process: mockProcess, 213 | startedAt: startedAt, 214 | } 215 | : undefined, 216 | }); 217 | 218 | const result = await swift_package_stopLogic({ pid: 54321 }, mockProcessManager); 219 | 220 | expect(result).toEqual({ 221 | content: [ 222 | { 223 | type: 'text', 224 | text: 'Error: Failed to stop process\nDetails: ESRCH: No such process', 225 | }, 226 | ], 227 | isError: true, 228 | }); 229 | }); 230 | 231 | it('should handle non-Error exception in catch block', async () => { 232 | const mockProcess = new MockProcess(11111); 233 | const startedAt = new Date('2023-04-10T16:45:00.000Z'); 234 | 235 | // Configure process to throw non-Error object 236 | mockProcess.shouldThrowOnKill = true; 237 | mockProcess.killError = 'Process termination failed'; 238 | 239 | const mockProcessManager = createMockProcessManager({ 240 | getProcess: (pid: number) => 241 | pid === 11111 242 | ? { 243 | process: mockProcess, 244 | startedAt: startedAt, 245 | } 246 | : undefined, 247 | }); 248 | 249 | const result = await swift_package_stopLogic({ pid: 11111 }, mockProcessManager); 250 | 251 | expect(result).toEqual({ 252 | content: [ 253 | { 254 | type: 'text', 255 | text: 'Error: Failed to stop process\nDetails: Process termination failed', 256 | }, 257 | ], 258 | isError: true, 259 | }); 260 | }); 261 | 262 | it('should handle process found but exit event never fires and timeout occurs', async () => { 263 | const mockProcess = new MockProcess(22222); 264 | const startedAt = new Date('2023-05-05T12:00:00.000Z'); 265 | 266 | const mockProcessManager = createMockProcessManager({ 267 | getProcess: (pid: number) => 268 | pid === 22222 269 | ? { 270 | process: mockProcess, 271 | startedAt: startedAt, 272 | } 273 | : undefined, 274 | removeProcess: () => true, 275 | }); 276 | 277 | const killCalls: string[] = []; 278 | const originalKill = mockProcess.kill.bind(mockProcess); 279 | mockProcess.kill = (signal?: string) => { 280 | killCalls.push(signal ?? 'default'); 281 | originalKill(signal); 282 | }; 283 | 284 | // Mock process.on to register the exit handler but never call it (timeout scenario) 285 | const originalOn = mockProcess.on.bind(mockProcess); 286 | mockProcess.on = (event: string, callback: () => void) => { 287 | originalOn(event, callback); 288 | // Handler is registered but callback never called (simulates hanging process) 289 | }; 290 | 291 | const result = await swift_package_stopLogic( 292 | { pid: 22222 }, 293 | mockProcessManager, 294 | 10, // Very short timeout for testing 295 | ); 296 | 297 | expect(killCalls).toEqual(['SIGTERM', 'SIGKILL']); 298 | expect(result).toEqual({ 299 | content: [ 300 | { 301 | type: 'text', 302 | text: '✅ Stopped executable (was running since 2023-05-05T12:00:00.000Z)', 303 | }, 304 | { 305 | type: 'text', 306 | text: '💡 Process terminated. You can now run swift_package_run again if needed.', 307 | }, 308 | ], 309 | }); 310 | }); 311 | 312 | it('should handle edge case with pid 0', async () => { 313 | const mockProcessManager = createMockProcessManager({ 314 | getProcess: () => undefined, 315 | }); 316 | 317 | const result = await swift_package_stopLogic({ pid: 0 }, mockProcessManager); 318 | 319 | expect(result).toEqual({ 320 | content: [ 321 | { 322 | type: 'text', 323 | text: '⚠️ No running process found with PID 0. Use swift_package_run to check active processes.', 324 | }, 325 | ], 326 | isError: true, 327 | }); 328 | }); 329 | 330 | it('should handle edge case with negative pid', async () => { 331 | const mockProcessManager = createMockProcessManager({ 332 | getProcess: () => undefined, 333 | }); 334 | 335 | const result = await swift_package_stopLogic({ pid: -1 }, mockProcessManager); 336 | 337 | expect(result).toEqual({ 338 | content: [ 339 | { 340 | type: 'text', 341 | text: '⚠️ No running process found with PID -1. Use swift_package_run to check active processes.', 342 | }, 343 | ], 344 | isError: true, 345 | }); 346 | }); 347 | 348 | it('should handle process that exits after first SIGTERM call', async () => { 349 | const mockProcess = new MockProcess(33333); 350 | const startedAt = new Date('2023-06-01T08:30:00.000Z'); 351 | 352 | const mockProcessManager = createMockProcessManager({ 353 | getProcess: (pid: number) => 354 | pid === 33333 355 | ? { 356 | process: mockProcess, 357 | startedAt: startedAt, 358 | } 359 | : undefined, 360 | removeProcess: () => true, 361 | }); 362 | 363 | const killCalls: string[] = []; 364 | const originalKill = mockProcess.kill.bind(mockProcess); 365 | mockProcess.kill = (signal?: string) => { 366 | killCalls.push(signal ?? 'default'); 367 | originalKill(signal); 368 | }; 369 | 370 | // Set up the process to exit immediately when exit handler is registered 371 | const originalOn = mockProcess.on.bind(mockProcess); 372 | mockProcess.on = (event: string, callback: () => void) => { 373 | originalOn(event, callback); 374 | if (event === 'exit') { 375 | // Simulate immediate graceful exit 376 | setImmediate(() => callback()); 377 | } 378 | }; 379 | 380 | const result = await swift_package_stopLogic( 381 | { pid: 33333 }, 382 | mockProcessManager, 383 | 10, // Very short timeout for testing 384 | ); 385 | 386 | expect(killCalls).toEqual(['SIGTERM']); // Should not call SIGKILL 387 | expect(result).toEqual({ 388 | content: [ 389 | { 390 | type: 'text', 391 | text: '✅ Stopped executable (was running since 2023-06-01T08:30:00.000Z)', 392 | }, 393 | { 394 | type: 'text', 395 | text: '💡 Process terminated. You can now run swift_package_run again if needed.', 396 | }, 397 | ], 398 | }); 399 | }); 400 | 401 | it('should handle undefined pid parameter', async () => { 402 | const mockProcessManager = createMockProcessManager({ 403 | getProcess: () => undefined, 404 | }); 405 | 406 | const result = await swift_package_stopLogic({} as any, mockProcessManager); 407 | 408 | expect(result).toEqual({ 409 | content: [ 410 | { 411 | type: 'text', 412 | text: '⚠️ No running process found with PID undefined. Use swift_package_run to check active processes.', 413 | }, 414 | ], 415 | isError: true, 416 | }); 417 | }); 418 | }); 419 | }); 420 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/screenshot.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for screenshot tool plugin 3 | */ 4 | 5 | import { describe, it, expect, beforeEach } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { 8 | createMockExecutor, 9 | createMockFileSystemExecutor, 10 | createNoopExecutor, 11 | } from '../../../../test-utils/mock-executors.ts'; 12 | import { SystemError } from '../../../../utils/responses/index.ts'; 13 | import screenshotPlugin, { screenshotLogic } from '../screenshot.ts'; 14 | 15 | describe('Screenshot Plugin', () => { 16 | describe('Export Field Validation (Literal)', () => { 17 | it('should have correct name', () => { 18 | expect(screenshotPlugin.name).toBe('screenshot'); 19 | }); 20 | 21 | it('should have correct description', () => { 22 | expect(screenshotPlugin.description).toBe( 23 | "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", 24 | ); 25 | }); 26 | 27 | it('should have handler function', () => { 28 | expect(typeof screenshotPlugin.handler).toBe('function'); 29 | }); 30 | 31 | it('should validate schema fields with safeParse', () => { 32 | const schema = z.object(screenshotPlugin.schema); 33 | 34 | // Valid case 35 | expect( 36 | schema.safeParse({ 37 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 38 | }).success, 39 | ).toBe(true); 40 | 41 | // Invalid simulatorUuid 42 | expect( 43 | schema.safeParse({ 44 | simulatorUuid: 'invalid-uuid', 45 | }).success, 46 | ).toBe(false); 47 | 48 | // Missing simulatorUuid 49 | expect(schema.safeParse({}).success).toBe(false); 50 | }); 51 | }); 52 | 53 | describe('Plugin Handler Validation', () => { 54 | it('should return Zod validation error for missing simulatorUuid', async () => { 55 | const result = await screenshotPlugin.handler({}); 56 | 57 | expect(result).toEqual({ 58 | content: [ 59 | { 60 | type: 'text', 61 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', 62 | }, 63 | ], 64 | isError: true, 65 | }); 66 | }); 67 | 68 | it('should return Zod validation error for invalid UUID format', async () => { 69 | const result = await screenshotPlugin.handler({ 70 | simulatorUuid: 'invalid-uuid', 71 | }); 72 | 73 | expect(result).toEqual({ 74 | content: [ 75 | { 76 | type: 'text', 77 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Invalid Simulator UUID format', 78 | }, 79 | ], 80 | isError: true, 81 | }); 82 | }); 83 | }); 84 | 85 | describe('Command Generation', () => { 86 | it('should generate correct xcrun simctl command for basic screenshot', async () => { 87 | const capturedCommands: string[][] = []; 88 | const trackingExecutor = async (command: string[]) => { 89 | capturedCommands.push(command); 90 | return { 91 | success: true, 92 | output: 'Screenshot saved', 93 | error: undefined, 94 | process: { pid: 12345 }, 95 | }; 96 | }; 97 | 98 | const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); 99 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 100 | readFile: async () => mockImageBuffer.toString('utf8'), 101 | }); 102 | 103 | await screenshotLogic( 104 | { 105 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 106 | }, 107 | trackingExecutor, 108 | mockFileSystemExecutor, 109 | { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, 110 | { v4: () => 'test-uuid' }, 111 | ); 112 | 113 | // Should capture the screenshot command first 114 | expect(capturedCommands[0]).toEqual([ 115 | 'xcrun', 116 | 'simctl', 117 | 'io', 118 | '12345678-1234-1234-1234-123456789012', 119 | 'screenshot', 120 | '/tmp/screenshot_test-uuid.png', 121 | ]); 122 | }); 123 | 124 | it('should generate correct xcrun simctl command with different simulator UUID', async () => { 125 | const capturedCommands: string[][] = []; 126 | const trackingExecutor = async (command: string[]) => { 127 | capturedCommands.push(command); 128 | return { 129 | success: true, 130 | output: 'Screenshot saved', 131 | error: undefined, 132 | process: { pid: 12345 }, 133 | }; 134 | }; 135 | 136 | const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); 137 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 138 | readFile: async () => mockImageBuffer.toString('utf8'), 139 | }); 140 | 141 | await screenshotLogic( 142 | { 143 | simulatorUuid: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 144 | }, 145 | trackingExecutor, 146 | mockFileSystemExecutor, 147 | { tmpdir: () => '/var/tmp', join: (...paths) => paths.join('/') }, 148 | { v4: () => 'another-uuid' }, 149 | ); 150 | 151 | expect(capturedCommands[0]).toEqual([ 152 | 'xcrun', 153 | 'simctl', 154 | 'io', 155 | 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 156 | 'screenshot', 157 | '/var/tmp/screenshot_another-uuid.png', 158 | ]); 159 | }); 160 | 161 | it('should generate correct xcrun simctl command with custom path dependencies', async () => { 162 | const capturedCommands: string[][] = []; 163 | const trackingExecutor = async (command: string[]) => { 164 | capturedCommands.push(command); 165 | return { 166 | success: true, 167 | output: 'Screenshot saved', 168 | error: undefined, 169 | process: { pid: 12345 }, 170 | }; 171 | }; 172 | 173 | const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); 174 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 175 | readFile: async () => mockImageBuffer.toString('utf8'), 176 | }); 177 | 178 | await screenshotLogic( 179 | { 180 | simulatorUuid: '98765432-1098-7654-3210-987654321098', 181 | }, 182 | trackingExecutor, 183 | mockFileSystemExecutor, 184 | { 185 | tmpdir: () => '/custom/temp/dir', 186 | join: (...paths) => paths.join('\\'), // Windows-style path joining 187 | }, 188 | { v4: () => 'custom-uuid' }, 189 | ); 190 | 191 | expect(capturedCommands[0]).toEqual([ 192 | 'xcrun', 193 | 'simctl', 194 | 'io', 195 | '98765432-1098-7654-3210-987654321098', 196 | 'screenshot', 197 | '/custom/temp/dir\\screenshot_custom-uuid.png', 198 | ]); 199 | }); 200 | 201 | it('should generate correct xcrun simctl command with generated UUID when no UUID deps provided', async () => { 202 | const capturedCommands: string[][] = []; 203 | const trackingExecutor = async (command: string[]) => { 204 | capturedCommands.push(command); 205 | return { 206 | success: true, 207 | output: 'Screenshot saved', 208 | error: undefined, 209 | process: { pid: 12345 }, 210 | }; 211 | }; 212 | 213 | const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); 214 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 215 | readFile: async () => mockImageBuffer.toString('utf8'), 216 | }); 217 | 218 | await screenshotLogic( 219 | { 220 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 221 | }, 222 | trackingExecutor, 223 | mockFileSystemExecutor, 224 | { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, 225 | // No UUID deps provided - should use real uuidv4() 226 | ); 227 | 228 | // Verify the command structure but not the exact UUID since it's generated 229 | expect(capturedCommands[0].slice(0, 5)).toEqual([ 230 | 'xcrun', 231 | 'simctl', 232 | 'io', 233 | '12345678-1234-1234-1234-123456789012', 234 | 'screenshot', 235 | ]); 236 | expect(capturedCommands[0][5]).toMatch(/^\/tmp\/screenshot_[a-f0-9-]+\.png$/); 237 | }); 238 | }); 239 | 240 | describe('Handler Behavior (Complete Literal Returns)', () => { 241 | it('should handle parameter validation via plugin handler (not logic function)', async () => { 242 | // Note: With Zod validation in createTypedTool, the screenshotLogic function 243 | // will never receive invalid parameters - validation happens at the handler level. 244 | // This test documents that screenshotLogic assumes valid parameters. 245 | const result = await screenshotLogic( 246 | { 247 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 248 | }, 249 | createMockExecutor({ 250 | success: true, 251 | output: 'Screenshot saved', 252 | error: undefined, 253 | }), 254 | createMockFileSystemExecutor({ 255 | readFile: async () => Buffer.from('fake-image-data', 'utf8').toString('utf8'), 256 | }), 257 | ); 258 | 259 | expect(result.isError).toBe(false); 260 | expect(result.content[0].type).toBe('image'); 261 | }); 262 | 263 | it('should return success for valid screenshot capture', async () => { 264 | const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); 265 | 266 | const mockExecutor = createMockExecutor({ 267 | success: true, 268 | output: 'Screenshot saved', 269 | error: undefined, 270 | }); 271 | 272 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 273 | readFile: async () => mockImageBuffer.toString('utf8'), 274 | }); 275 | 276 | const result = await screenshotLogic( 277 | { 278 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 279 | }, 280 | mockExecutor, 281 | mockFileSystemExecutor, 282 | ); 283 | 284 | expect(result).toEqual({ 285 | content: [ 286 | { 287 | type: 'image', 288 | data: 'fake-image-data', 289 | mimeType: 'image/jpeg', 290 | }, 291 | ], 292 | isError: false, 293 | }); 294 | }); 295 | 296 | it('should handle command execution failure', async () => { 297 | const mockExecutor = createMockExecutor({ 298 | success: false, 299 | output: '', 300 | error: 'Simulator not found', 301 | }); 302 | 303 | const result = await screenshotLogic( 304 | { 305 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 306 | }, 307 | mockExecutor, 308 | createMockFileSystemExecutor(), 309 | ); 310 | 311 | expect(result).toEqual({ 312 | content: [ 313 | { 314 | type: 'text', 315 | text: 'Error: System error executing screenshot: Failed to capture screenshot: Simulator not found', 316 | }, 317 | ], 318 | isError: true, 319 | }); 320 | }); 321 | 322 | it('should handle file reading errors', async () => { 323 | const mockExecutor = createMockExecutor({ 324 | success: true, 325 | output: 'Screenshot saved', 326 | error: undefined, 327 | }); 328 | 329 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 330 | readFile: async () => { 331 | throw new Error('File not found'); 332 | }, 333 | }); 334 | 335 | const result = await screenshotLogic( 336 | { 337 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 338 | }, 339 | mockExecutor, 340 | mockFileSystemExecutor, 341 | ); 342 | 343 | expect(result).toEqual({ 344 | content: [ 345 | { 346 | type: 'text', 347 | text: 'Error: Screenshot captured but failed to process image file: File not found', 348 | }, 349 | ], 350 | isError: true, 351 | }); 352 | }); 353 | 354 | it('should handle file cleanup errors gracefully', async () => { 355 | const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); 356 | 357 | const mockExecutor = createMockExecutor({ 358 | success: true, 359 | output: 'Screenshot saved', 360 | error: undefined, 361 | }); 362 | 363 | const mockFileSystemExecutor = createMockFileSystemExecutor({ 364 | readFile: async () => mockImageBuffer.toString('utf8'), 365 | // unlink method is not overridden, so it will use the default (no-op) 366 | // which simulates the cleanup failure being caught and logged 367 | }); 368 | 369 | const result = await screenshotLogic( 370 | { 371 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 372 | }, 373 | mockExecutor, 374 | mockFileSystemExecutor, 375 | ); 376 | 377 | // Should still return successful result despite cleanup failure 378 | expect(result).toEqual({ 379 | content: [ 380 | { 381 | type: 'image', 382 | data: 'fake-image-data', 383 | mimeType: 'image/jpeg', 384 | }, 385 | ], 386 | isError: false, 387 | }); 388 | }); 389 | 390 | it('should handle SystemError from command execution', async () => { 391 | const mockExecutor = async () => { 392 | throw new SystemError('System error occurred'); 393 | }; 394 | 395 | const result = await screenshotLogic( 396 | { 397 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 398 | }, 399 | mockExecutor, 400 | createMockFileSystemExecutor(), 401 | ); 402 | 403 | expect(result).toEqual({ 404 | content: [ 405 | { type: 'text', text: 'Error: System error executing screenshot: System error occurred' }, 406 | ], 407 | isError: true, 408 | }); 409 | }); 410 | 411 | it('should handle unexpected Error objects', async () => { 412 | const mockExecutor = async () => { 413 | throw new Error('Unexpected error'); 414 | }; 415 | 416 | const result = await screenshotLogic( 417 | { 418 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 419 | }, 420 | mockExecutor, 421 | createMockFileSystemExecutor(), 422 | ); 423 | 424 | expect(result).toEqual({ 425 | content: [{ type: 'text', text: 'Error: An unexpected error occurred: Unexpected error' }], 426 | isError: true, 427 | }); 428 | }); 429 | 430 | it('should handle unexpected string errors', async () => { 431 | const mockExecutor = async () => { 432 | throw 'String error'; 433 | }; 434 | 435 | const result = await screenshotLogic( 436 | { 437 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 438 | }, 439 | mockExecutor, 440 | createMockFileSystemExecutor(), 441 | ); 442 | 443 | expect(result).toEqual({ 444 | content: [{ type: 'text', text: 'Error: An unexpected error occurred: String error' }], 445 | isError: true, 446 | }); 447 | }); 448 | }); 449 | }); 450 | ``` -------------------------------------------------------------------------------- /src/utils/build-utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Build Utilities - Higher-level abstractions for Xcode build operations 3 | * 4 | * This utility module provides specialized functions for build-related operations 5 | * across different platforms (macOS, iOS, watchOS, etc.). It serves as a higher-level 6 | * abstraction layer on top of the core Xcode utilities. 7 | * 8 | * Responsibilities: 9 | * - Providing a unified interface (executeXcodeBuild) for all build operations 10 | * - Handling build-specific parameter formatting and validation 11 | * - Standardizing response formatting for build results 12 | * - Managing build-specific error handling and reporting 13 | * - Supporting various build actions (build, clean, showBuildSettings, etc.) 14 | * - Supporting xcodemake as an alternative build strategy for faster incremental builds 15 | * 16 | * This file depends on the lower-level utilities in xcode.ts for command execution 17 | * while adding build-specific behavior, formatting, and error handling. 18 | */ 19 | 20 | import { log } from './logger.ts'; 21 | import { XcodePlatform, constructDestinationString } from './xcode.ts'; 22 | import { CommandExecutor, CommandExecOptions } from './command.ts'; 23 | import { ToolResponse, SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; 24 | import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; 25 | import { 26 | isXcodemakeEnabled, 27 | isXcodemakeAvailable, 28 | executeXcodemakeCommand, 29 | executeMakeCommand, 30 | doesMakefileExist, 31 | doesMakeLogFileExist, 32 | } from './xcodemake.ts'; 33 | import path from 'path'; 34 | 35 | /** 36 | * Common function to execute an Xcode build command across platforms 37 | * @param params Common build parameters 38 | * @param platformOptions Platform-specific options 39 | * @param preferXcodebuild Whether to prefer xcodebuild over xcodemake, useful for if xcodemake is failing 40 | * @param buildAction The xcodebuild action to perform (e.g., 'build', 'clean', 'test') 41 | * @param executor Optional command executor for dependency injection (used for testing) 42 | * @returns Promise resolving to tool response 43 | */ 44 | export async function executeXcodeBuildCommand( 45 | params: SharedBuildParams, 46 | platformOptions: PlatformBuildOptions, 47 | preferXcodebuild: boolean = false, 48 | buildAction: string = 'build', 49 | executor: CommandExecutor, 50 | execOpts?: CommandExecOptions, 51 | ): Promise<ToolResponse> { 52 | // Collect warnings, errors, and stderr messages from the build output 53 | const buildMessages: { type: 'text'; text: string }[] = []; 54 | function grepWarningsAndErrors(text: string): { type: 'warning' | 'error'; content: string }[] { 55 | return text 56 | .split('\n') 57 | .map((content) => { 58 | if (/warning:/i.test(content)) return { type: 'warning', content }; 59 | if (/error:/i.test(content)) return { type: 'error', content }; 60 | return null; 61 | }) 62 | .filter(Boolean) as { type: 'warning' | 'error'; content: string }[]; 63 | } 64 | 65 | log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); 66 | 67 | // Check if xcodemake is enabled and available 68 | const isXcodemakeEnabledFlag = isXcodemakeEnabled(); 69 | let xcodemakeAvailableFlag = false; 70 | 71 | if (isXcodemakeEnabledFlag && buildAction === 'build') { 72 | xcodemakeAvailableFlag = await isXcodemakeAvailable(); 73 | 74 | if (xcodemakeAvailableFlag && preferXcodebuild) { 75 | log( 76 | 'info', 77 | 'xcodemake is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', 78 | ); 79 | buildMessages.push({ 80 | type: 'text', 81 | text: '⚠️ incremental build support is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', 82 | }); 83 | } else if (!xcodemakeAvailableFlag) { 84 | buildMessages.push({ 85 | type: 'text', 86 | text: '⚠️ xcodemake is enabled but not available. Falling back to xcodebuild.', 87 | }); 88 | log('info', 'xcodemake is enabled but not available. Falling back to xcodebuild.'); 89 | } else { 90 | log('info', 'xcodemake is enabled and available, using it for incremental builds.'); 91 | buildMessages.push({ 92 | type: 'text', 93 | text: 'ℹ️ xcodemake is enabled and available, using it for incremental builds.', 94 | }); 95 | } 96 | } 97 | 98 | try { 99 | const command = ['xcodebuild']; 100 | 101 | let projectDir = ''; 102 | if (params.workspacePath) { 103 | projectDir = path.dirname(params.workspacePath); 104 | command.push('-workspace', params.workspacePath); 105 | } else if (params.projectPath) { 106 | projectDir = path.dirname(params.projectPath); 107 | command.push('-project', params.projectPath); 108 | } 109 | 110 | command.push('-scheme', params.scheme); 111 | command.push('-configuration', params.configuration); 112 | command.push('-skipMacroValidation'); 113 | 114 | // Construct destination string based on platform 115 | let destinationString: string; 116 | const isSimulatorPlatform = [ 117 | XcodePlatform.iOSSimulator, 118 | XcodePlatform.watchOSSimulator, 119 | XcodePlatform.tvOSSimulator, 120 | XcodePlatform.visionOSSimulator, 121 | ].includes(platformOptions.platform); 122 | 123 | if (isSimulatorPlatform) { 124 | if (platformOptions.simulatorId) { 125 | destinationString = constructDestinationString( 126 | platformOptions.platform, 127 | undefined, 128 | platformOptions.simulatorId, 129 | ); 130 | } else if (platformOptions.simulatorName) { 131 | destinationString = constructDestinationString( 132 | platformOptions.platform, 133 | platformOptions.simulatorName, 134 | undefined, 135 | platformOptions.useLatestOS, 136 | ); 137 | } else { 138 | return createTextResponse( 139 | `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`, 140 | true, 141 | ); 142 | } 143 | } else if (platformOptions.platform === XcodePlatform.macOS) { 144 | destinationString = constructDestinationString( 145 | platformOptions.platform, 146 | undefined, 147 | undefined, 148 | false, 149 | platformOptions.arch, 150 | ); 151 | } else if (platformOptions.platform === XcodePlatform.iOS) { 152 | if (platformOptions.deviceId) { 153 | destinationString = `platform=iOS,id=${platformOptions.deviceId}`; 154 | } else { 155 | destinationString = 'generic/platform=iOS'; 156 | } 157 | } else if (platformOptions.platform === XcodePlatform.watchOS) { 158 | if (platformOptions.deviceId) { 159 | destinationString = `platform=watchOS,id=${platformOptions.deviceId}`; 160 | } else { 161 | destinationString = 'generic/platform=watchOS'; 162 | } 163 | } else if (platformOptions.platform === XcodePlatform.tvOS) { 164 | if (platformOptions.deviceId) { 165 | destinationString = `platform=tvOS,id=${platformOptions.deviceId}`; 166 | } else { 167 | destinationString = 'generic/platform=tvOS'; 168 | } 169 | } else if (platformOptions.platform === XcodePlatform.visionOS) { 170 | if (platformOptions.deviceId) { 171 | destinationString = `platform=visionOS,id=${platformOptions.deviceId}`; 172 | } else { 173 | destinationString = 'generic/platform=visionOS'; 174 | } 175 | } else { 176 | return createTextResponse(`Unsupported platform: ${platformOptions.platform}`, true); 177 | } 178 | 179 | command.push('-destination', destinationString); 180 | 181 | if (params.derivedDataPath) { 182 | command.push('-derivedDataPath', params.derivedDataPath); 183 | } 184 | 185 | if (params.extraArgs && params.extraArgs.length > 0) { 186 | command.push(...params.extraArgs); 187 | } 188 | 189 | command.push(buildAction); 190 | 191 | // Execute the command using xcodemake or xcodebuild 192 | let result; 193 | if ( 194 | isXcodemakeEnabledFlag && 195 | xcodemakeAvailableFlag && 196 | buildAction === 'build' && 197 | !preferXcodebuild 198 | ) { 199 | // Check if Makefile already exists 200 | const makefileExists = doesMakefileExist(projectDir); 201 | log('debug', 'Makefile exists: ' + makefileExists); 202 | 203 | // Check if Makefile log already exists 204 | const makeLogFileExists = doesMakeLogFileExist(projectDir, command); 205 | log('debug', 'Makefile log exists: ' + makeLogFileExists); 206 | 207 | if (makefileExists && makeLogFileExists) { 208 | // Use make for incremental builds 209 | buildMessages.push({ 210 | type: 'text', 211 | text: 'ℹ️ Using make for incremental build', 212 | }); 213 | result = await executeMakeCommand(projectDir, platformOptions.logPrefix); 214 | } else { 215 | // Generate Makefile using xcodemake 216 | buildMessages.push({ 217 | type: 'text', 218 | text: 'ℹ️ Generating Makefile with xcodemake (first build may take longer)', 219 | }); 220 | // Remove 'xcodebuild' from the command array before passing to executeXcodemakeCommand 221 | result = await executeXcodemakeCommand( 222 | projectDir, 223 | command.slice(1), 224 | platformOptions.logPrefix, 225 | ); 226 | } 227 | } else { 228 | // Use standard xcodebuild 229 | result = await executor(command, platformOptions.logPrefix, true, execOpts); 230 | } 231 | 232 | // Grep warnings and errors from stdout (build output) 233 | const warningOrErrorLines = grepWarningsAndErrors(result.output); 234 | warningOrErrorLines.forEach(({ type, content }) => { 235 | buildMessages.push({ 236 | type: 'text', 237 | text: type === 'warning' ? `⚠️ Warning: ${content}` : `❌ Error: ${content}`, 238 | }); 239 | }); 240 | 241 | // Include all stderr lines as errors 242 | if (result.error) { 243 | result.error.split('\n').forEach((content) => { 244 | if (content.trim()) { 245 | buildMessages.push({ type: 'text', text: `❌ [stderr] ${content}` }); 246 | } 247 | }); 248 | } 249 | 250 | if (!result.success) { 251 | const isMcpError = result.exitCode === 64; 252 | 253 | log( 254 | isMcpError ? 'error' : 'warning', 255 | `${platformOptions.logPrefix} ${buildAction} failed: ${result.error}`, 256 | { sentry: isMcpError }, 257 | ); 258 | const errorResponse = createTextResponse( 259 | `❌ ${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`, 260 | true, 261 | ); 262 | 263 | if (buildMessages.length > 0 && errorResponse.content) { 264 | errorResponse.content.unshift(...buildMessages); 265 | } 266 | 267 | // If using xcodemake and build failed but no compiling errors, suggest using xcodebuild 268 | if ( 269 | warningOrErrorLines.length == 0 && 270 | isXcodemakeEnabledFlag && 271 | xcodemakeAvailableFlag && 272 | buildAction === 'build' && 273 | !preferXcodebuild 274 | ) { 275 | errorResponse.content.push({ 276 | type: 'text', 277 | text: `💡 Incremental build using xcodemake failed, suggest using preferXcodebuild option to try build again using slower xcodebuild command.`, 278 | }); 279 | } 280 | 281 | return consolidateContentForClaudeCode(errorResponse); 282 | } 283 | 284 | log('info', `✅ ${platformOptions.logPrefix} ${buildAction} succeeded.`); 285 | 286 | // Create additional info based on platform and action 287 | let additionalInfo = ''; 288 | 289 | // Add xcodemake info if relevant 290 | if ( 291 | isXcodemakeEnabledFlag && 292 | xcodemakeAvailableFlag && 293 | buildAction === 'build' && 294 | !preferXcodebuild 295 | ) { 296 | additionalInfo += `xcodemake: Using faster incremental builds with xcodemake. 297 | Future builds will use the generated Makefile for improved performance. 298 | 299 | `; 300 | } 301 | 302 | // Only show next steps for 'build' action 303 | if (buildAction === 'build') { 304 | if (platformOptions.platform === XcodePlatform.macOS) { 305 | additionalInfo = `Next Steps: 306 | 1. Get app path: get_mac_app_path({ scheme: '${params.scheme}' }) 307 | 2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) 308 | 3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })`; 309 | } else if (platformOptions.platform === XcodePlatform.iOS) { 310 | additionalInfo = `Next Steps: 311 | 1. Get app path: get_device_app_path({ scheme: '${params.scheme}' }) 312 | 2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) 313 | 3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })`; 314 | } else if (isSimulatorPlatform) { 315 | const simIdParam = platformOptions.simulatorId ? 'simulatorId' : 'simulatorName'; 316 | const simIdValue = platformOptions.simulatorId ?? platformOptions.simulatorName; 317 | 318 | additionalInfo = `Next Steps: 319 | 1. Get app path: get_sim_app_path({ ${simIdParam}: '${simIdValue}', scheme: '${params.scheme}', platform: 'iOS Simulator' }) 320 | 2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) 321 | 3. Launch: launch_app_sim({ ${simIdParam}: '${simIdValue}', bundleId: 'BUNDLE_ID_FROM_STEP_2' }) 322 | Or with logs: launch_app_logs_sim({ ${simIdParam}: '${simIdValue}', bundleId: 'BUNDLE_ID_FROM_STEP_2' })`; 323 | } 324 | } 325 | 326 | const successResponse: ToolResponse = { 327 | content: [ 328 | ...buildMessages, 329 | { 330 | type: 'text', 331 | text: `✅ ${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.`, 332 | }, 333 | ], 334 | }; 335 | 336 | // Only add additional info if we have any 337 | if (additionalInfo) { 338 | successResponse.content.push({ 339 | type: 'text', 340 | text: additionalInfo, 341 | }); 342 | } 343 | 344 | return consolidateContentForClaudeCode(successResponse); 345 | } catch (error) { 346 | const errorMessage = error instanceof Error ? error.message : String(error); 347 | 348 | const isSpawnError = 349 | error instanceof Error && 350 | 'code' in error && 351 | ['ENOENT', 'EACCES', 'EPERM'].includes((error as NodeJS.ErrnoException).code ?? ''); 352 | 353 | log('error', `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, { 354 | sentry: !isSpawnError, 355 | }); 356 | 357 | return consolidateContentForClaudeCode( 358 | createTextResponse( 359 | `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, 360 | true, 361 | ), 362 | ); 363 | } 364 | } 365 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for swift_package_list plugin 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using pure dependency injection for deterministic testing 5 | */ 6 | 7 | import { describe, it, expect, beforeEach } from 'vitest'; 8 | import swiftPackageList, { swift_package_listLogic } from '../swift_package_list.ts'; 9 | 10 | describe('swift_package_list plugin', () => { 11 | // No mocks to clear with pure dependency injection 12 | 13 | describe('Export Field Validation (Literal)', () => { 14 | it('should have correct name', () => { 15 | expect(swiftPackageList.name).toBe('swift_package_list'); 16 | }); 17 | 18 | it('should have correct description', () => { 19 | expect(swiftPackageList.description).toBe('Lists currently running Swift Package processes'); 20 | }); 21 | 22 | it('should have handler function', () => { 23 | expect(typeof swiftPackageList.handler).toBe('function'); 24 | }); 25 | 26 | it('should validate schema correctly', () => { 27 | // The schema is an empty object, so any input should be valid 28 | expect(typeof swiftPackageList.schema).toBe('object'); 29 | expect(Object.keys(swiftPackageList.schema)).toEqual([]); 30 | }); 31 | }); 32 | 33 | describe('Handler Behavior (Complete Literal Returns)', () => { 34 | it('should return empty list when no processes are running', async () => { 35 | // Create empty mock process map 36 | const mockProcessMap = new Map(); 37 | 38 | // Use pure dependency injection with stub functions 39 | const mockArrayFrom = () => []; 40 | const mockDateNow = () => Date.now(); 41 | 42 | const result = await swift_package_listLogic( 43 | {}, 44 | { 45 | processMap: mockProcessMap, 46 | arrayFrom: mockArrayFrom, 47 | dateNow: mockDateNow, 48 | }, 49 | ); 50 | 51 | expect(result).toEqual({ 52 | content: [ 53 | { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, 54 | { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, 55 | ], 56 | }); 57 | }); 58 | 59 | it('should handle empty args object', async () => { 60 | // Create empty mock process map 61 | const mockProcessMap = new Map(); 62 | 63 | // Use pure dependency injection with stub functions 64 | const mockArrayFrom = () => []; 65 | const mockDateNow = () => Date.now(); 66 | 67 | const result = await swift_package_listLogic( 68 | {}, 69 | { 70 | processMap: mockProcessMap, 71 | arrayFrom: mockArrayFrom, 72 | dateNow: mockDateNow, 73 | }, 74 | ); 75 | 76 | expect(result).toEqual({ 77 | content: [ 78 | { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, 79 | { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, 80 | ], 81 | }); 82 | }); 83 | 84 | it('should handle null args', async () => { 85 | // Create empty mock process map 86 | const mockProcessMap = new Map(); 87 | 88 | // Use pure dependency injection with stub functions 89 | const mockArrayFrom = () => []; 90 | const mockDateNow = () => Date.now(); 91 | 92 | const result = await swift_package_listLogic(null, { 93 | processMap: mockProcessMap, 94 | arrayFrom: mockArrayFrom, 95 | dateNow: mockDateNow, 96 | }); 97 | 98 | expect(result).toEqual({ 99 | content: [ 100 | { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, 101 | { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, 102 | ], 103 | }); 104 | }); 105 | 106 | it('should handle undefined args', async () => { 107 | // Create empty mock process map 108 | const mockProcessMap = new Map(); 109 | 110 | // Use pure dependency injection with stub functions 111 | const mockArrayFrom = () => []; 112 | const mockDateNow = () => Date.now(); 113 | 114 | const result = await swift_package_listLogic(undefined, { 115 | processMap: mockProcessMap, 116 | arrayFrom: mockArrayFrom, 117 | dateNow: mockDateNow, 118 | }); 119 | 120 | expect(result).toEqual({ 121 | content: [ 122 | { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, 123 | { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, 124 | ], 125 | }); 126 | }); 127 | 128 | it('should handle args with extra properties', async () => { 129 | // Create empty mock process map 130 | const mockProcessMap = new Map(); 131 | 132 | // Use pure dependency injection with stub functions 133 | const mockArrayFrom = () => []; 134 | const mockDateNow = () => Date.now(); 135 | 136 | const result = await swift_package_listLogic( 137 | { 138 | extraProperty: 'value', 139 | anotherProperty: 123, 140 | }, 141 | { 142 | processMap: mockProcessMap, 143 | arrayFrom: mockArrayFrom, 144 | dateNow: mockDateNow, 145 | }, 146 | ); 147 | 148 | expect(result).toEqual({ 149 | content: [ 150 | { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, 151 | { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, 152 | ], 153 | }); 154 | }); 155 | 156 | it('should return single process when one process is running', async () => { 157 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 158 | const mockProcess = { 159 | executableName: 'MyApp', 160 | packagePath: '/test/package', 161 | startedAt: startedAt, 162 | }; 163 | 164 | // Create mock process map with one process 165 | const mockProcessMap = new Map([[12345, mockProcess]]); 166 | 167 | // Use pure dependency injection with stub functions 168 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 169 | const mockDateNow = () => startedAt.getTime() + 5000; // 5 seconds after start 170 | 171 | const result = await swift_package_listLogic( 172 | {}, 173 | { 174 | processMap: mockProcessMap, 175 | arrayFrom: mockArrayFrom, 176 | dateNow: mockDateNow, 177 | }, 178 | ); 179 | 180 | expect(result).toEqual({ 181 | content: [ 182 | { type: 'text', text: '📋 Active Swift Package processes (1):' }, 183 | { type: 'text', text: ' • PID 12345: MyApp (/test/package) - running 5s' }, 184 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 185 | ], 186 | }); 187 | }); 188 | 189 | it('should return multiple processes when several are running', async () => { 190 | const startedAt1 = new Date('2023-01-01T10:00:00.000Z'); 191 | const startedAt2 = new Date('2023-01-01T10:00:07.000Z'); 192 | 193 | const mockProcess1 = { 194 | executableName: 'MyApp', 195 | packagePath: '/test/package1', 196 | startedAt: startedAt1, 197 | }; 198 | 199 | const mockProcess2 = { 200 | executableName: undefined, // Test default executable name 201 | packagePath: '/test/package2', 202 | startedAt: startedAt2, 203 | }; 204 | 205 | // Create mock process map with multiple processes 206 | const mockProcessMap = new Map([ 207 | [12345, mockProcess1], 208 | [12346, mockProcess2], 209 | ]); 210 | 211 | // Use pure dependency injection with stub functions 212 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 213 | const mockDateNow = () => startedAt1.getTime() + 10000; // 10 seconds after first start 214 | 215 | const result = await swift_package_listLogic( 216 | {}, 217 | { 218 | processMap: mockProcessMap, 219 | arrayFrom: mockArrayFrom, 220 | dateNow: mockDateNow, 221 | }, 222 | ); 223 | 224 | expect(result).toEqual({ 225 | content: [ 226 | { type: 'text', text: '📋 Active Swift Package processes (2):' }, 227 | { type: 'text', text: ' • PID 12345: MyApp (/test/package1) - running 10s' }, 228 | { type: 'text', text: ' • PID 12346: default (/test/package2) - running 3s' }, 229 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 230 | ], 231 | }); 232 | }); 233 | 234 | it('should handle process with null executableName', async () => { 235 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 236 | const mockProcess = { 237 | executableName: null, // Test null executable name 238 | packagePath: '/test/package', 239 | startedAt: startedAt, 240 | }; 241 | 242 | // Create mock process map with one process 243 | const mockProcessMap = new Map([[12345, mockProcess]]); 244 | 245 | // Use pure dependency injection with stub functions 246 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 247 | const mockDateNow = () => startedAt.getTime() + 1000; // 1 second after start 248 | 249 | const result = await swift_package_listLogic( 250 | {}, 251 | { 252 | processMap: mockProcessMap, 253 | arrayFrom: mockArrayFrom, 254 | dateNow: mockDateNow, 255 | }, 256 | ); 257 | 258 | expect(result).toEqual({ 259 | content: [ 260 | { type: 'text', text: '📋 Active Swift Package processes (1):' }, 261 | { type: 'text', text: ' • PID 12345: default (/test/package) - running 1s' }, 262 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 263 | ], 264 | }); 265 | }); 266 | 267 | it('should handle process with empty string executableName', async () => { 268 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 269 | const mockProcess = { 270 | executableName: '', // Test empty string executable name 271 | packagePath: '/test/package', 272 | startedAt: startedAt, 273 | }; 274 | 275 | // Create mock process map with one process 276 | const mockProcessMap = new Map([[12345, mockProcess]]); 277 | 278 | // Use pure dependency injection with stub functions 279 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 280 | const mockDateNow = () => startedAt.getTime() + 2000; // 2 seconds after start 281 | 282 | const result = await swift_package_listLogic( 283 | {}, 284 | { 285 | processMap: mockProcessMap, 286 | arrayFrom: mockArrayFrom, 287 | dateNow: mockDateNow, 288 | }, 289 | ); 290 | 291 | expect(result).toEqual({ 292 | content: [ 293 | { type: 'text', text: '📋 Active Swift Package processes (1):' }, 294 | { type: 'text', text: ' • PID 12345: default (/test/package) - running 2s' }, 295 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 296 | ], 297 | }); 298 | }); 299 | 300 | it('should handle very recent process (less than 1 second)', async () => { 301 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 302 | const mockProcess = { 303 | executableName: 'FastApp', 304 | packagePath: '/test/package', 305 | startedAt: startedAt, 306 | }; 307 | 308 | // Create mock process map with one process 309 | const mockProcessMap = new Map([[12345, mockProcess]]); 310 | 311 | // Use pure dependency injection with stub functions 312 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 313 | const mockDateNow = () => startedAt.getTime() + 500; // 500ms after start 314 | 315 | const result = await swift_package_listLogic( 316 | {}, 317 | { 318 | processMap: mockProcessMap, 319 | arrayFrom: mockArrayFrom, 320 | dateNow: mockDateNow, 321 | }, 322 | ); 323 | 324 | expect(result).toEqual({ 325 | content: [ 326 | { type: 'text', text: '📋 Active Swift Package processes (1):' }, 327 | { type: 'text', text: ' • PID 12345: FastApp (/test/package) - running 1s' }, 328 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 329 | ], 330 | }); 331 | }); 332 | 333 | it('should handle process running for exactly 0 milliseconds', async () => { 334 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 335 | const mockProcess = { 336 | executableName: 'InstantApp', 337 | packagePath: '/test/package', 338 | startedAt: startedAt, 339 | }; 340 | 341 | // Create mock process map with one process 342 | const mockProcessMap = new Map([[12345, mockProcess]]); 343 | 344 | // Use pure dependency injection with stub functions 345 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 346 | const mockDateNow = () => startedAt.getTime(); // Same time as start 347 | 348 | const result = await swift_package_listLogic( 349 | {}, 350 | { 351 | processMap: mockProcessMap, 352 | arrayFrom: mockArrayFrom, 353 | dateNow: mockDateNow, 354 | }, 355 | ); 356 | 357 | expect(result).toEqual({ 358 | content: [ 359 | { type: 'text', text: '📋 Active Swift Package processes (1):' }, 360 | { type: 'text', text: ' • PID 12345: InstantApp (/test/package) - running 1s' }, 361 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 362 | ], 363 | }); 364 | }); 365 | 366 | it('should handle process running for a long time', async () => { 367 | const startedAt = new Date('2023-01-01T10:00:00.000Z'); 368 | const mockProcess = { 369 | executableName: 'LongRunningApp', 370 | packagePath: '/test/package', 371 | startedAt: startedAt, 372 | }; 373 | 374 | // Create mock process map with one process 375 | const mockProcessMap = new Map([[12345, mockProcess]]); 376 | 377 | // Use pure dependency injection with stub functions 378 | const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); 379 | const mockDateNow = () => startedAt.getTime() + 7200000; // 2 hours later 380 | 381 | const result = await swift_package_listLogic( 382 | {}, 383 | { 384 | processMap: mockProcessMap, 385 | arrayFrom: mockArrayFrom, 386 | dateNow: mockDateNow, 387 | }, 388 | ); 389 | 390 | expect(result).toEqual({ 391 | content: [ 392 | { type: 'text', text: '📋 Active Swift Package processes (1):' }, 393 | { type: 'text', text: ' • PID 12345: LongRunningApp (/test/package) - running 7200s' }, 394 | { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, 395 | ], 396 | }); 397 | }); 398 | }); 399 | }); 400 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-scaffolding/scaffold_macos_project.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Utilities Plugin: Scaffold macOS Project 3 | * 4 | * Scaffold a new macOS project from templates. 5 | */ 6 | 7 | import { z } from 'zod'; 8 | import { join, dirname, basename } from 'path'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import { ValidationError } from '../../../utils/responses/index.ts'; 11 | import { TemplateManager } from '../../../utils/template/index.ts'; 12 | import { ToolResponse } from '../../../types/common.ts'; 13 | import { 14 | CommandExecutor, 15 | getDefaultCommandExecutor, 16 | getDefaultFileSystemExecutor, 17 | } from '../../../utils/command.ts'; 18 | import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; 19 | 20 | // Common base schema for both iOS and macOS 21 | const BaseScaffoldSchema = z.object({ 22 | projectName: z.string().min(1).describe('Name of the new project'), 23 | outputPath: z.string().describe('Path where the project should be created'), 24 | bundleIdentifier: z 25 | .string() 26 | .optional() 27 | .describe( 28 | 'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname', 29 | ), 30 | displayName: z 31 | .string() 32 | .optional() 33 | .describe( 34 | 'App display name (shown on home screen/dock). If not provided, will use projectName', 35 | ), 36 | marketingVersion: z 37 | .string() 38 | .optional() 39 | .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'), 40 | currentProjectVersion: z 41 | .string() 42 | .optional() 43 | .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'), 44 | customizeNames: z 45 | .boolean() 46 | .default(true) 47 | .describe('Whether to customize project names and identifiers. Default is true.'), 48 | }); 49 | 50 | // macOS-specific schema 51 | const ScaffoldmacOSProjectSchema = BaseScaffoldSchema.extend({ 52 | deploymentTarget: z 53 | .string() 54 | .optional() 55 | .describe('macOS deployment target (e.g., 15.4, 14.0). If not provided, will use 15.4'), 56 | }); 57 | 58 | // Use z.infer for type safety 59 | type ScaffoldMacOSProjectParams = z.infer<typeof ScaffoldmacOSProjectSchema>; 60 | 61 | /** 62 | * Update Package.swift file with deployment target 63 | */ 64 | function updatePackageSwiftFile( 65 | content: string, 66 | params: ScaffoldMacOSProjectParams & { platform: string }, 67 | ): string { 68 | let result = content; 69 | 70 | // Update ALL target name references in Package.swift 71 | const featureName = `${params.projectName}Feature`; 72 | const testName = `${params.projectName}FeatureTests`; 73 | 74 | // Replace ALL occurrences of MyProjectFeatureTests first (more specific) 75 | result = result.replace(/MyProjectFeatureTests/g, testName); 76 | // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after) 77 | result = result.replace(/MyProjectFeature/g, featureName); 78 | 79 | // Update deployment targets based on platform 80 | if (params.platform === 'macOS') { 81 | if (params.deploymentTarget) { 82 | // Extract major version (e.g., "14.0" -> "14") 83 | const majorVersion = params.deploymentTarget.split('.')[0]; 84 | result = result.replace(/\.macOS\(\.v\d+\)/, `.macOS(.v${majorVersion})`); 85 | } 86 | } 87 | 88 | return result; 89 | } 90 | 91 | /** 92 | * Update XCConfig file with scaffold parameters 93 | */ 94 | function updateXCConfigFile( 95 | content: string, 96 | params: ScaffoldMacOSProjectParams & { platform: string }, 97 | ): string { 98 | let result = content; 99 | 100 | // Update project identity settings 101 | result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${params.projectName}`); 102 | result = result.replace( 103 | /PRODUCT_DISPLAY_NAME = .+/g, 104 | `PRODUCT_DISPLAY_NAME = ${params.displayName ?? params.projectName}`, 105 | ); 106 | result = result.replace( 107 | /PRODUCT_BUNDLE_IDENTIFIER = .+/g, 108 | `PRODUCT_BUNDLE_IDENTIFIER = ${params.bundleIdentifier ?? `com.example.${params.projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`, 109 | ); 110 | result = result.replace( 111 | /MARKETING_VERSION = .+/g, 112 | `MARKETING_VERSION = ${params.marketingVersion ?? '1.0'}`, 113 | ); 114 | result = result.replace( 115 | /CURRENT_PROJECT_VERSION = .+/g, 116 | `CURRENT_PROJECT_VERSION = ${params.currentProjectVersion ?? '1'}`, 117 | ); 118 | 119 | // Platform-specific updates 120 | if (params.platform === 'macOS') { 121 | // macOS deployment target 122 | if (params.deploymentTarget) { 123 | result = result.replace( 124 | /MACOSX_DEPLOYMENT_TARGET = .+/g, 125 | `MACOSX_DEPLOYMENT_TARGET = ${params.deploymentTarget}`, 126 | ); 127 | } 128 | 129 | // Update entitlements path for macOS 130 | result = result.replace( 131 | /CODE_SIGN_ENTITLEMENTS = .+/g, 132 | `CODE_SIGN_ENTITLEMENTS = Config/${params.projectName}.entitlements`, 133 | ); 134 | } 135 | 136 | // Update test bundle identifier and target name 137 | result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${params.projectName}`); 138 | 139 | // Update comments that reference MyProject in entitlements paths 140 | result = result.replace( 141 | /Config\/MyProject\.entitlements/g, 142 | `Config/${params.projectName}.entitlements`, 143 | ); 144 | 145 | return result; 146 | } 147 | 148 | /** 149 | * Replace placeholders in a string (for non-XCConfig files) 150 | */ 151 | function replacePlaceholders( 152 | content: string, 153 | projectName: string, 154 | bundleIdentifier: string, 155 | ): string { 156 | let result = content; 157 | 158 | // Replace project name 159 | result = result.replace(/MyProject/g, projectName); 160 | 161 | // Replace bundle identifier - check for both patterns used in templates 162 | if (bundleIdentifier) { 163 | result = result.replace(/com\.example\.MyProject/g, bundleIdentifier); 164 | result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier); 165 | } 166 | 167 | return result; 168 | } 169 | 170 | /** 171 | * Process a single file, replacing placeholders if it's a text file 172 | */ 173 | async function processFile( 174 | sourcePath: string, 175 | destPath: string, 176 | params: ScaffoldMacOSProjectParams & { platform: string }, 177 | fileSystemExecutor: FileSystemExecutor, 178 | ): Promise<void> { 179 | // Determine the destination file path 180 | let finalDestPath = destPath; 181 | if (params.customizeNames) { 182 | // Replace MyProject in file/directory names 183 | const fileName = basename(destPath); 184 | const dirName = dirname(destPath); 185 | const newFileName = fileName.replace(/MyProject/g, params.projectName); 186 | finalDestPath = join(dirName, newFileName); 187 | } 188 | 189 | // Text file extensions that should be processed 190 | const textExtensions = [ 191 | '.swift', 192 | '.h', 193 | '.m', 194 | '.mm', 195 | '.cpp', 196 | '.c', 197 | '.pbxproj', 198 | '.plist', 199 | '.xcscheme', 200 | '.xctestplan', 201 | '.xcworkspacedata', 202 | '.xcconfig', 203 | '.json', 204 | '.xml', 205 | '.entitlements', 206 | '.storyboard', 207 | '.xib', 208 | '.md', 209 | ]; 210 | 211 | const ext = sourcePath.toLowerCase(); 212 | const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt)); 213 | const isXCConfig = sourcePath.endsWith('.xcconfig'); 214 | const isPackageSwift = sourcePath.endsWith('Package.swift'); 215 | 216 | if (isTextFile && params.customizeNames) { 217 | // Read the file content 218 | const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8'); 219 | 220 | let processedContent; 221 | 222 | if (isXCConfig) { 223 | // Use special XCConfig processing 224 | processedContent = updateXCConfigFile(content, params); 225 | } else if (isPackageSwift) { 226 | // Use special Package.swift processing 227 | processedContent = updatePackageSwiftFile(content, params); 228 | } else { 229 | // Use standard placeholder replacement 230 | const bundleIdentifier = 231 | params.bundleIdentifier ?? 232 | `com.example.${params.projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; 233 | processedContent = replacePlaceholders(content, params.projectName, bundleIdentifier); 234 | } 235 | 236 | await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); 237 | await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8'); 238 | } else { 239 | // Copy binary files as-is 240 | await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); 241 | await fileSystemExecutor.cp(sourcePath, finalDestPath, { recursive: true }); 242 | } 243 | } 244 | 245 | /** 246 | * Recursively process a directory 247 | */ 248 | async function processDirectory( 249 | sourceDir: string, 250 | destDir: string, 251 | params: ScaffoldMacOSProjectParams & { platform: string }, 252 | fileSystemExecutor: FileSystemExecutor, 253 | ): Promise<void> { 254 | const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true }); 255 | 256 | for (const entry of entries) { 257 | const dirent = entry as { isDirectory(): boolean; isFile(): boolean; name: string }; 258 | const sourcePath = join(sourceDir, dirent.name); 259 | let destName = dirent.name; 260 | 261 | if (params.customizeNames) { 262 | // Replace MyProject in directory names 263 | destName = destName.replace(/MyProject/g, params.projectName); 264 | } 265 | 266 | const destPath = join(destDir, destName); 267 | 268 | if (dirent.isDirectory()) { 269 | // Skip certain directories 270 | if (dirent.name === '.git' || dirent.name === 'xcuserdata') { 271 | continue; 272 | } 273 | await fileSystemExecutor.mkdir(destPath, { recursive: true }); 274 | await processDirectory(sourcePath, destPath, params, fileSystemExecutor); 275 | } else if (dirent.isFile()) { 276 | // Skip certain files 277 | if (dirent.name === '.DS_Store' || dirent.name.endsWith('.xcuserstate')) { 278 | continue; 279 | } 280 | await processFile(sourcePath, destPath, params, fileSystemExecutor); 281 | } 282 | } 283 | } 284 | 285 | /** 286 | * Scaffold a new iOS or macOS project 287 | */ 288 | async function scaffoldProject( 289 | params: ScaffoldMacOSProjectParams & { platform: string }, 290 | commandExecutor: CommandExecutor, 291 | fileSystemExecutor: FileSystemExecutor, 292 | ): Promise<string> { 293 | const projectName = params.projectName; 294 | const outputPath = params.outputPath; 295 | const platform = params.platform; 296 | const customizeNames = params.customizeNames ?? true; 297 | 298 | log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`); 299 | 300 | // Validate project name 301 | if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) { 302 | throw new ValidationError( 303 | 'Project name must start with a letter and contain only letters, numbers, and underscores', 304 | ); 305 | } 306 | 307 | // Get template path from TemplateManager 308 | let templatePath; 309 | try { 310 | templatePath = await TemplateManager.getTemplatePath( 311 | platform as 'macOS' | 'iOS', 312 | commandExecutor, 313 | fileSystemExecutor, 314 | ); 315 | } catch (error) { 316 | throw new ValidationError( 317 | `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`, 318 | ); 319 | } 320 | 321 | // Use outputPath directly as the destination 322 | const projectPath = outputPath; 323 | 324 | // Check if the output directory already has Xcode project files 325 | const xcworkspaceExists = fileSystemExecutor.existsSync( 326 | join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`), 327 | ); 328 | const xcodeprojExists = fileSystemExecutor.existsSync( 329 | join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`), 330 | ); 331 | 332 | if (xcworkspaceExists || xcodeprojExists) { 333 | throw new ValidationError(`Xcode project files already exist in ${projectPath}`); 334 | } 335 | 336 | try { 337 | // Process the template directly into the output path 338 | await processDirectory(templatePath, projectPath, params, fileSystemExecutor); 339 | 340 | return projectPath; 341 | } finally { 342 | // Clean up downloaded template if needed 343 | await TemplateManager.cleanup(templatePath, fileSystemExecutor); 344 | } 345 | } 346 | 347 | /** 348 | * Business logic for scaffolding macOS projects 349 | * Extracted for testability and Separation of Concerns 350 | */ 351 | export async function scaffold_macos_projectLogic( 352 | params: ScaffoldMacOSProjectParams, 353 | commandExecutor: CommandExecutor, 354 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), 355 | ): Promise<ToolResponse> { 356 | try { 357 | const projectParams = { ...params, platform: 'macOS' as const }; 358 | const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); 359 | 360 | const response = { 361 | success: true, 362 | projectPath, 363 | platform: 'macOS', 364 | message: `Successfully scaffolded macOS project "${params.projectName}" in ${projectPath}`, 365 | nextSteps: [ 366 | `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, 367 | `Build for macOS: build_macos({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}" })`, 368 | `Build & Run on macOS: build_run_macos({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}" })`, 369 | ], 370 | }; 371 | 372 | return { 373 | content: [ 374 | { 375 | type: 'text', 376 | text: JSON.stringify(response, null, 2), 377 | }, 378 | ], 379 | }; 380 | } catch (error) { 381 | log( 382 | 'error', 383 | `Failed to scaffold macOS project: ${error instanceof Error ? error.message : String(error)}`, 384 | ); 385 | 386 | return { 387 | content: [ 388 | { 389 | type: 'text', 390 | text: JSON.stringify( 391 | { 392 | success: false, 393 | error: error instanceof Error ? error.message : 'Unknown error occurred', 394 | }, 395 | null, 396 | 2, 397 | ), 398 | }, 399 | ], 400 | isError: true, 401 | }; 402 | } 403 | } 404 | 405 | export default { 406 | name: 'scaffold_macos_project', 407 | description: 408 | 'Scaffold a new macOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper macOS configuration.', 409 | schema: ScaffoldmacOSProjectSchema.shape, 410 | async handler(args: Record<string, unknown>): Promise<ToolResponse> { 411 | // Validate the arguments against the schema before processing 412 | const validatedArgs = ScaffoldmacOSProjectSchema.parse(args); 413 | return scaffold_macos_projectLogic( 414 | validatedArgs, 415 | getDefaultCommandExecutor(), 416 | getDefaultFileSystemExecutor(), 417 | ); 418 | }, 419 | }; 420 | ``` -------------------------------------------------------------------------------- /docs/NODEJS_2025.md: -------------------------------------------------------------------------------- ```markdown 1 | # Modern Node.js Development Guide 2 | 3 | This guide provides actionable instructions for AI agents to apply modern Node.js patterns when the scenarios are applicable. Use these patterns when creating or modifying Node.js code that fits these use cases. 4 | 5 | ## Core Principles 6 | 7 | **WHEN APPLICABLE** apply these modern patterns: 8 | 9 | 1. **Use ES Modules** with `node:` prefix for built-in modules 10 | 2. **Leverage built-in APIs** over external dependencies when the functionality matches 11 | 3. **Use top-level await** instead of IIFE patterns when initialization is needed 12 | 4. **Implement structured error handling** with proper context when handling application errors 13 | 5. **Use built-in testing** over external test frameworks when adding tests 14 | 6. **Apply modern async patterns** for better performance when dealing with async operations 15 | 16 | ## 1. Module System Patterns 17 | 18 | ### WHEN USING MODULES: ES Modules with node: Prefix 19 | 20 | **✅ DO THIS:** 21 | ```javascript 22 | // Use ES modules with node: prefix for built-ins 23 | import { readFile } from 'node:fs/promises'; 24 | import { createServer } from 'node:http'; 25 | import { EventEmitter } from 'node:events'; 26 | 27 | export function myFunction() { 28 | return 'modern code'; 29 | } 30 | ``` 31 | 32 | **❌ AVOID:** 33 | ```javascript 34 | // Don't use CommonJS or bare imports for built-ins 35 | const fs = require('fs'); 36 | const { readFile } = require('fs/promises'); 37 | import { readFile } from 'fs/promises'; // Missing node: prefix 38 | ``` 39 | 40 | ### WHEN INITIALIZING: Top-Level Await 41 | 42 | **✅ DO THIS:** 43 | ```javascript 44 | // Use top-level await for initialization 45 | import { readFile } from 'node:fs/promises'; 46 | 47 | const config = JSON.parse(await readFile('config.json', 'utf8')); 48 | const server = createServer(/* ... */); 49 | 50 | console.log('App started with config:', config.appName); 51 | ``` 52 | 53 | **❌ AVOID:** 54 | ```javascript 55 | // Don't wrap in IIFE 56 | (async () => { 57 | const config = JSON.parse(await readFile('config.json', 'utf8')); 58 | // ... 59 | })(); 60 | ``` 61 | 62 | ### WHEN USING ES MODULES: Package.json Settings 63 | 64 | **✅ ENSURE package.json includes:** 65 | ```json 66 | { 67 | "type": "module", 68 | "engines": { 69 | "node": ">=20.0.0" 70 | } 71 | } 72 | ``` 73 | 74 | ## 2. HTTP and Network Patterns 75 | 76 | ### WHEN MAKING HTTP REQUESTS: Use Built-in fetch 77 | 78 | **✅ DO THIS:** 79 | ```javascript 80 | // Use built-in fetch with AbortSignal.timeout 81 | async function fetchData(url) { 82 | try { 83 | const response = await fetch(url, { 84 | signal: AbortSignal.timeout(5000) 85 | }); 86 | 87 | if (!response.ok) { 88 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 89 | } 90 | 91 | return await response.json(); 92 | } catch (error) { 93 | if (error.name === 'TimeoutError') { 94 | throw new Error('Request timed out'); 95 | } 96 | throw error; 97 | } 98 | } 99 | ``` 100 | 101 | **❌ AVOID:** 102 | ```javascript 103 | // Don't add axios, node-fetch, or similar dependencies 104 | const axios = require('axios'); 105 | const response = await axios.get(url); 106 | ``` 107 | 108 | ### WHEN NEEDING CANCELLATION: AbortController Pattern 109 | 110 | **✅ DO THIS:** 111 | ```javascript 112 | // Implement proper cancellation 113 | const controller = new AbortController(); 114 | setTimeout(() => controller.abort(), 10000); 115 | 116 | try { 117 | const data = await fetch(url, { signal: controller.signal }); 118 | console.log('Data received:', data); 119 | } catch (error) { 120 | if (error.name === 'AbortError') { 121 | console.log('Request was cancelled'); 122 | } else { 123 | console.error('Unexpected error:', error); 124 | } 125 | } 126 | ``` 127 | 128 | ## 3. Testing Patterns 129 | 130 | ### WHEN ADDING TESTS: Use Built-in Test Runner 131 | 132 | **✅ DO THIS:** 133 | ```javascript 134 | // Use node:test instead of external frameworks 135 | import { test, describe } from 'node:test'; 136 | import assert from 'node:assert'; 137 | 138 | describe('My Module', () => { 139 | test('should work correctly', () => { 140 | assert.strictEqual(myFunction(), 'expected'); 141 | }); 142 | 143 | test('should handle async operations', async () => { 144 | const result = await myAsyncFunction(); 145 | assert.strictEqual(result, 'expected'); 146 | }); 147 | 148 | test('should throw on invalid input', () => { 149 | assert.throws(() => myFunction('invalid'), /Expected error/); 150 | }); 151 | }); 152 | ``` 153 | 154 | **✅ RECOMMENDED package.json scripts:** 155 | ```json 156 | { 157 | "scripts": { 158 | "test": "node --test", 159 | "test:watch": "node --test --watch", 160 | "test:coverage": "node --test --experimental-test-coverage" 161 | } 162 | } 163 | ``` 164 | 165 | **❌ AVOID:** 166 | ```javascript 167 | // Don't add Jest, Mocha, or other test frameworks unless specifically required 168 | ``` 169 | 170 | ## 4. Async Pattern Recommendations 171 | 172 | ### WHEN HANDLING MULTIPLE ASYNC OPERATIONS: Parallel Execution with Promise.all 173 | 174 | **✅ DO THIS:** 175 | ```javascript 176 | // Execute independent operations in parallel 177 | async function processData() { 178 | try { 179 | const [config, userData] = await Promise.all([ 180 | readFile('config.json', 'utf8'), 181 | fetch('/api/user').then(r => r.json()) 182 | ]); 183 | 184 | const processed = processUserData(userData, JSON.parse(config)); 185 | await writeFile('output.json', JSON.stringify(processed, null, 2)); 186 | 187 | return processed; 188 | } catch (error) { 189 | console.error('Processing failed:', { 190 | error: error.message, 191 | stack: error.stack, 192 | timestamp: new Date().toISOString() 193 | }); 194 | throw error; 195 | } 196 | } 197 | ``` 198 | 199 | ### WHEN PROCESSING EVENT STREAMS: AsyncIterators Pattern 200 | 201 | **✅ DO THIS:** 202 | ```javascript 203 | // Use async iterators for event processing 204 | import { EventEmitter } from 'node:events'; 205 | 206 | class DataProcessor extends EventEmitter { 207 | async *processStream() { 208 | for (let i = 0; i < 10; i++) { 209 | this.emit('data', `chunk-${i}`); 210 | yield `processed-${i}`; 211 | await new Promise(resolve => setTimeout(resolve, 100)); 212 | } 213 | this.emit('end'); 214 | } 215 | } 216 | 217 | // Consume with for-await-of 218 | const processor = new DataProcessor(); 219 | for await (const result of processor.processStream()) { 220 | console.log('Processed:', result); 221 | } 222 | ``` 223 | 224 | ## 5. Stream Processing Patterns 225 | 226 | ### WHEN PROCESSING STREAMS: Use pipeline with Promises 227 | 228 | **✅ DO THIS:** 229 | ```javascript 230 | import { pipeline } from 'node:stream/promises'; 231 | import { createReadStream, createWriteStream } from 'node:fs'; 232 | import { Transform } from 'node:stream'; 233 | 234 | // Always use pipeline for stream processing 235 | async function processFile(inputFile, outputFile) { 236 | try { 237 | await pipeline( 238 | createReadStream(inputFile), 239 | new Transform({ 240 | transform(chunk, encoding, callback) { 241 | this.push(chunk.toString().toUpperCase()); 242 | callback(); 243 | } 244 | }), 245 | createWriteStream(outputFile) 246 | ); 247 | console.log('File processed successfully'); 248 | } catch (error) { 249 | console.error('Pipeline failed:', error); 250 | throw error; 251 | } 252 | } 253 | ``` 254 | 255 | ### WHEN NEEDING BROWSER COMPATIBILITY: Web Streams 256 | 257 | **✅ DO THIS:** 258 | ```javascript 259 | import { Readable } from 'node:stream'; 260 | 261 | // Convert between Web Streams and Node streams when needed 262 | const webReadable = new ReadableStream({ 263 | start(controller) { 264 | controller.enqueue('Hello '); 265 | controller.enqueue('World!'); 266 | controller.close(); 267 | } 268 | }); 269 | 270 | const nodeStream = Readable.fromWeb(webReadable); 271 | ``` 272 | 273 | ## 6. CPU-Intensive Task Patterns 274 | 275 | ### WHEN DOING HEAVY COMPUTATION: Worker Threads 276 | 277 | **✅ DO THIS:** 278 | ```javascript 279 | // worker.js - Separate file for CPU-intensive tasks 280 | import { parentPort, workerData } from 'node:worker_threads'; 281 | 282 | function heavyComputation(data) { 283 | // CPU-intensive work here 284 | return processedData; 285 | } 286 | 287 | const result = heavyComputation(workerData); 288 | parentPort.postMessage(result); 289 | ``` 290 | 291 | ```javascript 292 | // main.js - Delegate to worker 293 | import { Worker } from 'node:worker_threads'; 294 | import { fileURLToPath } from 'node:url'; 295 | 296 | async function processHeavyTask(data) { 297 | return new Promise((resolve, reject) => { 298 | const worker = new Worker( 299 | fileURLToPath(new URL('./worker.js', import.meta.url)), 300 | { workerData: data } 301 | ); 302 | 303 | worker.on('message', resolve); 304 | worker.on('error', reject); 305 | worker.on('exit', (code) => { 306 | if (code !== 0) { 307 | reject(new Error(`Worker stopped with exit code ${code}`)); 308 | } 309 | }); 310 | }); 311 | } 312 | ``` 313 | 314 | ## 7. Development Configuration Patterns 315 | 316 | ### FOR NEW PROJECTS: Modern package.json 317 | 318 | **✅ RECOMMENDED for new projects:** 319 | ```json 320 | { 321 | "name": "modern-node-app", 322 | "type": "module", 323 | "engines": { 324 | "node": ">=20.0.0" 325 | }, 326 | "scripts": { 327 | "dev": "node --watch --env-file=.env app.js", 328 | "test": "node --test --watch", 329 | "start": "node app.js" 330 | } 331 | } 332 | ``` 333 | 334 | ### WHEN LOADING ENVIRONMENT VARIABLES: Built-in Support 335 | 336 | **✅ DO THIS:** 337 | ```javascript 338 | // Use --env-file flag instead of dotenv package 339 | // Environment variables are automatically available 340 | console.log('Database URL:', process.env.DATABASE_URL); 341 | console.log('API Key loaded:', process.env.API_KEY ? 'Yes' : 'No'); 342 | ``` 343 | 344 | **❌ AVOID:** 345 | ```javascript 346 | // Don't add dotenv dependency 347 | require('dotenv').config(); 348 | ``` 349 | 350 | ## 8. Error Handling Patterns 351 | 352 | ### WHEN CREATING CUSTOM ERRORS: Structured Error Classes 353 | 354 | **✅ DO THIS:** 355 | ```javascript 356 | class AppError extends Error { 357 | constructor(message, code, statusCode = 500, context = {}) { 358 | super(message); 359 | this.name = 'AppError'; 360 | this.code = code; 361 | this.statusCode = statusCode; 362 | this.context = context; 363 | this.timestamp = new Date().toISOString(); 364 | } 365 | 366 | toJSON() { 367 | return { 368 | name: this.name, 369 | message: this.message, 370 | code: this.code, 371 | statusCode: this.statusCode, 372 | context: this.context, 373 | timestamp: this.timestamp, 374 | stack: this.stack 375 | }; 376 | } 377 | } 378 | 379 | // Usage with rich context 380 | throw new AppError( 381 | 'Database connection failed', 382 | 'DB_CONNECTION_ERROR', 383 | 503, 384 | { host: 'localhost', port: 5432, retryAttempt: 3 } 385 | ); 386 | ``` 387 | 388 | ## 9. Performance Monitoring Patterns 389 | 390 | ### WHEN MONITORING PERFORMANCE: Built-in Performance APIs 391 | 392 | **✅ DO THIS:** 393 | ```javascript 394 | import { PerformanceObserver, performance } from 'node:perf_hooks'; 395 | 396 | // Set up performance monitoring 397 | const obs = new PerformanceObserver((list) => { 398 | for (const entry of list.getEntries()) { 399 | if (entry.duration > 100) { 400 | console.log(`Slow operation: ${entry.name} took ${entry.duration}ms`); 401 | } 402 | } 403 | }); 404 | obs.observe({ entryTypes: ['function', 'http', 'dns'] }); 405 | 406 | // Instrument operations 407 | async function processLargeDataset(data) { 408 | performance.mark('processing-start'); 409 | 410 | const result = await heavyProcessing(data); 411 | 412 | performance.mark('processing-end'); 413 | performance.measure('data-processing', 'processing-start', 'processing-end'); 414 | 415 | return result; 416 | } 417 | ``` 418 | 419 | ## 10. Module Organization Patterns 420 | 421 | ### WHEN ORGANIZING INTERNAL MODULES: Import Maps 422 | 423 | **✅ DO THIS in package.json:** 424 | ```json 425 | { 426 | "imports": { 427 | "#config": "./src/config/index.js", 428 | "#utils/*": "./src/utils/*.js", 429 | "#db": "./src/database/connection.js" 430 | } 431 | } 432 | ``` 433 | 434 | **✅ Use in code:** 435 | ```javascript 436 | // Clean internal imports 437 | import config from '#config'; 438 | import { logger, validator } from '#utils/common'; 439 | import db from '#db'; 440 | ``` 441 | 442 | ### WHEN LOADING CONDITIONALLY: Dynamic Imports 443 | 444 | **✅ DO THIS:** 445 | ```javascript 446 | // Load features based on environment 447 | async function loadDatabaseAdapter() { 448 | const dbType = process.env.DATABASE_TYPE || 'sqlite'; 449 | 450 | try { 451 | const adapter = await import(`#db/adapters/${dbType}`); 452 | return adapter.default; 453 | } catch (error) { 454 | console.warn(`Database adapter ${dbType} not available, falling back to sqlite`); 455 | const fallback = await import('#db/adapters/sqlite'); 456 | return fallback.default; 457 | } 458 | } 459 | ``` 460 | 461 | ## 11. Diagnostic Patterns 462 | 463 | ### WHEN ADDING OBSERVABILITY: Diagnostic Channels 464 | 465 | **✅ DO THIS:** 466 | ```javascript 467 | import diagnostics_channel from 'node:diagnostics_channel'; 468 | 469 | // Create diagnostic channels 470 | const dbChannel = diagnostics_channel.channel('app:database'); 471 | 472 | // Subscribe to events 473 | dbChannel.subscribe((message) => { 474 | console.log('Database operation:', { 475 | operation: message.operation, 476 | duration: message.duration, 477 | query: message.query 478 | }); 479 | }); 480 | 481 | // Publish diagnostic information 482 | async function queryDatabase(sql, params) { 483 | const start = performance.now(); 484 | 485 | try { 486 | const result = await db.query(sql, params); 487 | 488 | dbChannel.publish({ 489 | operation: 'query', 490 | sql, 491 | params, 492 | duration: performance.now() - start, 493 | success: true 494 | }); 495 | 496 | return result; 497 | } catch (error) { 498 | dbChannel.publish({ 499 | operation: 'query', 500 | sql, 501 | params, 502 | duration: performance.now() - start, 503 | success: false, 504 | error: error.message 505 | }); 506 | throw error; 507 | } 508 | } 509 | ``` 510 | 511 | ## Modernization Checklist 512 | 513 | When working with Node.js code, consider applying these patterns where applicable: 514 | 515 | - [ ] `"type": "module"` in package.json 516 | - [ ] `"engines": {"node": ">=20.0.0"}` specified 517 | - [ ] All built-in imports use `node:` prefix 518 | - [ ] Using `fetch()` instead of HTTP libraries 519 | - [ ] Using `node --test` instead of external test frameworks 520 | - [ ] Using `--watch` and `--env-file` flags 521 | - [ ] Implementing structured error handling 522 | - [ ] Using `Promise.all()` for parallel operations 523 | - [ ] Using `pipeline()` for stream processing 524 | - [ ] Implementing performance monitoring where appropriate 525 | - [ ] Using worker threads for CPU-intensive tasks 526 | - [ ] Using import maps for internal modules 527 | 528 | ## Dependencies to Remove 529 | 530 | When modernizing, remove these dependencies if present: 531 | 532 | - `axios`, `node-fetch`, `got` → Use built-in `fetch()` 533 | - `jest`, `mocha`, `ava` → Use `node:test` 534 | - `nodemon` → Use `node --watch` 535 | - `dotenv` → Use `--env-file` 536 | - `cross-env` → Use native environment handling 537 | 538 | ## Security Patterns 539 | 540 | **WHEN SECURITY IS A CONCERN** apply these practices: 541 | 542 | ```bash 543 | # Use permission model for enhanced security 544 | node --experimental-permission --allow-fs-read=./data --allow-fs-write=./logs app.js 545 | 546 | # Network restrictions 547 | node --experimental-permission --allow-net=api.example.com app.js 548 | ``` 549 | 550 | This guide provides modern Node.js patterns to apply when the specific scenarios are encountered, ensuring code follows 2025 best practices for performance, security, and maintainability without forcing unnecessary changes. ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/type_text.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for type_text plugin 3 | */ 4 | 5 | import { describe, it, expect } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { 8 | createMockExecutor, 9 | createMockFileSystemExecutor, 10 | createNoopExecutor, 11 | } from '../../../../test-utils/mock-executors.ts'; 12 | import typeTextPlugin, { type_textLogic } from '../type_text.ts'; 13 | 14 | // Mock axe helpers for dependency injection 15 | function createMockAxeHelpers( 16 | overrides: { 17 | getAxePathReturn?: string | null; 18 | getBundledAxeEnvironmentReturn?: Record<string, string>; 19 | } = {}, 20 | ) { 21 | return { 22 | getAxePath: () => { 23 | return Object.prototype.hasOwnProperty.call(overrides, 'getAxePathReturn') 24 | ? overrides.getAxePathReturn 25 | : '/usr/local/bin/axe'; 26 | }, 27 | getBundledAxeEnvironment: () => overrides.getBundledAxeEnvironmentReturn ?? {}, 28 | }; 29 | } 30 | 31 | // Mock executor that tracks rejections for testing 32 | function createRejectingExecutor(error: any) { 33 | return async () => { 34 | throw error; 35 | }; 36 | } 37 | 38 | describe('Type Text Plugin', () => { 39 | describe('Export Field Validation (Literal)', () => { 40 | it('should have correct name', () => { 41 | expect(typeTextPlugin.name).toBe('type_text'); 42 | }); 43 | 44 | it('should have correct description', () => { 45 | expect(typeTextPlugin.description).toBe( 46 | 'Type text (supports US keyboard characters). Use describe_ui to find text field, tap to focus, then type.', 47 | ); 48 | }); 49 | 50 | it('should have handler function', () => { 51 | expect(typeof typeTextPlugin.handler).toBe('function'); 52 | }); 53 | 54 | it('should validate schema fields with safeParse', () => { 55 | const schema = z.object(typeTextPlugin.schema); 56 | 57 | // Valid case 58 | expect( 59 | schema.safeParse({ 60 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 61 | text: 'Hello World', 62 | }).success, 63 | ).toBe(true); 64 | 65 | // Invalid simulatorUuid 66 | expect( 67 | schema.safeParse({ 68 | simulatorUuid: 'invalid-uuid', 69 | text: 'Hello World', 70 | }).success, 71 | ).toBe(false); 72 | 73 | // Invalid text - empty string 74 | expect( 75 | schema.safeParse({ 76 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 77 | text: '', 78 | }).success, 79 | ).toBe(false); 80 | 81 | // Invalid text - non-string 82 | expect( 83 | schema.safeParse({ 84 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 85 | text: 123, 86 | }).success, 87 | ).toBe(false); 88 | 89 | // Missing required fields 90 | expect(schema.safeParse({}).success).toBe(false); 91 | }); 92 | }); 93 | 94 | describe('Command Generation', () => { 95 | it('should generate correct axe command for basic text typing', async () => { 96 | let capturedCommand: string[] = []; 97 | const trackingExecutor = async (command: string[]) => { 98 | capturedCommand = command; 99 | return { 100 | success: true, 101 | output: 'Text typed successfully', 102 | error: undefined, 103 | process: { pid: 12345 }, 104 | }; 105 | }; 106 | 107 | const mockAxeHelpers = createMockAxeHelpers({ 108 | getAxePathReturn: '/usr/local/bin/axe', 109 | getBundledAxeEnvironmentReturn: {}, 110 | }); 111 | 112 | await type_textLogic( 113 | { 114 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 115 | text: 'Hello World', 116 | }, 117 | trackingExecutor, 118 | mockAxeHelpers, 119 | ); 120 | 121 | expect(capturedCommand).toEqual([ 122 | '/usr/local/bin/axe', 123 | 'type', 124 | 'Hello World', 125 | '--udid', 126 | '12345678-1234-1234-1234-123456789012', 127 | ]); 128 | }); 129 | 130 | it('should generate correct axe command for text with special characters', async () => { 131 | let capturedCommand: string[] = []; 132 | const trackingExecutor = async (command: string[]) => { 133 | capturedCommand = command; 134 | return { 135 | success: true, 136 | output: 'Text typed successfully', 137 | error: undefined, 138 | process: { pid: 12345 }, 139 | }; 140 | }; 141 | 142 | const mockAxeHelpers = createMockAxeHelpers({ 143 | getAxePathReturn: '/usr/local/bin/axe', 144 | getBundledAxeEnvironmentReturn: {}, 145 | }); 146 | 147 | await type_textLogic( 148 | { 149 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 150 | text: '[email protected]', 151 | }, 152 | trackingExecutor, 153 | mockAxeHelpers, 154 | ); 155 | 156 | expect(capturedCommand).toEqual([ 157 | '/usr/local/bin/axe', 158 | 'type', 159 | '[email protected]', 160 | '--udid', 161 | '12345678-1234-1234-1234-123456789012', 162 | ]); 163 | }); 164 | 165 | it('should generate correct axe command for text with numbers and symbols', async () => { 166 | let capturedCommand: string[] = []; 167 | const trackingExecutor = async (command: string[]) => { 168 | capturedCommand = command; 169 | return { 170 | success: true, 171 | output: 'Text typed successfully', 172 | error: undefined, 173 | process: { pid: 12345 }, 174 | }; 175 | }; 176 | 177 | const mockAxeHelpers = createMockAxeHelpers({ 178 | getAxePathReturn: '/usr/local/bin/axe', 179 | getBundledAxeEnvironmentReturn: {}, 180 | }); 181 | 182 | await type_textLogic( 183 | { 184 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 185 | text: 'Password123!@#', 186 | }, 187 | trackingExecutor, 188 | mockAxeHelpers, 189 | ); 190 | 191 | expect(capturedCommand).toEqual([ 192 | '/usr/local/bin/axe', 193 | 'type', 194 | 'Password123!@#', 195 | '--udid', 196 | '12345678-1234-1234-1234-123456789012', 197 | ]); 198 | }); 199 | 200 | it('should generate correct axe command for long text', async () => { 201 | let capturedCommand: string[] = []; 202 | const trackingExecutor = async (command: string[]) => { 203 | capturedCommand = command; 204 | return { 205 | success: true, 206 | output: 'Text typed successfully', 207 | error: undefined, 208 | process: { pid: 12345 }, 209 | }; 210 | }; 211 | 212 | const mockAxeHelpers = createMockAxeHelpers({ 213 | getAxePathReturn: '/usr/local/bin/axe', 214 | getBundledAxeEnvironmentReturn: {}, 215 | }); 216 | 217 | const longText = 218 | 'This is a very long text that needs to be typed into the simulator for testing purposes.'; 219 | 220 | await type_textLogic( 221 | { 222 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 223 | text: longText, 224 | }, 225 | trackingExecutor, 226 | mockAxeHelpers, 227 | ); 228 | 229 | expect(capturedCommand).toEqual([ 230 | '/usr/local/bin/axe', 231 | 'type', 232 | longText, 233 | '--udid', 234 | '12345678-1234-1234-1234-123456789012', 235 | ]); 236 | }); 237 | 238 | it('should generate correct axe command with bundled axe path', async () => { 239 | let capturedCommand: string[] = []; 240 | const trackingExecutor = async (command: string[]) => { 241 | capturedCommand = command; 242 | return { 243 | success: true, 244 | output: 'Text typed successfully', 245 | error: undefined, 246 | process: { pid: 12345 }, 247 | }; 248 | }; 249 | 250 | const mockAxeHelpers = createMockAxeHelpers({ 251 | getAxePathReturn: '/path/to/bundled/axe', 252 | getBundledAxeEnvironmentReturn: { AXE_PATH: '/some/path' }, 253 | }); 254 | 255 | await type_textLogic( 256 | { 257 | simulatorUuid: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 258 | text: 'Test message', 259 | }, 260 | trackingExecutor, 261 | mockAxeHelpers, 262 | ); 263 | 264 | expect(capturedCommand).toEqual([ 265 | '/path/to/bundled/axe', 266 | 'type', 267 | 'Test message', 268 | '--udid', 269 | 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', 270 | ]); 271 | }); 272 | }); 273 | 274 | describe('Handler Behavior (Complete Literal Returns)', () => { 275 | it('should handle axe dependency error', async () => { 276 | const mockAxeHelpers = createMockAxeHelpers({ 277 | getAxePathReturn: null, 278 | }); 279 | 280 | const result = await type_textLogic( 281 | { 282 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 283 | text: 'Hello World', 284 | }, 285 | createNoopExecutor(), 286 | mockAxeHelpers, 287 | ); 288 | 289 | expect(result).toEqual({ 290 | content: [ 291 | { 292 | type: 'text', 293 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 294 | }, 295 | ], 296 | isError: true, 297 | }); 298 | }); 299 | 300 | it('should successfully type text', async () => { 301 | const mockAxeHelpers = createMockAxeHelpers({ 302 | getAxePathReturn: '/usr/local/bin/axe', 303 | getBundledAxeEnvironmentReturn: {}, 304 | }); 305 | const mockExecutor = createMockExecutor({ 306 | success: true, 307 | output: 'Text typed successfully', 308 | error: undefined, 309 | }); 310 | 311 | const result = await type_textLogic( 312 | { 313 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 314 | text: 'Hello World', 315 | }, 316 | mockExecutor, 317 | mockAxeHelpers, 318 | ); 319 | 320 | expect(result).toEqual({ 321 | content: [{ type: 'text', text: 'Text typing simulated successfully.' }], 322 | isError: false, 323 | }); 324 | }); 325 | 326 | it('should return success for valid text typing', async () => { 327 | const mockAxeHelpers = createMockAxeHelpers({ 328 | getAxePathReturn: '/usr/local/bin/axe', 329 | getBundledAxeEnvironmentReturn: {}, 330 | }); 331 | 332 | const mockExecutor = createMockExecutor({ 333 | success: true, 334 | output: 'Text typed successfully', 335 | error: undefined, 336 | }); 337 | 338 | const result = await type_textLogic( 339 | { 340 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 341 | text: 'Hello World', 342 | }, 343 | mockExecutor, 344 | mockAxeHelpers, 345 | ); 346 | 347 | expect(result).toEqual({ 348 | content: [{ type: 'text', text: 'Text typing simulated successfully.' }], 349 | isError: false, 350 | }); 351 | }); 352 | 353 | it('should handle DependencyError when axe binary not found', async () => { 354 | const mockAxeHelpers = createMockAxeHelpers({ 355 | getAxePathReturn: null, 356 | }); 357 | 358 | const result = await type_textLogic( 359 | { 360 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 361 | text: 'Hello World', 362 | }, 363 | createNoopExecutor(), 364 | mockAxeHelpers, 365 | ); 366 | 367 | expect(result).toEqual({ 368 | content: [ 369 | { 370 | type: 'text', 371 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', 372 | }, 373 | ], 374 | isError: true, 375 | }); 376 | }); 377 | 378 | it('should handle AxeError from command execution', async () => { 379 | const mockAxeHelpers = createMockAxeHelpers({ 380 | getAxePathReturn: '/usr/local/bin/axe', 381 | getBundledAxeEnvironmentReturn: {}, 382 | }); 383 | 384 | const mockExecutor = createMockExecutor({ 385 | success: false, 386 | output: '', 387 | error: 'Text field not found', 388 | }); 389 | 390 | const result = await type_textLogic( 391 | { 392 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 393 | text: 'Hello World', 394 | }, 395 | mockExecutor, 396 | mockAxeHelpers, 397 | ); 398 | 399 | expect(result).toEqual({ 400 | content: [ 401 | { 402 | type: 'text', 403 | text: "Error: Failed to simulate text typing: axe command 'type' failed.\nDetails: Text field not found", 404 | }, 405 | ], 406 | isError: true, 407 | }); 408 | }); 409 | 410 | it('should handle SystemError from command execution', async () => { 411 | const mockAxeHelpers = createMockAxeHelpers({ 412 | getAxePathReturn: '/usr/local/bin/axe', 413 | getBundledAxeEnvironmentReturn: {}, 414 | }); 415 | 416 | const mockExecutor = createRejectingExecutor(new Error('ENOENT: no such file or directory')); 417 | 418 | const result = await type_textLogic( 419 | { 420 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 421 | text: 'Hello World', 422 | }, 423 | mockExecutor, 424 | mockAxeHelpers, 425 | ); 426 | 427 | expect(result).toEqual({ 428 | content: [ 429 | { 430 | type: 'text', 431 | text: expect.stringContaining( 432 | 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', 433 | ), 434 | }, 435 | ], 436 | isError: true, 437 | }); 438 | }); 439 | 440 | it('should handle unexpected Error objects', async () => { 441 | const mockAxeHelpers = createMockAxeHelpers({ 442 | getAxePathReturn: '/usr/local/bin/axe', 443 | getBundledAxeEnvironmentReturn: {}, 444 | }); 445 | 446 | const mockExecutor = createRejectingExecutor(new Error('Unexpected error')); 447 | 448 | const result = await type_textLogic( 449 | { 450 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 451 | text: 'Hello World', 452 | }, 453 | mockExecutor, 454 | mockAxeHelpers, 455 | ); 456 | 457 | expect(result).toEqual({ 458 | content: [ 459 | { 460 | type: 'text', 461 | text: expect.stringContaining( 462 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error', 463 | ), 464 | }, 465 | ], 466 | isError: true, 467 | }); 468 | }); 469 | 470 | it('should handle unexpected string errors', async () => { 471 | const mockAxeHelpers = createMockAxeHelpers({ 472 | getAxePathReturn: '/usr/local/bin/axe', 473 | getBundledAxeEnvironmentReturn: {}, 474 | }); 475 | 476 | const mockExecutor = createRejectingExecutor('String error'); 477 | 478 | const result = await type_textLogic( 479 | { 480 | simulatorUuid: '12345678-1234-1234-1234-123456789012', 481 | text: 'Hello World', 482 | }, 483 | mockExecutor, 484 | mockAxeHelpers, 485 | ); 486 | 487 | expect(result).toEqual({ 488 | content: [ 489 | { 490 | type: 'text', 491 | text: 'Error: System error executing axe: Failed to execute axe command: String error', 492 | }, 493 | ], 494 | isError: true, 495 | }); 496 | }); 497 | }); 498 | }); 499 | ``` -------------------------------------------------------------------------------- /docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md: -------------------------------------------------------------------------------- ```markdown 1 | # TEST_RUNNER_ Environment Variables Implementation Plan 2 | 3 | ## Problem Statement 4 | 5 | **GitHub Issue**: [#101 - Support TEST_RUNNER_ prefixed env vars](https://github.com/cameroncooke/XcodeBuildMCP/issues/101) 6 | 7 | **Core Need**: Enable conditional test behavior by passing TEST_RUNNER_ prefixed environment variables from MCP client configurations to xcodebuild test processes. This addresses the specific use case of disabling `runsForEachTargetApplicationUIConfiguration` for faster development testing. 8 | 9 | ## Background Context 10 | 11 | ### xcodebuild Environment Variable Support 12 | 13 | From the xcodebuild man page: 14 | ``` 15 | TEST_RUNNER_<VAR> Set an environment variable whose name is prefixed 16 | with TEST_RUNNER_ to have that variable passed, with 17 | its prefix stripped, to all test runner processes 18 | launched during a test action. For example, 19 | TEST_RUNNER_Foo=Bar xcodebuild test ... sets the 20 | environment variable Foo=Bar in the test runner's 21 | environment. 22 | ``` 23 | 24 | ### User Requirements 25 | 26 | Users want to configure their MCP server with TEST_RUNNER_ prefixed environment variables: 27 | 28 | ```json 29 | { 30 | "mcpServers": { 31 | "XcodeBuildMCP": { 32 | "type": "stdio", 33 | "command": "npx", 34 | "args": ["-y", "xcodebuildmcp@latest"], 35 | "env": { 36 | "TEST_RUNNER_USE_DEV_MODE": "YES" 37 | } 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | And have tests that can conditionally execute based on these variables: 44 | 45 | ```swift 46 | func testFoo() throws { 47 | let useDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" 48 | guard useDevMode else { 49 | XCTFail("Test requires USE_DEV_MODE to be true") 50 | return 51 | } 52 | // Test logic here... 53 | } 54 | ``` 55 | 56 | ## Current Architecture Analysis 57 | 58 | ### XcodeBuildMCP Execution Flow 59 | 1. All Xcode commands flow through `executeXcodeBuildCommand()` function 60 | 2. Generic `CommandExecutor` interface handles all command execution 61 | 3. Test tools exist for device/simulator/macOS platforms 62 | 4. Zod schemas provide parameter validation and type safety 63 | 64 | ### Key Files in Current Architecture 65 | - `src/utils/CommandExecutor.ts` - Command execution interface 66 | - `src/utils/build-utils.ts` - Contains `executeXcodeBuildCommand` 67 | - `src/mcp/tools/device/test_device.ts` - Device testing tool 68 | - `src/mcp/tools/simulator/test_sim.ts` - Simulator testing tool 69 | - `src/mcp/tools/macos/test_macos.ts` - macOS testing tool 70 | - `src/utils/test/index.ts` - Shared test logic for simulator 71 | 72 | ## Solution Analysis 73 | 74 | ### Design Options Considered 75 | 76 | 1. **Automatic Detection** (❌ Rejected) 77 | - Scan `process.env` for TEST_RUNNER_ variables and always pass them 78 | - **Issue**: Security risk of environment variable leakage 79 | - **Issue**: Unpredictable behavior based on server environment 80 | 81 | 2. **Explicit Parameter** (✅ Chosen) 82 | - Add `testRunnerEnv` parameter to test tools 83 | - Users explicitly specify which variables to pass 84 | - **Benefits**: Secure, predictable, well-validated 85 | 86 | 3. **Hybrid Approach** (🤔 Future Enhancement) 87 | - Both automatic + explicit with explicit overriding 88 | - **Issue**: Adds complexity, deferred for future consideration 89 | 90 | ### Expert Analysis Summary 91 | 92 | **RepoPrompt Analysis**: Comprehensive architectural plan emphasizing security, type safety, and integration with existing patterns. 93 | 94 | **Gemini Analysis**: Confirmed explicit approach as optimal, highlighting: 95 | - Security benefits of explicit allow-list approach 96 | - Architectural soundness of extending CommandExecutor 97 | - Recommendation for automatic prefix handling for better UX 98 | 99 | ## Recommended Solution: Explicit Parameter with Automatic Prefix Handling 100 | 101 | ### Key Design Decisions 102 | 103 | 1. **Security-First**: Only explicitly provided variables are passed (no automatic process.env scanning) 104 | 2. **User Experience**: Automatic prefix handling - users provide unprefixed keys 105 | 3. **Architecture**: Extend execution layer generically for future extensibility 106 | 4. **Validation**: Zod schema enforcement with proper type safety 107 | 108 | ### User Experience Design 109 | 110 | **Input** (what users specify): 111 | ```json 112 | { 113 | "testRunnerEnv": { 114 | "USE_DEV_MODE": "YES", 115 | "runsForEachTargetApplicationUIConfiguration": "NO" 116 | } 117 | } 118 | ``` 119 | 120 | **Output** (what gets passed to xcodebuild): 121 | ```bash 122 | TEST_RUNNER_USE_DEV_MODE=YES \ 123 | TEST_RUNNER_runsForEachTargetApplicationUIConfiguration=NO \ 124 | xcodebuild test ... 125 | ``` 126 | 127 | ## Implementation Plan 128 | 129 | ### Phase 0: Test-Driven Development Setup 130 | 131 | **Objective**: Create reproduction test to validate issue and later prove fix works 132 | 133 | #### Tasks: 134 | - [ ] Create test in `example_projects/iOS/MCPTest` that checks for environment variable 135 | - [ ] Run current test tools to demonstrate limitation (test should fail) 136 | - [ ] Document baseline behavior 137 | 138 | **Test Code Example**: 139 | ```swift 140 | func testEnvironmentVariablePassthrough() throws { 141 | let useDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" 142 | guard useDevMode else { 143 | XCTFail("Test requires USE_DEV_MODE=YES via TEST_RUNNER_USE_DEV_MODE") 144 | return 145 | } 146 | XCTAssertTrue(true, "Environment variable successfully passed through") 147 | } 148 | ``` 149 | 150 | ### Phase 1: Core Infrastructure Updates 151 | 152 | **Objective**: Extend CommandExecutor and build utilities to support environment variables 153 | 154 | #### 1.1 Update CommandExecutor Interface 155 | 156 | **File**: `src/utils/CommandExecutor.ts` 157 | 158 | **Changes**: 159 | - Add `CommandExecOptions` type for execution options 160 | - Update `CommandExecutor` type signature to accept optional execution options 161 | 162 | ```typescript 163 | export type CommandExecOptions = { 164 | cwd?: string; 165 | env?: Record<string, string | undefined>; 166 | }; 167 | 168 | export type CommandExecutor = ( 169 | args: string[], 170 | description?: string, 171 | quiet?: boolean, 172 | opts?: CommandExecOptions 173 | ) => Promise<CommandResponse>; 174 | ``` 175 | 176 | #### 1.2 Update Execution Facade 177 | 178 | **File**: `src/utils/execution/index.ts` 179 | 180 | **Changes**: 181 | - Re-export `CommandExecOptions` type 182 | 183 | ```typescript 184 | export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.js'; 185 | ``` 186 | 187 | #### 1.3 Update Default Command Executor 188 | 189 | **File**: `src/utils/command.ts` 190 | 191 | **Changes**: 192 | - Modify `getDefaultCommandExecutor` to merge `opts.env` with `process.env` when spawning 193 | 194 | ```typescript 195 | // In the returned function: 196 | const env = { ...process.env, ...(opts?.env ?? {}) }; 197 | // Pass env and opts?.cwd to spawn/exec call 198 | ``` 199 | 200 | #### 1.4 Create Environment Variable Utility 201 | 202 | **File**: `src/utils/environment.ts` 203 | 204 | **Changes**: 205 | - Add `normalizeTestRunnerEnv` function 206 | 207 | ```typescript 208 | export function normalizeTestRunnerEnv( 209 | userVars?: Record<string, string | undefined> 210 | ): Record<string, string> { 211 | const result: Record<string, string> = {}; 212 | if (userVars) { 213 | for (const [key, value] of Object.entries(userVars)) { 214 | if (value !== undefined) { 215 | result[`TEST_RUNNER_${key}`] = value; 216 | } 217 | } 218 | } 219 | return result; 220 | } 221 | ``` 222 | 223 | #### 1.5 Update executeXcodeBuildCommand 224 | 225 | **File**: `src/utils/build-utils.ts` 226 | 227 | **Changes**: 228 | - Add optional `execOpts?: CommandExecOptions` parameter (6th parameter) 229 | - Pass execution options through to `CommandExecutor` calls 230 | 231 | ```typescript 232 | export async function executeXcodeBuildCommand( 233 | build: { /* existing fields */ }, 234 | runtime: { /* existing fields */ }, 235 | preferXcodebuild = false, 236 | action: 'build' | 'test' | 'archive' | 'analyze' | string, 237 | executor: CommandExecutor = getDefaultCommandExecutor(), 238 | execOpts?: CommandExecOptions, // NEW 239 | ): Promise<ToolResponse> 240 | ``` 241 | 242 | ### Phase 2: Test Tool Integration 243 | 244 | **Objective**: Add `testRunnerEnv` parameter to all test tools and wire through execution 245 | 246 | #### 2.1 Update Device Test Tool 247 | 248 | **File**: `src/mcp/tools/device/test_device.ts` 249 | 250 | **Changes**: 251 | - Add `testRunnerEnv` to Zod schema with validation 252 | - Import and use `normalizeTestRunnerEnv` 253 | - Pass execution options to `executeXcodeBuildCommand` 254 | 255 | **Schema Addition**: 256 | ```typescript 257 | testRunnerEnv: z 258 | .record(z.string(), z.string().optional()) 259 | .optional() 260 | .describe('Test runner environment variables (TEST_RUNNER_ prefix added automatically)') 261 | ``` 262 | 263 | **Usage**: 264 | ```typescript 265 | const execEnv = normalizeTestRunnerEnv(params.testRunnerEnv); 266 | const testResult = await executeXcodeBuildCommand( 267 | { /* build params */ }, 268 | { /* runtime params */ }, 269 | params.preferXcodebuild ?? false, 270 | 'test', 271 | executor, 272 | { env: execEnv } // NEW 273 | ); 274 | ``` 275 | 276 | #### 2.2 Update macOS Test Tool 277 | 278 | **File**: `src/mcp/tools/macos/test_macos.ts` 279 | 280 | **Changes**: Same pattern as device test tool 281 | - Schema addition for `testRunnerEnv` 282 | - Import `normalizeTestRunnerEnv` 283 | - Pass execution options to `executeXcodeBuildCommand` 284 | 285 | #### 2.3 Update Simulator Test Tool and Logic 286 | 287 | **File**: `src/mcp/tools/simulator/test_sim.ts` 288 | 289 | **Changes**: 290 | - Add `testRunnerEnv` to schema 291 | - Pass through to `handleTestLogic` 292 | 293 | **File**: `src/utils/test/index.ts` 294 | 295 | **Changes**: 296 | - Update `handleTestLogic` signature to accept `testRunnerEnv?: Record<string, string | undefined>` 297 | - Import and use `normalizeTestRunnerEnv` 298 | - Pass execution options to `executeXcodeBuildCommand` 299 | 300 | ### Phase 3: Testing and Validation 301 | 302 | **Objective**: Comprehensive testing coverage for new functionality 303 | 304 | #### 3.1 Unit Tests 305 | 306 | **File**: `src/utils/__tests__/environment.test.ts` 307 | 308 | **Tests**: 309 | - Test `normalizeTestRunnerEnv` with various inputs 310 | - Verify prefix addition 311 | - Verify undefined filtering 312 | - Verify empty input handling 313 | 314 | #### 3.2 Integration Tests 315 | 316 | **Files**: Update existing test files for test tools 317 | 318 | **Tests**: 319 | - Verify `testRunnerEnv` parameter is properly validated 320 | - Verify environment variables are passed through `CommandExecutor` 321 | - Mock executor to verify correct env object construction 322 | 323 | #### 3.3 Tool Export Validation 324 | 325 | **Files**: Test files in each tool directory 326 | 327 | **Tests**: 328 | - Verify schema exports include new `testRunnerEnv` field 329 | - Verify parameter typing is correct 330 | 331 | ### Phase 4: End-to-End Validation 332 | 333 | **Objective**: Prove the fix works with real xcodebuild scenarios 334 | 335 | #### 4.1 Reproduction Test Validation 336 | 337 | **Tasks**: 338 | - Run reproduction test from Phase 0 with new `testRunnerEnv` parameter 339 | - Verify test passes (proving env var was successfully passed) 340 | - Document the before/after behavior 341 | 342 | #### 4.2 Real-World Scenario Testing 343 | 344 | **Tasks**: 345 | - Test with actual iOS project using `runsForEachTargetApplicationUIConfiguration` 346 | - Verify performance difference when variable is set 347 | - Test with multiple environment variables 348 | - Test edge cases (empty values, special characters) 349 | 350 | ## Security Considerations 351 | 352 | ### Security Benefits 353 | - **No Environment Leakage**: Only explicit user-provided variables are passed 354 | - **Command Injection Prevention**: Environment variables passed as separate object, not interpolated into command string 355 | - **Input Validation**: Zod schemas prevent malformed inputs 356 | - **Prefix Enforcement**: Only TEST_RUNNER_ prefixed variables can be set 357 | 358 | ### Security Best Practices 359 | - Never log environment variable values (keys only for debugging) 360 | - Filter out undefined values to prevent accidental exposure 361 | - Validate all user inputs through Zod schemas 362 | - Document supported TEST_RUNNER_ variables from Apple's documentation 363 | 364 | ## Architectural Benefits 365 | 366 | ### Clean Integration 367 | - Extends existing `CommandExecutor` pattern generically 368 | - Maintains backward compatibility (all existing calls remain valid) 369 | - Follows established Zod validation patterns 370 | - Consistent API across all test tools 371 | 372 | ### Future Extensibility 373 | - `CommandExecOptions` can support additional execution options (timeout, cwd, etc.) 374 | - Pattern can be extended to other tools that need environment variables 375 | - Generic approach allows for non-TEST_RUNNER_ use cases in the future 376 | 377 | ## File Modification Summary 378 | 379 | ### New Files 380 | - `src/utils/__tests__/environment.test.ts` - Unit tests for environment utilities 381 | 382 | ### Modified Files 383 | - `src/utils/CommandExecutor.ts` - Add execution options types 384 | - `src/utils/execution/index.ts` - Re-export new types 385 | - `src/utils/command.ts` - Update default executor to handle env 386 | - `src/utils/environment.ts` - Add `normalizeTestRunnerEnv` utility 387 | - `src/utils/build-utils.ts` - Update `executeXcodeBuildCommand` signature 388 | - `src/mcp/tools/device/test_device.ts` - Add schema and integration 389 | - `src/mcp/tools/macos/test_macos.ts` - Add schema and integration 390 | - `src/mcp/tools/simulator/test_sim.ts` - Add schema and pass-through 391 | - `src/utils/test/index.ts` - Update `handleTestLogic` for simulator path 392 | - Test files for each modified tool - Add validation tests 393 | 394 | ## Success Criteria 395 | 396 | 1. **Functionality**: Users can pass `testRunnerEnv` parameter to test tools and have variables appear in test runner environment 397 | 2. **Security**: No unintended environment variable leakage from server process 398 | 3. **Usability**: Users specify unprefixed variable names for better UX 399 | 4. **Compatibility**: All existing test tool calls continue to work unchanged 400 | 5. **Validation**: Comprehensive test coverage proves the feature works end-to-end 401 | 402 | ## Future Enhancements (Out of Scope) 403 | 404 | 1. **Configuration Profiles**: Allow users to define common TEST_RUNNER_ variable sets in config files 405 | 2. **Variable Discovery**: Help users discover available TEST_RUNNER_ variables 406 | 3. **Build Tool Support**: Extend to build tools if Apple adds similar BUILD_RUNNER_ support 407 | 4. **Performance Monitoring**: Track impact of environment variable passing on build times 408 | 409 | ## Implementation Timeline 410 | 411 | - **Phase 0**: 1-2 hours (reproduction test setup) 412 | - **Phase 1**: 4-6 hours (infrastructure changes) 413 | - **Phase 2**: 3-4 hours (tool integration) 414 | - **Phase 3**: 4-5 hours (testing) 415 | - **Phase 4**: 2-3 hours (validation) 416 | 417 | **Total Estimated Time**: 14-20 hours 418 | 419 | ## Conclusion 420 | 421 | This implementation plan provides a secure, user-friendly, and architecturally sound solution for TEST_RUNNER_ environment variable support. The explicit parameter approach with automatic prefix handling balances security concerns with user experience, while the test-driven development approach ensures we can prove the solution works as intended. 422 | 423 | The plan leverages XcodeBuildMCP's existing patterns and provides a foundation for future environment variable needs across the tool ecosystem. ```