This is page 7 of 16. 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
│ ├── README.md
│ ├── release.yml
│ ├── sentry.yml
│ └── stale.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
├── docs
│ ├── CONFIGURATION.md
│ ├── DAP_BACKEND_IMPLEMENTATION_PLAN.md
│ ├── DEBUGGING_ARCHITECTURE.md
│ ├── DEMOS.md
│ ├── dev
│ │ ├── ARCHITECTURE.md
│ │ ├── CODE_QUALITY.md
│ │ ├── CONTRIBUTING.md
│ │ ├── ESLINT_TYPE_SAFETY.md
│ │ ├── MANUAL_TESTING.md
│ │ ├── NODEJS_2025.md
│ │ ├── PLUGIN_DEVELOPMENT.md
│ │ ├── README.md
│ │ ├── RELEASE_PROCESS.md
│ │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md
│ │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
│ │ ├── RELOADEROO.md
│ │ ├── session_management_plan.md
│ │ ├── session-aware-migration-todo.md
│ │ ├── SMITHERY.md
│ │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
│ │ ├── TESTING.md
│ │ └── ZOD_MIGRATION_GUIDE.md
│ ├── DEVICE_CODE_SIGNING.md
│ ├── GETTING_STARTED.md
│ ├── investigations
│ │ ├── issue-154-screenshot-downscaling.md
│ │ ├── issue-163.md
│ │ ├── issue-debugger-attach-stopped.md
│ │ └── issue-describe-ui-empty-after-debugger-resume.md
│ ├── OVERVIEW.md
│ ├── PRIVACY.md
│ ├── README.md
│ ├── SESSION_DEFAULTS.md
│ ├── TOOLS.md
│ └── TROUBLESHOOTING.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
│ │ ├── .gitignore
│ │ ├── 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
│ │ └── MCPTestTests
│ │ └── MCPTestTests.swift
│ └── 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
│ ├── generate-loaders.ts
│ ├── generate-version.ts
│ ├── release.sh
│ ├── tools-cli.ts
│ ├── update-tools-docs.ts
│ └── verify-smithery-bundle.sh
├── server.json
├── smithery.config.js
├── smithery.yaml
├── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── resources.test.ts
│ │ ├── generated-plugins.ts
│ │ ├── generated-resources.ts
│ │ ├── plugin-registry.ts
│ │ ├── plugin-types.ts
│ │ └── resources.ts
│ ├── doctor-cli.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── resources
│ │ │ ├── __tests__
│ │ │ │ ├── devices.test.ts
│ │ │ │ ├── doctor.test.ts
│ │ │ │ ├── session-status.test.ts
│ │ │ │ └── simulators.test.ts
│ │ │ ├── devices.ts
│ │ │ ├── doctor.ts
│ │ │ ├── session-status.ts
│ │ │ └── simulators.ts
│ │ └── tools
│ │ ├── debugging
│ │ │ ├── debug_attach_sim.ts
│ │ │ ├── debug_breakpoint_add.ts
│ │ │ ├── debug_breakpoint_remove.ts
│ │ │ ├── debug_continue.ts
│ │ │ ├── debug_detach.ts
│ │ │ ├── debug_lldb_command.ts
│ │ │ ├── debug_stack.ts
│ │ │ ├── debug_variables.ts
│ │ │ └── index.ts
│ │ ├── 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
│ │ ├── 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
│ │ ├── bootstrap.ts
│ │ └── server.ts
│ ├── smithery.ts
│ ├── test-utils
│ │ └── mock-executors.ts
│ ├── types
│ │ └── common.ts
│ ├── utils
│ │ ├── __tests__
│ │ │ ├── build-utils-suppress-warnings.test.ts
│ │ │ ├── build-utils.test.ts
│ │ │ ├── debugger-simctl.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
│ │ │ └── workflow-selection.test.ts
│ │ ├── axe
│ │ │ └── index.ts
│ │ ├── axe-helpers.ts
│ │ ├── build
│ │ │ └── index.ts
│ │ ├── build-utils.ts
│ │ ├── capabilities.ts
│ │ ├── command.ts
│ │ ├── CommandExecutor.ts
│ │ ├── debugger
│ │ │ ├── __tests__
│ │ │ │ └── debugger-manager-dap.test.ts
│ │ │ ├── backends
│ │ │ │ ├── __tests__
│ │ │ │ │ └── dap-backend.test.ts
│ │ │ │ ├── dap-backend.ts
│ │ │ │ ├── DebuggerBackend.ts
│ │ │ │ └── lldb-cli-backend.ts
│ │ │ ├── dap
│ │ │ │ ├── __tests__
│ │ │ │ │ └── transport-framing.test.ts
│ │ │ │ ├── adapter-discovery.ts
│ │ │ │ ├── transport.ts
│ │ │ │ └── types.ts
│ │ │ ├── debugger-manager.ts
│ │ │ ├── index.ts
│ │ │ ├── simctl.ts
│ │ │ ├── tool-context.ts
│ │ │ ├── types.ts
│ │ │ └── ui-automation-guard.ts
│ │ ├── environment.ts
│ │ ├── errors.ts
│ │ ├── execution
│ │ │ ├── index.ts
│ │ │ └── interactive-process.ts
│ │ ├── FileSystemExecutor.ts
│ │ ├── log_capture.ts
│ │ ├── log-capture
│ │ │ ├── device-log-sessions.ts
│ │ │ └── index.ts
│ │ ├── logger.ts
│ │ ├── logging
│ │ │ └── index.ts
│ │ ├── plugin-registry
│ │ │ └── index.ts
│ │ ├── responses
│ │ │ └── index.ts
│ │ ├── runtime-registry.ts
│ │ ├── schema-helpers.ts
│ │ ├── sentry.ts
│ │ ├── session-status.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
│ │ ├── workflow-selection.ts
│ │ ├── xcode.ts
│ │ ├── xcodemake
│ │ │ └── index.ts
│ │ └── xcodemake.ts
│ └── version.ts
├── tsconfig.json
├── tsconfig.test.json
├── tsconfig.tests.json
├── tsup.config.ts
├── vitest.config.ts
└── XcodeBuildMCP.code-workspace
```
# Files
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/install_app_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for install_app_device plugin (device-shared)
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 * as z from 'zod';
9 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
10 | import installAppDevice, { install_app_deviceLogic } from '../install_app_device.ts';
11 | import { sessionStore } from '../../../../utils/session-store.ts';
12 |
13 | describe('install_app_device plugin', () => {
14 | beforeEach(() => {
15 | sessionStore.clear();
16 | });
17 |
18 | describe('Handler Requirements', () => {
19 | it('should require deviceId when session defaults are missing', async () => {
20 | const result = await installAppDevice.handler({
21 | appPath: '/path/to/test.app',
22 | });
23 |
24 | expect(result.isError).toBe(true);
25 | expect(result.content[0].text).toContain('deviceId is required');
26 | });
27 | });
28 |
29 | describe('Export Field Validation (Literal)', () => {
30 | it('should have correct name', () => {
31 | expect(installAppDevice.name).toBe('install_app_device');
32 | });
33 |
34 | it('should have correct description', () => {
35 | expect(installAppDevice.description).toBe('Installs an app on a connected device.');
36 | });
37 |
38 | it('should have handler function', () => {
39 | expect(typeof installAppDevice.handler).toBe('function');
40 | });
41 |
42 | it('should require appPath in public schema', () => {
43 | const schema = z.strictObject(installAppDevice.schema);
44 | expect(schema.safeParse({ appPath: '/path/to/test.app' }).success).toBe(true);
45 | expect(schema.safeParse({}).success).toBe(false);
46 | expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false);
47 |
48 | expect(Object.keys(installAppDevice.schema)).toEqual(['appPath']);
49 | });
50 | });
51 |
52 | describe('Command Generation', () => {
53 | it('should generate correct devicectl command with basic parameters', async () => {
54 | let capturedCommand: string[] = [];
55 | let capturedDescription: string = '';
56 | let capturedUseShell: boolean = false;
57 | let capturedEnv: Record<string, string> | undefined = undefined;
58 |
59 | const mockExecutor = createMockExecutor({
60 | success: true,
61 | output: 'App installation successful',
62 | process: { pid: 12345 },
63 | });
64 |
65 | const trackingExecutor = async (
66 | command: string[],
67 | description?: string,
68 | useShell?: boolean,
69 | opts?: { env?: Record<string, string> },
70 | _detached?: boolean,
71 | ) => {
72 | capturedCommand = command;
73 | capturedDescription = description ?? '';
74 | capturedUseShell = !!useShell;
75 | capturedEnv = opts?.env;
76 | return mockExecutor(command, description, useShell, opts, _detached);
77 | };
78 |
79 | await install_app_deviceLogic(
80 | {
81 | deviceId: 'test-device-123',
82 | appPath: '/path/to/test.app',
83 | },
84 | trackingExecutor,
85 | );
86 |
87 | expect(capturedCommand).toEqual([
88 | 'xcrun',
89 | 'devicectl',
90 | 'device',
91 | 'install',
92 | 'app',
93 | '--device',
94 | 'test-device-123',
95 | '/path/to/test.app',
96 | ]);
97 | expect(capturedDescription).toBe('Install app on device');
98 | expect(capturedUseShell).toBe(true);
99 | expect(capturedEnv).toBe(undefined);
100 | });
101 |
102 | it('should generate correct command with different device ID', async () => {
103 | let capturedCommand: string[] = [];
104 |
105 | const mockExecutor = createMockExecutor({
106 | success: true,
107 | output: 'App installation successful',
108 | process: { pid: 12345 },
109 | });
110 |
111 | const trackingExecutor = async (command: string[]) => {
112 | capturedCommand = command;
113 | return mockExecutor(command);
114 | };
115 |
116 | await install_app_deviceLogic(
117 | {
118 | deviceId: 'different-device-uuid',
119 | appPath: '/apps/MyApp.app',
120 | },
121 | trackingExecutor,
122 | );
123 |
124 | expect(capturedCommand).toEqual([
125 | 'xcrun',
126 | 'devicectl',
127 | 'device',
128 | 'install',
129 | 'app',
130 | '--device',
131 | 'different-device-uuid',
132 | '/apps/MyApp.app',
133 | ]);
134 | });
135 |
136 | it('should generate correct command with paths containing spaces', async () => {
137 | let capturedCommand: string[] = [];
138 |
139 | const mockExecutor = createMockExecutor({
140 | success: true,
141 | output: 'App installation successful',
142 | process: { pid: 12345 },
143 | });
144 |
145 | const trackingExecutor = async (command: string[]) => {
146 | capturedCommand = command;
147 | return mockExecutor(command);
148 | };
149 |
150 | await install_app_deviceLogic(
151 | {
152 | deviceId: 'test-device-123',
153 | appPath: '/path/to/My App.app',
154 | },
155 | trackingExecutor,
156 | );
157 |
158 | expect(capturedCommand).toEqual([
159 | 'xcrun',
160 | 'devicectl',
161 | 'device',
162 | 'install',
163 | 'app',
164 | '--device',
165 | 'test-device-123',
166 | '/path/to/My App.app',
167 | ]);
168 | });
169 | });
170 |
171 | describe('Success Path Tests', () => {
172 | it('should return successful installation response', async () => {
173 | const mockExecutor = createMockExecutor({
174 | success: true,
175 | output: 'App installation successful',
176 | });
177 |
178 | const result = await install_app_deviceLogic(
179 | {
180 | deviceId: 'test-device-123',
181 | appPath: '/path/to/test.app',
182 | },
183 | mockExecutor,
184 | );
185 |
186 | expect(result).toEqual({
187 | content: [
188 | {
189 | type: 'text',
190 | text: '✅ App installed successfully on device test-device-123\n\nApp installation successful',
191 | },
192 | ],
193 | });
194 | });
195 |
196 | it('should return successful installation with detailed output', async () => {
197 | const mockExecutor = createMockExecutor({
198 | success: true,
199 | output:
200 | 'Installing app...\nApp bundle: /path/to/test.app\nInstallation completed successfully',
201 | });
202 |
203 | const result = await install_app_deviceLogic(
204 | {
205 | deviceId: 'device-456',
206 | appPath: '/apps/TestApp.app',
207 | },
208 | mockExecutor,
209 | );
210 |
211 | expect(result).toEqual({
212 | content: [
213 | {
214 | type: 'text',
215 | text: '✅ App installed successfully on device device-456\n\nInstalling app...\nApp bundle: /path/to/test.app\nInstallation completed successfully',
216 | },
217 | ],
218 | });
219 | });
220 |
221 | it('should return successful installation with empty output', async () => {
222 | const mockExecutor = createMockExecutor({
223 | success: true,
224 | output: '',
225 | });
226 |
227 | const result = await install_app_deviceLogic(
228 | {
229 | deviceId: 'empty-output-device',
230 | appPath: '/path/to/app.app',
231 | },
232 | mockExecutor,
233 | );
234 |
235 | expect(result).toEqual({
236 | content: [
237 | {
238 | type: 'text',
239 | text: '✅ App installed successfully on device empty-output-device\n\n',
240 | },
241 | ],
242 | });
243 | });
244 | });
245 |
246 | describe('Error Handling', () => {
247 | it('should return installation failure response', async () => {
248 | const mockExecutor = createMockExecutor({
249 | success: false,
250 | error: 'Installation failed: App not found',
251 | });
252 |
253 | const result = await install_app_deviceLogic(
254 | {
255 | deviceId: 'test-device-123',
256 | appPath: '/path/to/nonexistent.app',
257 | },
258 | mockExecutor,
259 | );
260 |
261 | expect(result).toEqual({
262 | content: [
263 | {
264 | type: 'text',
265 | text: 'Failed to install app: Installation failed: App not found',
266 | },
267 | ],
268 | isError: true,
269 | });
270 | });
271 |
272 | it('should return exception handling response', async () => {
273 | const mockExecutor = createMockExecutor(new Error('Network error'));
274 |
275 | const result = await install_app_deviceLogic(
276 | {
277 | deviceId: 'test-device-123',
278 | appPath: '/path/to/test.app',
279 | },
280 | mockExecutor,
281 | );
282 |
283 | expect(result).toEqual({
284 | content: [
285 | {
286 | type: 'text',
287 | text: 'Failed to install app on device: Network error',
288 | },
289 | ],
290 | isError: true,
291 | });
292 | });
293 |
294 | it('should return string error handling response', async () => {
295 | const mockExecutor = createMockExecutor('String error');
296 |
297 | const result = await install_app_deviceLogic(
298 | {
299 | deviceId: 'test-device-123',
300 | appPath: '/path/to/test.app',
301 | },
302 | mockExecutor,
303 | );
304 |
305 | expect(result).toEqual({
306 | content: [
307 | {
308 | type: 'text',
309 | text: 'Failed to install app on device: String error',
310 | },
311 | ],
312 | isError: true,
313 | });
314 | });
315 | });
316 | });
317 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/doctor/lib/doctor.deps.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as os from 'os';
2 | import type { CommandExecutor } from '../../../../utils/execution/index.ts';
3 | import { loadWorkflowGroups } from '../../../../utils/plugin-registry/index.ts';
4 | import { getRuntimeRegistration } from '../../../../utils/runtime-registry.ts';
5 | import {
6 | collectToolNames,
7 | resolveSelectedWorkflows,
8 | } from '../../../../utils/workflow-selection.ts';
9 | import { areAxeToolsAvailable, resolveAxeBinary } from '../../../../utils/axe/index.ts';
10 | import {
11 | isXcodemakeEnabled,
12 | isXcodemakeAvailable,
13 | doesMakefileExist,
14 | } from '../../../../utils/xcodemake/index.ts';
15 |
16 | export interface BinaryChecker {
17 | checkBinaryAvailability(binary: string): Promise<{ available: boolean; version?: string }>;
18 | }
19 |
20 | export interface XcodeInfoProvider {
21 | getXcodeInfo(): Promise<
22 | | { version: string; path: string; selectedXcode: string; xcrunVersion: string }
23 | | { error: string }
24 | >;
25 | }
26 |
27 | export interface EnvironmentInfoProvider {
28 | getEnvironmentVariables(): Record<string, string | undefined>;
29 | getSystemInfo(): {
30 | platform: string;
31 | release: string;
32 | arch: string;
33 | cpus: string;
34 | memory: string;
35 | hostname: string;
36 | username: string;
37 | homedir: string;
38 | tmpdir: string;
39 | };
40 | getNodeInfo(): {
41 | version: string;
42 | execPath: string;
43 | pid: string;
44 | ppid: string;
45 | platform: string;
46 | arch: string;
47 | cwd: string;
48 | argv: string;
49 | };
50 | }
51 |
52 | export interface PluginInfoProvider {
53 | getPluginSystemInfo(): Promise<
54 | | {
55 | totalPlugins: number;
56 | pluginDirectories: number;
57 | pluginsByDirectory: Record<string, string[]>;
58 | systemMode: string;
59 | }
60 | | { error: string; systemMode: string }
61 | >;
62 | }
63 |
64 | export interface RuntimeInfoProvider {
65 | getRuntimeToolInfo(): Promise<
66 | | {
67 | mode: 'runtime';
68 | enabledWorkflows: string[];
69 | enabledTools: string[];
70 | totalRegistered: number;
71 | }
72 | | {
73 | mode: 'static';
74 | enabledWorkflows: string[];
75 | enabledTools: string[];
76 | totalRegistered: number;
77 | note: string;
78 | }
79 | >;
80 | }
81 |
82 | export interface FeatureDetector {
83 | areAxeToolsAvailable(): boolean;
84 | isXcodemakeEnabled(): boolean;
85 | isXcodemakeAvailable(): Promise<boolean>;
86 | doesMakefileExist(path: string): boolean;
87 | }
88 |
89 | export interface DoctorDependencies {
90 | commandExecutor: CommandExecutor;
91 | binaryChecker: BinaryChecker;
92 | xcode: XcodeInfoProvider;
93 | env: EnvironmentInfoProvider;
94 | plugins: PluginInfoProvider;
95 | runtime: RuntimeInfoProvider;
96 | features: FeatureDetector;
97 | }
98 |
99 | export function createDoctorDependencies(executor: CommandExecutor): DoctorDependencies {
100 | const commandExecutor = executor;
101 | const binaryChecker: BinaryChecker = {
102 | async checkBinaryAvailability(binary: string) {
103 | if (binary === 'axe') {
104 | const axeBinary = resolveAxeBinary();
105 | if (!axeBinary) {
106 | return { available: false };
107 | }
108 |
109 | let version: string | undefined;
110 | try {
111 | const res = await executor([axeBinary.path, '--version'], 'Get AXe Version');
112 | if (res.success && res.output) {
113 | version = res.output.trim();
114 | }
115 | } catch {
116 | // ignore
117 | }
118 |
119 | return {
120 | available: true,
121 | version: version ?? 'Available (version info not available)',
122 | };
123 | }
124 | try {
125 | const which = await executor(['which', binary], 'Check Binary Availability');
126 | if (!which.success) {
127 | return { available: false };
128 | }
129 | } catch {
130 | return { available: false };
131 | }
132 |
133 | let version: string | undefined;
134 | const versionCommands: Record<string, string> = {
135 | mise: 'mise --version',
136 | };
137 |
138 | if (binary in versionCommands) {
139 | try {
140 | const res = await executor(versionCommands[binary]!.split(' '), 'Get Binary Version');
141 | if (res.success && res.output) {
142 | version = res.output.trim();
143 | }
144 | } catch {
145 | // ignore
146 | }
147 | }
148 |
149 | return { available: true, version: version ?? 'Available (version info not available)' };
150 | },
151 | };
152 |
153 | const xcode: XcodeInfoProvider = {
154 | async getXcodeInfo() {
155 | try {
156 | const xcodebuild = await executor(['xcodebuild', '-version'], 'Get Xcode Version');
157 | if (!xcodebuild.success) throw new Error('xcodebuild command failed');
158 | const version = xcodebuild.output.trim().split('\n').slice(0, 2).join(' - ');
159 |
160 | const pathRes = await executor(['xcode-select', '-p'], 'Get Xcode Path');
161 | if (!pathRes.success) throw new Error('xcode-select command failed');
162 | const path = pathRes.output.trim();
163 |
164 | const selected = await executor(['xcrun', '--find', 'xcodebuild'], 'Find Xcodebuild');
165 | if (!selected.success) throw new Error('xcrun --find command failed');
166 | const selectedXcode = selected.output.trim();
167 |
168 | const xcrun = await executor(['xcrun', '--version'], 'Get Xcrun Version');
169 | if (!xcrun.success) throw new Error('xcrun --version command failed');
170 | const xcrunVersion = xcrun.output.trim();
171 |
172 | return { version, path, selectedXcode, xcrunVersion };
173 | } catch (error) {
174 | return { error: error instanceof Error ? error.message : String(error) };
175 | }
176 | },
177 | };
178 |
179 | const env: EnvironmentInfoProvider = {
180 | getEnvironmentVariables() {
181 | const relevantVars = [
182 | 'INCREMENTAL_BUILDS_ENABLED',
183 | 'PATH',
184 | 'DEVELOPER_DIR',
185 | 'HOME',
186 | 'USER',
187 | 'TMPDIR',
188 | 'NODE_ENV',
189 | 'SENTRY_DISABLED',
190 | ];
191 |
192 | const envVars: Record<string, string | undefined> = {};
193 | for (const varName of relevantVars) {
194 | envVars[varName] = process.env[varName];
195 | }
196 |
197 | Object.keys(process.env).forEach((key) => {
198 | if (key.startsWith('XCODEBUILDMCP_')) {
199 | envVars[key] = process.env[key];
200 | }
201 | });
202 |
203 | return envVars;
204 | },
205 |
206 | getSystemInfo() {
207 | return {
208 | platform: os.platform(),
209 | release: os.release(),
210 | arch: os.arch(),
211 | cpus: `${os.cpus().length} x ${os.cpus()[0]?.model ?? 'Unknown'}`,
212 | memory: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`,
213 | hostname: os.hostname(),
214 | username: os.userInfo().username,
215 | homedir: os.homedir(),
216 | tmpdir: os.tmpdir(),
217 | };
218 | },
219 |
220 | getNodeInfo() {
221 | return {
222 | version: process.version,
223 | execPath: process.execPath,
224 | pid: process.pid.toString(),
225 | ppid: process.ppid.toString(),
226 | platform: process.platform,
227 | arch: process.arch,
228 | cwd: process.cwd(),
229 | argv: process.argv.join(' '),
230 | };
231 | },
232 | };
233 |
234 | const plugins: PluginInfoProvider = {
235 | async getPluginSystemInfo() {
236 | try {
237 | const workflows = await loadWorkflowGroups();
238 | const pluginsByDirectory: Record<string, string[]> = {};
239 | let totalPlugins = 0;
240 |
241 | for (const [dirName, wf] of workflows.entries()) {
242 | const toolNames = wf.tools.map((t) => t.name).filter(Boolean) as string[];
243 | totalPlugins += toolNames.length;
244 | pluginsByDirectory[dirName] = toolNames;
245 | }
246 |
247 | return {
248 | totalPlugins,
249 | pluginDirectories: workflows.size,
250 | pluginsByDirectory,
251 | systemMode: 'plugin-based',
252 | };
253 | } catch (error) {
254 | return {
255 | error: `Failed to load plugins: ${error instanceof Error ? error.message : 'Unknown error'}`,
256 | systemMode: 'error',
257 | };
258 | }
259 | },
260 | };
261 |
262 | const runtime: RuntimeInfoProvider = {
263 | async getRuntimeToolInfo() {
264 | const runtimeInfo = getRuntimeRegistration();
265 | if (runtimeInfo) {
266 | return runtimeInfo;
267 | }
268 |
269 | const workflows = await loadWorkflowGroups();
270 | const enabledWorkflowEnv = process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS ?? '';
271 | const workflowNames = enabledWorkflowEnv
272 | .split(',')
273 | .map((workflow) => workflow.trim())
274 | .filter(Boolean);
275 | const selection = resolveSelectedWorkflows(workflows, workflowNames);
276 | const enabledWorkflows = selection.selectedWorkflows.map(
277 | (workflow) => workflow.directoryName,
278 | );
279 | const enabledTools = collectToolNames(selection.selectedWorkflows);
280 | return {
281 | mode: 'static',
282 | enabledWorkflows,
283 | enabledTools,
284 | totalRegistered: enabledTools.length,
285 | note: 'Runtime registry unavailable; showing expected tools from selection rules.',
286 | };
287 | },
288 | };
289 |
290 | const features: FeatureDetector = {
291 | areAxeToolsAvailable,
292 | isXcodemakeEnabled,
293 | isXcodemakeAvailable,
294 | doesMakefileExist,
295 | };
296 |
297 | return { commandExecutor, binaryChecker, xcode, env, plugins, runtime, features };
298 | }
299 |
300 | export type { CommandExecutor };
301 |
302 | export default {} as const;
303 |
```
--------------------------------------------------------------------------------
/build-plugins/plugin-discovery.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Plugin } from 'esbuild';
2 | import { readdirSync, readFileSync, existsSync } from 'fs';
3 | import { join } from 'path';
4 | import path from 'path';
5 |
6 | export interface WorkflowMetadata {
7 | name: string;
8 | description: string;
9 | }
10 |
11 | export function createPluginDiscoveryPlugin(): Plugin {
12 | return {
13 | name: 'plugin-discovery',
14 | setup(build) {
15 | // Generate the workflow loaders file before build starts
16 | build.onStart(async () => {
17 | try {
18 | await generateWorkflowLoaders();
19 | await generateResourceLoaders();
20 | } catch (error) {
21 | console.error('Failed to generate loaders:', error);
22 | throw error;
23 | }
24 | });
25 | },
26 | };
27 | }
28 |
29 | export async function generateWorkflowLoaders(): Promise<void> {
30 | const pluginsDir = path.resolve(process.cwd(), 'src/mcp/tools');
31 |
32 | if (!existsSync(pluginsDir)) {
33 | throw new Error(`Plugins directory not found: ${pluginsDir}`);
34 | }
35 |
36 | // Scan for workflow directories
37 | const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true })
38 | .filter((dirent) => dirent.isDirectory())
39 | .map((dirent) => dirent.name);
40 |
41 | const workflowLoaders: Record<string, string> = {};
42 | const workflowMetadata: Record<string, WorkflowMetadata> = {};
43 |
44 | for (const dirName of workflowDirs) {
45 | const dirPath = join(pluginsDir, dirName);
46 | const indexPath = join(dirPath, 'index.ts');
47 |
48 | // Check if workflow has index.ts file
49 | if (!existsSync(indexPath)) {
50 | console.warn(`Skipping ${dirName}: no index.ts file found`);
51 | continue;
52 | }
53 |
54 | // Try to extract workflow metadata from index.ts
55 | try {
56 | const indexContent = readFileSync(indexPath, 'utf8');
57 | const metadata = extractWorkflowMetadata(indexContent);
58 |
59 | if (metadata) {
60 | // Find all tool files in this workflow directory
61 | const toolFiles = readdirSync(dirPath, { withFileTypes: true })
62 | .filter((dirent) => dirent.isFile())
63 | .map((dirent) => dirent.name)
64 | .filter(
65 | (name) =>
66 | (name.endsWith('.ts') || name.endsWith('.js')) &&
67 | name !== 'index.ts' &&
68 | name !== 'index.js' &&
69 | !name.endsWith('.test.ts') &&
70 | !name.endsWith('.test.js') &&
71 | name !== 'active-processes.ts',
72 | );
73 |
74 | workflowLoaders[dirName] = generateWorkflowLoader(dirName, toolFiles);
75 | workflowMetadata[dirName] = metadata;
76 |
77 | console.log(
78 | `✅ Discovered workflow: ${dirName} - ${metadata.name} (${toolFiles.length} tools)`,
79 | );
80 | } else {
81 | console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`);
82 | }
83 | } catch (error) {
84 | console.warn(`⚠️ Error processing ${dirName}:`, error);
85 | }
86 | }
87 |
88 | // Generate the content for generated-plugins.ts
89 | const generatedContent = await generatePluginsFileContent(workflowLoaders, workflowMetadata);
90 |
91 | // Write to the generated file
92 | const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts');
93 |
94 | const fs = await import('fs');
95 | await fs.promises.writeFile(outputPath, generatedContent, 'utf8');
96 |
97 | console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`);
98 | }
99 |
100 | function generateWorkflowLoader(workflowName: string, toolFiles: string[]): string {
101 | const toolImports = toolFiles
102 | .map((file, index) => {
103 | const toolName = file.replace(/\.(ts|js)$/, '');
104 | return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.ts').then(m => m.default)`;
105 | })
106 | .join(';\n ');
107 |
108 | const toolExports = toolFiles
109 | .map((file, index) => {
110 | const toolName = file.replace(/\.(ts|js)$/, '');
111 | return `'${toolName}': tool_${index}`;
112 | })
113 | .join(',\n ');
114 |
115 | return `async () => {
116 | const { workflow } = await import('../mcp/tools/${workflowName}/index.ts');
117 | ${toolImports ? toolImports + ';\n ' : ''}
118 | return {
119 | workflow,
120 | ${toolExports ? toolExports : ''}
121 | };
122 | }`;
123 | }
124 |
125 | function extractWorkflowMetadata(content: string): WorkflowMetadata | null {
126 | try {
127 | // Simple regex to extract workflow export object
128 | const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/);
129 |
130 | if (!workflowMatch) {
131 | return null;
132 | }
133 |
134 | const workflowObj = workflowMatch[1];
135 |
136 | // Extract name
137 | const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/);
138 | if (!nameMatch) return null;
139 |
140 | // Extract description
141 | const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/);
142 | if (!descMatch) return null;
143 |
144 | return {
145 | name: nameMatch[1],
146 | description: descMatch[1],
147 | };
148 | } catch (error) {
149 | console.warn('Failed to extract workflow metadata:', error);
150 | return null;
151 | }
152 | }
153 |
154 | async function generatePluginsFileContent(
155 | workflowLoaders: Record<string, string>,
156 | workflowMetadata: Record<string, WorkflowMetadata>,
157 | ): Promise<string> {
158 | const loaderEntries = Object.entries(workflowLoaders)
159 | .map(([key, loader]) => {
160 | const indentedLoader = loader
161 | .split('\n')
162 | .map((line, index) => (index === 0 ? ` '${key}': ${line}` : ` ${line}`))
163 | .join('\n');
164 | return indentedLoader;
165 | })
166 | .join(',\n');
167 |
168 | const metadataEntries = Object.entries(workflowMetadata)
169 | .map(([key, metadata]) => {
170 | const metadataJson = JSON.stringify(metadata, null, 4)
171 | .split('\n')
172 | .map((line) => ` ${line}`)
173 | .join('\n');
174 | return ` '${key}': ${metadataJson.trim()}`;
175 | })
176 | .join(',\n');
177 |
178 | const content = `// AUTO-GENERATED - DO NOT EDIT
179 | // This file is generated by the plugin discovery esbuild plugin
180 |
181 | // Generated based on filesystem scan
182 | export const WORKFLOW_LOADERS = {
183 | ${loaderEntries}
184 | };
185 |
186 | export type WorkflowName = keyof typeof WORKFLOW_LOADERS;
187 |
188 | // Optional: Export workflow metadata for quick access
189 | export const WORKFLOW_METADATA = {
190 | ${metadataEntries}
191 | };
192 | `;
193 | return formatGenerated(content);
194 | }
195 |
196 | export async function generateResourceLoaders(): Promise<void> {
197 | const resourcesDir = path.resolve(process.cwd(), 'src/mcp/resources');
198 |
199 | if (!existsSync(resourcesDir)) {
200 | console.log('Resources directory not found, skipping resource generation');
201 | return;
202 | }
203 |
204 | const resourceFiles = readdirSync(resourcesDir, { withFileTypes: true })
205 | .filter((dirent) => dirent.isFile())
206 | .map((dirent) => dirent.name)
207 | .filter(
208 | (name) =>
209 | (name.endsWith('.ts') || name.endsWith('.js')) &&
210 | !name.endsWith('.test.ts') &&
211 | !name.endsWith('.test.js') &&
212 | !name.startsWith('__'),
213 | );
214 |
215 | const resourceLoaders: Record<string, string> = {};
216 |
217 | for (const fileName of resourceFiles) {
218 | const resourceName = fileName.replace(/\.(ts|js)$/, '');
219 | resourceLoaders[resourceName] = `async () => {
220 | const module = await import('../mcp/resources/${resourceName}.ts');
221 | return module.default;
222 | }`;
223 |
224 | console.log(`✅ Discovered resource: ${resourceName}`);
225 | }
226 |
227 | const generatedContent = await generateResourcesFileContent(resourceLoaders);
228 | const outputPath = path.resolve(process.cwd(), 'src/core/generated-resources.ts');
229 |
230 | const fs = await import('fs');
231 | await fs.promises.writeFile(outputPath, generatedContent, 'utf8');
232 |
233 | console.log(`🔧 Generated resource loaders for ${Object.keys(resourceLoaders).length} resources`);
234 | }
235 |
236 | async function generateResourcesFileContent(
237 | resourceLoaders: Record<string, string>,
238 | ): Promise<string> {
239 | const loaderEntries = Object.entries(resourceLoaders)
240 | .map(([key, loader]) => ` '${key}': ${loader}`)
241 | .join(',\n');
242 |
243 | const content = `// AUTO-GENERATED - DO NOT EDIT
244 | // This file is generated by the plugin discovery esbuild plugin
245 |
246 | export const RESOURCE_LOADERS = {
247 | ${loaderEntries}
248 | };
249 |
250 | export type ResourceName = keyof typeof RESOURCE_LOADERS;
251 | `;
252 | return formatGenerated(content);
253 | }
254 |
255 | async function formatGenerated(content: string): Promise<string> {
256 | try {
257 | const { resolve } = await import('node:path');
258 | const { pathToFileURL } = await import('node:url');
259 | const prettier = await import('prettier');
260 | let config = (await prettier.resolveConfig(process.cwd())) ?? null;
261 | if (!config) {
262 | try {
263 | const configUrl = pathToFileURL(resolve(process.cwd(), '.prettierrc.js')).href;
264 | const configModule = await import(configUrl);
265 | config = (configModule as { default?: unknown }).default ?? configModule;
266 | } catch {
267 | config = null;
268 | }
269 | }
270 | const options = {
271 | semi: true,
272 | trailingComma: 'all' as const,
273 | singleQuote: true,
274 | printWidth: 100,
275 | tabWidth: 2,
276 | endOfLine: 'auto' as const,
277 | ...(config as Record<string, unknown> | null),
278 | parser: 'typescript',
279 | };
280 | return prettier.format(content, options);
281 | } catch {
282 | return content;
283 | }
284 | }
285 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for swift_package_build plugin
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import {
9 | createMockExecutor,
10 | createMockFileSystemExecutor,
11 | createNoopExecutor,
12 | createMockCommandResponse,
13 | } from '../../../../test-utils/mock-executors.ts';
14 | import swiftPackageBuild, { swift_package_buildLogic } from '../swift_package_build.ts';
15 | import type { CommandExecutor } from '../../../../utils/execution/index.ts';
16 |
17 | describe('swift_package_build plugin', () => {
18 | describe('Export Field Validation (Literal)', () => {
19 | it('should have correct name', () => {
20 | expect(swiftPackageBuild.name).toBe('swift_package_build');
21 | });
22 |
23 | it('should have correct description', () => {
24 | expect(swiftPackageBuild.description).toBe('Builds a Swift Package with swift build');
25 | });
26 |
27 | it('should have handler function', () => {
28 | expect(typeof swiftPackageBuild.handler).toBe('function');
29 | });
30 |
31 | it('should validate schema correctly', () => {
32 | // Test required fields
33 | expect(swiftPackageBuild.schema.packagePath.safeParse('/test/package').success).toBe(true);
34 | expect(swiftPackageBuild.schema.packagePath.safeParse('').success).toBe(true);
35 |
36 | // Test optional fields
37 | expect(swiftPackageBuild.schema.targetName.safeParse('MyTarget').success).toBe(true);
38 | expect(swiftPackageBuild.schema.targetName.safeParse(undefined).success).toBe(true);
39 | expect(swiftPackageBuild.schema.configuration.safeParse('debug').success).toBe(true);
40 | expect(swiftPackageBuild.schema.configuration.safeParse('release').success).toBe(true);
41 | expect(swiftPackageBuild.schema.configuration.safeParse(undefined).success).toBe(true);
42 | expect(swiftPackageBuild.schema.architectures.safeParse(['arm64']).success).toBe(true);
43 | expect(swiftPackageBuild.schema.architectures.safeParse(undefined).success).toBe(true);
44 | expect(swiftPackageBuild.schema.parseAsLibrary.safeParse(true).success).toBe(true);
45 | expect(swiftPackageBuild.schema.parseAsLibrary.safeParse(undefined).success).toBe(true);
46 |
47 | // Test invalid inputs
48 | expect(swiftPackageBuild.schema.packagePath.safeParse(null).success).toBe(false);
49 | expect(swiftPackageBuild.schema.configuration.safeParse('invalid').success).toBe(false);
50 | expect(swiftPackageBuild.schema.architectures.safeParse('not-array').success).toBe(false);
51 | expect(swiftPackageBuild.schema.parseAsLibrary.safeParse('yes').success).toBe(false);
52 | });
53 | });
54 |
55 | let executorCalls: any[] = [];
56 |
57 | beforeEach(() => {
58 | executorCalls = [];
59 | });
60 |
61 | describe('Command Generation Testing', () => {
62 | it('should build correct command for basic build', async () => {
63 | const executor: CommandExecutor = async (args, description, useShell, opts) => {
64 | executorCalls.push({ args, description, useShell, cwd: opts?.cwd });
65 | return createMockCommandResponse({
66 | success: true,
67 | output: 'Build succeeded',
68 | error: undefined,
69 | });
70 | };
71 |
72 | await swift_package_buildLogic(
73 | {
74 | packagePath: '/test/package',
75 | },
76 | executor,
77 | );
78 |
79 | expect(executorCalls).toEqual([
80 | {
81 | args: ['swift', 'build', '--package-path', '/test/package'],
82 | description: 'Swift Package Build',
83 | useShell: true,
84 | cwd: undefined,
85 | },
86 | ]);
87 | });
88 |
89 | it('should build correct command with release configuration', async () => {
90 | const executor: CommandExecutor = async (args, description, useShell, opts) => {
91 | executorCalls.push({ args, description, useShell, cwd: opts?.cwd });
92 | return createMockCommandResponse({
93 | success: true,
94 | output: 'Build succeeded',
95 | error: undefined,
96 | });
97 | };
98 |
99 | await swift_package_buildLogic(
100 | {
101 | packagePath: '/test/package',
102 | configuration: 'release',
103 | },
104 | executor,
105 | );
106 |
107 | expect(executorCalls).toEqual([
108 | {
109 | args: ['swift', 'build', '--package-path', '/test/package', '-c', 'release'],
110 | description: 'Swift Package Build',
111 | useShell: true,
112 | cwd: undefined,
113 | },
114 | ]);
115 | });
116 |
117 | it('should build correct command with all parameters', async () => {
118 | const executor: CommandExecutor = async (args, description, useShell, opts) => {
119 | executorCalls.push({ args, description, useShell, cwd: opts?.cwd });
120 | return createMockCommandResponse({
121 | success: true,
122 | output: 'Build succeeded',
123 | error: undefined,
124 | });
125 | };
126 |
127 | await swift_package_buildLogic(
128 | {
129 | packagePath: '/test/package',
130 | targetName: 'MyTarget',
131 | configuration: 'release',
132 | architectures: ['arm64', 'x86_64'],
133 | parseAsLibrary: true,
134 | },
135 | executor,
136 | );
137 |
138 | expect(executorCalls).toEqual([
139 | {
140 | args: [
141 | 'swift',
142 | 'build',
143 | '--package-path',
144 | '/test/package',
145 | '-c',
146 | 'release',
147 | '--target',
148 | 'MyTarget',
149 | '--arch',
150 | 'arm64',
151 | '--arch',
152 | 'x86_64',
153 | '-Xswiftc',
154 | '-parse-as-library',
155 | ],
156 | description: 'Swift Package Build',
157 | useShell: true,
158 | cwd: undefined,
159 | },
160 | ]);
161 | });
162 | });
163 |
164 | describe('Response Logic Testing', () => {
165 | it('should handle missing packagePath parameter (Zod handles validation)', async () => {
166 | // Note: With createTypedTool, Zod validation happens before the logic function is called
167 | // So we test with a valid but minimal parameter set since validation is handled upstream
168 | const executor = createMockExecutor({
169 | success: true,
170 | output: 'Build succeeded',
171 | });
172 |
173 | const result = await swift_package_buildLogic({ packagePath: '/test/package' }, executor);
174 |
175 | // The logic function should execute normally with valid parameters
176 | // Zod validation errors are handled by createTypedTool wrapper
177 | expect(result.isError).toBe(false);
178 | });
179 |
180 | it('should return successful build response', async () => {
181 | const executor = createMockExecutor({
182 | success: true,
183 | output: 'Build complete.',
184 | });
185 |
186 | const result = await swift_package_buildLogic(
187 | {
188 | packagePath: '/test/package',
189 | },
190 | executor,
191 | );
192 |
193 | expect(result).toEqual({
194 | content: [
195 | { type: 'text', text: '✅ Swift package build succeeded.' },
196 | {
197 | type: 'text',
198 | text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run',
199 | },
200 | { type: 'text', text: 'Build complete.' },
201 | ],
202 | isError: false,
203 | });
204 | });
205 |
206 | it('should return error response for build failure', async () => {
207 | const executor = createMockExecutor({
208 | success: false,
209 | error: 'Compilation failed: error in main.swift',
210 | });
211 |
212 | const result = await swift_package_buildLogic(
213 | {
214 | packagePath: '/test/package',
215 | },
216 | executor,
217 | );
218 |
219 | expect(result).toEqual({
220 | content: [
221 | {
222 | type: 'text',
223 | text: 'Error: Swift package build failed\nDetails: Compilation failed: error in main.swift',
224 | },
225 | ],
226 | isError: true,
227 | });
228 | });
229 |
230 | it('should handle spawn error', async () => {
231 | const executor = async () => {
232 | throw new Error('spawn ENOENT');
233 | };
234 |
235 | const result = await swift_package_buildLogic(
236 | {
237 | packagePath: '/test/package',
238 | },
239 | executor,
240 | );
241 |
242 | expect(result).toEqual({
243 | content: [
244 | {
245 | type: 'text',
246 | text: 'Error: Failed to execute swift build\nDetails: spawn ENOENT',
247 | },
248 | ],
249 | isError: true,
250 | });
251 | });
252 |
253 | it('should handle successful build with parameters', async () => {
254 | const executor = createMockExecutor({
255 | success: true,
256 | output: 'Build complete.',
257 | });
258 |
259 | const result = await swift_package_buildLogic(
260 | {
261 | packagePath: '/test/package',
262 | targetName: 'MyTarget',
263 | configuration: 'release',
264 | architectures: ['arm64', 'x86_64'],
265 | parseAsLibrary: true,
266 | },
267 | executor,
268 | );
269 |
270 | expect(result).toEqual({
271 | content: [
272 | { type: 'text', text: '✅ Swift package build succeeded.' },
273 | {
274 | type: 'text',
275 | text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run',
276 | },
277 | { type: 'text', text: 'Build complete.' },
278 | ],
279 | isError: false,
280 | });
281 | });
282 | });
283 | });
284 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Pure dependency injection test for launch_mac_app plugin
3 | *
4 | * Tests plugin structure and macOS app launching functionality including parameter validation,
5 | * command generation, file validation, and response formatting.
6 | *
7 | * Uses manual call tracking and createMockFileSystemExecutor for file operations.
8 | */
9 |
10 | import { describe, it, expect } from 'vitest';
11 | import * as z from 'zod';
12 | import {
13 | createMockCommandResponse,
14 | createMockFileSystemExecutor,
15 | } from '../../../../test-utils/mock-executors.ts';
16 | import launchMacApp, { launch_mac_appLogic } from '../launch_mac_app.ts';
17 |
18 | describe('launch_mac_app plugin', () => {
19 | describe('Export Field Validation (Literal)', () => {
20 | it('should have correct name', () => {
21 | expect(launchMacApp.name).toBe('launch_mac_app');
22 | });
23 |
24 | it('should have correct description', () => {
25 | expect(launchMacApp.description).toBe(
26 | "Launches a macOS application. IMPORTANT: You MUST provide the appPath parameter. Example: launch_mac_app({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app.",
27 | );
28 | });
29 |
30 | it('should have handler function', () => {
31 | expect(typeof launchMacApp.handler).toBe('function');
32 | });
33 |
34 | it('should validate schema with valid inputs', () => {
35 | const schema = z.object(launchMacApp.schema);
36 | expect(
37 | schema.safeParse({
38 | appPath: '/path/to/MyApp.app',
39 | }).success,
40 | ).toBe(true);
41 | expect(
42 | schema.safeParse({
43 | appPath: '/Applications/Calculator.app',
44 | args: ['--debug'],
45 | }).success,
46 | ).toBe(true);
47 | expect(
48 | schema.safeParse({
49 | appPath: '/path/to/MyApp.app',
50 | args: ['--debug', '--verbose'],
51 | }).success,
52 | ).toBe(true);
53 | });
54 |
55 | it('should validate schema with invalid inputs', () => {
56 | const schema = z.object(launchMacApp.schema);
57 | expect(schema.safeParse({}).success).toBe(false);
58 | expect(schema.safeParse({ appPath: null }).success).toBe(false);
59 | expect(schema.safeParse({ appPath: 123 }).success).toBe(false);
60 | expect(schema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success).toBe(
61 | false,
62 | );
63 | });
64 | });
65 |
66 | describe('Input Validation', () => {
67 | it('should handle non-existent app path', async () => {
68 | const mockExecutor = async () => Promise.resolve(createMockCommandResponse());
69 | const mockFileSystem = createMockFileSystemExecutor({
70 | existsSync: () => false,
71 | });
72 |
73 | const result = await launch_mac_appLogic(
74 | {
75 | appPath: '/path/to/NonExistent.app',
76 | },
77 | mockExecutor,
78 | mockFileSystem,
79 | );
80 |
81 | expect(result).toEqual({
82 | content: [
83 | {
84 | type: 'text',
85 | text: "File not found: '/path/to/NonExistent.app'. Please check the path and try again.",
86 | },
87 | ],
88 | isError: true,
89 | });
90 | });
91 | });
92 |
93 | describe('Command Generation', () => {
94 | it('should generate correct command with minimal parameters', async () => {
95 | const calls: any[] = [];
96 | const mockExecutor = async (command: string[]) => {
97 | calls.push({ command });
98 | return createMockCommandResponse();
99 | };
100 |
101 | const mockFileSystem = createMockFileSystemExecutor({
102 | existsSync: () => true,
103 | });
104 |
105 | await launch_mac_appLogic(
106 | {
107 | appPath: '/path/to/MyApp.app',
108 | },
109 | mockExecutor,
110 | mockFileSystem,
111 | );
112 |
113 | expect(calls).toHaveLength(1);
114 | expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']);
115 | });
116 |
117 | it('should generate correct command with args parameter', async () => {
118 | const calls: any[] = [];
119 | const mockExecutor = async (command: string[]) => {
120 | calls.push({ command });
121 | return createMockCommandResponse();
122 | };
123 |
124 | const mockFileSystem = createMockFileSystemExecutor({
125 | existsSync: () => true,
126 | });
127 |
128 | await launch_mac_appLogic(
129 | {
130 | appPath: '/path/to/MyApp.app',
131 | args: ['--debug', '--verbose'],
132 | },
133 | mockExecutor,
134 | mockFileSystem,
135 | );
136 |
137 | expect(calls).toHaveLength(1);
138 | expect(calls[0].command).toEqual([
139 | 'open',
140 | '/path/to/MyApp.app',
141 | '--args',
142 | '--debug',
143 | '--verbose',
144 | ]);
145 | });
146 |
147 | it('should generate correct command with empty args array', async () => {
148 | const calls: any[] = [];
149 | const mockExecutor = async (command: string[]) => {
150 | calls.push({ command });
151 | return createMockCommandResponse();
152 | };
153 |
154 | const mockFileSystem = createMockFileSystemExecutor({
155 | existsSync: () => true,
156 | });
157 |
158 | await launch_mac_appLogic(
159 | {
160 | appPath: '/path/to/MyApp.app',
161 | args: [],
162 | },
163 | mockExecutor,
164 | mockFileSystem,
165 | );
166 |
167 | expect(calls).toHaveLength(1);
168 | expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']);
169 | });
170 |
171 | it('should handle paths with spaces correctly', async () => {
172 | const calls: any[] = [];
173 | const mockExecutor = async (command: string[]) => {
174 | calls.push({ command });
175 | return createMockCommandResponse();
176 | };
177 |
178 | const mockFileSystem = createMockFileSystemExecutor({
179 | existsSync: () => true,
180 | });
181 |
182 | await launch_mac_appLogic(
183 | {
184 | appPath: '/Applications/My App.app',
185 | },
186 | mockExecutor,
187 | mockFileSystem,
188 | );
189 |
190 | expect(calls).toHaveLength(1);
191 | expect(calls[0].command).toEqual(['open', '/Applications/My App.app']);
192 | });
193 | });
194 |
195 | describe('Response Processing', () => {
196 | it('should return successful launch response', async () => {
197 | const mockExecutor = async () => Promise.resolve(createMockCommandResponse());
198 |
199 | const mockFileSystem = createMockFileSystemExecutor({
200 | existsSync: () => true,
201 | });
202 |
203 | const result = await launch_mac_appLogic(
204 | {
205 | appPath: '/path/to/MyApp.app',
206 | },
207 | mockExecutor,
208 | mockFileSystem,
209 | );
210 |
211 | expect(result).toEqual({
212 | content: [
213 | {
214 | type: 'text',
215 | text: '✅ macOS app launched successfully: /path/to/MyApp.app',
216 | },
217 | ],
218 | });
219 | });
220 |
221 | it('should return successful launch response with args', async () => {
222 | const mockExecutor = async () => Promise.resolve(createMockCommandResponse());
223 |
224 | const mockFileSystem = createMockFileSystemExecutor({
225 | existsSync: () => true,
226 | });
227 |
228 | const result = await launch_mac_appLogic(
229 | {
230 | appPath: '/path/to/MyApp.app',
231 | args: ['--debug', '--verbose'],
232 | },
233 | mockExecutor,
234 | mockFileSystem,
235 | );
236 |
237 | expect(result).toEqual({
238 | content: [
239 | {
240 | type: 'text',
241 | text: '✅ macOS app launched successfully: /path/to/MyApp.app',
242 | },
243 | ],
244 | });
245 | });
246 |
247 | it('should handle launch failure with Error object', async () => {
248 | const mockExecutor = async () => {
249 | throw new Error('App not found');
250 | };
251 |
252 | const mockFileSystem = createMockFileSystemExecutor({
253 | existsSync: () => true,
254 | });
255 |
256 | const result = await launch_mac_appLogic(
257 | {
258 | appPath: '/path/to/MyApp.app',
259 | },
260 | mockExecutor,
261 | mockFileSystem,
262 | );
263 |
264 | expect(result).toEqual({
265 | content: [
266 | {
267 | type: 'text',
268 | text: '❌ Launch macOS app operation failed: App not found',
269 | },
270 | ],
271 | isError: true,
272 | });
273 | });
274 |
275 | it('should handle launch failure with string error', async () => {
276 | const mockExecutor = async () => {
277 | throw 'Permission denied';
278 | };
279 |
280 | const mockFileSystem = createMockFileSystemExecutor({
281 | existsSync: () => true,
282 | });
283 |
284 | const result = await launch_mac_appLogic(
285 | {
286 | appPath: '/path/to/MyApp.app',
287 | },
288 | mockExecutor,
289 | mockFileSystem,
290 | );
291 |
292 | expect(result).toEqual({
293 | content: [
294 | {
295 | type: 'text',
296 | text: '❌ Launch macOS app operation failed: Permission denied',
297 | },
298 | ],
299 | isError: true,
300 | });
301 | });
302 |
303 | it('should handle launch failure with unknown error type', async () => {
304 | const mockExecutor = async () => {
305 | throw 123;
306 | };
307 |
308 | const mockFileSystem = createMockFileSystemExecutor({
309 | existsSync: () => true,
310 | });
311 |
312 | const result = await launch_mac_appLogic(
313 | {
314 | appPath: '/path/to/MyApp.app',
315 | },
316 | mockExecutor,
317 | mockFileSystem,
318 | );
319 |
320 | expect(result).toEqual({
321 | content: [
322 | {
323 | type: 'text',
324 | text: '❌ Launch macOS app operation failed: 123',
325 | },
326 | ],
327 | isError: true,
328 | });
329 | });
330 | });
331 | });
332 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import * as z from 'zod';
3 | import plugin, { get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts';
4 | import {
5 | createMockFileSystemExecutor,
6 | createCommandMatchingMockExecutor,
7 | } from '../../../../test-utils/mock-executors.ts';
8 |
9 | describe('get_mac_bundle_id plugin', () => {
10 | // Helper function to create mock executor for command matching
11 | const createMockExecutorForCommands = (results: Record<string, string | Error>) => {
12 | return createCommandMatchingMockExecutor(
13 | Object.fromEntries(
14 | Object.entries(results).map(([command, result]) => [
15 | command,
16 | result instanceof Error
17 | ? { success: false, error: result.message }
18 | : { success: true, output: result },
19 | ]),
20 | ),
21 | );
22 | };
23 |
24 | describe('Export Field Validation (Literal)', () => {
25 | it('should have correct name', () => {
26 | expect(plugin.name).toBe('get_mac_bundle_id');
27 | });
28 |
29 | it('should have correct description', () => {
30 | expect(plugin.description).toBe(
31 | "Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_mac_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.",
32 | );
33 | });
34 |
35 | it('should have handler function', () => {
36 | expect(typeof plugin.handler).toBe('function');
37 | });
38 |
39 | it('should validate schema with valid inputs', () => {
40 | const schema = z.object(plugin.schema);
41 | expect(schema.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true);
42 | expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true);
43 | });
44 |
45 | it('should validate schema with invalid inputs', () => {
46 | const schema = z.object(plugin.schema);
47 | expect(schema.safeParse({}).success).toBe(false);
48 | expect(schema.safeParse({ appPath: 123 }).success).toBe(false);
49 | expect(schema.safeParse({ appPath: null }).success).toBe(false);
50 | expect(schema.safeParse({ appPath: undefined }).success).toBe(false);
51 | });
52 | });
53 |
54 | describe('Handler Behavior (Complete Literal Returns)', () => {
55 | // Note: appPath validation is now handled by Zod schema validation in createTypedTool
56 | // This test would not reach the logic function as Zod validation occurs before it
57 |
58 | it('should return error when file exists validation fails', async () => {
59 | const mockExecutor = createMockExecutorForCommands({});
60 | const mockFileSystemExecutor = createMockFileSystemExecutor({
61 | existsSync: () => false,
62 | });
63 |
64 | const result = await get_mac_bundle_idLogic(
65 | { appPath: '/Applications/MyApp.app' },
66 | mockExecutor,
67 | mockFileSystemExecutor,
68 | );
69 |
70 | expect(result).toEqual({
71 | content: [
72 | {
73 | type: 'text',
74 | text: "File not found: '/Applications/MyApp.app'. Please check the path and try again.",
75 | },
76 | ],
77 | isError: true,
78 | });
79 | });
80 |
81 | it('should return success with bundle ID using defaults read', async () => {
82 | const mockExecutor = createMockExecutorForCommands({
83 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier':
84 | 'com.example.MyMacApp',
85 | });
86 | const mockFileSystemExecutor = createMockFileSystemExecutor({
87 | existsSync: () => true,
88 | });
89 |
90 | const result = await get_mac_bundle_idLogic(
91 | { appPath: '/Applications/MyApp.app' },
92 | mockExecutor,
93 | mockFileSystemExecutor,
94 | );
95 |
96 | expect(result).toEqual({
97 | content: [
98 | {
99 | type: 'text',
100 | text: '✅ Bundle ID: com.example.MyMacApp',
101 | },
102 | {
103 | type: 'text',
104 | text: `Next Steps:
105 | - Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" })
106 | - Build again: build_macos({ scheme: "SCHEME_NAME" })`,
107 | },
108 | ],
109 | isError: false,
110 | });
111 | });
112 |
113 | it('should fallback to PlistBuddy when defaults read fails', async () => {
114 | const mockExecutor = createMockExecutorForCommands({
115 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
116 | 'defaults read failed',
117 | ),
118 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
119 | 'com.example.MyMacApp',
120 | });
121 | const mockFileSystemExecutor = createMockFileSystemExecutor({
122 | existsSync: () => true,
123 | });
124 |
125 | const result = await get_mac_bundle_idLogic(
126 | { appPath: '/Applications/MyApp.app' },
127 | mockExecutor,
128 | mockFileSystemExecutor,
129 | );
130 |
131 | expect(result).toEqual({
132 | content: [
133 | {
134 | type: 'text',
135 | text: '✅ Bundle ID: com.example.MyMacApp',
136 | },
137 | {
138 | type: 'text',
139 | text: `Next Steps:
140 | - Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" })
141 | - Build again: build_macos({ scheme: "SCHEME_NAME" })`,
142 | },
143 | ],
144 | isError: false,
145 | });
146 | });
147 |
148 | it('should return error when both extraction methods fail', async () => {
149 | const mockExecutor = createMockExecutorForCommands({
150 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
151 | 'Command failed',
152 | ),
153 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
154 | new Error('Command failed'),
155 | });
156 | const mockFileSystemExecutor = createMockFileSystemExecutor({
157 | existsSync: () => true,
158 | });
159 |
160 | const result = await get_mac_bundle_idLogic(
161 | { appPath: '/Applications/MyApp.app' },
162 | mockExecutor,
163 | mockFileSystemExecutor,
164 | );
165 |
166 | expect(result.isError).toBe(true);
167 | expect(result.content).toHaveLength(2);
168 | expect(result.content[0].type).toBe('text');
169 | expect(result.content[0].text).toContain('Error extracting macOS bundle ID');
170 | expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist');
171 | expect(result.content[0].text).toContain('Command failed');
172 | expect(result.content[1].type).toBe('text');
173 | expect(result.content[1].text).toBe(
174 | 'Make sure the path points to a valid macOS app bundle (.app directory).',
175 | );
176 | });
177 |
178 | it('should handle Error objects in catch blocks', async () => {
179 | const mockExecutor = createMockExecutorForCommands({
180 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
181 | 'Custom error message',
182 | ),
183 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
184 | new Error('Custom error message'),
185 | });
186 | const mockFileSystemExecutor = createMockFileSystemExecutor({
187 | existsSync: () => true,
188 | });
189 |
190 | const result = await get_mac_bundle_idLogic(
191 | { appPath: '/Applications/MyApp.app' },
192 | mockExecutor,
193 | mockFileSystemExecutor,
194 | );
195 |
196 | expect(result.isError).toBe(true);
197 | expect(result.content).toHaveLength(2);
198 | expect(result.content[0].type).toBe('text');
199 | expect(result.content[0].text).toContain('Error extracting macOS bundle ID');
200 | expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist');
201 | expect(result.content[0].text).toContain('Custom error message');
202 | expect(result.content[1].type).toBe('text');
203 | expect(result.content[1].text).toBe(
204 | 'Make sure the path points to a valid macOS app bundle (.app directory).',
205 | );
206 | });
207 |
208 | it('should handle string errors in catch blocks', async () => {
209 | const mockExecutor = createMockExecutorForCommands({
210 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
211 | 'String error',
212 | ),
213 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
214 | new Error('String error'),
215 | });
216 | const mockFileSystemExecutor = createMockFileSystemExecutor({
217 | existsSync: () => true,
218 | });
219 |
220 | const result = await get_mac_bundle_idLogic(
221 | { appPath: '/Applications/MyApp.app' },
222 | mockExecutor,
223 | mockFileSystemExecutor,
224 | );
225 |
226 | expect(result.isError).toBe(true);
227 | expect(result.content).toHaveLength(2);
228 | expect(result.content[0].type).toBe('text');
229 | expect(result.content[0].text).toContain('Error extracting macOS bundle ID');
230 | expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist');
231 | expect(result.content[0].text).toContain('String error');
232 | expect(result.content[1].type).toBe('text');
233 | expect(result.content[1].text).toBe(
234 | 'Make sure the path points to a valid macOS app bundle (.app directory).',
235 | );
236 | });
237 | });
238 | });
239 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/doctor/__tests__/doctor.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for doctor plugin
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import * as z from 'zod';
9 | import doctor, { runDoctor, type DoctorDependencies } from '../doctor.ts';
10 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
11 |
12 | function createDeps(overrides?: Partial<DoctorDependencies>): DoctorDependencies {
13 | const base: DoctorDependencies = {
14 | commandExecutor: createMockExecutor({ output: 'lldb-dap' }),
15 | binaryChecker: {
16 | async checkBinaryAvailability(binary: string) {
17 | // default: all available with generic version
18 | return { available: true, version: `${binary} version 1.0.0` };
19 | },
20 | },
21 | xcode: {
22 | async getXcodeInfo() {
23 | return {
24 | version: 'Xcode 15.0 - Build version 15A240d',
25 | path: '/Applications/Xcode.app/Contents/Developer',
26 | selectedXcode: '/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild',
27 | xcrunVersion: 'xcrun version 65',
28 | };
29 | },
30 | },
31 | env: {
32 | getEnvironmentVariables() {
33 | const x: Record<string, string | undefined> = {
34 | XCODEBUILDMCP_DEBUG: 'true',
35 | INCREMENTAL_BUILDS_ENABLED: '1',
36 | PATH: '/usr/local/bin:/usr/bin:/bin',
37 | DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
38 | HOME: '/Users/testuser',
39 | USER: 'testuser',
40 | TMPDIR: '/tmp',
41 | NODE_ENV: 'test',
42 | SENTRY_DISABLED: 'false',
43 | };
44 | return x;
45 | },
46 | getSystemInfo() {
47 | return {
48 | platform: 'darwin',
49 | release: '25.0.0',
50 | arch: 'arm64',
51 | cpus: '10 x Apple M3',
52 | memory: '32 GB',
53 | hostname: 'localhost',
54 | username: 'testuser',
55 | homedir: '/Users/testuser',
56 | tmpdir: '/tmp',
57 | };
58 | },
59 | getNodeInfo() {
60 | return {
61 | version: 'v22.0.0',
62 | execPath: '/usr/local/bin/node',
63 | pid: '123',
64 | ppid: '1',
65 | platform: 'darwin',
66 | arch: 'arm64',
67 | cwd: '/',
68 | argv: 'node build/index.js',
69 | };
70 | },
71 | },
72 | plugins: {
73 | async getPluginSystemInfo() {
74 | return {
75 | totalPlugins: 1,
76 | pluginDirectories: 1,
77 | pluginsByDirectory: { doctor: ['doctor'] },
78 | systemMode: 'plugin-based',
79 | };
80 | },
81 | },
82 | features: {
83 | areAxeToolsAvailable: () => true,
84 | isXcodemakeEnabled: () => true,
85 | isXcodemakeAvailable: async () => true,
86 | doesMakefileExist: () => true,
87 | },
88 | runtime: {
89 | async getRuntimeToolInfo() {
90 | return {
91 | mode: 'runtime' as const,
92 | enabledWorkflows: ['doctor'],
93 | enabledTools: ['doctor'],
94 | totalRegistered: 1,
95 | };
96 | },
97 | },
98 | };
99 |
100 | return {
101 | ...base,
102 | ...overrides,
103 | binaryChecker: {
104 | ...base.binaryChecker,
105 | ...(overrides?.binaryChecker ?? {}),
106 | },
107 | xcode: {
108 | ...base.xcode,
109 | ...(overrides?.xcode ?? {}),
110 | },
111 | env: {
112 | ...base.env,
113 | ...(overrides?.env ?? {}),
114 | },
115 | plugins: {
116 | ...base.plugins,
117 | ...(overrides?.plugins ?? {}),
118 | },
119 | features: {
120 | ...base.features,
121 | ...(overrides?.features ?? {}),
122 | },
123 | };
124 | }
125 |
126 | describe('doctor tool', () => {
127 | // Reset any state if needed
128 |
129 | describe('Export Field Validation (Literal)', () => {
130 | it('should have correct name', () => {
131 | expect(doctor.name).toBe('doctor');
132 | });
133 |
134 | it('should have correct description', () => {
135 | expect(doctor.description).toBe(
136 | 'Provides comprehensive information about the MCP server environment, available dependencies, and configuration status.',
137 | );
138 | });
139 |
140 | it('should have handler function', () => {
141 | expect(typeof doctor.handler).toBe('function');
142 | });
143 |
144 | it('should have correct schema with enabled boolean field', () => {
145 | const schema = z.object(doctor.schema);
146 |
147 | // Valid inputs
148 | expect(schema.safeParse({ enabled: true }).success).toBe(true);
149 | expect(schema.safeParse({ enabled: false }).success).toBe(true);
150 | expect(schema.safeParse({}).success).toBe(true); // enabled is optional
151 |
152 | // Invalid inputs
153 | expect(schema.safeParse({ enabled: 'true' }).success).toBe(false);
154 | expect(schema.safeParse({ enabled: 1 }).success).toBe(false);
155 | expect(schema.safeParse({ enabled: null }).success).toBe(false);
156 | });
157 | });
158 |
159 | describe('Handler Behavior (Complete Literal Returns)', () => {
160 | it('should handle successful doctor execution', async () => {
161 | const deps = createDeps();
162 | const result = await runDoctor({ enabled: true }, deps);
163 |
164 | expect(result.content).toEqual([
165 | {
166 | type: 'text',
167 | text: result.content[0].text,
168 | },
169 | ]);
170 | expect(typeof result.content[0].text).toBe('string');
171 | });
172 |
173 | it('should handle plugin loading failure', async () => {
174 | const deps = createDeps({
175 | plugins: {
176 | async getPluginSystemInfo() {
177 | return { error: 'Plugin loading failed', systemMode: 'error' };
178 | },
179 | },
180 | });
181 |
182 | const result = await runDoctor({ enabled: true }, deps);
183 |
184 | expect(result.content).toEqual([
185 | {
186 | type: 'text',
187 | text: result.content[0].text,
188 | },
189 | ]);
190 | expect(typeof result.content[0].text).toBe('string');
191 | });
192 |
193 | it('should handle xcode command failure', async () => {
194 | const deps = createDeps({
195 | xcode: {
196 | async getXcodeInfo() {
197 | return { error: 'Xcode not found' };
198 | },
199 | },
200 | });
201 | const result = await runDoctor({ enabled: true }, deps);
202 |
203 | expect(result.content).toEqual([
204 | {
205 | type: 'text',
206 | text: result.content[0].text,
207 | },
208 | ]);
209 | expect(typeof result.content[0].text).toBe('string');
210 | });
211 |
212 | it('should handle xcodemake check failure', async () => {
213 | const deps = createDeps({
214 | features: {
215 | areAxeToolsAvailable: () => true,
216 | isXcodemakeEnabled: () => true,
217 | isXcodemakeAvailable: async () => false,
218 | doesMakefileExist: () => true,
219 | },
220 | binaryChecker: {
221 | async checkBinaryAvailability(binary: string) {
222 | if (binary === 'xcodemake') return { available: false };
223 | return { available: true, version: `${binary} version 1.0.0` };
224 | },
225 | },
226 | });
227 | const result = await runDoctor({ enabled: true }, deps);
228 |
229 | expect(result.content).toEqual([
230 | {
231 | type: 'text',
232 | text: result.content[0].text,
233 | },
234 | ]);
235 | expect(typeof result.content[0].text).toBe('string');
236 | });
237 |
238 | it('should handle axe tools not available', async () => {
239 | const deps = createDeps({
240 | features: {
241 | areAxeToolsAvailable: () => false,
242 | isXcodemakeEnabled: () => false,
243 | isXcodemakeAvailable: async () => false,
244 | doesMakefileExist: () => false,
245 | },
246 | binaryChecker: {
247 | async checkBinaryAvailability(binary: string) {
248 | if (binary === 'axe') return { available: false };
249 | if (binary === 'xcodemake') return { available: false };
250 | if (binary === 'mise') return { available: true, version: 'mise 1.0.0' };
251 | return { available: true };
252 | },
253 | },
254 | env: {
255 | getEnvironmentVariables() {
256 | const x: Record<string, string | undefined> = {
257 | XCODEBUILDMCP_DEBUG: 'true',
258 | INCREMENTAL_BUILDS_ENABLED: '0',
259 | PATH: '/usr/local/bin:/usr/bin:/bin',
260 | DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
261 | HOME: '/Users/testuser',
262 | USER: 'testuser',
263 | TMPDIR: '/tmp',
264 | NODE_ENV: 'test',
265 | SENTRY_DISABLED: 'true',
266 | };
267 | return x;
268 | },
269 | getSystemInfo: () => ({
270 | platform: 'darwin',
271 | release: '25.0.0',
272 | arch: 'arm64',
273 | cpus: '10 x Apple M3',
274 | memory: '32 GB',
275 | hostname: 'localhost',
276 | username: 'testuser',
277 | homedir: '/Users/testuser',
278 | tmpdir: '/tmp',
279 | }),
280 | getNodeInfo: () => ({
281 | version: 'v22.0.0',
282 | execPath: '/usr/local/bin/node',
283 | pid: '123',
284 | ppid: '1',
285 | platform: 'darwin',
286 | arch: 'arm64',
287 | cwd: '/',
288 | argv: 'node build/index.js',
289 | }),
290 | },
291 | });
292 |
293 | const result = await runDoctor({ enabled: true }, deps);
294 |
295 | expect(result.content).toEqual([
296 | {
297 | type: 'text',
298 | text: result.content[0].text,
299 | },
300 | ]);
301 | expect(typeof result.content[0].text).toBe('string');
302 | });
303 | });
304 | });
305 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for start_sim_log_cap plugin
3 | */
4 | import { describe, it, expect } from 'vitest';
5 | import * as z from 'zod';
6 | import plugin, { start_sim_log_capLogic } from '../start_sim_log_cap.ts';
7 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
8 |
9 | describe('start_sim_log_cap plugin', () => {
10 | // Reset any test state if needed
11 |
12 | describe('Export Field Validation (Literal)', () => {
13 | it('should export an object with required properties', () => {
14 | expect(plugin).toHaveProperty('name');
15 | expect(plugin).toHaveProperty('description');
16 | expect(plugin).toHaveProperty('schema');
17 | expect(plugin).toHaveProperty('handler');
18 | });
19 |
20 | it('should have correct tool name', () => {
21 | expect(plugin.name).toBe('start_sim_log_cap');
22 | });
23 |
24 | it('should have correct description', () => {
25 | expect(plugin.description).toBe(
26 | 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.',
27 | );
28 | });
29 |
30 | it('should have handler as a function', () => {
31 | expect(typeof plugin.handler).toBe('function');
32 | });
33 |
34 | it('should validate schema with valid parameters', () => {
35 | const schema = z.object(plugin.schema);
36 | expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
37 | expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: true }).success).toBe(
38 | true,
39 | );
40 | expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: false }).success).toBe(
41 | true,
42 | );
43 | });
44 |
45 | it('should reject invalid schema parameters', () => {
46 | const schema = z.object(plugin.schema);
47 | expect(schema.safeParse({ bundleId: null }).success).toBe(false);
48 | expect(schema.safeParse({ captureConsole: true }).success).toBe(false);
49 | expect(schema.safeParse({}).success).toBe(false);
50 | expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: 'yes' }).success).toBe(
51 | false,
52 | );
53 | expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: 123 }).success).toBe(
54 | false,
55 | );
56 |
57 | const withSimId = schema.safeParse({ simulatorId: 'test-uuid', bundleId: 'com.example.app' });
58 | expect(withSimId.success).toBe(true);
59 | expect('simulatorId' in (withSimId.data as any)).toBe(false);
60 | });
61 | });
62 |
63 | describe('Handler Behavior (Complete Literal Returns)', () => {
64 | // Note: Parameter validation is now handled by createTypedTool wrapper
65 | // Invalid parameters will not reach the logic function, so we test valid scenarios
66 |
67 | it('should return error when log capture fails', async () => {
68 | const mockExecutor = createMockExecutor({ success: true, output: '' });
69 | const logCaptureStub = (params: any, executor: any) => {
70 | return Promise.resolve({
71 | sessionId: '',
72 | logFilePath: '',
73 | processes: [],
74 | error: 'Permission denied',
75 | });
76 | };
77 |
78 | const result = await start_sim_log_capLogic(
79 | {
80 | simulatorId: 'test-uuid',
81 | bundleId: 'com.example.app',
82 | },
83 | mockExecutor,
84 | logCaptureStub,
85 | );
86 |
87 | expect(result.isError).toBe(true);
88 | expect(result.content[0].text).toBe('Error starting log capture: Permission denied');
89 | });
90 |
91 | it('should return success with session ID when log capture starts successfully', async () => {
92 | const mockExecutor = createMockExecutor({ success: true, output: '' });
93 | const logCaptureStub = (params: any, executor: any) => {
94 | return Promise.resolve({
95 | sessionId: 'test-uuid-123',
96 | logFilePath: '/tmp/test.log',
97 | processes: [],
98 | error: undefined,
99 | });
100 | };
101 |
102 | const result = await start_sim_log_capLogic(
103 | {
104 | simulatorId: 'test-uuid',
105 | bundleId: 'com.example.app',
106 | },
107 | mockExecutor,
108 | logCaptureStub,
109 | );
110 |
111 | expect(result.isError).toBeUndefined();
112 | expect(result.content[0].text).toBe(
113 | "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Only structured logs are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
114 | );
115 | });
116 |
117 | it('should indicate console capture when captureConsole is true', async () => {
118 | const mockExecutor = createMockExecutor({ success: true, output: '' });
119 | const logCaptureStub = (params: any, executor: any) => {
120 | return Promise.resolve({
121 | sessionId: 'test-uuid-123',
122 | logFilePath: '/tmp/test.log',
123 | processes: [],
124 | error: undefined,
125 | });
126 | };
127 |
128 | const result = await start_sim_log_capLogic(
129 | {
130 | simulatorId: 'test-uuid',
131 | bundleId: 'com.example.app',
132 | captureConsole: true,
133 | },
134 | mockExecutor,
135 | logCaptureStub,
136 | );
137 |
138 | expect(result.content[0].text).toBe(
139 | "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Your app was relaunched to capture console output.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
140 | );
141 | });
142 |
143 | it('should create correct spawn commands for console capture', async () => {
144 | const mockExecutor = createMockExecutor({ success: true, output: '' });
145 | const spawnCalls: Array<{
146 | command: string;
147 | args: string[];
148 | }> = [];
149 |
150 | const logCaptureStub = (params: any, executor: any) => {
151 | if (params.captureConsole) {
152 | // Record the console capture spawn call
153 | spawnCalls.push({
154 | command: 'xcrun',
155 | args: [
156 | 'simctl',
157 | 'launch',
158 | '--console-pty',
159 | '--terminate-running-process',
160 | params.simulatorUuid,
161 | params.bundleId,
162 | ],
163 | });
164 | }
165 | // Record the structured log capture spawn call
166 | spawnCalls.push({
167 | command: 'xcrun',
168 | args: [
169 | 'simctl',
170 | 'spawn',
171 | params.simulatorUuid,
172 | 'log',
173 | 'stream',
174 | '--level=debug',
175 | '--predicate',
176 | `subsystem == "${params.bundleId}"`,
177 | ],
178 | });
179 |
180 | return Promise.resolve({
181 | sessionId: 'test-uuid-123',
182 | logFilePath: '/tmp/test.log',
183 | processes: [],
184 | error: undefined,
185 | });
186 | };
187 |
188 | await start_sim_log_capLogic(
189 | {
190 | simulatorId: 'test-uuid',
191 | bundleId: 'com.example.app',
192 | captureConsole: true,
193 | },
194 | mockExecutor,
195 | logCaptureStub,
196 | );
197 |
198 | // Should spawn both console capture and structured log capture
199 | expect(spawnCalls).toHaveLength(2);
200 | expect(spawnCalls[0]).toEqual({
201 | command: 'xcrun',
202 | args: [
203 | 'simctl',
204 | 'launch',
205 | '--console-pty',
206 | '--terminate-running-process',
207 | 'test-uuid',
208 | 'com.example.app',
209 | ],
210 | });
211 | expect(spawnCalls[1]).toEqual({
212 | command: 'xcrun',
213 | args: [
214 | 'simctl',
215 | 'spawn',
216 | 'test-uuid',
217 | 'log',
218 | 'stream',
219 | '--level=debug',
220 | '--predicate',
221 | 'subsystem == "com.example.app"',
222 | ],
223 | });
224 | });
225 |
226 | it('should create correct spawn commands for structured logs only', async () => {
227 | const mockExecutor = createMockExecutor({ success: true, output: '' });
228 | const spawnCalls: Array<{
229 | command: string;
230 | args: string[];
231 | }> = [];
232 |
233 | const logCaptureStub = (params: any, executor: any) => {
234 | // Record the structured log capture spawn call only
235 | spawnCalls.push({
236 | command: 'xcrun',
237 | args: [
238 | 'simctl',
239 | 'spawn',
240 | params.simulatorUuid,
241 | 'log',
242 | 'stream',
243 | '--level=debug',
244 | '--predicate',
245 | `subsystem == "${params.bundleId}"`,
246 | ],
247 | });
248 |
249 | return Promise.resolve({
250 | sessionId: 'test-uuid-123',
251 | logFilePath: '/tmp/test.log',
252 | processes: [],
253 | error: undefined,
254 | });
255 | };
256 |
257 | await start_sim_log_capLogic(
258 | {
259 | simulatorId: 'test-uuid',
260 | bundleId: 'com.example.app',
261 | captureConsole: false,
262 | },
263 | mockExecutor,
264 | logCaptureStub,
265 | );
266 |
267 | // Should only spawn structured log capture
268 | expect(spawnCalls).toHaveLength(1);
269 | expect(spawnCalls[0]).toEqual({
270 | command: 'xcrun',
271 | args: [
272 | 'simctl',
273 | 'spawn',
274 | 'test-uuid',
275 | 'log',
276 | 'stream',
277 | '--level=debug',
278 | '--predicate',
279 | 'subsystem == "com.example.app"',
280 | ],
281 | });
282 | });
283 | });
284 | });
285 |
```
--------------------------------------------------------------------------------
/src/utils/typed-tool-factory.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Type-safe tool factory for XcodeBuildMCP
3 | *
4 | * This module provides a factory function to create MCP tool handlers that safely
5 | * convert from the generic Record<string, unknown> signature required by the MCP SDK
6 | * to strongly-typed parameters using runtime validation with Zod.
7 | *
8 | * This eliminates the need for unsafe type assertions while maintaining full
9 | * compatibility with the MCP SDK's tool handler signature requirements.
10 | */
11 |
12 | import * as z from 'zod';
13 | import { ToolResponse } from '../types/common.ts';
14 | import type { CommandExecutor } from './execution/index.ts';
15 | import { createErrorResponse } from './responses/index.ts';
16 | import { sessionStore, type SessionDefaults } from './session-store.ts';
17 | import { isSessionDefaultsSchemaOptOutEnabled } from './environment.ts';
18 |
19 | function createValidatedHandler<TParams, TContext>(
20 | schema: z.ZodType<TParams, unknown>,
21 | logicFunction: (params: TParams, context: TContext) => Promise<ToolResponse>,
22 | getContext: () => TContext,
23 | ): (args: Record<string, unknown>) => Promise<ToolResponse> {
24 | return async (args: Record<string, unknown>): Promise<ToolResponse> => {
25 | try {
26 | const validatedParams = schema.parse(args);
27 |
28 | return await logicFunction(validatedParams, getContext());
29 | } catch (error) {
30 | if (error instanceof z.ZodError) {
31 | const details = `Invalid parameters:\n${formatZodIssues(error)}`;
32 | return createErrorResponse('Parameter validation failed', details);
33 | }
34 |
35 | // Re-throw unexpected errors (they'll be caught by the MCP framework)
36 | throw error;
37 | }
38 | };
39 | }
40 |
41 | /**
42 | * Creates a type-safe tool handler that validates parameters at runtime
43 | * before passing them to the typed logic function.
44 | *
45 | * @param schema - Zod schema for parameter validation
46 | * @param logicFunction - The typed logic function to execute
47 | * @param getExecutor - Function to get the command executor (must be provided)
48 | * @returns A handler function compatible with MCP SDK requirements
49 | */
50 | export function createTypedTool<TParams>(
51 | schema: z.ZodType<TParams, unknown>,
52 | logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>,
53 | getExecutor: () => CommandExecutor,
54 | ): (args: Record<string, unknown>) => Promise<ToolResponse> {
55 | return createValidatedHandler(schema, logicFunction, getExecutor);
56 | }
57 |
58 | export function createTypedToolWithContext<TParams, TContext>(
59 | schema: z.ZodType<TParams, unknown>,
60 | logicFunction: (params: TParams, context: TContext) => Promise<ToolResponse>,
61 | getContext: () => TContext,
62 | ): (args: Record<string, unknown>) => Promise<ToolResponse> {
63 | return createValidatedHandler(schema, logicFunction, getContext);
64 | }
65 |
66 | export type SessionRequirement =
67 | | { allOf: (keyof SessionDefaults)[]; message?: string }
68 | | { oneOf: (keyof SessionDefaults)[]; message?: string };
69 |
70 | function missingFromMerged(
71 | keys: (keyof SessionDefaults)[],
72 | merged: Record<string, unknown>,
73 | ): string[] {
74 | return keys.filter((k) => merged[k] == null);
75 | }
76 |
77 | function formatRequirementError(opts: {
78 | message: string;
79 | setHint?: string;
80 | optOutEnabled: boolean;
81 | }): { title: string; body: string } {
82 | const title = opts.optOutEnabled
83 | ? 'Missing required parameters'
84 | : 'Missing required session defaults';
85 | const body = opts.optOutEnabled
86 | ? opts.message
87 | : [opts.message, opts.setHint].filter(Boolean).join('\n');
88 | return { title, body };
89 | }
90 |
91 | type ToolSchemaShape = Record<string, z.ZodType>;
92 |
93 | export function getSessionAwareToolSchemaShape(opts: {
94 | sessionAware: z.ZodObject<ToolSchemaShape>;
95 | legacy: z.ZodObject<ToolSchemaShape>;
96 | }): ToolSchemaShape {
97 | return isSessionDefaultsSchemaOptOutEnabled() ? opts.legacy.shape : opts.sessionAware.shape;
98 | }
99 |
100 | export function createSessionAwareTool<TParams>(opts: {
101 | internalSchema: z.ZodType<TParams, unknown>;
102 | logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>;
103 | getExecutor: () => CommandExecutor;
104 | requirements?: SessionRequirement[];
105 | exclusivePairs?: (keyof SessionDefaults)[][]; // when args provide one side, drop conflicting session-default side(s)
106 | }): (rawArgs: Record<string, unknown>) => Promise<ToolResponse> {
107 | return createSessionAwareHandler({
108 | internalSchema: opts.internalSchema,
109 | logicFunction: opts.logicFunction,
110 | getContext: opts.getExecutor,
111 | requirements: opts.requirements,
112 | exclusivePairs: opts.exclusivePairs,
113 | });
114 | }
115 |
116 | export function createSessionAwareToolWithContext<TParams, TContext>(opts: {
117 | internalSchema: z.ZodType<TParams, unknown>;
118 | logicFunction: (params: TParams, context: TContext) => Promise<ToolResponse>;
119 | getContext: () => TContext;
120 | requirements?: SessionRequirement[];
121 | exclusivePairs?: (keyof SessionDefaults)[][];
122 | }): (rawArgs: Record<string, unknown>) => Promise<ToolResponse> {
123 | return createSessionAwareHandler(opts);
124 | }
125 |
126 | function createSessionAwareHandler<TParams, TContext>(opts: {
127 | internalSchema: z.ZodType<TParams, unknown>;
128 | logicFunction: (params: TParams, context: TContext) => Promise<ToolResponse>;
129 | getContext: () => TContext;
130 | requirements?: SessionRequirement[];
131 | exclusivePairs?: (keyof SessionDefaults)[][];
132 | }): (rawArgs: Record<string, unknown>) => Promise<ToolResponse> {
133 | const {
134 | internalSchema,
135 | logicFunction,
136 | getContext,
137 | requirements = [],
138 | exclusivePairs = [],
139 | } = opts;
140 |
141 | return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => {
142 | try {
143 | // Sanitize args: treat null/undefined as "not provided" so they don't override session defaults
144 | const sanitizedArgs: Record<string, unknown> = {};
145 | for (const [k, v] of Object.entries(rawArgs)) {
146 | if (v === null || v === undefined) continue;
147 | if (typeof v === 'string' && v.trim() === '') continue;
148 | sanitizedArgs[k] = v;
149 | }
150 |
151 | // Factory-level mutual exclusivity check: if user provides multiple explicit values
152 | // within an exclusive group, reject early even if tool schema doesn't enforce XOR.
153 | for (const pair of exclusivePairs) {
154 | const provided = pair.filter((k) => Object.prototype.hasOwnProperty.call(sanitizedArgs, k));
155 | if (provided.length >= 2) {
156 | return createErrorResponse(
157 | 'Parameter validation failed',
158 | `Invalid parameters:\nMutually exclusive parameters provided: ${provided.join(
159 | ', ',
160 | )}. Provide only one.`,
161 | );
162 | }
163 | }
164 |
165 | // Start with session defaults merged with explicit args (args override session)
166 | const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...sanitizedArgs };
167 |
168 | // Apply exclusive pair pruning: only when caller provided a concrete (non-null/undefined) value
169 | // for any key in the pair. When activated, drop other keys in the pair coming from session defaults.
170 | for (const pair of exclusivePairs) {
171 | const userProvidedConcrete = pair.some((k) =>
172 | Object.prototype.hasOwnProperty.call(sanitizedArgs, k),
173 | );
174 | if (!userProvidedConcrete) continue;
175 |
176 | for (const k of pair) {
177 | if (!Object.prototype.hasOwnProperty.call(sanitizedArgs, k) && k in merged) {
178 | delete merged[k];
179 | }
180 | }
181 | }
182 |
183 | for (const req of requirements) {
184 | if ('allOf' in req) {
185 | const missing = missingFromMerged(req.allOf, merged);
186 | if (missing.length > 0) {
187 | const setHint = `Set with: session-set-defaults { ${missing
188 | .map((k) => `"${k}": "..."`)
189 | .join(', ')} }`;
190 | const { title, body } = formatRequirementError({
191 | message: req.message ?? `Required: ${req.allOf.join(', ')}`,
192 | setHint,
193 | optOutEnabled: isSessionDefaultsSchemaOptOutEnabled(),
194 | });
195 | return createErrorResponse(title, body);
196 | }
197 | } else if ('oneOf' in req) {
198 | const satisfied = req.oneOf.some((k) => merged[k] != null);
199 | if (!satisfied) {
200 | const options = req.oneOf.join(', ');
201 | const setHints = req.oneOf
202 | .map((k) => `session-set-defaults { "${k}": "..." }`)
203 | .join(' OR ');
204 | const { title, body } = formatRequirementError({
205 | message: req.message ?? `Provide one of: ${options}`,
206 | setHint: `Set with: ${setHints}`,
207 | optOutEnabled: isSessionDefaultsSchemaOptOutEnabled(),
208 | });
209 | return createErrorResponse(title, body);
210 | }
211 | }
212 | }
213 |
214 | const validated = internalSchema.parse(merged);
215 | return await logicFunction(validated, getContext());
216 | } catch (error) {
217 | if (error instanceof z.ZodError) {
218 | const details = `Invalid parameters:\n${formatZodIssues(error)}`;
219 | return createErrorResponse('Parameter validation failed', details);
220 | }
221 | throw error;
222 | }
223 | };
224 | }
225 |
226 | function formatZodIssues(error: z.ZodError): string {
227 | return error.issues
228 | .map((issue) => {
229 | const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
230 | return `${path}: ${issue.message}`;
231 | })
232 | .join('\n');
233 | }
234 |
```
--------------------------------------------------------------------------------
/docs/dev/SMITHERY.md:
--------------------------------------------------------------------------------
```markdown
1 | # TypeScript Servers
2 |
3 | > Deploy and publish TypeScript MCP servers on Smithery using Smithery CLI
4 |
5 | ## Overview
6 |
7 | Deploy TypeScript MCP servers using the official MCP SDK with two deployment options:
8 |
9 | * **Remote deployment**: Automatic containerization and infrastructure managed by Smithery
10 | * **Local servers** (Beta): Distribute your server as [MCP bundle](https://github.com/anthropics/mcpb) allowing users to run it locally and one-click install it
11 |
12 | ## Prerequisites
13 |
14 | * TypeScript MCP server using the official MCP SDK that exports the MCP server object at entry point
15 | * Node.js 18+ and npm installed locally
16 | * Smithery CLI installed as a dev dependency (`npm i -D @smithery/cli`)
17 |
18 | <Note>
19 | **New to MCP servers?** See the [Getting Started guide](/getting_started) to learn how to build TypeScript MCP servers from scratch using the official SDK.
20 | </Note>
21 |
22 | ## Project Structure
23 |
24 | Your TypeScript project should look like this:
25 |
26 | ```
27 | my-mcp-server/
28 | smithery.yaml # Smithery configuration
29 | package.json # Node.js dependencies and scripts
30 | tsconfig.json # TypeScript configuration
31 | src/
32 | index.ts # Your MCP server code with exported createServer function
33 | ```
34 |
35 | ## Setup
36 |
37 | ### 1. Configure smithery.yaml
38 |
39 | Create a `smithery.yaml` file in your repository root (usually where the `package.json` is):
40 |
41 | #### Remote Deployment (Default)
42 |
43 | ```yaml theme={null}
44 | runtime: "typescript"
45 | ```
46 |
47 | #### Local Server (Beta)
48 |
49 | ```yaml theme={null}
50 | runtime: "typescript"
51 | target: "local"
52 | ```
53 |
54 | <Note>
55 | **Local servers are in beta** - When you set `target: "local"`, your server runs locally on user's machine but is accessible through Smithery's registry for easy discovery and connection by MCP clients.
56 | </Note>
57 |
58 | ### 2. Configure package.json
59 |
60 | Your `package.json` must include the `module` field pointing to your server entry point:
61 |
62 | ```json theme={null}
63 | {
64 | "name": "my-mcp-server",
65 | "version": "1.0.0",
66 | "type": "module",
67 | "module": "src/index.ts", // Points to your server entry point
68 | "scripts": {
69 | "build": "npx smithery build",
70 | "dev": "npx smithery dev"
71 | },
72 | "dependencies": {
73 | "@modelcontextprotocol/sdk": "^1.17.3",
74 | "zod": "^3.25.46"
75 | },
76 | "devDependencies": {
77 | "@smithery/cli": "^1.4.6"
78 | }
79 | }
80 | ```
81 |
82 | <Note>
83 | Install the CLI locally with:
84 |
85 | ```bash theme={null}
86 | npm i -D @smithery/cli
87 | ```
88 |
89 | The Smithery CLI externalizes your SDKs during bundling so your runtime uses the versions you install. If you see a warning about missing SDKs, add them to your dependencies (most servers need `@modelcontextprotocol/sdk` and `@smithery/sdk`).
90 | </Note>
91 |
92 | ### 3. Ensure Proper Server Structure
93 |
94 | Your TypeScript MCP server must export a default `createServer` function that returns the MCP server object. If you built your server following the [Getting Started guide](/getting_started), it should already have this structure.
95 |
96 | ```typescript theme={null}
97 | // src/index.ts
98 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
99 |
100 | // Required: Export default createServer function
101 | export default function createServer({ config }) {
102 | // config contains user-provided settings (see configSchema below)
103 | const server = new McpServer({
104 | name: "Your Server Name",
105 | version: "1.0.0",
106 | });
107 |
108 | // Register your tools here...
109 |
110 | return server.server; // Must return the MCP server object
111 | }
112 | ```
113 |
114 | **Optional Configuration Schema**: If your server needs user configuration (API keys, settings, etc.), export a `configSchema`:
115 |
116 | ```typescript theme={null}
117 | // Optional: If your server doesn't need configuration, omit this
118 | export const configSchema = z.object({
119 | apiKey: z.string().describe("Your API key"),
120 | timeout: z.number().default(5000).describe("Request timeout in milliseconds"),
121 | });
122 | ```
123 |
124 | **Where it goes**: Export `configSchema` from the same file as your `createServer` function (typically `src/index.ts`).
125 |
126 | **What it does**: Automatically generates [session configuration](/build/session-config) forms for users connecting to your server.
127 |
128 | ## OAuth
129 |
130 | <Note>
131 | OAuth is designed only for **remote servers**. OAuth is not available for local servers (`target: "local"`).
132 | </Note>
133 |
134 | If your entry module exports `oauth`, Smithery CLI auto-mounts the required OAuth endpoints for you during remote deployment.
135 |
136 | ### Export an OAuth provider
137 |
138 | ```typescript theme={null}
139 | // src/index.ts
140 | import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"
141 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
142 | import type { OAuthProvider } from "@smithery/sdk"
143 | import { MyProvider } from "./provider.js"
144 |
145 | export default function createServer({ auth }: { auth: AuthInfo }) {
146 | const server = new McpServer({ name: "My MCP", version: "1.0.0" })
147 | // register tools...
148 | return server.server
149 | }
150 |
151 | export const oauth: OAuthProvider = new MyProvider() // [!code highlight]
152 | ```
153 |
154 | The CLI detects `oauth` and injects the auth routes automatically. For implementing `OAuthServerProvider`, see the [official MCP SDK authorization guide](https://modelcontextprotocol.io/docs/tutorials/security/authorization).
155 |
156 | <Tip>
157 | **You don't need to implement client registration.** Modern MCP clients use [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents) (CIMD). Your server should advertise `client_id_metadata_document_supported: true` in its OAuth metadata — see the [spec requirements](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#implementation-requirements).
158 | </Tip>
159 |
160 | ## Local Development
161 |
162 | Test your server locally using the Smithery CLI:
163 |
164 | ```bash theme={null}
165 | # Start development server with interactive playground
166 | npm run dev
167 | ```
168 |
169 | This opens the **Smithery interactive playground** where you can:
170 |
171 | * Test your MCP server tools in real-time
172 | * See tool responses and debug issues
173 | * Validate your configuration schema
174 | * Experiment with different inputs
175 |
176 | ## Advanced Build Configuration
177 |
178 | For advanced use cases, you can customize the build process using a `smithery.config.js` file. This is useful for:
179 |
180 | * Marking packages as external (to avoid bundling issues)
181 | * Configuring minification, targets, and other build options
182 | * Adding custom esbuild plugins
183 |
184 | ### Configuration File
185 |
186 | Create `smithery.config.js` in your project root:
187 |
188 | ```javascript theme={null}
189 | export default {
190 | esbuild: {
191 | // Mark problematic packages as external
192 | external: ["playwright-core", "puppeteer-core"],
193 |
194 | // Enable minification for production
195 | minify: true,
196 |
197 | // Set Node.js target version
198 | target: "node18",
199 | },
200 | };
201 | ```
202 |
203 | ### Common Use Cases
204 |
205 | **External Dependencies**: If you encounter bundling issues with packages like Playwright or native modules:
206 |
207 | ```javascript theme={null}
208 | export default {
209 | esbuild: {
210 | external: ["playwright-core", "sharp", "@grpc/grpc-js"],
211 | },
212 | };
213 | ```
214 |
215 | Configuration applies to both `build` and `dev` commands.
216 |
217 | ## Deploy
218 |
219 | 1. Push your code (including `smithery.yaml`) to GitHub
220 | 2. [Connect your GitHub](https://smithery.ai/new) to Smithery (or claim your server if already listed)
221 | 3. Navigate to the Deployments tab on your server page
222 | 4. Click Deploy to build and host your server
223 |
224 | ## Good to Know
225 |
226 | <Accordion title="What happens under the hood">
227 | **Remote Deployment**: When you deploy to Smithery's infrastructure:
228 |
229 | 1. Clone your repository
230 | 2. Parse your `smithery.yaml` to detect TypeScript runtime
231 | 3. Install dependencies with `npm ci`
232 | 4. Build your TypeScript code using the `module` entry point from your `package.json`
233 | 5. Package your server into a containerized HTTP service
234 | 6. Deploy the container to our hosting infrastructure
235 | 7. Send MCP `initialize` and `list_tools` messages with a dummy configuration to discover your server's capabilities
236 | 8. Make it available at `https://server.smithery.ai/your-server`
237 | 9. Handle load balancing, scaling, and monitoring
238 |
239 | **Local Server (Beta)**: When you use `target: "local"`:
240 |
241 | 1. Your server runs locally on user's machine using `npm run dev`
242 | 2. Smithery registers your server in the registry for discovery
243 | 3. MCP clients can find and connect to your local server through Smithery
244 | 4. Your server remains under your control while being accessible to others
245 | </Accordion>
246 |
247 | ## Troubleshooting
248 |
249 | <Accordion title="Why does my deployment fail?">
250 | Common issues and solutions:
251 |
252 | **Remote Deployment Issues**:
253 |
254 | * **Missing module field**: Ensure your `package.json` has the `module` field pointing to your entry point
255 | * **Dependencies not found**: All dependencies must be listed in `dependencies` or `devDependencies`
256 | * **Server doesn't build locally**: Before deploying, verify your server builds and runs locally:
257 | ```bash theme={null}
258 | npm install
259 | npm run build
260 | ```
261 | If this fails, fix any TypeScript compilation errors or missing dependencies first
262 |
263 | **Local Server Issues** (Beta):
264 |
265 | * **Server not discoverable**: Ensure you have `target: "local"` in your `smithery.yaml`
266 | * **Local server won't start**: Verify your server runs with `npm run dev` before expecting registry integration
267 | * **Connection issues**: Make sure your local development environment allows the necessary network connections
268 | </Accordion>
269 |
270 |
271 | ---
272 |
273 | > To find navigation and other pages in this documentation, fetch the llms.txt file at: https://smithery.ai/docs/llms.txt
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/discover_projs.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Project Discovery Plugin: Discover Projects
3 | *
4 | * Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj)
5 | * and workspace (.xcworkspace) files.
6 | */
7 |
8 | import * as z from 'zod';
9 | import * as path from 'node:path';
10 | import { log } from '../../../utils/logging/index.ts';
11 | import { ToolResponse, createTextContent } from '../../../types/common.ts';
12 | import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts';
13 | import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts';
14 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
15 |
16 | // Constants
17 | const DEFAULT_MAX_DEPTH = 5;
18 | const SKIPPED_DIRS = new Set(['build', 'DerivedData', 'Pods', '.git', 'node_modules']);
19 |
20 | // Type definition for Dirent-like objects returned by readdir with withFileTypes: true
21 | interface DirentLike {
22 | name: string;
23 | isDirectory(): boolean;
24 | isSymbolicLink(): boolean;
25 | }
26 |
27 | /**
28 | * Recursively scans directories to find Xcode projects and workspaces.
29 | */
30 | async function _findProjectsRecursive(
31 | currentDirAbs: string,
32 | workspaceRootAbs: string,
33 | currentDepth: number,
34 | maxDepth: number,
35 | results: { projects: string[]; workspaces: string[] },
36 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
37 | ): Promise<void> {
38 | // Explicit depth check (now simplified as maxDepth is always non-negative)
39 | if (currentDepth >= maxDepth) {
40 | log('debug', `Max depth ${maxDepth} reached at ${currentDirAbs}, stopping recursion.`);
41 | return;
42 | }
43 |
44 | log('debug', `Scanning directory: ${currentDirAbs} at depth ${currentDepth}`);
45 | const normalizedWorkspaceRoot = path.normalize(workspaceRootAbs);
46 |
47 | try {
48 | // Use the injected fileSystemExecutor
49 | const entries = await fileSystemExecutor.readdir(currentDirAbs, { withFileTypes: true });
50 | for (const rawEntry of entries) {
51 | // Cast the unknown entry to DirentLike interface for type safety
52 | const entry = rawEntry as DirentLike;
53 | const absoluteEntryPath = path.join(currentDirAbs, entry.name);
54 | const relativePath = path.relative(workspaceRootAbs, absoluteEntryPath);
55 |
56 | // --- Skip conditions ---
57 | if (entry.isSymbolicLink()) {
58 | log('debug', `Skipping symbolic link: ${relativePath}`);
59 | continue;
60 | }
61 |
62 | // Skip common build/dependency directories by name
63 | if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) {
64 | log('debug', `Skipping standard directory: ${relativePath}`);
65 | continue;
66 | }
67 |
68 | // Ensure entry is within the workspace root (security/sanity check)
69 | if (!path.normalize(absoluteEntryPath).startsWith(normalizedWorkspaceRoot)) {
70 | log(
71 | 'warn',
72 | `Skipping entry outside workspace root: ${absoluteEntryPath} (Workspace: ${workspaceRootAbs})`,
73 | );
74 | continue;
75 | }
76 |
77 | // --- Process entries ---
78 | if (entry.isDirectory()) {
79 | let isXcodeBundle = false;
80 |
81 | if (entry.name.endsWith('.xcodeproj')) {
82 | results.projects.push(absoluteEntryPath); // Use absolute path
83 | log('debug', `Found project: ${absoluteEntryPath}`);
84 | isXcodeBundle = true;
85 | } else if (entry.name.endsWith('.xcworkspace')) {
86 | results.workspaces.push(absoluteEntryPath); // Use absolute path
87 | log('debug', `Found workspace: ${absoluteEntryPath}`);
88 | isXcodeBundle = true;
89 | }
90 |
91 | // Recurse into regular directories, but not into found project/workspace bundles
92 | if (!isXcodeBundle) {
93 | await _findProjectsRecursive(
94 | absoluteEntryPath,
95 | workspaceRootAbs,
96 | currentDepth + 1,
97 | maxDepth,
98 | results,
99 | fileSystemExecutor,
100 | );
101 | }
102 | }
103 | }
104 | } catch (error) {
105 | let code;
106 | let message = 'Unknown error';
107 |
108 | if (error instanceof Error) {
109 | message = error.message;
110 | if ('code' in error) {
111 | code = error.code;
112 | }
113 | } else if (typeof error === 'object' && error !== null) {
114 | if ('message' in error && typeof error.message === 'string') {
115 | message = error.message;
116 | }
117 | if ('code' in error && typeof error.code === 'string') {
118 | code = error.code;
119 | }
120 | } else {
121 | message = String(error);
122 | }
123 |
124 | if (code === 'EPERM' || code === 'EACCES') {
125 | log('debug', `Permission denied scanning directory: ${currentDirAbs}`);
126 | } else {
127 | log(
128 | 'warning',
129 | `Error scanning directory ${currentDirAbs}: ${message} (Code: ${code ?? 'N/A'})`,
130 | );
131 | }
132 | }
133 | }
134 |
135 | // Define schema as ZodObject
136 | const discoverProjsSchema = z.object({
137 | workspaceRoot: z.string().describe('The absolute path of the workspace root to scan within.'),
138 | scanPath: z
139 | .string()
140 | .optional()
141 | .describe('Optional: Path relative to workspace root to scan. Defaults to workspace root.'),
142 | maxDepth: z
143 | .number()
144 | .int()
145 | .nonnegative()
146 | .optional()
147 | .describe(`Optional: Maximum directory depth to scan. Defaults to ${DEFAULT_MAX_DEPTH}.`),
148 | });
149 |
150 | // Use z.infer for type safety
151 | type DiscoverProjsParams = z.infer<typeof discoverProjsSchema>;
152 |
153 | /**
154 | * Business logic for discovering projects.
155 | * Exported for testing purposes.
156 | */
157 | export async function discover_projsLogic(
158 | params: DiscoverProjsParams,
159 | fileSystemExecutor: FileSystemExecutor,
160 | ): Promise<ToolResponse> {
161 | // Apply defaults
162 | const scanPath = params.scanPath ?? '.';
163 | const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH;
164 | const workspaceRoot = params.workspaceRoot;
165 |
166 | const relativeScanPath = scanPath;
167 |
168 | // Calculate and validate the absolute scan path
169 | const requestedScanPath = path.resolve(workspaceRoot, relativeScanPath ?? '.');
170 | let absoluteScanPath = requestedScanPath;
171 | const normalizedWorkspaceRoot = path.normalize(workspaceRoot);
172 | if (!path.normalize(absoluteScanPath).startsWith(normalizedWorkspaceRoot)) {
173 | log(
174 | 'warn',
175 | `Requested scan path '${relativeScanPath}' resolved outside workspace root '${workspaceRoot}'. Defaulting scan to workspace root.`,
176 | );
177 | absoluteScanPath = normalizedWorkspaceRoot;
178 | }
179 |
180 | const results = { projects: [], workspaces: [] };
181 |
182 | log(
183 | 'info',
184 | `Starting project discovery request: path=${absoluteScanPath}, maxDepth=${maxDepth}, workspace=${workspaceRoot}`,
185 | );
186 |
187 | try {
188 | // Ensure the scan path exists and is a directory
189 | const stats = await fileSystemExecutor.stat(absoluteScanPath);
190 | if (!stats.isDirectory()) {
191 | const errorMsg = `Scan path is not a directory: ${absoluteScanPath}`;
192 | log('error', errorMsg);
193 | // Return ToolResponse error format
194 | return {
195 | content: [createTextContent(errorMsg)],
196 | isError: true,
197 | };
198 | }
199 | } catch (error) {
200 | let code;
201 | let message = 'Unknown error accessing scan path';
202 |
203 | // Type guards - refined
204 | if (error instanceof Error) {
205 | message = error.message;
206 | // Check for code property specific to Node.js fs errors
207 | if ('code' in error) {
208 | code = error.code;
209 | }
210 | } else if (typeof error === 'object' && error !== null) {
211 | if ('message' in error && typeof error.message === 'string') {
212 | message = error.message;
213 | }
214 | if ('code' in error && typeof error.code === 'string') {
215 | code = error.code;
216 | }
217 | } else {
218 | message = String(error);
219 | }
220 |
221 | const errorMsg = `Failed to access scan path: ${absoluteScanPath}. Error: ${message}`;
222 | log('error', `${errorMsg} - Code: ${code ?? 'N/A'}`);
223 | return {
224 | content: [createTextContent(errorMsg)],
225 | isError: true,
226 | };
227 | }
228 |
229 | // Start the recursive scan from the validated absolute path
230 | await _findProjectsRecursive(
231 | absoluteScanPath,
232 | workspaceRoot,
233 | 0,
234 | maxDepth,
235 | results,
236 | fileSystemExecutor,
237 | );
238 |
239 | log(
240 | 'info',
241 | `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`,
242 | );
243 |
244 | const responseContent = [
245 | createTextContent(
246 | `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`,
247 | ),
248 | ];
249 |
250 | // Sort results for consistent output
251 | results.projects.sort();
252 | results.workspaces.sort();
253 |
254 | if (results.projects.length > 0) {
255 | responseContent.push(
256 | createTextContent(`Projects found:\n - ${results.projects.join('\n - ')}`),
257 | );
258 | }
259 |
260 | if (results.workspaces.length > 0) {
261 | responseContent.push(
262 | createTextContent(`Workspaces found:\n - ${results.workspaces.join('\n - ')}`),
263 | );
264 | }
265 |
266 | if (results.projects.length > 0 || results.workspaces.length > 0) {
267 | responseContent.push(
268 | createTextContent(
269 | "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.",
270 | ),
271 | );
272 | }
273 |
274 | return {
275 | content: responseContent,
276 | isError: false,
277 | };
278 | }
279 |
280 | export default {
281 | name: 'discover_projs',
282 | description:
283 | 'Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.',
284 | schema: discoverProjsSchema.shape, // MCP SDK compatibility
285 | annotations: {
286 | title: 'Discover Projects',
287 | readOnlyHint: true,
288 | },
289 | handler: createTypedTool(
290 | discoverProjsSchema,
291 | (params: DiscoverProjsParams) => {
292 | return discover_projsLogic(params, getDefaultFileSystemExecutor());
293 | },
294 | getDefaultCommandExecutor,
295 | ),
296 | };
297 |
```
--------------------------------------------------------------------------------
/src/utils/debugger/backends/lldb-cli-backend.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { InteractiveProcess, InteractiveSpawner } from '../../execution/index.ts';
2 | import { getDefaultInteractiveSpawner } from '../../execution/index.ts';
3 | import type { DebuggerBackend } from './DebuggerBackend.ts';
4 | import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts';
5 |
6 | const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
7 | const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
8 | const LLDB_PROMPT = 'XCODEBUILDMCP_LLDB> ';
9 | const COMMAND_SENTINEL = '__XCODEBUILDMCP_DONE__';
10 | const COMMAND_SENTINEL_REGEX = new RegExp(`(^|\\r?\\n)${COMMAND_SENTINEL}(\\r?\\n)`);
11 |
12 | class LldbCliBackend implements DebuggerBackend {
13 | readonly kind = 'lldb-cli' as const;
14 |
15 | private readonly spawner: InteractiveSpawner;
16 | private readonly prompt = LLDB_PROMPT;
17 | private readonly process: InteractiveProcess;
18 | private buffer = '';
19 | private pending: {
20 | resolve: (output: string) => void;
21 | reject: (error: Error) => void;
22 | timeout: NodeJS.Timeout;
23 | } | null = null;
24 | private queue: Promise<unknown> = Promise.resolve();
25 | private ready: Promise<void>;
26 | private disposed = false;
27 |
28 | constructor(spawner: InteractiveSpawner) {
29 | this.spawner = spawner;
30 | const lldbCommand = [
31 | 'xcrun',
32 | 'lldb',
33 | '--no-lldbinit',
34 | '-o',
35 | `settings set prompt "${this.prompt}"`,
36 | ];
37 |
38 | this.process = this.spawner(lldbCommand);
39 |
40 | this.process.process.stdout?.on('data', (data: Buffer) => this.handleData(data));
41 | this.process.process.stderr?.on('data', (data: Buffer) => this.handleData(data));
42 | this.process.process.on('exit', (code, signal) => {
43 | const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
44 | this.failPending(new Error(`LLDB process exited (${detail})`));
45 | });
46 |
47 | this.ready = this.initialize();
48 | }
49 |
50 | private async initialize(): Promise<void> {
51 | // Prime the prompt by running a sentinel command we can parse reliably.
52 | this.process.write(`script print("${COMMAND_SENTINEL}")\n`);
53 | await this.waitForSentinel(DEFAULT_STARTUP_TIMEOUT_MS);
54 | }
55 |
56 | async waitUntilReady(): Promise<void> {
57 | await this.ready;
58 | }
59 |
60 | async attach(opts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise<void> {
61 | const command = opts.waitFor
62 | ? `process attach --pid ${opts.pid} --waitfor`
63 | : `process attach --pid ${opts.pid}`;
64 | const output = await this.runCommand(command);
65 | assertNoLldbError('attach', output);
66 | }
67 |
68 | async detach(): Promise<void> {
69 | const output = await this.runCommand('process detach');
70 | assertNoLldbError('detach', output);
71 | }
72 |
73 | async runCommand(command: string, opts?: { timeoutMs?: number }): Promise<string> {
74 | return this.enqueue(async () => {
75 | if (this.disposed) {
76 | throw new Error('LLDB backend disposed');
77 | }
78 | await this.ready;
79 | this.process.write(`${command}\n`);
80 | this.process.write(`script print("${COMMAND_SENTINEL}")\n`);
81 | const output = await this.waitForSentinel(opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS);
82 | return sanitizeOutput(output, this.prompt).trimEnd();
83 | });
84 | }
85 |
86 | async resume(): Promise<void> {
87 | return this.enqueue(async () => {
88 | if (this.disposed) {
89 | throw new Error('LLDB backend disposed');
90 | }
91 | await this.ready;
92 | this.process.write('process continue\n');
93 | });
94 | }
95 |
96 | async addBreakpoint(
97 | spec: BreakpointSpec,
98 | opts?: { condition?: string },
99 | ): Promise<BreakpointInfo> {
100 | const command =
101 | spec.kind === 'file-line'
102 | ? `breakpoint set --file "${spec.file}" --line ${spec.line}`
103 | : `breakpoint set --name "${spec.name}"`;
104 | const output = await this.runCommand(command);
105 | assertNoLldbError('breakpoint', output);
106 |
107 | const match = output.match(/Breakpoint\s+(\d+):/);
108 | if (!match) {
109 | throw new Error(`Unable to parse breakpoint id from output: ${output}`);
110 | }
111 |
112 | const id = Number(match[1]);
113 |
114 | if (opts?.condition) {
115 | const condition = formatConditionForLldb(opts.condition);
116 | const modifyOutput = await this.runCommand(`breakpoint modify -c ${condition} ${id}`);
117 | assertNoLldbError('breakpoint modify', modifyOutput);
118 | }
119 |
120 | return {
121 | id,
122 | spec,
123 | rawOutput: output,
124 | };
125 | }
126 |
127 | async removeBreakpoint(id: number): Promise<string> {
128 | const output = await this.runCommand(`breakpoint delete ${id}`);
129 | assertNoLldbError('breakpoint delete', output);
130 | return output;
131 | }
132 |
133 | async getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise<string> {
134 | let command = 'thread backtrace';
135 | if (typeof opts?.maxFrames === 'number') {
136 | command += ` -c ${opts.maxFrames}`;
137 | }
138 | if (typeof opts?.threadIndex === 'number') {
139 | command += ` ${opts.threadIndex}`;
140 | }
141 | return this.runCommand(command);
142 | }
143 |
144 | async getVariables(opts?: { frameIndex?: number }): Promise<string> {
145 | if (typeof opts?.frameIndex === 'number') {
146 | await this.runCommand(`frame select ${opts.frameIndex}`);
147 | }
148 | return this.runCommand('frame variable');
149 | }
150 |
151 | async getExecutionState(opts?: { timeoutMs?: number }): Promise<DebugExecutionState> {
152 | try {
153 | const output = await this.runCommand('process status', {
154 | timeoutMs: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
155 | });
156 | const normalized = output.toLowerCase();
157 |
158 | if (/no process|exited|terminated/.test(normalized)) {
159 | return { status: 'terminated', description: output.trim() };
160 | }
161 | if (/\bstopped\b/.test(normalized)) {
162 | return {
163 | status: 'stopped',
164 | reason: parseStopReason(output),
165 | description: output.trim(),
166 | };
167 | }
168 | if (/\brunning\b/.test(normalized)) {
169 | return { status: 'running', description: output.trim() };
170 | }
171 | if (/error:/.test(normalized)) {
172 | return { status: 'unknown', description: output.trim() };
173 | }
174 |
175 | return { status: 'unknown', description: output.trim() };
176 | } catch (error) {
177 | return {
178 | status: 'unknown',
179 | description: error instanceof Error ? error.message : String(error),
180 | };
181 | }
182 | }
183 |
184 | async dispose(): Promise<void> {
185 | if (this.disposed) return;
186 | this.disposed = true;
187 | this.failPending(new Error('LLDB backend disposed'));
188 | this.process.dispose();
189 | }
190 |
191 | private enqueue<T>(work: () => Promise<T>): Promise<T> {
192 | const next = this.queue.then(work, work) as Promise<T>;
193 | this.queue = next.then(
194 | () => undefined,
195 | () => undefined,
196 | );
197 | return next;
198 | }
199 |
200 | private handleData(data: Buffer): void {
201 | this.buffer += data.toString('utf8');
202 | this.checkPending();
203 | }
204 |
205 | private waitForSentinel(timeoutMs: number): Promise<string> {
206 | if (this.pending) {
207 | return Promise.reject(new Error('LLDB command already pending'));
208 | }
209 |
210 | return new Promise((resolve, reject) => {
211 | const timeout = setTimeout(() => {
212 | this.pending = null;
213 | reject(new Error(`LLDB command timed out after ${timeoutMs}ms`));
214 | }, timeoutMs);
215 |
216 | this.pending = { resolve, reject, timeout };
217 | this.checkPending();
218 | });
219 | }
220 |
221 | private checkPending(): void {
222 | if (!this.pending) return;
223 | const sentinelMatch = this.buffer.match(COMMAND_SENTINEL_REGEX);
224 | const sentinelIndex = sentinelMatch?.index;
225 | const sentinelLength = sentinelMatch?.[0].length;
226 | if (sentinelIndex == null || sentinelLength == null) return;
227 |
228 | const output = this.buffer.slice(0, sentinelIndex);
229 | const remainderStart = sentinelIndex + sentinelLength;
230 |
231 | const promptIndex = this.buffer.indexOf(this.prompt, remainderStart);
232 | if (promptIndex !== -1) {
233 | this.buffer = this.buffer.slice(promptIndex + this.prompt.length);
234 | } else {
235 | this.buffer = this.buffer.slice(remainderStart);
236 | }
237 |
238 | const { resolve, timeout } = this.pending;
239 | this.pending = null;
240 | clearTimeout(timeout);
241 | resolve(output);
242 | }
243 |
244 | private failPending(error: Error): void {
245 | if (!this.pending) return;
246 | const { reject, timeout } = this.pending;
247 | this.pending = null;
248 | clearTimeout(timeout);
249 | reject(error);
250 | }
251 | }
252 |
253 | function assertNoLldbError(context: string, output: string): void {
254 | if (/error:/i.test(output)) {
255 | throw new Error(`LLDB ${context} failed: ${output.trim()}`);
256 | }
257 | }
258 |
259 | function sanitizeOutput(output: string, prompt: string): string {
260 | const lines = output.split(/\r?\n/);
261 | const filtered = lines.filter((line) => {
262 | if (!line) return false;
263 | if (line.startsWith(prompt)) return false;
264 | if (line.includes(`script print("${COMMAND_SENTINEL}")`)) return false;
265 | if (line.includes(COMMAND_SENTINEL)) return false;
266 | return true;
267 | });
268 | return filtered.join('\n');
269 | }
270 |
271 | function formatConditionForLldb(condition: string): string {
272 | const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
273 | return `"${escaped}"`;
274 | }
275 |
276 | function parseStopReason(output: string): string | undefined {
277 | const match = output.match(/stop reason\s*=\s*(.+)/i);
278 | if (!match) return undefined;
279 | return match[1]?.trim() || undefined;
280 | }
281 |
282 | export async function createLldbCliBackend(
283 | spawner: InteractiveSpawner = getDefaultInteractiveSpawner(),
284 | ): Promise<DebuggerBackend> {
285 | const backend = new LldbCliBackend(spawner);
286 | try {
287 | await backend.waitUntilReady();
288 | } catch (error) {
289 | try {
290 | await backend.dispose();
291 | } catch {
292 | // Best-effort cleanup; keep original error.
293 | }
294 | throw error;
295 | }
296 | return backend;
297 | }
298 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for describe_ui tool plugin
3 | */
4 |
5 | import { describe, it, expect } from 'vitest';
6 | import * as z from 'zod';
7 | import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts';
8 | import type { CommandExecutor } from '../../../../utils/execution/index.ts';
9 | import describeUIPlugin, { describe_uiLogic } from '../describe_ui.ts';
10 |
11 | describe('Describe UI Plugin', () => {
12 | describe('Export Field Validation (Literal)', () => {
13 | it('should have correct name', () => {
14 | expect(describeUIPlugin.name).toBe('describe_ui');
15 | });
16 |
17 | it('should have correct description', () => {
18 | expect(describeUIPlugin.description).toBe(
19 | 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. Requires the target process to be running; paused debugger/breakpoints can yield an empty tree.',
20 | );
21 | });
22 |
23 | it('should have handler function', () => {
24 | expect(typeof describeUIPlugin.handler).toBe('function');
25 | });
26 |
27 | it('should expose public schema without simulatorId field', () => {
28 | const schema = z.object(describeUIPlugin.schema);
29 |
30 | expect(schema.safeParse({}).success).toBe(true);
31 |
32 | const withSimId = schema.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012' });
33 | expect(withSimId.success).toBe(true);
34 | expect('simulatorId' in (withSimId.data as any)).toBe(false);
35 | });
36 | });
37 |
38 | describe('Handler Behavior (Complete Literal Returns)', () => {
39 | it('should surface session default requirement when simulatorId is missing', async () => {
40 | const result = await describeUIPlugin.handler({});
41 |
42 | expect(result.isError).toBe(true);
43 | expect(result.content[0].text).toContain('Missing required session defaults');
44 | expect(result.content[0].text).toContain('simulatorId is required');
45 | });
46 |
47 | it('should handle invalid simulatorId format via schema validation', async () => {
48 | // Test the actual handler with invalid UUID format
49 | const result = await describeUIPlugin.handler({
50 | simulatorId: 'invalid-uuid-format',
51 | });
52 |
53 | expect(result.isError).toBe(true);
54 | expect(result.content[0].text).toContain('Parameter validation failed');
55 | expect(result.content[0].text).toContain('Invalid Simulator UUID format');
56 | });
57 |
58 | it('should return success for valid describe_ui execution', async () => {
59 | const uiHierarchy =
60 | '{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}';
61 |
62 | const mockExecutor = createMockExecutor({
63 | success: true,
64 | output: uiHierarchy,
65 | error: undefined,
66 | process: { pid: 12345 },
67 | });
68 |
69 | // Create mock axe helpers
70 | const mockAxeHelpers = {
71 | getAxePath: () => '/usr/local/bin/axe',
72 | getBundledAxeEnvironment: () => ({}),
73 | createAxeNotAvailableResponse: () => ({
74 | content: [{ type: 'text' as const, text: 'axe not available' }],
75 | isError: true,
76 | }),
77 | };
78 |
79 | // Wrap executor to track calls
80 | const executorCalls: any[] = [];
81 | const trackingExecutor: CommandExecutor = async (...args) => {
82 | executorCalls.push(args);
83 | return mockExecutor(...args);
84 | };
85 |
86 | const result = await describe_uiLogic(
87 | {
88 | simulatorId: '12345678-1234-4234-8234-123456789012',
89 | },
90 | trackingExecutor,
91 | mockAxeHelpers,
92 | );
93 |
94 | expect(executorCalls[0]).toEqual([
95 | ['/usr/local/bin/axe', 'describe-ui', '--udid', '12345678-1234-4234-8234-123456789012'],
96 | '[AXe]: describe-ui',
97 | false,
98 | { env: {} },
99 | ]);
100 |
101 | expect(result).toEqual({
102 | content: [
103 | {
104 | type: 'text' as const,
105 | text: 'Accessibility hierarchy retrieved successfully:\n```json\n{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}\n```',
106 | },
107 | {
108 | type: 'text' as const,
109 | text: `Next Steps:
110 | - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)
111 | - Re-run describe_ui after layout changes
112 | - If a debugger is attached, ensure the app is running (not stopped on breakpoints)
113 | - Screenshots are for visual verification only`,
114 | },
115 | ],
116 | });
117 | });
118 |
119 | it('should handle DependencyError when axe is not available', async () => {
120 | // Create mock axe helpers that return null for axe path
121 | const mockAxeHelpers = {
122 | getAxePath: () => null,
123 | getBundledAxeEnvironment: () => ({}),
124 | createAxeNotAvailableResponse: () => ({
125 | content: [
126 | {
127 | type: 'text' as const,
128 | text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
129 | },
130 | ],
131 | isError: true,
132 | }),
133 | };
134 |
135 | const result = await describe_uiLogic(
136 | {
137 | simulatorId: '12345678-1234-4234-8234-123456789012',
138 | },
139 | createNoopExecutor(),
140 | mockAxeHelpers,
141 | );
142 |
143 | expect(result).toEqual({
144 | content: [
145 | {
146 | type: 'text' as const,
147 | text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
148 | },
149 | ],
150 | isError: true,
151 | });
152 | });
153 |
154 | it('should handle AxeError from failed command execution', async () => {
155 | const mockExecutor = createMockExecutor({
156 | success: false,
157 | output: '',
158 | error: 'axe command failed',
159 | process: { pid: 12345 },
160 | });
161 |
162 | // Create mock axe helpers
163 | const mockAxeHelpers = {
164 | getAxePath: () => '/usr/local/bin/axe',
165 | getBundledAxeEnvironment: () => ({}),
166 | createAxeNotAvailableResponse: () => ({
167 | content: [{ type: 'text' as const, text: 'axe not available' }],
168 | isError: true,
169 | }),
170 | };
171 |
172 | const result = await describe_uiLogic(
173 | {
174 | simulatorId: '12345678-1234-4234-8234-123456789012',
175 | },
176 | mockExecutor,
177 | mockAxeHelpers,
178 | );
179 |
180 | expect(result).toEqual({
181 | content: [
182 | {
183 | type: 'text' as const,
184 | text: "Error: Failed to get accessibility hierarchy: axe command 'describe-ui' failed.\nDetails: axe command failed",
185 | },
186 | ],
187 | isError: true,
188 | });
189 | });
190 |
191 | it('should handle SystemError from command execution', async () => {
192 | const mockExecutor = createMockExecutor(new Error('ENOENT: no such file or directory'));
193 |
194 | // Create mock axe helpers
195 | const mockAxeHelpers = {
196 | getAxePath: () => '/usr/local/bin/axe',
197 | getBundledAxeEnvironment: () => ({}),
198 | createAxeNotAvailableResponse: () => ({
199 | content: [{ type: 'text' as const, text: 'axe not available' }],
200 | isError: true,
201 | }),
202 | };
203 |
204 | const result = await describe_uiLogic(
205 | {
206 | simulatorId: '12345678-1234-4234-8234-123456789012',
207 | },
208 | mockExecutor,
209 | mockAxeHelpers,
210 | );
211 |
212 | expect(result).toEqual({
213 | content: [
214 | {
215 | type: 'text' as const,
216 | text: expect.stringContaining(
217 | 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory',
218 | ),
219 | },
220 | ],
221 | isError: true,
222 | });
223 | });
224 |
225 | it('should handle unexpected Error objects', async () => {
226 | const mockExecutor = createMockExecutor(new Error('Unexpected error'));
227 |
228 | // Create mock axe helpers
229 | const mockAxeHelpers = {
230 | getAxePath: () => '/usr/local/bin/axe',
231 | getBundledAxeEnvironment: () => ({}),
232 | createAxeNotAvailableResponse: () => ({
233 | content: [{ type: 'text' as const, text: 'axe not available' }],
234 | isError: true,
235 | }),
236 | };
237 |
238 | const result = await describe_uiLogic(
239 | {
240 | simulatorId: '12345678-1234-4234-8234-123456789012',
241 | },
242 | mockExecutor,
243 | mockAxeHelpers,
244 | );
245 |
246 | expect(result).toEqual({
247 | content: [
248 | {
249 | type: 'text' as const,
250 | text: expect.stringContaining(
251 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error',
252 | ),
253 | },
254 | ],
255 | isError: true,
256 | });
257 | });
258 |
259 | it('should handle unexpected string errors', async () => {
260 | const mockExecutor = createMockExecutor('String error');
261 |
262 | // Create mock axe helpers
263 | const mockAxeHelpers = {
264 | getAxePath: () => '/usr/local/bin/axe',
265 | getBundledAxeEnvironment: () => ({}),
266 | createAxeNotAvailableResponse: () => ({
267 | content: [{ type: 'text' as const, text: 'axe not available' }],
268 | isError: true,
269 | }),
270 | };
271 |
272 | const result = await describe_uiLogic(
273 | {
274 | simulatorId: '12345678-1234-4234-8234-123456789012',
275 | },
276 | mockExecutor,
277 | mockAxeHelpers,
278 | );
279 |
280 | expect(result).toEqual({
281 | content: [
282 | {
283 | type: 'text' as const,
284 | text: 'Error: System error executing axe: Failed to execute axe command: String error',
285 | },
286 | ],
287 | isError: true,
288 | });
289 | });
290 | });
291 | });
292 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/tap.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as z from 'zod';
2 | import type { ToolResponse } from '../../../types/common.ts';
3 | import { log } from '../../../utils/logging/index.ts';
4 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts';
5 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
6 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
7 | import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
8 | import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts';
9 | import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts';
10 | import {
11 | createAxeNotAvailableResponse,
12 | getAxePath,
13 | getBundledAxeEnvironment,
14 | } from '../../../utils/axe-helpers.ts';
15 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts';
16 | import {
17 | createSessionAwareTool,
18 | getSessionAwareToolSchemaShape,
19 | } from '../../../utils/typed-tool-factory.ts';
20 |
21 | export interface AxeHelpers {
22 | getAxePath: () => string | null;
23 | getBundledAxeEnvironment: () => Record<string, string>;
24 | createAxeNotAvailableResponse: () => ToolResponse;
25 | }
26 |
27 | // Define schema as ZodObject
28 | const baseTapSchema = z.object({
29 | simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }),
30 | x: z.number().int({ message: 'X coordinate must be an integer' }).optional(),
31 | y: z.number().int({ message: 'Y coordinate must be an integer' }).optional(),
32 | id: z.string().min(1, { message: 'Id must be non-empty' }).optional(),
33 | label: z.string().min(1, { message: 'Label must be non-empty' }).optional(),
34 | preDelay: z.number().min(0, { message: 'Pre-delay must be non-negative' }).optional(),
35 | postDelay: z.number().min(0, { message: 'Post-delay must be non-negative' }).optional(),
36 | });
37 |
38 | const tapSchema = baseTapSchema.superRefine((values, ctx) => {
39 | const hasX = values.x !== undefined;
40 | const hasY = values.y !== undefined;
41 | const hasId = values.id !== undefined;
42 | const hasLabel = values.label !== undefined;
43 |
44 | if (!hasX && !hasY && hasId && hasLabel) {
45 | ctx.addIssue({
46 | code: z.ZodIssueCode.custom,
47 | path: ['id'],
48 | message: 'Provide either id or label, not both.',
49 | });
50 | }
51 |
52 | if (hasX !== hasY) {
53 | if (!hasX) {
54 | ctx.addIssue({
55 | code: z.ZodIssueCode.custom,
56 | path: ['x'],
57 | message: 'X coordinate is required when y is provided.',
58 | });
59 | }
60 | if (!hasY) {
61 | ctx.addIssue({
62 | code: z.ZodIssueCode.custom,
63 | path: ['y'],
64 | message: 'Y coordinate is required when x is provided.',
65 | });
66 | }
67 | }
68 |
69 | if (!hasX && !hasY && !hasId && !hasLabel) {
70 | ctx.addIssue({
71 | code: z.ZodIssueCode.custom,
72 | path: ['x'],
73 | message: 'Provide x/y coordinates or an element id/label.',
74 | });
75 | }
76 | });
77 |
78 | // Use z.infer for type safety
79 | type TapParams = z.infer<typeof tapSchema>;
80 |
81 | const publicSchemaObject = z.strictObject(baseTapSchema.omit({ simulatorId: true } as const).shape);
82 |
83 | const LOG_PREFIX = '[AXe]';
84 |
85 | // Session tracking for describe_ui warnings (shared across UI tools)
86 | const describeUITimestamps = new Map<string, { timestamp: number }>();
87 | const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds
88 |
89 | function getCoordinateWarning(simulatorId: string): string | null {
90 | const session = describeUITimestamps.get(simulatorId);
91 | if (!session) {
92 | return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.';
93 | }
94 |
95 | const timeSinceDescribe = Date.now() - session.timestamp;
96 | if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) {
97 | const secondsAgo = Math.round(timeSinceDescribe / 1000);
98 | return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`;
99 | }
100 |
101 | return null;
102 | }
103 |
104 | export async function tapLogic(
105 | params: TapParams,
106 | executor: CommandExecutor,
107 | axeHelpers: AxeHelpers = {
108 | getAxePath,
109 | getBundledAxeEnvironment,
110 | createAxeNotAvailableResponse,
111 | },
112 | debuggerManager: DebuggerManager = getDefaultDebuggerManager(),
113 | ): Promise<ToolResponse> {
114 | const toolName = 'tap';
115 | const { simulatorId, x, y, id, label, preDelay, postDelay } = params;
116 |
117 | const guard = await guardUiAutomationAgainstStoppedDebugger({
118 | debugger: debuggerManager,
119 | simulatorId,
120 | toolName,
121 | });
122 | if (guard.blockedResponse) return guard.blockedResponse;
123 |
124 | let targetDescription = '';
125 | let actionDescription = '';
126 | let usesCoordinates = false;
127 | const commandArgs = ['tap'];
128 |
129 | if (x !== undefined && y !== undefined) {
130 | usesCoordinates = true;
131 | targetDescription = `(${x}, ${y})`;
132 | actionDescription = `Tap at ${targetDescription}`;
133 | commandArgs.push('-x', String(x), '-y', String(y));
134 | } else if (id !== undefined) {
135 | targetDescription = `element id "${id}"`;
136 | actionDescription = `Tap on ${targetDescription}`;
137 | commandArgs.push('--id', id);
138 | } else if (label !== undefined) {
139 | targetDescription = `element label "${label}"`;
140 | actionDescription = `Tap on ${targetDescription}`;
141 | commandArgs.push('--label', label);
142 | } else {
143 | return createErrorResponse(
144 | 'Parameter validation failed',
145 | 'Invalid parameters:\nroot: Missing tap target',
146 | );
147 | }
148 |
149 | if (preDelay !== undefined) {
150 | commandArgs.push('--pre-delay', String(preDelay));
151 | }
152 | if (postDelay !== undefined) {
153 | commandArgs.push('--post-delay', String(postDelay));
154 | }
155 |
156 | log('info', `${LOG_PREFIX}/${toolName}: Starting for ${targetDescription} on ${simulatorId}`);
157 |
158 | try {
159 | await executeAxeCommand(commandArgs, simulatorId, 'tap', executor, axeHelpers);
160 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`);
161 |
162 | const coordinateWarning = usesCoordinates ? getCoordinateWarning(simulatorId) : null;
163 | const message = `${actionDescription} simulated successfully.`;
164 | const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n');
165 |
166 | if (warnings) {
167 | return createTextResponse(`${message}\n\n${warnings}`);
168 | }
169 |
170 | return createTextResponse(message);
171 | } catch (error: unknown) {
172 | const errorMessage = error instanceof Error ? error.message : String(error);
173 | log('error', `${LOG_PREFIX}/${toolName}: Failed - ${errorMessage}`);
174 | if (error instanceof DependencyError) {
175 | return axeHelpers.createAxeNotAvailableResponse();
176 | } else if (error instanceof AxeError) {
177 | return createErrorResponse(
178 | `Failed to simulate ${actionDescription.toLowerCase()}: ${error.message}`,
179 | error.axeOutput,
180 | );
181 | } else if (error instanceof SystemError) {
182 | return createErrorResponse(
183 | `System error executing axe: ${error.message}`,
184 | error.originalError?.stack,
185 | );
186 | }
187 | return createErrorResponse(
188 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`,
189 | );
190 | }
191 | }
192 |
193 | export default {
194 | name: 'tap',
195 | description:
196 | "Tap at specific coordinates or target elements by accessibility id or label. Use describe_ui to get precise element coordinates prior to using x/y parameters (don't guess from screenshots). Supports optional timing delays.",
197 | schema: getSessionAwareToolSchemaShape({
198 | sessionAware: publicSchemaObject,
199 | legacy: baseTapSchema,
200 | }),
201 | annotations: {
202 | title: 'Tap',
203 | destructiveHint: true,
204 | },
205 | handler: createSessionAwareTool<TapParams>({
206 | internalSchema: tapSchema as unknown as z.ZodType<TapParams, unknown>,
207 | logicFunction: (params: TapParams, executor: CommandExecutor) =>
208 | tapLogic(params, executor, {
209 | getAxePath,
210 | getBundledAxeEnvironment,
211 | createAxeNotAvailableResponse,
212 | }),
213 | getExecutor: getDefaultCommandExecutor,
214 | requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
215 | }),
216 | };
217 |
218 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
219 | async function executeAxeCommand(
220 | commandArgs: string[],
221 | simulatorId: string,
222 | commandName: string,
223 | executor: CommandExecutor = getDefaultCommandExecutor(),
224 | axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse },
225 | ): Promise<void> {
226 | // Get the appropriate axe binary path
227 | const axeBinary = axeHelpers.getAxePath();
228 | if (!axeBinary) {
229 | throw new DependencyError('AXe binary not found');
230 | }
231 |
232 | // Add --udid parameter to all commands
233 | const fullArgs = [...commandArgs, '--udid', simulatorId];
234 |
235 | // Construct the full command array with the axe binary as the first element
236 | const fullCommand = [axeBinary, ...fullArgs];
237 |
238 | try {
239 | // Determine environment variables for bundled AXe
240 | const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined;
241 |
242 | const result = await executor(
243 | fullCommand,
244 | `${LOG_PREFIX}: ${commandName}`,
245 | false,
246 | axeEnv ? { env: axeEnv } : undefined,
247 | );
248 |
249 | if (!result.success) {
250 | throw new AxeError(
251 | `axe command '${commandName}' failed.`,
252 | commandName,
253 | result.error ?? result.output,
254 | simulatorId,
255 | );
256 | }
257 |
258 | // Check for stderr output in successful commands
259 | if (result.error) {
260 | log(
261 | 'warn',
262 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`,
263 | );
264 | }
265 |
266 | // Function now returns void - the calling code creates its own response
267 | } catch (error: unknown) {
268 | if (error instanceof Error) {
269 | if (error instanceof AxeError) {
270 | throw error;
271 | }
272 |
273 | // Otherwise wrap it in a SystemError
274 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error);
275 | }
276 |
277 | // For any other type of error
278 | throw new SystemError(`Failed to execute axe command: ${String(error)}`);
279 | }
280 | }
281 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for set_sim_location 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 * as z from 'zod';
9 | import {
10 | createMockCommandResponse,
11 | createMockExecutor,
12 | createNoopExecutor,
13 | } from '../../../../test-utils/mock-executors.ts';
14 | import setSimLocation, { set_sim_locationLogic } from '../set_sim_location.ts';
15 |
16 | describe('set_sim_location tool', () => {
17 | // No mocks to clear since we use pure dependency injection
18 |
19 | describe('Export Field Validation (Literal)', () => {
20 | it('should have correct name', () => {
21 | expect(setSimLocation.name).toBe('set_sim_location');
22 | });
23 |
24 | it('should have correct description', () => {
25 | expect(setSimLocation.description).toBe('Sets a custom GPS location for the simulator.');
26 | });
27 |
28 | it('should have handler function', () => {
29 | expect(typeof setSimLocation.handler).toBe('function');
30 | });
31 |
32 | it('should expose public schema without simulatorId field', () => {
33 | const schema = z.object(setSimLocation.schema);
34 |
35 | expect(schema.safeParse({ latitude: 37.7749, longitude: -122.4194 }).success).toBe(true);
36 | expect(schema.safeParse({ latitude: 0, longitude: 0 }).success).toBe(true);
37 | expect(schema.safeParse({ latitude: 37.7749 }).success).toBe(false);
38 | expect(schema.safeParse({ longitude: -122.4194 }).success).toBe(false);
39 | const withSimId = schema.safeParse({
40 | simulatorId: 'test-uuid-123',
41 | latitude: 37.7749,
42 | longitude: -122.4194,
43 | });
44 | expect(withSimId.success).toBe(true);
45 | expect('simulatorId' in (withSimId.data as any)).toBe(false);
46 | });
47 | });
48 |
49 | describe('Command Generation', () => {
50 | it('should generate correct simctl command', async () => {
51 | let capturedCommand: string[] = [];
52 |
53 | const mockExecutor = async (command: string[]) => {
54 | capturedCommand = command;
55 | return createMockCommandResponse({
56 | success: true,
57 | output: 'Location set successfully',
58 | error: undefined,
59 | });
60 | };
61 |
62 | await set_sim_locationLogic(
63 | {
64 | simulatorId: 'test-uuid-123',
65 | latitude: 37.7749,
66 | longitude: -122.4194,
67 | },
68 | mockExecutor,
69 | );
70 |
71 | expect(capturedCommand).toEqual([
72 | 'xcrun',
73 | 'simctl',
74 | 'location',
75 | 'test-uuid-123',
76 | 'set',
77 | '37.7749,-122.4194',
78 | ]);
79 | });
80 |
81 | it('should generate command with different coordinates', async () => {
82 | let capturedCommand: string[] = [];
83 |
84 | const mockExecutor = async (command: string[]) => {
85 | capturedCommand = command;
86 | return createMockCommandResponse({
87 | success: true,
88 | output: 'Location set successfully',
89 | error: undefined,
90 | });
91 | };
92 |
93 | await set_sim_locationLogic(
94 | {
95 | simulatorId: 'different-uuid',
96 | latitude: 45.5,
97 | longitude: -73.6,
98 | },
99 | mockExecutor,
100 | );
101 |
102 | expect(capturedCommand).toEqual([
103 | 'xcrun',
104 | 'simctl',
105 | 'location',
106 | 'different-uuid',
107 | 'set',
108 | '45.5,-73.6',
109 | ]);
110 | });
111 |
112 | it('should generate command with negative coordinates', async () => {
113 | let capturedCommand: string[] = [];
114 |
115 | const mockExecutor = async (command: string[]) => {
116 | capturedCommand = command;
117 | return createMockCommandResponse({
118 | success: true,
119 | output: 'Location set successfully',
120 | error: undefined,
121 | });
122 | };
123 |
124 | await set_sim_locationLogic(
125 | {
126 | simulatorId: 'test-uuid',
127 | latitude: -90,
128 | longitude: -180,
129 | },
130 | mockExecutor,
131 | );
132 |
133 | expect(capturedCommand).toEqual([
134 | 'xcrun',
135 | 'simctl',
136 | 'location',
137 | 'test-uuid',
138 | 'set',
139 | '-90,-180',
140 | ]);
141 | });
142 | });
143 |
144 | describe('Response Processing', () => {
145 | it('should handle successful location setting', async () => {
146 | const mockExecutor = createMockExecutor({
147 | success: true,
148 | output: 'Location set successfully',
149 | error: undefined,
150 | });
151 |
152 | const result = await set_sim_locationLogic(
153 | {
154 | simulatorId: 'test-uuid-123',
155 | latitude: 37.7749,
156 | longitude: -122.4194,
157 | },
158 | mockExecutor,
159 | );
160 |
161 | expect(result).toEqual({
162 | content: [
163 | {
164 | type: 'text',
165 | text: 'Successfully set simulator test-uuid-123 location to 37.7749,-122.4194',
166 | },
167 | ],
168 | });
169 | });
170 |
171 | it('should handle latitude validation failure', async () => {
172 | const result = await set_sim_locationLogic(
173 | {
174 | simulatorId: 'test-uuid-123',
175 | latitude: 95,
176 | longitude: -122.4194,
177 | },
178 | createNoopExecutor(),
179 | );
180 |
181 | expect(result).toEqual({
182 | content: [
183 | {
184 | type: 'text',
185 | text: 'Latitude must be between -90 and 90 degrees',
186 | },
187 | ],
188 | });
189 | });
190 |
191 | it('should handle longitude validation failure', async () => {
192 | const result = await set_sim_locationLogic(
193 | {
194 | simulatorId: 'test-uuid-123',
195 | latitude: 37.7749,
196 | longitude: -185,
197 | },
198 | createNoopExecutor(),
199 | );
200 |
201 | expect(result).toEqual({
202 | content: [
203 | {
204 | type: 'text',
205 | text: 'Longitude must be between -180 and 180 degrees',
206 | },
207 | ],
208 | });
209 | });
210 |
211 | it('should handle command failure', async () => {
212 | const mockExecutor = createMockExecutor({
213 | success: false,
214 | output: '',
215 | error: 'Simulator not found',
216 | });
217 |
218 | const result = await set_sim_locationLogic(
219 | {
220 | simulatorId: 'invalid-uuid',
221 | latitude: 37.7749,
222 | longitude: -122.4194,
223 | },
224 | mockExecutor,
225 | );
226 |
227 | expect(result).toEqual({
228 | content: [
229 | {
230 | type: 'text',
231 | text: 'Failed to set simulator location: Simulator not found',
232 | },
233 | ],
234 | });
235 | });
236 |
237 | it('should handle exception with Error object', async () => {
238 | const mockExecutor = createMockExecutor(new Error('Connection failed'));
239 |
240 | const result = await set_sim_locationLogic(
241 | {
242 | simulatorId: 'test-uuid-123',
243 | latitude: 37.7749,
244 | longitude: -122.4194,
245 | },
246 | mockExecutor,
247 | );
248 |
249 | expect(result).toEqual({
250 | content: [
251 | {
252 | type: 'text',
253 | text: 'Failed to set simulator location: Connection failed',
254 | },
255 | ],
256 | });
257 | });
258 |
259 | it('should handle exception with string error', async () => {
260 | const mockExecutor = createMockExecutor('String error');
261 |
262 | const result = await set_sim_locationLogic(
263 | {
264 | simulatorId: 'test-uuid-123',
265 | latitude: 37.7749,
266 | longitude: -122.4194,
267 | },
268 | mockExecutor,
269 | );
270 |
271 | expect(result).toEqual({
272 | content: [
273 | {
274 | type: 'text',
275 | text: 'Failed to set simulator location: String error',
276 | },
277 | ],
278 | });
279 | });
280 |
281 | it('should handle boundary values for coordinates', async () => {
282 | const mockExecutor = createMockExecutor({
283 | success: true,
284 | output: 'Location set successfully',
285 | error: undefined,
286 | });
287 |
288 | const result = await set_sim_locationLogic(
289 | {
290 | simulatorId: 'test-uuid-123',
291 | latitude: 90,
292 | longitude: 180,
293 | },
294 | mockExecutor,
295 | );
296 |
297 | expect(result).toEqual({
298 | content: [
299 | {
300 | type: 'text',
301 | text: 'Successfully set simulator test-uuid-123 location to 90,180',
302 | },
303 | ],
304 | });
305 | });
306 |
307 | it('should handle boundary values for negative coordinates', async () => {
308 | const mockExecutor = createMockExecutor({
309 | success: true,
310 | output: 'Location set successfully',
311 | error: undefined,
312 | });
313 |
314 | const result = await set_sim_locationLogic(
315 | {
316 | simulatorId: 'test-uuid-123',
317 | latitude: -90,
318 | longitude: -180,
319 | },
320 | mockExecutor,
321 | );
322 |
323 | expect(result).toEqual({
324 | content: [
325 | {
326 | type: 'text',
327 | text: 'Successfully set simulator test-uuid-123 location to -90,-180',
328 | },
329 | ],
330 | });
331 | });
332 |
333 | it('should handle zero coordinates', async () => {
334 | const mockExecutor = createMockExecutor({
335 | success: true,
336 | output: 'Location set successfully',
337 | error: undefined,
338 | });
339 |
340 | const result = await set_sim_locationLogic(
341 | {
342 | simulatorId: 'test-uuid-123',
343 | latitude: 0,
344 | longitude: 0,
345 | },
346 | mockExecutor,
347 | );
348 |
349 | expect(result).toEqual({
350 | content: [
351 | {
352 | type: 'text',
353 | text: 'Successfully set simulator test-uuid-123 location to 0,0',
354 | },
355 | ],
356 | });
357 | });
358 |
359 | it('should verify correct executor arguments', async () => {
360 | let capturedArgs: any[] = [];
361 |
362 | const mockExecutor = async (...args: any[]) => {
363 | capturedArgs = args;
364 | return createMockCommandResponse({
365 | success: true,
366 | output: 'Location set successfully',
367 | error: undefined,
368 | });
369 | };
370 |
371 | await set_sim_locationLogic(
372 | {
373 | simulatorId: 'test-uuid-123',
374 | latitude: 37.7749,
375 | longitude: -122.4194,
376 | },
377 | mockExecutor,
378 | );
379 |
380 | expect(capturedArgs).toEqual([
381 | ['xcrun', 'simctl', 'location', 'test-uuid-123', 'set', '37.7749,-122.4194'],
382 | 'Set Simulator Location',
383 | true,
384 | {},
385 | ]);
386 | });
387 | });
388 | });
389 |
```