This is page 8 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/utils/__tests__/build-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for build-utils Sentry classification logic
3 | */
4 |
5 | import { describe, it, expect } from 'vitest';
6 | import { createMockExecutor } from '../../test-utils/mock-executors.ts';
7 | import { executeXcodeBuildCommand } from '../build-utils.ts';
8 | import { XcodePlatform } from '../xcode.ts';
9 |
10 | describe('build-utils Sentry Classification', () => {
11 | const mockPlatformOptions = {
12 | platform: XcodePlatform.macOS,
13 | logPrefix: 'Test Build',
14 | };
15 |
16 | const mockParams = {
17 | scheme: 'TestScheme',
18 | configuration: 'Debug',
19 | projectPath: '/path/to/project.xcodeproj',
20 | };
21 |
22 | describe('Exit Code 64 Classification (MCP Error)', () => {
23 | it('should trigger Sentry logging for exit code 64 (invalid arguments)', async () => {
24 | const mockExecutor = createMockExecutor({
25 | success: false,
26 | error: 'xcodebuild: error: invalid option',
27 | exitCode: 64,
28 | });
29 |
30 | const result = await executeXcodeBuildCommand(
31 | mockParams,
32 | mockPlatformOptions,
33 | false,
34 | 'build',
35 | mockExecutor,
36 | );
37 |
38 | expect(result.isError).toBe(true);
39 | expect(result.content[0].text).toContain('❌ [stderr] xcodebuild: error: invalid option');
40 | expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme');
41 | });
42 | });
43 |
44 | describe('Other Exit Codes Classification (User Error)', () => {
45 | it('should not trigger Sentry logging for exit code 65 (user error)', async () => {
46 | const mockExecutor = createMockExecutor({
47 | success: false,
48 | error: 'Scheme TestScheme was not found',
49 | exitCode: 65,
50 | });
51 |
52 | const result = await executeXcodeBuildCommand(
53 | mockParams,
54 | mockPlatformOptions,
55 | false,
56 | 'build',
57 | mockExecutor,
58 | );
59 |
60 | expect(result.isError).toBe(true);
61 | expect(result.content[0].text).toContain('❌ [stderr] Scheme TestScheme was not found');
62 | expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme');
63 | });
64 |
65 | it('should not trigger Sentry logging for exit code 66 (file not found)', async () => {
66 | const mockExecutor = createMockExecutor({
67 | success: false,
68 | error: 'project.xcodeproj cannot be opened',
69 | exitCode: 66,
70 | });
71 |
72 | const result = await executeXcodeBuildCommand(
73 | mockParams,
74 | mockPlatformOptions,
75 | false,
76 | 'build',
77 | mockExecutor,
78 | );
79 |
80 | expect(result.isError).toBe(true);
81 | expect(result.content[0].text).toContain('❌ [stderr] project.xcodeproj cannot be opened');
82 | });
83 |
84 | it('should not trigger Sentry logging for exit code 70 (destination error)', async () => {
85 | const mockExecutor = createMockExecutor({
86 | success: false,
87 | error: 'Unable to find a destination matching the provided destination specifier',
88 | exitCode: 70,
89 | });
90 |
91 | const result = await executeXcodeBuildCommand(
92 | mockParams,
93 | mockPlatformOptions,
94 | false,
95 | 'build',
96 | mockExecutor,
97 | );
98 |
99 | expect(result.isError).toBe(true);
100 | expect(result.content[0].text).toContain('❌ [stderr] Unable to find a destination matching');
101 | });
102 |
103 | it('should not trigger Sentry logging for exit code 1 (general build failure)', async () => {
104 | const mockExecutor = createMockExecutor({
105 | success: false,
106 | error: 'Build failed with errors',
107 | exitCode: 1,
108 | });
109 |
110 | const result = await executeXcodeBuildCommand(
111 | mockParams,
112 | mockPlatformOptions,
113 | false,
114 | 'build',
115 | mockExecutor,
116 | );
117 |
118 | expect(result.isError).toBe(true);
119 | expect(result.content[0].text).toContain('❌ [stderr] Build failed with errors');
120 | });
121 | });
122 |
123 | describe('Spawn Error Classification (Environment Error)', () => {
124 | it('should not trigger Sentry logging for ENOENT spawn error', async () => {
125 | const spawnError = new Error('spawn xcodebuild ENOENT') as NodeJS.ErrnoException;
126 | spawnError.code = 'ENOENT';
127 |
128 | const mockExecutor = createMockExecutor({
129 | success: false,
130 | error: '',
131 | shouldThrow: spawnError,
132 | });
133 |
134 | const result = await executeXcodeBuildCommand(
135 | mockParams,
136 | mockPlatformOptions,
137 | false,
138 | 'build',
139 | mockExecutor,
140 | );
141 |
142 | expect(result.isError).toBe(true);
143 | expect(result.content[0].text).toContain(
144 | 'Error during Test Build build: spawn xcodebuild ENOENT',
145 | );
146 | });
147 |
148 | it('should not trigger Sentry logging for EACCES spawn error', async () => {
149 | const spawnError = new Error('spawn xcodebuild EACCES') as NodeJS.ErrnoException;
150 | spawnError.code = 'EACCES';
151 |
152 | const mockExecutor = createMockExecutor({
153 | success: false,
154 | error: '',
155 | shouldThrow: spawnError,
156 | });
157 |
158 | const result = await executeXcodeBuildCommand(
159 | mockParams,
160 | mockPlatformOptions,
161 | false,
162 | 'build',
163 | mockExecutor,
164 | );
165 |
166 | expect(result.isError).toBe(true);
167 | expect(result.content[0].text).toContain(
168 | 'Error during Test Build build: spawn xcodebuild EACCES',
169 | );
170 | });
171 |
172 | it('should not trigger Sentry logging for EPERM spawn error', async () => {
173 | const spawnError = new Error('spawn xcodebuild EPERM') as NodeJS.ErrnoException;
174 | spawnError.code = 'EPERM';
175 |
176 | const mockExecutor = createMockExecutor({
177 | success: false,
178 | error: '',
179 | shouldThrow: spawnError,
180 | });
181 |
182 | const result = await executeXcodeBuildCommand(
183 | mockParams,
184 | mockPlatformOptions,
185 | false,
186 | 'build',
187 | mockExecutor,
188 | );
189 |
190 | expect(result.isError).toBe(true);
191 | expect(result.content[0].text).toContain(
192 | 'Error during Test Build build: spawn xcodebuild EPERM',
193 | );
194 | });
195 |
196 | it('should trigger Sentry logging for non-spawn exceptions', async () => {
197 | const otherError = new Error('Unexpected internal error');
198 |
199 | const mockExecutor = createMockExecutor({
200 | success: false,
201 | error: '',
202 | shouldThrow: otherError,
203 | });
204 |
205 | const result = await executeXcodeBuildCommand(
206 | mockParams,
207 | mockPlatformOptions,
208 | false,
209 | 'build',
210 | mockExecutor,
211 | );
212 |
213 | expect(result.isError).toBe(true);
214 | expect(result.content[0].text).toContain(
215 | 'Error during Test Build build: Unexpected internal error',
216 | );
217 | });
218 | });
219 |
220 | describe('Success Case (No Sentry Logging)', () => {
221 | it('should not trigger any error logging for successful builds', async () => {
222 | const mockExecutor = createMockExecutor({
223 | success: true,
224 | output: 'BUILD SUCCEEDED',
225 | exitCode: 0,
226 | });
227 |
228 | const result = await executeXcodeBuildCommand(
229 | mockParams,
230 | mockPlatformOptions,
231 | false,
232 | 'build',
233 | mockExecutor,
234 | );
235 |
236 | expect(result.isError).toBeFalsy();
237 | expect(result.content[0].text).toContain(
238 | '✅ Test Build build succeeded for scheme TestScheme',
239 | );
240 | });
241 | });
242 |
243 | describe('Exit Code Undefined Cases', () => {
244 | it('should not trigger Sentry logging when exitCode is undefined', async () => {
245 | const mockExecutor = createMockExecutor({
246 | success: false,
247 | error: 'Some error without exit code',
248 | exitCode: undefined,
249 | });
250 |
251 | const result = await executeXcodeBuildCommand(
252 | mockParams,
253 | mockPlatformOptions,
254 | false,
255 | 'build',
256 | mockExecutor,
257 | );
258 |
259 | expect(result.isError).toBe(true);
260 | expect(result.content[0].text).toContain('❌ [stderr] Some error without exit code');
261 | });
262 | });
263 |
264 | describe('Working Directory (cwd) Handling', () => {
265 | it('should pass project directory as cwd for workspace builds', async () => {
266 | let capturedOptions: any;
267 | const mockExecutor = createMockExecutor({
268 | success: true,
269 | output: 'BUILD SUCCEEDED',
270 | exitCode: 0,
271 | onExecute: (_command, _logPrefix, _useShell, opts) => {
272 | capturedOptions = opts;
273 | },
274 | });
275 |
276 | await executeXcodeBuildCommand(
277 | {
278 | scheme: 'TestScheme',
279 | configuration: 'Debug',
280 | workspacePath: '/path/to/project/MyProject.xcworkspace',
281 | },
282 | mockPlatformOptions,
283 | false,
284 | 'build',
285 | mockExecutor,
286 | );
287 |
288 | expect(capturedOptions).toBeDefined();
289 | expect(capturedOptions.cwd).toBe('/path/to/project');
290 | });
291 |
292 | it('should pass project directory as cwd for project builds', async () => {
293 | let capturedOptions: any;
294 | const mockExecutor = createMockExecutor({
295 | success: true,
296 | output: 'BUILD SUCCEEDED',
297 | exitCode: 0,
298 | onExecute: (_command, _logPrefix, _useShell, opts) => {
299 | capturedOptions = opts;
300 | },
301 | });
302 |
303 | await executeXcodeBuildCommand(
304 | {
305 | scheme: 'TestScheme',
306 | configuration: 'Debug',
307 | projectPath: '/path/to/project/MyProject.xcodeproj',
308 | },
309 | mockPlatformOptions,
310 | false,
311 | 'build',
312 | mockExecutor,
313 | );
314 |
315 | expect(capturedOptions).toBeDefined();
316 | expect(capturedOptions.cwd).toBe('/path/to/project');
317 | });
318 |
319 | it('should merge cwd with existing execOpts', async () => {
320 | let capturedOptions: any;
321 | const mockExecutor = createMockExecutor({
322 | success: true,
323 | output: 'BUILD SUCCEEDED',
324 | exitCode: 0,
325 | onExecute: (_command, _logPrefix, _useShell, opts) => {
326 | capturedOptions = opts;
327 | },
328 | });
329 |
330 | await executeXcodeBuildCommand(
331 | {
332 | scheme: 'TestScheme',
333 | configuration: 'Debug',
334 | workspacePath: '/path/to/project/MyProject.xcworkspace',
335 | },
336 | mockPlatformOptions,
337 | false,
338 | 'build',
339 | mockExecutor,
340 | { env: { CUSTOM_VAR: 'value' } },
341 | );
342 |
343 | expect(capturedOptions).toBeDefined();
344 | expect(capturedOptions.cwd).toBe('/path/to/project');
345 | expect(capturedOptions.env).toEqual({ CUSTOM_VAR: 'value' });
346 | });
347 | });
348 | });
349 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for stop_device_log_cap plugin
3 | */
4 | import { describe, it, expect, beforeEach } from 'vitest';
5 | import { EventEmitter } from 'events';
6 | import * as z from 'zod';
7 | import plugin, { stop_device_log_capLogic } from '../stop_device_log_cap.ts';
8 | import {
9 | activeDeviceLogSessions,
10 | type DeviceLogSession,
11 | } from '../../../../utils/log-capture/device-log-sessions.ts';
12 | import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts';
13 |
14 | // Note: Logger is allowed to execute normally (integration testing pattern)
15 |
16 | describe('stop_device_log_cap plugin', () => {
17 | beforeEach(() => {
18 | // Clear actual active sessions before each test
19 | activeDeviceLogSessions.clear();
20 | });
21 |
22 | describe('Plugin Structure', () => {
23 | it('should export an object with required properties', () => {
24 | expect(plugin).toHaveProperty('name');
25 | expect(plugin).toHaveProperty('description');
26 | expect(plugin).toHaveProperty('schema');
27 | expect(plugin).toHaveProperty('handler');
28 | });
29 |
30 | it('should have correct tool name', () => {
31 | expect(plugin.name).toBe('stop_device_log_cap');
32 | });
33 |
34 | it('should have correct description', () => {
35 | expect(plugin.description).toBe(
36 | 'Stops an active Apple device log capture session and returns the captured logs.',
37 | );
38 | });
39 |
40 | it('should have correct schema structure', () => {
41 | // Schema should be a plain object for MCP protocol compliance
42 | expect(typeof plugin.schema).toBe('object');
43 | expect(plugin.schema).toHaveProperty('logSessionId');
44 |
45 | // Validate that schema fields are Zod types that can be used for validation
46 | const schema = z.object(plugin.schema);
47 | expect(schema.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true);
48 | expect(schema.safeParse({ logSessionId: 123 }).success).toBe(false);
49 | });
50 |
51 | it('should have handler as a function', () => {
52 | expect(typeof plugin.handler).toBe('function');
53 | });
54 | });
55 |
56 | describe('Handler Functionality', () => {
57 | // Helper function to create a test process
58 | function createTestProcess(
59 | options: {
60 | killed?: boolean;
61 | exitCode?: number | null;
62 | } = {},
63 | ) {
64 | const emitter = new EventEmitter();
65 | const processState = {
66 | killed: options.killed ?? false,
67 | exitCode: options.exitCode ?? (options.killed ? 0 : null),
68 | killCalls: [] as string[],
69 | kill(signal?: string) {
70 | if (this.killed) {
71 | return false;
72 | }
73 | this.killCalls.push(signal ?? 'SIGTERM');
74 | this.killed = true;
75 | this.exitCode = 0;
76 | emitter.emit('close', 0);
77 | return true;
78 | },
79 | };
80 |
81 | const testProcess = Object.assign(emitter, processState);
82 | return testProcess as typeof testProcess;
83 | }
84 |
85 | it('should handle stop log capture when session not found', async () => {
86 | const mockFileSystem = createMockFileSystemExecutor();
87 |
88 | const result = await stop_device_log_capLogic(
89 | {
90 | logSessionId: 'device-log-00008110-001A2C3D4E5F-com.example.MyApp',
91 | },
92 | mockFileSystem,
93 | );
94 |
95 | expect(result.content[0].text).toBe(
96 | 'Failed to stop device log capture session device-log-00008110-001A2C3D4E5F-com.example.MyApp: Device log capture session not found: device-log-00008110-001A2C3D4E5F-com.example.MyApp',
97 | );
98 | expect(result.isError).toBe(true);
99 | });
100 |
101 | it('should handle successful log capture stop', async () => {
102 | const testSessionId = 'test-session-123';
103 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-123.log';
104 | const testLogContent = 'Device log content here...';
105 |
106 | // Test active session
107 | const testProcess = createTestProcess({
108 | killed: false,
109 | exitCode: null,
110 | });
111 |
112 | activeDeviceLogSessions.set(testSessionId, {
113 | process: testProcess as unknown as DeviceLogSession['process'],
114 | logFilePath: testLogFilePath,
115 | deviceUuid: '00008110-001A2C3D4E5F',
116 | bundleId: 'com.example.MyApp',
117 | hasEnded: false,
118 | });
119 |
120 | // Configure test file system for successful operation
121 | const mockFileSystem = createMockFileSystemExecutor({
122 | existsSync: () => true,
123 | readFile: async () => testLogContent,
124 | });
125 |
126 | const result = await stop_device_log_capLogic(
127 | {
128 | logSessionId: testSessionId,
129 | },
130 | mockFileSystem,
131 | );
132 |
133 | expect(result).toEqual({
134 | content: [
135 | {
136 | type: 'text',
137 | text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`,
138 | },
139 | ],
140 | });
141 | expect(result.isError).toBeUndefined();
142 | expect(testProcess.killCalls).toEqual(['SIGTERM']);
143 | expect(activeDeviceLogSessions.has(testSessionId)).toBe(false);
144 | });
145 |
146 | it('should handle already killed process', async () => {
147 | const testSessionId = 'test-session-456';
148 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-456.log';
149 | const testLogContent = 'Device log content...';
150 |
151 | // Test active session with already killed process
152 | const testProcess = createTestProcess({
153 | killed: true,
154 | exitCode: 0,
155 | });
156 |
157 | activeDeviceLogSessions.set(testSessionId, {
158 | process: testProcess as unknown as DeviceLogSession['process'],
159 | logFilePath: testLogFilePath,
160 | deviceUuid: '00008110-001A2C3D4E5F',
161 | bundleId: 'com.example.MyApp',
162 | hasEnded: false,
163 | });
164 |
165 | // Configure test file system for successful operation
166 | const mockFileSystem = createMockFileSystemExecutor({
167 | existsSync: () => true,
168 | readFile: async () => testLogContent,
169 | });
170 |
171 | const result = await stop_device_log_capLogic(
172 | {
173 | logSessionId: testSessionId,
174 | },
175 | mockFileSystem,
176 | );
177 |
178 | expect(result).toEqual({
179 | content: [
180 | {
181 | type: 'text',
182 | text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`,
183 | },
184 | ],
185 | });
186 | expect(testProcess.killCalls).toEqual([]); // Should not kill already killed process
187 | });
188 |
189 | it('should handle file access failure', async () => {
190 | const testSessionId = 'test-session-789';
191 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-789.log';
192 |
193 | // Test active session
194 | const testProcess = createTestProcess({
195 | killed: false,
196 | exitCode: null,
197 | });
198 |
199 | activeDeviceLogSessions.set(testSessionId, {
200 | process: testProcess as unknown as DeviceLogSession['process'],
201 | logFilePath: testLogFilePath,
202 | deviceUuid: '00008110-001A2C3D4E5F',
203 | bundleId: 'com.example.MyApp',
204 | hasEnded: false,
205 | });
206 |
207 | // Configure test file system for access failure (file doesn't exist)
208 | const mockFileSystem = createMockFileSystemExecutor({
209 | existsSync: () => false,
210 | });
211 |
212 | const result = await stop_device_log_capLogic(
213 | {
214 | logSessionId: testSessionId,
215 | },
216 | mockFileSystem,
217 | );
218 |
219 | expect(result).toEqual({
220 | content: [
221 | {
222 | type: 'text',
223 | text: `Failed to stop device log capture session ${testSessionId}: Log file not found: ${testLogFilePath}`,
224 | },
225 | ],
226 | isError: true,
227 | });
228 | expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); // Session still removed
229 | });
230 |
231 | it('should handle file read failure', async () => {
232 | const testSessionId = 'test-session-abc';
233 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-abc.log';
234 |
235 | // Test active session
236 | const testProcess = createTestProcess({
237 | killed: false,
238 | exitCode: null,
239 | });
240 |
241 | activeDeviceLogSessions.set(testSessionId, {
242 | process: testProcess as unknown as DeviceLogSession['process'],
243 | logFilePath: testLogFilePath,
244 | deviceUuid: '00008110-001A2C3D4E5F',
245 | bundleId: 'com.example.MyApp',
246 | hasEnded: false,
247 | });
248 |
249 | // Configure test file system for successful access but failed read
250 | const mockFileSystem = createMockFileSystemExecutor({
251 | existsSync: () => true,
252 | readFile: async () => {
253 | throw new Error('Read permission denied');
254 | },
255 | });
256 |
257 | const result = await stop_device_log_capLogic(
258 | {
259 | logSessionId: testSessionId,
260 | },
261 | mockFileSystem,
262 | );
263 |
264 | expect(result).toEqual({
265 | content: [
266 | {
267 | type: 'text',
268 | text: `Failed to stop device log capture session ${testSessionId}: Read permission denied`,
269 | },
270 | ],
271 | isError: true,
272 | });
273 | });
274 |
275 | it('should handle string error objects', async () => {
276 | const testSessionId = 'test-session-def';
277 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-def.log';
278 |
279 | // Test active session
280 | const testProcess = createTestProcess({
281 | killed: false,
282 | exitCode: null,
283 | });
284 |
285 | activeDeviceLogSessions.set(testSessionId, {
286 | process: testProcess as unknown as DeviceLogSession['process'],
287 | logFilePath: testLogFilePath,
288 | deviceUuid: '00008110-001A2C3D4E5F',
289 | bundleId: 'com.example.MyApp',
290 | hasEnded: false,
291 | });
292 |
293 | // Configure test file system for access failure with string error
294 | const mockFileSystem = createMockFileSystemExecutor({
295 | existsSync: () => true,
296 | readFile: async () => {
297 | throw 'String error message';
298 | },
299 | });
300 |
301 | const result = await stop_device_log_capLogic(
302 | {
303 | logSessionId: testSessionId,
304 | },
305 | mockFileSystem,
306 | );
307 |
308 | expect(result).toEqual({
309 | content: [
310 | {
311 | type: 'text',
312 | text: `Failed to stop device log capture session ${testSessionId}: String error message`,
313 | },
314 | ],
315 | isError: true,
316 | });
317 | });
318 | });
319 | });
320 |
```
--------------------------------------------------------------------------------
/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:
--------------------------------------------------------------------------------
```swift
1 | //
2 | // CalculatorAppTests.swift
3 | // CalculatorAppTests
4 | //
5 | // Created by Cameron on 05/06/2025.
6 | //
7 |
8 | import XCTest
9 | import SwiftUI
10 | @testable import CalculatorApp
11 | import CalculatorAppFeature
12 |
13 | final class CalculatorAppTests: XCTestCase {
14 |
15 | override func setUpWithError() throws {
16 | continueAfterFailure = false
17 | }
18 |
19 | override func tearDownWithError() throws {
20 | // Clean up after each test
21 | }
22 | }
23 |
24 | // MARK: - App Lifecycle Tests
25 | extension CalculatorAppTests {
26 |
27 | func testAppLaunch() throws {
28 | // Test that the app launches without crashing
29 | let app = CalculatorApp()
30 | XCTAssertNotNil(app, "App should initialize successfully")
31 | }
32 |
33 | func testContentViewInitialization() throws {
34 | // Test that ContentView initializes properly
35 | let contentView = ContentView()
36 | XCTAssertNotNil(contentView, "ContentView should initialize successfully")
37 | }
38 | }
39 |
40 | // MARK: - Calculator Service Integration Tests
41 | extension CalculatorAppTests {
42 |
43 | func testCalculatorServiceCreation() throws {
44 | let service = CalculatorService()
45 | XCTAssertEqual(service.display, "0", "Calculator should start with display showing 0")
46 | XCTAssertEqual(service.expressionDisplay, "", "Calculator should start with empty expression")
47 | }
48 |
49 | func testCalculatorServiceFailure() throws {
50 | let service = CalculatorService()
51 | // This test is designed to fail to test error reporting
52 | XCTAssertEqual(service.display, "999", "This test should fail - display should be 0, not 999")
53 | }
54 |
55 | func testCalculatorServiceBasicOperation() throws {
56 | let service = CalculatorService()
57 |
58 | // Test basic addition
59 | service.inputNumber("5")
60 | service.setOperation(.add)
61 | service.inputNumber("3")
62 | service.calculate()
63 |
64 | XCTAssertEqual(service.display, "8", "5 + 3 should equal 8")
65 | }
66 |
67 | func testCalculatorServiceChainedOperations() throws {
68 | let service = CalculatorService()
69 |
70 | // Test chained operations: 10 + 5 * 2 = 30 (since calculator evaluates left to right)
71 | service.inputNumber("10")
72 | service.setOperation(.add)
73 | service.inputNumber("5")
74 | service.setOperation(.multiply)
75 | service.inputNumber("2")
76 | service.calculate()
77 |
78 | XCTAssertEqual(service.display, "30", "10 + 5 * 2 should equal 30 (left-to-right evaluation)")
79 | }
80 |
81 | func testCalculatorServiceClear() throws {
82 | let service = CalculatorService()
83 |
84 | // Set up some state
85 | service.inputNumber("123")
86 | service.setOperation(.add)
87 | service.inputNumber("456")
88 |
89 | // Clear should reset everything
90 | service.clear()
91 |
92 | XCTAssertEqual(service.display, "0", "Display should be 0 after clear")
93 | XCTAssertEqual(service.expressionDisplay, "", "Expression should be empty after clear")
94 | }
95 | }
96 |
97 | // MARK: - API Surface Tests
98 | extension CalculatorAppTests {
99 |
100 | func testCalculatorServicePublicInterface() throws {
101 | let service = CalculatorService()
102 |
103 | // Test that all expected public methods are available
104 | XCTAssertNoThrow(service.inputNumber("5"))
105 | XCTAssertNoThrow(service.inputDecimal())
106 | XCTAssertNoThrow(service.setOperation(.add))
107 | XCTAssertNoThrow(service.calculate())
108 | XCTAssertNoThrow(service.toggleSign())
109 | XCTAssertNoThrow(service.percentage())
110 | XCTAssertNoThrow(service.clear())
111 | }
112 |
113 | func testCalculatorServicePublicProperties() throws {
114 | let service = CalculatorService()
115 |
116 | // Test that all expected public properties are accessible
117 | XCTAssertNotNil(service.display)
118 | XCTAssertNotNil(service.expressionDisplay)
119 | XCTAssertEqual(service.hasError, false)
120 |
121 | // Test testing support properties
122 | XCTAssertEqual(service.currentValue, 0)
123 | XCTAssertEqual(service.previousValue, 0)
124 | XCTAssertNil(service.currentOperation)
125 | XCTAssertEqual(service.willResetDisplay, false)
126 | }
127 |
128 | func testCalculatorOperationsEnum() throws {
129 | // Test that all operations are available
130 | XCTAssertEqual(CalculatorService.Operation.add.rawValue, "+")
131 | XCTAssertEqual(CalculatorService.Operation.subtract.rawValue, "-")
132 | XCTAssertEqual(CalculatorService.Operation.multiply.rawValue, "×")
133 | XCTAssertEqual(CalculatorService.Operation.divide.rawValue, "÷")
134 |
135 | // Test operation calculations
136 | XCTAssertEqual(CalculatorService.Operation.add.calculate(5, 3), 8)
137 | XCTAssertEqual(CalculatorService.Operation.subtract.calculate(5, 3), 2)
138 | XCTAssertEqual(CalculatorService.Operation.multiply.calculate(5, 3), 15)
139 | XCTAssertEqual(CalculatorService.Operation.divide.calculate(6, 3), 2)
140 | XCTAssertEqual(CalculatorService.Operation.divide.calculate(5, 0), 0) // Division by zero
141 | }
142 | }
143 |
144 | // MARK: - Edge Case and Error Handling Tests
145 | extension CalculatorAppTests {
146 |
147 | func testDivisionByZero() throws {
148 | let service = CalculatorService()
149 |
150 | service.inputNumber("10")
151 | service.setOperation(.divide)
152 | service.inputNumber("0")
153 | service.calculate()
154 |
155 | XCTAssertEqual(service.display, "0", "Division by zero should return 0")
156 | }
157 |
158 | func testLargeNumbers() throws {
159 | let service = CalculatorService()
160 |
161 | // Test large number input
162 | service.inputNumber("999999999")
163 | XCTAssertEqual(service.display, "999999999", "Should handle large numbers")
164 |
165 | // Test large number calculation
166 | service.setOperation(.multiply)
167 | service.inputNumber("2")
168 | service.calculate()
169 |
170 | // Should handle the result without crashing
171 | XCTAssertNotEqual(service.display, "", "Should display some result for large calculations")
172 | }
173 |
174 | func testRepeatedEquals() throws {
175 | let service = CalculatorService()
176 |
177 | service.inputNumber("5")
178 | service.setOperation(.add)
179 | service.inputNumber("3")
180 | service.calculate() // 5 + 3 = 8
181 |
182 | let firstResult = service.display
183 |
184 | service.calculate() // Should repeat last operation: 8 + 3 = 11
185 | let secondResult = service.display
186 |
187 | XCTAssertEqual(firstResult, "8", "First calculation should be correct")
188 | XCTAssertEqual(secondResult, "11", "Repeated equals should repeat last operation")
189 | }
190 | }
191 |
192 | // MARK: - Performance Tests
193 | extension CalculatorAppTests {
194 |
195 | func testCalculationPerformance() throws {
196 | let service = CalculatorService()
197 |
198 | measure {
199 | // Measure performance of 100 calculations
200 | for i in 1...100 {
201 | service.clear()
202 | service.inputNumber("\(i)")
203 | service.setOperation(.multiply)
204 | service.inputNumber("2")
205 | service.calculate()
206 | }
207 | }
208 | }
209 |
210 | func testLargeNumberInputPerformance() throws {
211 | let service = CalculatorService()
212 |
213 | measure {
214 | // Measure performance of inputting large numbers
215 | service.clear()
216 | for digit in "123456789012345" {
217 | service.inputNumber(String(digit))
218 | }
219 | }
220 | }
221 | }
222 |
223 | // MARK: - State Consistency Tests
224 | extension CalculatorAppTests {
225 |
226 | func testStateConsistencyAfterOperations() throws {
227 | let service = CalculatorService()
228 |
229 | // Perform a series of operations and verify state remains consistent
230 | service.inputNumber("10")
231 | XCTAssertEqual(service.display, "10")
232 |
233 | service.setOperation(.add)
234 | XCTAssertEqual(service.display, "10")
235 | XCTAssertTrue(service.expressionDisplay.contains("10 +"))
236 |
237 | service.inputNumber("5")
238 | XCTAssertEqual(service.display, "5")
239 |
240 | service.calculate()
241 | XCTAssertEqual(service.display, "15")
242 | }
243 |
244 | func testStateConsistencyWithDecimalNumbers() throws {
245 | let service = CalculatorService()
246 |
247 | service.inputNumber("3")
248 | service.inputDecimal()
249 | service.inputNumber("14")
250 | XCTAssertEqual(service.display, "3.14")
251 |
252 | service.setOperation(.multiply)
253 | service.inputNumber("2")
254 | service.calculate()
255 |
256 | XCTAssertEqual(service.display, "6.28")
257 | }
258 |
259 | func testMultipleDecimalPointsHandling() throws {
260 | let service = CalculatorService()
261 |
262 | service.inputNumber("1")
263 | service.inputDecimal()
264 | service.inputNumber("5")
265 | service.inputDecimal() // This should be ignored
266 | service.inputNumber("9")
267 |
268 | XCTAssertEqual(service.display, "1.59", "Multiple decimal points should be ignored")
269 | }
270 | }
271 |
272 | // MARK: - Component Integration Tests
273 | extension CalculatorAppTests {
274 |
275 | func testComplexCalculationWorkflow() throws {
276 | let service = CalculatorService()
277 |
278 | // Test complex workflow through direct service calls
279 | service.inputNumber("2")
280 | service.inputNumber("5")
281 | service.setOperation(.divide)
282 | service.inputNumber("5")
283 | service.calculate()
284 |
285 | XCTAssertEqual(service.display, "5", "Complex workflow should work correctly")
286 |
287 | // Test that we can continue with the result
288 | service.setOperation(.multiply)
289 | service.inputNumber("4")
290 | service.calculate()
291 |
292 | XCTAssertEqual(service.display, "20", "Should be able to continue with previous result")
293 | }
294 |
295 | func testPercentageCalculation() throws {
296 | let service = CalculatorService()
297 |
298 | service.inputNumber("50")
299 | service.percentage()
300 |
301 | XCTAssertEqual(service.display, "0.5", "50% should equal 0.5")
302 | }
303 |
304 | func testSignToggle() throws {
305 | let service = CalculatorService()
306 |
307 | service.inputNumber("42")
308 | service.toggleSign()
309 | XCTAssertEqual(service.display, "-42", "Should toggle to negative")
310 |
311 | service.toggleSign()
312 | XCTAssertEqual(service.display, "42", "Should toggle back to positive")
313 | }
314 | }
315 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/launch_app_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Pure dependency injection test for launch_app_device plugin (device-shared)
3 | *
4 | * Tests plugin structure and app launching functionality including parameter validation,
5 | * command generation, file operations, and response formatting.
6 | *
7 | * Uses createMockExecutor for command execution and manual stubs for file operations.
8 | */
9 |
10 | import { describe, it, expect, beforeEach } from 'vitest';
11 | import * as z from 'zod';
12 | import {
13 | createMockExecutor,
14 | createMockFileSystemExecutor,
15 | } from '../../../../test-utils/mock-executors.ts';
16 | import launchAppDevice, { launch_app_deviceLogic } from '../launch_app_device.ts';
17 | import { sessionStore } from '../../../../utils/session-store.ts';
18 |
19 | describe('launch_app_device plugin (device-shared)', () => {
20 | beforeEach(() => {
21 | sessionStore.clear();
22 | });
23 |
24 | describe('Export Field Validation (Literal)', () => {
25 | it('should have correct name', () => {
26 | expect(launchAppDevice.name).toBe('launch_app_device');
27 | });
28 |
29 | it('should have correct description', () => {
30 | expect(launchAppDevice.description).toBe('Launches an app on a connected device.');
31 | });
32 |
33 | it('should have handler function', () => {
34 | expect(typeof launchAppDevice.handler).toBe('function');
35 | });
36 |
37 | it('should validate schema with valid inputs', () => {
38 | const schema = z.strictObject(launchAppDevice.schema);
39 | expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
40 | expect(schema.safeParse({}).success).toBe(false);
41 | expect(Object.keys(launchAppDevice.schema)).toEqual(['bundleId']);
42 | });
43 |
44 | it('should validate schema with invalid inputs', () => {
45 | const schema = z.strictObject(launchAppDevice.schema);
46 | expect(schema.safeParse({ bundleId: null }).success).toBe(false);
47 | expect(schema.safeParse({ bundleId: 123 }).success).toBe(false);
48 | });
49 | });
50 |
51 | describe('Handler Requirements', () => {
52 | it('should require deviceId when not provided', async () => {
53 | const result = await launchAppDevice.handler({ bundleId: 'com.example.app' });
54 |
55 | expect(result.isError).toBe(true);
56 | expect(result.content[0].text).toContain('deviceId is required');
57 | });
58 | });
59 |
60 | describe('Command Generation', () => {
61 | it('should generate correct devicectl command with required parameters', async () => {
62 | const calls: any[] = [];
63 | const mockExecutor = createMockExecutor({
64 | success: true,
65 | output: 'App launched successfully',
66 | process: { pid: 12345 },
67 | });
68 |
69 | const trackingExecutor = async (
70 | command: string[],
71 | logPrefix?: string,
72 | useShell?: boolean,
73 | opts?: { env?: Record<string, string> },
74 | _detached?: boolean,
75 | ) => {
76 | calls.push({ command, logPrefix, useShell, env: opts?.env });
77 | return mockExecutor(command, logPrefix, useShell, opts, _detached);
78 | };
79 |
80 | await launch_app_deviceLogic(
81 | {
82 | deviceId: 'test-device-123',
83 | bundleId: 'com.example.app',
84 | },
85 | trackingExecutor,
86 | createMockFileSystemExecutor(),
87 | );
88 |
89 | expect(calls).toHaveLength(1);
90 | expect(calls[0].command).toEqual([
91 | 'xcrun',
92 | 'devicectl',
93 | 'device',
94 | 'process',
95 | 'launch',
96 | '--device',
97 | 'test-device-123',
98 | '--json-output',
99 | expect.stringMatching(/^\/.*\/launch-\d+\.json$/),
100 | '--terminate-existing',
101 | 'com.example.app',
102 | ]);
103 | expect(calls[0].logPrefix).toBe('Launch app on device');
104 | expect(calls[0].useShell).toBe(true);
105 | expect(calls[0].env).toBeUndefined();
106 | });
107 |
108 | it('should generate command with different device and bundle parameters', async () => {
109 | const calls: any[] = [];
110 | const mockExecutor = createMockExecutor({
111 | success: true,
112 | output: 'Launch successful',
113 | process: { pid: 54321 },
114 | });
115 |
116 | const trackingExecutor = async (command: string[]) => {
117 | calls.push({ command });
118 | return mockExecutor(command);
119 | };
120 |
121 | await launch_app_deviceLogic(
122 | {
123 | deviceId: '00008030-001E14BE2288802E',
124 | bundleId: 'com.apple.mobilesafari',
125 | },
126 | trackingExecutor,
127 | createMockFileSystemExecutor(),
128 | );
129 |
130 | expect(calls[0].command).toEqual([
131 | 'xcrun',
132 | 'devicectl',
133 | 'device',
134 | 'process',
135 | 'launch',
136 | '--device',
137 | '00008030-001E14BE2288802E',
138 | '--json-output',
139 | expect.stringMatching(/^\/.*\/launch-\d+\.json$/),
140 | '--terminate-existing',
141 | 'com.apple.mobilesafari',
142 | ]);
143 | });
144 | });
145 |
146 | describe('Success Path Tests', () => {
147 | it('should return successful launch response without process ID', async () => {
148 | const mockExecutor = createMockExecutor({
149 | success: true,
150 | output: 'App launched successfully',
151 | });
152 |
153 | const result = await launch_app_deviceLogic(
154 | {
155 | deviceId: 'test-device-123',
156 | bundleId: 'com.example.app',
157 | },
158 | mockExecutor,
159 | createMockFileSystemExecutor(),
160 | );
161 |
162 | expect(result).toEqual({
163 | content: [
164 | {
165 | type: 'text',
166 | text: '✅ App launched successfully\n\nApp launched successfully',
167 | },
168 | ],
169 | });
170 | });
171 |
172 | it('should return successful launch response with detailed output', async () => {
173 | const mockExecutor = createMockExecutor({
174 | success: true,
175 | output: 'Launch succeeded with detailed output',
176 | });
177 |
178 | const result = await launch_app_deviceLogic(
179 | {
180 | deviceId: 'test-device-123',
181 | bundleId: 'com.example.app',
182 | },
183 | mockExecutor,
184 | createMockFileSystemExecutor(),
185 | );
186 |
187 | expect(result).toEqual({
188 | content: [
189 | {
190 | type: 'text',
191 | text: '✅ App launched successfully\n\nLaunch succeeded with detailed output',
192 | },
193 | ],
194 | });
195 | });
196 |
197 | it('should handle successful launch with process ID information', async () => {
198 | const mockFileSystem = createMockFileSystemExecutor({
199 | readFile: async () =>
200 | JSON.stringify({
201 | result: {
202 | process: {
203 | processIdentifier: 12345,
204 | },
205 | },
206 | }),
207 | rm: async () => {},
208 | });
209 |
210 | const mockExecutor = createMockExecutor({
211 | success: true,
212 | output: 'App launched successfully',
213 | });
214 |
215 | const result = await launch_app_deviceLogic(
216 | {
217 | deviceId: 'test-device-123',
218 | bundleId: 'com.example.app',
219 | },
220 | mockExecutor,
221 | mockFileSystem,
222 | );
223 |
224 | expect(result).toEqual({
225 | content: [
226 | {
227 | type: 'text',
228 | text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nNext Steps:\n1. Interact with your app on the device\n2. Stop the app: stop_app_device({ deviceId: "test-device-123", processId: 12345 })',
229 | },
230 | ],
231 | });
232 | });
233 |
234 | it('should handle successful launch with command output', async () => {
235 | const mockExecutor = createMockExecutor({
236 | success: true,
237 | output: 'App "com.example.app" launched on device "test-device-123"',
238 | });
239 |
240 | const result = await launch_app_deviceLogic(
241 | {
242 | deviceId: 'test-device-123',
243 | bundleId: 'com.example.app',
244 | },
245 | mockExecutor,
246 | createMockFileSystemExecutor(),
247 | );
248 |
249 | expect(result).toEqual({
250 | content: [
251 | {
252 | type: 'text',
253 | text: '✅ App launched successfully\n\nApp "com.example.app" launched on device "test-device-123"',
254 | },
255 | ],
256 | });
257 | });
258 | });
259 |
260 | describe('Error Handling', () => {
261 | it('should return launch failure response', async () => {
262 | const mockExecutor = createMockExecutor({
263 | success: false,
264 | error: 'Launch failed: App not found',
265 | });
266 |
267 | const result = await launch_app_deviceLogic(
268 | {
269 | deviceId: 'test-device-123',
270 | bundleId: 'com.nonexistent.app',
271 | },
272 | mockExecutor,
273 | createMockFileSystemExecutor(),
274 | );
275 |
276 | expect(result).toEqual({
277 | content: [
278 | {
279 | type: 'text',
280 | text: 'Failed to launch app: Launch failed: App not found',
281 | },
282 | ],
283 | isError: true,
284 | });
285 | });
286 |
287 | it('should return command failure response with specific error', async () => {
288 | const mockExecutor = createMockExecutor({
289 | success: false,
290 | error: 'Device not found: test-device-invalid',
291 | });
292 |
293 | const result = await launch_app_deviceLogic(
294 | {
295 | deviceId: 'test-device-invalid',
296 | bundleId: 'com.example.app',
297 | },
298 | mockExecutor,
299 | createMockFileSystemExecutor(),
300 | );
301 |
302 | expect(result).toEqual({
303 | content: [
304 | {
305 | type: 'text',
306 | text: 'Failed to launch app: Device not found: test-device-invalid',
307 | },
308 | ],
309 | isError: true,
310 | });
311 | });
312 |
313 | it('should handle executor exception with Error object', async () => {
314 | const mockExecutor = createMockExecutor(new Error('Network error'));
315 |
316 | const result = await launch_app_deviceLogic(
317 | {
318 | deviceId: 'test-device-123',
319 | bundleId: 'com.example.app',
320 | },
321 | mockExecutor,
322 | createMockFileSystemExecutor(),
323 | );
324 |
325 | expect(result).toEqual({
326 | content: [
327 | {
328 | type: 'text',
329 | text: 'Failed to launch app on device: Network error',
330 | },
331 | ],
332 | isError: true,
333 | });
334 | });
335 |
336 | it('should handle executor exception with string error', async () => {
337 | const mockExecutor = createMockExecutor('String error');
338 |
339 | const result = await launch_app_deviceLogic(
340 | {
341 | deviceId: 'test-device-123',
342 | bundleId: 'com.example.app',
343 | },
344 | mockExecutor,
345 | createMockFileSystemExecutor(),
346 | );
347 |
348 | expect(result).toEqual({
349 | content: [
350 | {
351 | type: 'text',
352 | text: 'Failed to launch app on device: String error',
353 | },
354 | ],
355 | isError: true,
356 | });
357 | });
358 | });
359 | });
360 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/list_sims.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import * as z from 'zod';
3 | import {
4 | createMockCommandResponse,
5 | createMockExecutor,
6 | } from '../../../../test-utils/mock-executors.ts';
7 |
8 | // Import the plugin and logic function
9 | import listSims, { list_simsLogic } from '../list_sims.ts';
10 |
11 | describe('list_sims tool', () => {
12 | let callHistory: Array<{
13 | command: string[];
14 | logPrefix?: string;
15 | useShell?: boolean;
16 | env?: Record<string, string>;
17 | }>;
18 |
19 | callHistory = [];
20 |
21 | describe('Export Field Validation (Literal)', () => {
22 | it('should have correct name', () => {
23 | expect(listSims.name).toBe('list_sims');
24 | });
25 |
26 | it('should have correct description', () => {
27 | expect(listSims.description).toBe('Lists available iOS simulators with their UUIDs. ');
28 | });
29 |
30 | it('should have handler function', () => {
31 | expect(typeof listSims.handler).toBe('function');
32 | });
33 |
34 | it('should have correct schema with enabled boolean field', () => {
35 | const schema = z.object(listSims.schema);
36 |
37 | // Valid inputs
38 | expect(schema.safeParse({ enabled: true }).success).toBe(true);
39 | expect(schema.safeParse({ enabled: false }).success).toBe(true);
40 | expect(schema.safeParse({ enabled: undefined }).success).toBe(true);
41 | expect(schema.safeParse({}).success).toBe(true);
42 |
43 | // Invalid inputs
44 | expect(schema.safeParse({ enabled: 'yes' }).success).toBe(false);
45 | expect(schema.safeParse({ enabled: 1 }).success).toBe(false);
46 | expect(schema.safeParse({ enabled: null }).success).toBe(false);
47 | });
48 | });
49 |
50 | describe('Handler Behavior (Complete Literal Returns)', () => {
51 | it('should handle successful simulator listing', async () => {
52 | const mockJsonOutput = JSON.stringify({
53 | devices: {
54 | 'iOS 17.0': [
55 | {
56 | name: 'iPhone 15',
57 | udid: 'test-uuid-123',
58 | isAvailable: true,
59 | state: 'Shutdown',
60 | },
61 | ],
62 | },
63 | });
64 |
65 | const mockTextOutput = `== Devices ==
66 | -- iOS 17.0 --
67 | iPhone 15 (test-uuid-123) (Shutdown)`;
68 |
69 | // Create a mock executor that returns different outputs based on command
70 | const mockExecutor = async (
71 | command: string[],
72 | logPrefix?: string,
73 | useShell?: boolean,
74 | opts?: { env?: Record<string, string> },
75 | detached?: boolean,
76 | ) => {
77 | callHistory.push({ command, logPrefix, useShell, env: opts?.env });
78 | void detached;
79 |
80 | // Return JSON output for JSON command
81 | if (command.includes('--json')) {
82 | return createMockCommandResponse({
83 | success: true,
84 | output: mockJsonOutput,
85 | error: undefined,
86 | });
87 | }
88 |
89 | // Return text output for text command
90 | return createMockCommandResponse({
91 | success: true,
92 | output: mockTextOutput,
93 | error: undefined,
94 | });
95 | };
96 |
97 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
98 |
99 | // Verify both commands were called
100 | expect(callHistory).toHaveLength(2);
101 | expect(callHistory[0]).toEqual({
102 | command: ['xcrun', 'simctl', 'list', 'devices', '--json'],
103 | logPrefix: 'List Simulators (JSON)',
104 | useShell: true,
105 | env: undefined,
106 | });
107 | expect(callHistory[1]).toEqual({
108 | command: ['xcrun', 'simctl', 'list', 'devices'],
109 | logPrefix: 'List Simulators (Text)',
110 | useShell: true,
111 | env: undefined,
112 | });
113 |
114 | expect(result).toEqual({
115 | content: [
116 | {
117 | type: 'text',
118 | text: `Available iOS Simulators:
119 |
120 | iOS 17.0:
121 | - iPhone 15 (test-uuid-123)
122 |
123 | Next Steps:
124 | 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
125 | 2. Open the simulator UI: open_sim({})
126 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
127 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
128 | Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
129 | },
130 | ],
131 | });
132 | });
133 |
134 | it('should handle successful listing with booted simulator', async () => {
135 | const mockJsonOutput = JSON.stringify({
136 | devices: {
137 | 'iOS 17.0': [
138 | {
139 | name: 'iPhone 15',
140 | udid: 'test-uuid-123',
141 | isAvailable: true,
142 | state: 'Booted',
143 | },
144 | ],
145 | },
146 | });
147 |
148 | const mockTextOutput = `== Devices ==
149 | -- iOS 17.0 --
150 | iPhone 15 (test-uuid-123) (Booted)`;
151 |
152 | const mockExecutor = async (command: string[]) => {
153 | if (command.includes('--json')) {
154 | return createMockCommandResponse({
155 | success: true,
156 | output: mockJsonOutput,
157 | error: undefined,
158 | });
159 | }
160 | return createMockCommandResponse({
161 | success: true,
162 | output: mockTextOutput,
163 | error: undefined,
164 | });
165 | };
166 |
167 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
168 |
169 | expect(result).toEqual({
170 | content: [
171 | {
172 | type: 'text',
173 | text: `Available iOS Simulators:
174 |
175 | iOS 17.0:
176 | - iPhone 15 (test-uuid-123) [Booted]
177 |
178 | Next Steps:
179 | 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
180 | 2. Open the simulator UI: open_sim({})
181 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
182 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
183 | Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
184 | },
185 | ],
186 | });
187 | });
188 |
189 | it('should merge devices from text that are missing from JSON', async () => {
190 | const mockJsonOutput = JSON.stringify({
191 | devices: {
192 | 'iOS 18.6': [
193 | {
194 | name: 'iPhone 15',
195 | udid: 'json-uuid-123',
196 | isAvailable: true,
197 | state: 'Shutdown',
198 | },
199 | ],
200 | },
201 | });
202 |
203 | const mockTextOutput = `== Devices ==
204 | -- iOS 18.6 --
205 | iPhone 15 (json-uuid-123) (Shutdown)
206 | -- iOS 26.0 --
207 | iPhone 17 Pro (text-uuid-456) (Shutdown)`;
208 |
209 | const mockExecutor = async (command: string[]) => {
210 | if (command.includes('--json')) {
211 | return createMockCommandResponse({
212 | success: true,
213 | output: mockJsonOutput,
214 | error: undefined,
215 | });
216 | }
217 | return createMockCommandResponse({
218 | success: true,
219 | output: mockTextOutput,
220 | error: undefined,
221 | });
222 | };
223 |
224 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
225 |
226 | // Should contain both iOS 18.6 from JSON and iOS 26.0 from text
227 | expect(result).toEqual({
228 | content: [
229 | {
230 | type: 'text',
231 | text: `Available iOS Simulators:
232 |
233 | iOS 18.6:
234 | - iPhone 15 (json-uuid-123)
235 |
236 | iOS 26.0:
237 | - iPhone 17 Pro (text-uuid-456)
238 |
239 | Next Steps:
240 | 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
241 | 2. Open the simulator UI: open_sim({})
242 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
243 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
244 | Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
245 | },
246 | ],
247 | });
248 | });
249 |
250 | it('should handle command failure', async () => {
251 | const mockExecutor = createMockExecutor({
252 | success: false,
253 | output: '',
254 | error: 'Command failed',
255 | process: { pid: 12345 },
256 | });
257 |
258 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
259 |
260 | expect(result).toEqual({
261 | content: [
262 | {
263 | type: 'text',
264 | text: 'Failed to list simulators: Command failed',
265 | },
266 | ],
267 | });
268 | });
269 |
270 | it('should handle JSON parse failure and fall back to text parsing', async () => {
271 | const mockTextOutput = `== Devices ==
272 | -- iOS 17.0 --
273 | iPhone 15 (test-uuid-456) (Shutdown)`;
274 |
275 | const mockExecutor = async (command: string[]) => {
276 | // JSON command returns invalid JSON
277 | if (command.includes('--json')) {
278 | return createMockCommandResponse({
279 | success: true,
280 | output: 'invalid json',
281 | error: undefined,
282 | });
283 | }
284 |
285 | // Text command returns valid text output
286 | return createMockCommandResponse({
287 | success: true,
288 | output: mockTextOutput,
289 | error: undefined,
290 | });
291 | };
292 |
293 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
294 |
295 | // Should fall back to text parsing and extract devices
296 | expect(result).toEqual({
297 | content: [
298 | {
299 | type: 'text',
300 | text: `Available iOS Simulators:
301 |
302 | iOS 17.0:
303 | - iPhone 15 (test-uuid-456)
304 |
305 | Next Steps:
306 | 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
307 | 2. Open the simulator UI: open_sim({})
308 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
309 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
310 | Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
311 | },
312 | ],
313 | });
314 | });
315 |
316 | it('should handle exception with Error object', async () => {
317 | const mockExecutor = createMockExecutor(new Error('Command execution failed'));
318 |
319 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
320 |
321 | expect(result).toEqual({
322 | content: [
323 | {
324 | type: 'text',
325 | text: 'Failed to list simulators: Command execution failed',
326 | },
327 | ],
328 | });
329 | });
330 |
331 | it('should handle exception with string error', async () => {
332 | const mockExecutor = createMockExecutor('String error');
333 |
334 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
335 |
336 | expect(result).toEqual({
337 | content: [
338 | {
339 | type: 'text',
340 | text: 'Failed to list simulators: String error',
341 | },
342 | ],
343 | });
344 | });
345 | });
346 | });
347 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for list_schemes 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 {
10 | createMockCommandResponse,
11 | createMockExecutor,
12 | } from '../../../../test-utils/mock-executors.ts';
13 | import plugin, { listSchemesLogic } from '../list_schemes.ts';
14 | import { sessionStore } from '../../../../utils/session-store.ts';
15 |
16 | describe('list_schemes plugin', () => {
17 | beforeEach(() => {
18 | sessionStore.clear();
19 | });
20 |
21 | describe('Export Field Validation (Literal)', () => {
22 | it('should have correct name', () => {
23 | expect(plugin.name).toBe('list_schemes');
24 | });
25 |
26 | it('should have correct description', () => {
27 | expect(plugin.description).toBe('Lists schemes for a project or workspace.');
28 | });
29 |
30 | it('should have handler function', () => {
31 | expect(typeof plugin.handler).toBe('function');
32 | });
33 |
34 | it('should expose an empty public schema', () => {
35 | const schema = z.strictObject(plugin.schema);
36 | expect(schema.safeParse({}).success).toBe(true);
37 | expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false);
38 | expect(Object.keys(plugin.schema)).toEqual([]);
39 | });
40 | });
41 |
42 | describe('Handler Behavior (Complete Literal Returns)', () => {
43 | it('should return success with schemes found', async () => {
44 | const mockExecutor = createMockExecutor({
45 | success: true,
46 | output: `Information about project "MyProject":
47 | Targets:
48 | MyProject
49 | MyProjectTests
50 |
51 | Build Configurations:
52 | Debug
53 | Release
54 |
55 | Schemes:
56 | MyProject
57 | MyProjectTests`,
58 | });
59 |
60 | const result = await listSchemesLogic(
61 | { projectPath: '/path/to/MyProject.xcodeproj' },
62 | mockExecutor,
63 | );
64 |
65 | expect(result).toEqual({
66 | content: [
67 | {
68 | type: 'text',
69 | text: '✅ Available schemes:',
70 | },
71 | {
72 | type: 'text',
73 | text: 'MyProject\nMyProjectTests',
74 | },
75 | {
76 | type: 'text',
77 | text: `Next Steps:
78 | 1. Build the app: build_macos({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })
79 | or for iOS: build_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject", simulatorName: "iPhone 16" })
80 | 2. Show build settings: show_build_settings({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })`,
81 | },
82 | {
83 | type: 'text',
84 | text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.',
85 | },
86 | ],
87 | isError: false,
88 | });
89 | });
90 |
91 | it('should return error when command fails', async () => {
92 | const mockExecutor = createMockExecutor({
93 | success: false,
94 | error: 'Project not found',
95 | });
96 |
97 | const result = await listSchemesLogic(
98 | { projectPath: '/path/to/MyProject.xcodeproj' },
99 | mockExecutor,
100 | );
101 |
102 | expect(result).toEqual({
103 | content: [{ type: 'text', text: 'Failed to list schemes: Project not found' }],
104 | isError: true,
105 | });
106 | });
107 |
108 | it('should return error when no schemes found in output', async () => {
109 | const mockExecutor = createMockExecutor({
110 | success: true,
111 | output: 'Information about project "MyProject":\n Targets:\n MyProject',
112 | });
113 |
114 | const result = await listSchemesLogic(
115 | { projectPath: '/path/to/MyProject.xcodeproj' },
116 | mockExecutor,
117 | );
118 |
119 | expect(result).toEqual({
120 | content: [{ type: 'text', text: 'No schemes found in the output' }],
121 | isError: true,
122 | });
123 | });
124 |
125 | it('should return success with empty schemes list', async () => {
126 | const mockExecutor = createMockExecutor({
127 | success: true,
128 | output: `Information about project "MinimalProject":
129 | Targets:
130 | MinimalProject
131 |
132 | Build Configurations:
133 | Debug
134 | Release
135 |
136 | Schemes:
137 |
138 | `,
139 | });
140 |
141 | const result = await listSchemesLogic(
142 | { projectPath: '/path/to/MyProject.xcodeproj' },
143 | mockExecutor,
144 | );
145 |
146 | expect(result).toEqual({
147 | content: [
148 | {
149 | type: 'text',
150 | text: '✅ Available schemes:',
151 | },
152 | {
153 | type: 'text',
154 | text: '',
155 | },
156 | {
157 | type: 'text',
158 | text: '',
159 | },
160 | ],
161 | isError: false,
162 | });
163 | });
164 |
165 | it('should handle Error objects in catch blocks', async () => {
166 | const mockExecutor = async () => {
167 | throw new Error('Command execution failed');
168 | };
169 |
170 | const result = await listSchemesLogic(
171 | { projectPath: '/path/to/MyProject.xcodeproj' },
172 | mockExecutor,
173 | );
174 |
175 | expect(result).toEqual({
176 | content: [{ type: 'text', text: 'Error listing schemes: Command execution failed' }],
177 | isError: true,
178 | });
179 | });
180 |
181 | it('should handle string error objects in catch blocks', async () => {
182 | const mockExecutor = async () => {
183 | throw 'String error';
184 | };
185 |
186 | const result = await listSchemesLogic(
187 | { projectPath: '/path/to/MyProject.xcodeproj' },
188 | mockExecutor,
189 | );
190 |
191 | expect(result).toEqual({
192 | content: [{ type: 'text', text: 'Error listing schemes: String error' }],
193 | isError: true,
194 | });
195 | });
196 |
197 | it('should verify command generation with mock executor', async () => {
198 | const calls: any[] = [];
199 | const mockExecutor = async (
200 | command: string[],
201 | action?: string,
202 | showOutput?: boolean,
203 | opts?: { cwd?: string },
204 | detached?: boolean,
205 | ) => {
206 | calls.push([command, action, showOutput, opts?.cwd]);
207 | void detached;
208 | return createMockCommandResponse({
209 | success: true,
210 | output: `Information about project "MyProject":
211 | Targets:
212 | MyProject
213 |
214 | Build Configurations:
215 | Debug
216 | Release
217 |
218 | Schemes:
219 | MyProject`,
220 | error: undefined,
221 | });
222 | };
223 |
224 | await listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor);
225 |
226 | expect(calls).toEqual([
227 | [
228 | ['xcodebuild', '-list', '-project', '/path/to/MyProject.xcodeproj'],
229 | 'List Schemes',
230 | true,
231 | undefined,
232 | ],
233 | ]);
234 | });
235 |
236 | it('should handle validation when testing with missing projectPath via plugin handler', async () => {
237 | // Note: Direct logic function calls bypass Zod validation, so we test the actual plugin handler
238 | // to verify Zod validation works properly. The createTypedTool wrapper handles validation.
239 | const result = await plugin.handler({});
240 | expect(result.isError).toBe(true);
241 | expect(result.content[0].text).toContain('Missing required session defaults');
242 | expect(result.content[0].text).toContain('Provide a project or workspace');
243 | });
244 | });
245 |
246 | describe('XOR Validation', () => {
247 | it('should error when neither projectPath nor workspacePath provided', async () => {
248 | const result = await plugin.handler({});
249 | expect(result.isError).toBe(true);
250 | expect(result.content[0].text).toContain('Missing required session defaults');
251 | expect(result.content[0].text).toContain('Provide a project or workspace');
252 | });
253 |
254 | it('should error when both projectPath and workspacePath provided', async () => {
255 | const result = await plugin.handler({
256 | projectPath: '/path/to/project.xcodeproj',
257 | workspacePath: '/path/to/workspace.xcworkspace',
258 | });
259 | expect(result.isError).toBe(true);
260 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
261 | });
262 |
263 | it('should handle empty strings as undefined', async () => {
264 | const result = await plugin.handler({
265 | projectPath: '',
266 | workspacePath: '',
267 | });
268 | expect(result.isError).toBe(true);
269 | expect(result.content[0].text).toContain('Missing required session defaults');
270 | expect(result.content[0].text).toContain('Provide a project or workspace');
271 | });
272 | });
273 |
274 | describe('Workspace Support', () => {
275 | it('should list schemes for workspace', async () => {
276 | const mockExecutor = createMockExecutor({
277 | success: true,
278 | output: `Information about workspace "MyWorkspace":
279 | Schemes:
280 | MyApp
281 | MyAppTests`,
282 | });
283 |
284 | const result = await listSchemesLogic(
285 | { workspacePath: '/path/to/MyProject.xcworkspace' },
286 | mockExecutor,
287 | );
288 |
289 | expect(result).toEqual({
290 | content: [
291 | {
292 | type: 'text',
293 | text: '✅ Available schemes:',
294 | },
295 | {
296 | type: 'text',
297 | text: 'MyApp\nMyAppTests',
298 | },
299 | {
300 | type: 'text',
301 | text: `Next Steps:
302 | 1. Build the app: build_macos({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })
303 | or for iOS: build_sim({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp", simulatorName: "iPhone 16" })
304 | 2. Show build settings: show_build_settings({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })`,
305 | },
306 | {
307 | type: 'text',
308 | text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.',
309 | },
310 | ],
311 | isError: false,
312 | });
313 | });
314 |
315 | it('should generate correct workspace command', async () => {
316 | const calls: any[] = [];
317 | const mockExecutor = async (
318 | command: string[],
319 | action?: string,
320 | showOutput?: boolean,
321 | opts?: { cwd?: string },
322 | detached?: boolean,
323 | ) => {
324 | calls.push([command, action, showOutput, opts?.cwd]);
325 | void detached;
326 | return createMockCommandResponse({
327 | success: true,
328 | output: `Information about workspace "MyWorkspace":
329 | Schemes:
330 | MyApp`,
331 | error: undefined,
332 | });
333 | };
334 |
335 | await listSchemesLogic({ workspacePath: '/path/to/MyProject.xcworkspace' }, mockExecutor);
336 |
337 | expect(calls).toEqual([
338 | [
339 | ['xcodebuild', '-list', '-workspace', '/path/to/MyProject.xcworkspace'],
340 | 'List Schemes',
341 | true,
342 | undefined,
343 | ],
344 | ]);
345 | });
346 | });
347 | });
348 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/build_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for build_device plugin (unified)
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import * as z from 'zod';
9 | import {
10 | createMockCommandResponse,
11 | createMockExecutor,
12 | createNoopExecutor,
13 | } from '../../../../test-utils/mock-executors.ts';
14 | import buildDevice, { buildDeviceLogic } from '../build_device.ts';
15 | import { sessionStore } from '../../../../utils/session-store.ts';
16 |
17 | describe('build_device plugin', () => {
18 | beforeEach(() => {
19 | sessionStore.clear();
20 | });
21 |
22 | describe('Export Field Validation (Literal)', () => {
23 | it('should have correct name', () => {
24 | expect(buildDevice.name).toBe('build_device');
25 | });
26 |
27 | it('should have correct description', () => {
28 | expect(buildDevice.description).toBe('Builds an app for a connected device.');
29 | });
30 |
31 | it('should have handler function', () => {
32 | expect(typeof buildDevice.handler).toBe('function');
33 | });
34 |
35 | it('should expose only optional build-tuning fields in public schema', () => {
36 | const schema = z.strictObject(buildDevice.schema);
37 | expect(schema.safeParse({}).success).toBe(true);
38 | expect(
39 | schema.safeParse({ derivedDataPath: '/path/to/derived-data', extraArgs: [] }).success,
40 | ).toBe(true);
41 | expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false);
42 |
43 | const schemaKeys = Object.keys(buildDevice.schema).sort();
44 | expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild']);
45 | });
46 | });
47 |
48 | describe('XOR Validation', () => {
49 | it('should error when neither projectPath nor workspacePath provided', async () => {
50 | const result = await buildDevice.handler({
51 | scheme: 'MyScheme',
52 | });
53 |
54 | expect(result.isError).toBe(true);
55 | expect(result.content[0].text).toContain('Missing required session defaults');
56 | expect(result.content[0].text).toContain('Provide a project or workspace');
57 | });
58 |
59 | it('should error when both projectPath and workspacePath provided', async () => {
60 | const result = await buildDevice.handler({
61 | projectPath: '/path/to/MyProject.xcodeproj',
62 | workspacePath: '/path/to/MyProject.xcworkspace',
63 | scheme: 'MyScheme',
64 | });
65 |
66 | expect(result.isError).toBe(true);
67 | expect(result.content[0].text).toContain('Parameter validation failed');
68 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
69 | });
70 | });
71 |
72 | describe('Parameter Validation (via Handler)', () => {
73 | it('should return Zod validation error for missing scheme', async () => {
74 | const result = await buildDevice.handler({
75 | projectPath: '/path/to/MyProject.xcodeproj',
76 | });
77 |
78 | expect(result.isError).toBe(true);
79 | expect(result.content[0].text).toContain('Missing required session defaults');
80 | expect(result.content[0].text).toContain('scheme is required');
81 | });
82 |
83 | it('should return Zod validation error for invalid parameter types', async () => {
84 | const result = await buildDevice.handler({
85 | projectPath: 123, // Should be string
86 | scheme: 'MyScheme',
87 | });
88 |
89 | expect(result.isError).toBe(true);
90 | expect(result.content[0].text).toContain('Parameter validation failed');
91 | expect(result.content[0].text).toContain('projectPath');
92 | });
93 | });
94 |
95 | describe('Handler Behavior (Complete Literal Returns)', () => {
96 | it('should pass validation and execute successfully with valid project parameters', async () => {
97 | const mockExecutor = createMockExecutor({
98 | success: true,
99 | output: 'Build succeeded',
100 | });
101 |
102 | const result = await buildDeviceLogic(
103 | {
104 | projectPath: '/path/to/MyProject.xcodeproj',
105 | scheme: 'MyScheme',
106 | },
107 | mockExecutor,
108 | );
109 |
110 | expect(result.isError).toBeUndefined();
111 | expect(result.content).toHaveLength(2);
112 | expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded');
113 | });
114 |
115 | it('should pass validation and execute successfully with valid workspace parameters', async () => {
116 | const mockExecutor = createMockExecutor({
117 | success: true,
118 | output: 'Build succeeded',
119 | });
120 |
121 | const result = await buildDeviceLogic(
122 | {
123 | workspacePath: '/path/to/MyProject.xcworkspace',
124 | scheme: 'MyScheme',
125 | },
126 | mockExecutor,
127 | );
128 |
129 | expect(result.isError).toBeUndefined();
130 | expect(result.content).toHaveLength(2);
131 | expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded');
132 | });
133 |
134 | it('should verify workspace command generation with mock executor', async () => {
135 | const commandCalls: Array<{
136 | args: string[];
137 | logPrefix?: string;
138 | silent?: boolean;
139 | opts: { cwd?: string } | undefined;
140 | }> = [];
141 |
142 | const stubExecutor = async (
143 | args: string[],
144 | logPrefix?: string,
145 | silent?: boolean,
146 | opts?: { cwd?: string },
147 | _detached?: boolean,
148 | ) => {
149 | commandCalls.push({ args, logPrefix, silent, opts });
150 | return createMockCommandResponse({
151 | success: true,
152 | output: 'Build succeeded',
153 | error: undefined,
154 | });
155 | };
156 |
157 | await buildDeviceLogic(
158 | {
159 | workspacePath: '/path/to/MyProject.xcworkspace',
160 | scheme: 'MyScheme',
161 | },
162 | stubExecutor,
163 | );
164 |
165 | expect(commandCalls).toHaveLength(1);
166 | expect(commandCalls[0]).toEqual({
167 | args: [
168 | 'xcodebuild',
169 | '-workspace',
170 | '/path/to/MyProject.xcworkspace',
171 | '-scheme',
172 | 'MyScheme',
173 | '-configuration',
174 | 'Debug',
175 | '-skipMacroValidation',
176 | '-destination',
177 | 'generic/platform=iOS',
178 | 'build',
179 | ],
180 | logPrefix: 'iOS Device Build',
181 | silent: true,
182 | opts: { cwd: '/path/to' },
183 | });
184 | });
185 |
186 | it('should verify command generation with mock executor', async () => {
187 | const commandCalls: Array<{
188 | args: string[];
189 | logPrefix?: string;
190 | silent?: boolean;
191 | opts: { cwd?: string } | undefined;
192 | }> = [];
193 |
194 | const stubExecutor = async (
195 | args: string[],
196 | logPrefix?: string,
197 | silent?: boolean,
198 | opts?: { cwd?: string },
199 | _detached?: boolean,
200 | ) => {
201 | commandCalls.push({ args, logPrefix, silent, opts });
202 | return createMockCommandResponse({
203 | success: true,
204 | output: 'Build succeeded',
205 | error: undefined,
206 | });
207 | };
208 |
209 | await buildDeviceLogic(
210 | {
211 | projectPath: '/path/to/MyProject.xcodeproj',
212 | scheme: 'MyScheme',
213 | },
214 | stubExecutor,
215 | );
216 |
217 | expect(commandCalls).toHaveLength(1);
218 | expect(commandCalls[0]).toEqual({
219 | args: [
220 | 'xcodebuild',
221 | '-project',
222 | '/path/to/MyProject.xcodeproj',
223 | '-scheme',
224 | 'MyScheme',
225 | '-configuration',
226 | 'Debug',
227 | '-skipMacroValidation',
228 | '-destination',
229 | 'generic/platform=iOS',
230 | 'build',
231 | ],
232 | logPrefix: 'iOS Device Build',
233 | silent: true,
234 | opts: { cwd: '/path/to' },
235 | });
236 | });
237 |
238 | it('should return exact successful build response', async () => {
239 | const mockExecutor = createMockExecutor({
240 | success: true,
241 | output: 'Build succeeded',
242 | });
243 |
244 | const result = await buildDeviceLogic(
245 | {
246 | projectPath: '/path/to/MyProject.xcodeproj',
247 | scheme: 'MyScheme',
248 | },
249 | mockExecutor,
250 | );
251 |
252 | expect(result).toEqual({
253 | content: [
254 | {
255 | type: 'text',
256 | text: '✅ iOS Device Build build succeeded for scheme MyScheme.',
257 | },
258 | {
259 | type: 'text',
260 | text: "Next Steps:\n1. Get app path: get_device_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })",
261 | },
262 | ],
263 | });
264 | });
265 |
266 | it('should return exact build failure response', async () => {
267 | const mockExecutor = createMockExecutor({
268 | success: false,
269 | error: 'Compilation error',
270 | });
271 |
272 | const result = await buildDeviceLogic(
273 | {
274 | projectPath: '/path/to/MyProject.xcodeproj',
275 | scheme: 'MyScheme',
276 | },
277 | mockExecutor,
278 | );
279 |
280 | expect(result).toEqual({
281 | content: [
282 | {
283 | type: 'text',
284 | text: '❌ [stderr] Compilation error',
285 | },
286 | {
287 | type: 'text',
288 | text: '❌ iOS Device Build build failed for scheme MyScheme.',
289 | },
290 | ],
291 | isError: true,
292 | });
293 | });
294 |
295 | it('should include optional parameters in command', async () => {
296 | const commandCalls: Array<{
297 | args: string[];
298 | logPrefix?: string;
299 | silent?: boolean;
300 | opts: { cwd?: string } | undefined;
301 | }> = [];
302 |
303 | const stubExecutor = async (
304 | args: string[],
305 | logPrefix?: string,
306 | silent?: boolean,
307 | opts?: { cwd?: string },
308 | _detached?: boolean,
309 | ) => {
310 | commandCalls.push({ args, logPrefix, silent, opts });
311 | return createMockCommandResponse({
312 | success: true,
313 | output: 'Build succeeded',
314 | error: undefined,
315 | });
316 | };
317 |
318 | await buildDeviceLogic(
319 | {
320 | projectPath: '/path/to/MyProject.xcodeproj',
321 | scheme: 'MyScheme',
322 | configuration: 'Release',
323 | derivedDataPath: '/tmp/derived-data',
324 | extraArgs: ['--verbose'],
325 | },
326 | stubExecutor,
327 | );
328 |
329 | expect(commandCalls).toHaveLength(1);
330 | expect(commandCalls[0]).toEqual({
331 | args: [
332 | 'xcodebuild',
333 | '-project',
334 | '/path/to/MyProject.xcodeproj',
335 | '-scheme',
336 | 'MyScheme',
337 | '-configuration',
338 | 'Release',
339 | '-skipMacroValidation',
340 | '-destination',
341 | 'generic/platform=iOS',
342 | '-derivedDataPath',
343 | '/tmp/derived-data',
344 | '--verbose',
345 | 'build',
346 | ],
347 | logPrefix: 'iOS Device Build',
348 | silent: true,
349 | opts: { cwd: '/path/to' },
350 | });
351 | });
352 | });
353 | });
354 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import * as z from 'zod';
3 | import {
4 | createMockExecutor,
5 | createMockFileSystemExecutor,
6 | createNoopExecutor,
7 | createMockCommandResponse,
8 | } from '../../../../test-utils/mock-executors.ts';
9 | import { sessionStore } from '../../../../utils/session-store.ts';
10 | import type { CommandExecutor } from '../../../../utils/execution/index.ts';
11 | import installAppSim, { install_app_simLogic } from '../install_app_sim.ts';
12 |
13 | describe('install_app_sim tool', () => {
14 | beforeEach(() => {
15 | sessionStore.clear();
16 | });
17 |
18 | describe('Export Field Validation (Literal)', () => {
19 | it('should have correct name', () => {
20 | expect(installAppSim.name).toBe('install_app_sim');
21 | });
22 |
23 | it('should have concise description', () => {
24 | expect(installAppSim.description).toBe('Installs an app in an iOS simulator.');
25 | });
26 |
27 | it('should expose public schema with only appPath', () => {
28 | const schema = z.object(installAppSim.schema);
29 |
30 | expect(schema.safeParse({ appPath: '/path/to/app.app' }).success).toBe(true);
31 | expect(schema.safeParse({ appPath: 42 }).success).toBe(false);
32 | expect(schema.safeParse({}).success).toBe(false);
33 |
34 | expect(Object.keys(installAppSim.schema)).toEqual(['appPath']);
35 |
36 | const withSimId = schema.safeParse({
37 | simulatorId: 'test-uuid-123',
38 | appPath: '/path/app.app',
39 | });
40 | expect(withSimId.success).toBe(true);
41 | expect('simulatorId' in (withSimId.data as Record<string, unknown>)).toBe(false);
42 | });
43 | });
44 |
45 | describe('Handler Requirements', () => {
46 | it('should require simulatorId when not provided', async () => {
47 | const result = await installAppSim.handler({ appPath: '/path/to/app.app' });
48 |
49 | expect(result.isError).toBe(true);
50 | expect(result.content[0].text).toContain('Missing required session defaults');
51 | expect(result.content[0].text).toContain('simulatorId is required');
52 | expect(result.content[0].text).toContain('session-set-defaults');
53 | });
54 |
55 | it('should validate appPath when simulatorId default exists', async () => {
56 | sessionStore.setDefaults({ simulatorId: 'SIM-UUID' });
57 |
58 | const result = await installAppSim.handler({});
59 |
60 | expect(result.isError).toBe(true);
61 | expect(result.content[0].text).toContain('Parameter validation failed');
62 | expect(result.content[0].text).toContain(
63 | 'appPath: Invalid input: expected string, received undefined',
64 | );
65 | });
66 | });
67 |
68 | describe('Command Generation', () => {
69 | it('should generate correct simctl install command', async () => {
70 | const executorCalls: Array<Parameters<CommandExecutor>> = [];
71 | const mockExecutor: CommandExecutor = (...args) => {
72 | executorCalls.push(args);
73 | return Promise.resolve(
74 | createMockCommandResponse({
75 | success: true,
76 | output: 'App installed',
77 | }),
78 | );
79 | };
80 |
81 | const mockFileSystem = createMockFileSystemExecutor({
82 | existsSync: () => true,
83 | });
84 |
85 | await install_app_simLogic(
86 | {
87 | simulatorId: 'test-uuid-123',
88 | appPath: '/path/to/app.app',
89 | },
90 | mockExecutor,
91 | mockFileSystem,
92 | );
93 |
94 | expect(executorCalls).toEqual([
95 | [
96 | ['xcrun', 'simctl', 'install', 'test-uuid-123', '/path/to/app.app'],
97 | 'Install App in Simulator',
98 | true,
99 | undefined,
100 | ],
101 | [
102 | ['defaults', 'read', '/path/to/app.app/Info', 'CFBundleIdentifier'],
103 | 'Extract Bundle ID',
104 | false,
105 | undefined,
106 | ],
107 | ]);
108 | });
109 |
110 | it('should generate command with different simulator identifier', async () => {
111 | const executorCalls: Array<Parameters<CommandExecutor>> = [];
112 | const mockExecutor: CommandExecutor = (...args) => {
113 | executorCalls.push(args);
114 | return Promise.resolve(
115 | createMockCommandResponse({
116 | success: true,
117 | output: 'App installed',
118 | }),
119 | );
120 | };
121 |
122 | const mockFileSystem = createMockFileSystemExecutor({
123 | existsSync: () => true,
124 | });
125 |
126 | await install_app_simLogic(
127 | {
128 | simulatorId: 'different-uuid-456',
129 | appPath: '/different/path/MyApp.app',
130 | },
131 | mockExecutor,
132 | mockFileSystem,
133 | );
134 |
135 | expect(executorCalls).toEqual([
136 | [
137 | ['xcrun', 'simctl', 'install', 'different-uuid-456', '/different/path/MyApp.app'],
138 | 'Install App in Simulator',
139 | true,
140 | undefined,
141 | ],
142 | [
143 | ['defaults', 'read', '/different/path/MyApp.app/Info', 'CFBundleIdentifier'],
144 | 'Extract Bundle ID',
145 | false,
146 | undefined,
147 | ],
148 | ]);
149 | });
150 | });
151 |
152 | describe('Logic Behavior (Literal Returns)', () => {
153 | it('should handle file does not exist', async () => {
154 | const mockFileSystem = createMockFileSystemExecutor({
155 | existsSync: () => false,
156 | });
157 |
158 | const result = await install_app_simLogic(
159 | {
160 | simulatorId: 'test-uuid-123',
161 | appPath: '/path/to/app.app',
162 | },
163 | createNoopExecutor(),
164 | mockFileSystem,
165 | );
166 |
167 | expect(result).toEqual({
168 | content: [
169 | {
170 | type: 'text',
171 | text: "File not found: '/path/to/app.app'. Please check the path and try again.",
172 | },
173 | ],
174 | isError: true,
175 | });
176 | });
177 |
178 | it('should handle bundle id extraction failure gracefully', async () => {
179 | const bundleIdCalls: Array<Parameters<CommandExecutor>> = [];
180 | const mockExecutor: CommandExecutor = (...args) => {
181 | bundleIdCalls.push(args);
182 | if (
183 | Array.isArray(args[0]) &&
184 | (args[0] as string[])[0] === 'xcrun' &&
185 | (args[0] as string[])[1] === 'simctl'
186 | ) {
187 | return Promise.resolve(
188 | createMockCommandResponse({
189 | success: true,
190 | output: 'App installed',
191 | error: undefined,
192 | }),
193 | );
194 | }
195 | return Promise.resolve(
196 | createMockCommandResponse({
197 | success: false,
198 | output: '',
199 | error: 'Failed to read bundle ID',
200 | }),
201 | );
202 | };
203 |
204 | const mockFileSystem = createMockFileSystemExecutor({
205 | existsSync: () => true,
206 | });
207 |
208 | const result = await install_app_simLogic(
209 | {
210 | simulatorId: 'test-uuid-123',
211 | appPath: '/path/to/app.app',
212 | },
213 | mockExecutor,
214 | mockFileSystem,
215 | );
216 |
217 | expect(result).toEqual({
218 | content: [
219 | {
220 | type: 'text',
221 | text: 'App installed successfully in simulator test-uuid-123',
222 | },
223 | {
224 | type: 'text',
225 | text: `Next Steps:
226 | 1. Open the Simulator app: open_sim({})
227 | 2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`,
228 | },
229 | ],
230 | });
231 | expect(bundleIdCalls).toHaveLength(2);
232 | });
233 |
234 | it('should include bundle id when extraction succeeds', async () => {
235 | const bundleIdCalls: Array<Parameters<CommandExecutor>> = [];
236 | const mockExecutor: CommandExecutor = (...args) => {
237 | bundleIdCalls.push(args);
238 | if (
239 | Array.isArray(args[0]) &&
240 | (args[0] as string[])[0] === 'xcrun' &&
241 | (args[0] as string[])[1] === 'simctl'
242 | ) {
243 | return Promise.resolve(
244 | createMockCommandResponse({
245 | success: true,
246 | output: 'App installed',
247 | error: undefined,
248 | }),
249 | );
250 | }
251 | return Promise.resolve(
252 | createMockCommandResponse({
253 | success: true,
254 | output: 'com.example.myapp',
255 | error: undefined,
256 | }),
257 | );
258 | };
259 |
260 | const mockFileSystem = createMockFileSystemExecutor({
261 | existsSync: () => true,
262 | });
263 |
264 | const result = await install_app_simLogic(
265 | {
266 | simulatorId: 'test-uuid-123',
267 | appPath: '/path/to/app.app',
268 | },
269 | mockExecutor,
270 | mockFileSystem,
271 | );
272 |
273 | expect(result).toEqual({
274 | content: [
275 | {
276 | type: 'text',
277 | text: 'App installed successfully in simulator test-uuid-123',
278 | },
279 | {
280 | type: 'text',
281 | text: `Next Steps:
282 | 1. Open the Simulator app: open_sim({})
283 | 2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "com.example.myapp" })`,
284 | },
285 | ],
286 | });
287 | expect(bundleIdCalls).toHaveLength(2);
288 | });
289 |
290 | it('should handle command failure', async () => {
291 | const mockExecutor: CommandExecutor = () =>
292 | Promise.resolve(
293 | createMockCommandResponse({
294 | success: false,
295 | output: '',
296 | error: 'Install failed',
297 | }),
298 | );
299 |
300 | const mockFileSystem = createMockFileSystemExecutor({
301 | existsSync: () => true,
302 | });
303 |
304 | const result = await install_app_simLogic(
305 | {
306 | simulatorId: 'test-uuid-123',
307 | appPath: '/path/to/app.app',
308 | },
309 | mockExecutor,
310 | mockFileSystem,
311 | );
312 |
313 | expect(result).toEqual({
314 | content: [
315 | {
316 | type: 'text',
317 | text: 'Install app in simulator operation failed: Install failed',
318 | },
319 | ],
320 | });
321 | });
322 |
323 | it('should handle exception with Error object', async () => {
324 | const mockExecutor = () => Promise.reject(new Error('Command execution failed'));
325 |
326 | const mockFileSystem = createMockFileSystemExecutor({
327 | existsSync: () => true,
328 | });
329 |
330 | const result = await install_app_simLogic(
331 | {
332 | simulatorId: 'test-uuid-123',
333 | appPath: '/path/to/app.app',
334 | },
335 | mockExecutor,
336 | mockFileSystem,
337 | );
338 |
339 | expect(result).toEqual({
340 | content: [
341 | {
342 | type: 'text',
343 | text: 'Install app in simulator operation failed: Command execution failed',
344 | },
345 | ],
346 | });
347 | });
348 |
349 | it('should handle exception with string error', async () => {
350 | const mockExecutor = () => Promise.reject('String error');
351 |
352 | const mockFileSystem = createMockFileSystemExecutor({
353 | existsSync: () => true,
354 | });
355 |
356 | const result = await install_app_simLogic(
357 | {
358 | simulatorId: 'test-uuid-123',
359 | appPath: '/path/to/app.app',
360 | },
361 | mockExecutor,
362 | mockFileSystem,
363 | );
364 |
365 | expect(result).toEqual({
366 | content: [
367 | {
368 | type: 'text',
369 | text: 'Install app in simulator operation failed: String error',
370 | },
371 | ],
372 | });
373 | });
374 | });
375 | });
376 |
```
--------------------------------------------------------------------------------
/docs/TOOLS.md:
--------------------------------------------------------------------------------
```markdown
1 | # XcodeBuildMCP Tools Reference
2 |
3 | XcodeBuildMCP provides 71 tools organized into 13 workflow groups for comprehensive Apple development workflows.
4 |
5 | ## Workflow Groups
6 |
7 | ### iOS Device Development (`device`)
8 | **Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware. (7 tools)
9 |
10 | - `build_device` - Builds an app for a connected device.
11 | - `get_device_app_path` - Retrieves the built app path for a connected device.
12 | - `install_app_device` - Installs an app on a connected device.
13 | - `launch_app_device` - Launches an app on a connected device.
14 | - `list_devices` - Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) with their UUIDs, names, and connection status. Use this to discover physical devices for testing.
15 | - `stop_app_device` - Stops a running app on a connected device.
16 | - `test_device` - Runs tests on a physical Apple device.
17 | ### iOS Simulator Development (`simulator`)
18 | **Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (12 tools)
19 |
20 | - `boot_sim` - Boots an iOS simulator.
21 | - `build_run_sim` - Builds and runs an app on an iOS simulator.
22 | - `build_sim` - Builds an app for an iOS simulator.
23 | - `get_sim_app_path` - Retrieves the built app path for an iOS simulator.
24 | - `install_app_sim` - Installs an app in an iOS simulator.
25 | - `launch_app_logs_sim` - Launches an app in an iOS simulator and captures its logs.
26 | - `launch_app_sim` - Launches an app in an iOS simulator.
27 | - `list_sims` - Lists available iOS simulators with their UUIDs.
28 | - `open_sim` - Opens the iOS Simulator app.
29 | - `record_sim_video` - Starts or stops video capture for an iOS simulator.
30 | - `stop_app_sim` - Stops an app running in an iOS simulator.
31 | - `test_sim` - Runs tests on an iOS simulator.
32 | ### Log Capture & Management (`logging`)
33 | **Purpose**: Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing. (4 tools)
34 |
35 | - `start_device_log_cap` - Starts log capture on a connected device.
36 | - `start_sim_log_cap` - Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.
37 | - `stop_device_log_cap` - Stops an active Apple device log capture session and returns the captured logs.
38 | - `stop_sim_log_cap` - Stops an active simulator log capture session and returns the captured logs.
39 | ### macOS Development (`macos`)
40 | **Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (6 tools)
41 |
42 | - `build_macos` - Builds a macOS app.
43 | - `build_run_macos` - Builds and runs a macOS app.
44 | - `get_mac_app_path` - Retrieves the built macOS app bundle path.
45 | - `launch_mac_app` - Launches a macOS application. Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app.
46 | - `stop_mac_app` - Stops a running macOS application. Can stop by app name or process ID.
47 | - `test_macos` - Runs tests for a macOS target.
48 | ### Project Discovery (`project-discovery`)
49 | **Purpose**: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. (5 tools)
50 |
51 | - `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.
52 | - `get_app_bundle_id` - Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS).
53 | - `get_mac_bundle_id` - Extracts the bundle identifier from a macOS app bundle (.app). Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.
54 | - `list_schemes` - Lists schemes for a project or workspace.
55 | - `show_build_settings` - Shows xcodebuild build settings.
56 | ### Project Scaffolding (`project-scaffolding`)
57 | **Purpose**: Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures. (2 tools)
58 |
59 | - `scaffold_ios_project` - Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.
60 | - `scaffold_macos_project` - Scaffold a new macOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper macOS configuration.
61 | ### Project Utilities (`utilities`)
62 | **Purpose**: Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files. (1 tools)
63 |
64 | - `clean` - Cleans build products with xcodebuild.
65 | ### session-management (`session-management`)
66 | **Purpose**: Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values. (3 tools)
67 |
68 | - `session_clear_defaults` - Clear selected or all session defaults.
69 | - `session_set_defaults` - Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set all relevant defaults up front in a single call (e.g., project/workspace, scheme, simulator or device ID, useLatestOS) to avoid iterative prompts; only set the keys your workflow needs.
70 | - `session_show_defaults` - Show current session defaults.
71 | ### Simulator Debugging (`debugging`)
72 | **Purpose**: Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands. (8 tools)
73 |
74 | - `debug_attach_sim` - Attach LLDB to a running iOS simulator app. Provide bundleId or pid, plus simulator defaults.
75 | - `debug_breakpoint_add` - Add a breakpoint by file/line or function name for the active debug session.
76 | - `debug_breakpoint_remove` - Remove a breakpoint by id for the active debug session.
77 | - `debug_continue` - Resume execution in the active debug session or a specific debugSessionId.
78 | - `debug_detach` - Detach the current debugger session or a specific debugSessionId.
79 | - `debug_lldb_command` - Run an arbitrary LLDB command within the active debug session.
80 | - `debug_stack` - Return a thread backtrace from the active debug session.
81 | - `debug_variables` - Return variables for a selected frame in the active debug session.
82 | ### Simulator Management (`simulator-management`)
83 | **Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (5 tools)
84 |
85 | - `erase_sims` - Erases a simulator by UDID.
86 | - `reset_sim_location` - Resets the simulator's location to default.
87 | - `set_sim_appearance` - Sets the appearance mode (dark/light) of an iOS simulator.
88 | - `set_sim_location` - Sets a custom GPS location for the simulator.
89 | - `sim_statusbar` - Sets the data network indicator in the iOS simulator status bar. Use "clear" to reset all overrides, or specify a network type (hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc).
90 | ### Swift Package Manager (`swift-package`)
91 | **Purpose**: Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support. (6 tools)
92 |
93 | - `swift_package_build` - Builds a Swift Package with swift build
94 | - `swift_package_clean` - Cleans Swift Package build artifacts and derived data
95 | - `swift_package_list` - Lists currently running Swift Package processes
96 | - `swift_package_run` - Runs an executable target from a Swift Package with swift run
97 | - `swift_package_stop` - Stops a running Swift Package executable started with swift_package_run
98 | - `swift_package_test` - Runs tests for a Swift Package with swift test
99 | ### System Doctor (`doctor`)
100 | **Purpose**: Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability. (1 tools)
101 |
102 | - `doctor` - Provides comprehensive information about the MCP server environment, available dependencies, and configuration status.
103 | ### UI Testing & Automation (`ui-testing`)
104 | **Purpose**: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. (11 tools)
105 |
106 | - `button` - Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri
107 | - `describe_ui` - 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.
108 | - `gesture` - Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge
109 | - `key_press` - Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.
110 | - `key_sequence` - Press key sequence using HID keycodes on iOS simulator with configurable delay
111 | - `long_press` - Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots).
112 | - `screenshot` - Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).
113 | - `swipe` - Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.
114 | - `tap` - 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.
115 | - `touch` - Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots).
116 | - `type_text` - Type text (supports US keyboard characters). Use describe_ui to find text field, tap to focus, then type.
117 |
118 | ## Summary Statistics
119 |
120 | - **Total Tools**: 71 canonical tools + 22 re-exports = 93 total
121 | - **Workflow Groups**: 13
122 |
123 | ---
124 |
125 | *This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-08*
126 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import * as z from 'zod';
3 | import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts';
4 | import plugin, { showBuildSettingsLogic } from '../show_build_settings.ts';
5 | import { sessionStore } from '../../../../utils/session-store.ts';
6 |
7 | describe('show_build_settings plugin', () => {
8 | beforeEach(() => {
9 | sessionStore.clear();
10 | });
11 | describe('Export Field Validation (Literal)', () => {
12 | it('should have correct name', () => {
13 | expect(plugin.name).toBe('show_build_settings');
14 | });
15 |
16 | it('should have correct description', () => {
17 | expect(plugin.description).toBe('Shows xcodebuild build settings.');
18 | });
19 |
20 | it('should have handler function', () => {
21 | expect(typeof plugin.handler).toBe('function');
22 | });
23 |
24 | it('should expose an empty public schema', () => {
25 | const schema = z.strictObject(plugin.schema);
26 | expect(schema.safeParse({}).success).toBe(true);
27 | expect(schema.safeParse({ projectPath: '/path.xcodeproj' }).success).toBe(false);
28 | expect(schema.safeParse({ scheme: 'App' }).success).toBe(false);
29 | expect(Object.keys(plugin.schema)).toEqual([]);
30 | });
31 | });
32 |
33 | describe('Handler Behavior (Complete Literal Returns)', () => {
34 | it('should execute with valid parameters', async () => {
35 | const mockExecutor = createMockExecutor({
36 | success: true,
37 | output: 'Mock build settings output',
38 | error: undefined,
39 | process: { pid: 12345 },
40 | });
41 |
42 | const result = await showBuildSettingsLogic(
43 | { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' },
44 | mockExecutor,
45 | );
46 | expect(result.isError).toBe(false);
47 | expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:');
48 | });
49 |
50 | it('should test Zod validation through handler', async () => {
51 | // Test the actual tool handler which includes Zod validation
52 | const result = await plugin.handler({
53 | projectPath: null,
54 | scheme: 'MyScheme',
55 | });
56 |
57 | expect(result.isError).toBe(true);
58 | expect(result.content[0].text).toContain('Missing required session defaults');
59 | expect(result.content[0].text).toContain('Provide a project or workspace');
60 | });
61 |
62 | it('should return success with build settings', async () => {
63 | const calls: any[] = [];
64 | const mockExecutor = createMockExecutor({
65 | success: true,
66 | output: `Build settings from command line:
67 | ARCHS = arm64
68 | BUILD_DIR = /Users/dev/Build/Products
69 | CONFIGURATION = Debug
70 | DEVELOPMENT_TEAM = ABC123DEF4
71 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
72 | PRODUCT_NAME = MyApp
73 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
74 | error: undefined,
75 | process: { pid: 12345 },
76 | });
77 |
78 | // Wrap mockExecutor to track calls
79 | const wrappedExecutor: CommandExecutor = (...args) => {
80 | calls.push(args);
81 | return mockExecutor(...args);
82 | };
83 |
84 | const result = await showBuildSettingsLogic(
85 | {
86 | projectPath: '/path/to/MyProject.xcodeproj',
87 | scheme: 'MyScheme',
88 | },
89 | wrappedExecutor,
90 | );
91 |
92 | expect(calls).toHaveLength(1);
93 | expect(calls[0]).toEqual([
94 | [
95 | 'xcodebuild',
96 | '-showBuildSettings',
97 | '-project',
98 | '/path/to/MyProject.xcodeproj',
99 | '-scheme',
100 | 'MyScheme',
101 | ],
102 | 'Show Build Settings',
103 | true,
104 | ]);
105 |
106 | expect(result).toEqual({
107 | content: [
108 | {
109 | type: 'text',
110 | text: '✅ Build settings for scheme MyScheme:',
111 | },
112 | {
113 | type: 'text',
114 | text: `Build settings from command line:
115 | ARCHS = arm64
116 | BUILD_DIR = /Users/dev/Build/Products
117 | CONFIGURATION = Debug
118 | DEVELOPMENT_TEAM = ABC123DEF4
119 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
120 | PRODUCT_NAME = MyApp
121 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
122 | },
123 | ],
124 | isError: false,
125 | });
126 | });
127 |
128 | it('should return error when command fails', async () => {
129 | const mockExecutor = createMockExecutor({
130 | success: false,
131 | output: '',
132 | error: 'Scheme not found',
133 | process: { pid: 12345 },
134 | });
135 |
136 | const result = await showBuildSettingsLogic(
137 | {
138 | projectPath: '/path/to/MyProject.xcodeproj',
139 | scheme: 'InvalidScheme',
140 | },
141 | mockExecutor,
142 | );
143 |
144 | expect(result).toEqual({
145 | content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }],
146 | isError: true,
147 | });
148 | });
149 |
150 | it('should handle Error objects in catch blocks', async () => {
151 | const mockExecutor = async () => {
152 | throw new Error('Command execution failed');
153 | };
154 |
155 | const result = await showBuildSettingsLogic(
156 | {
157 | projectPath: '/path/to/MyProject.xcodeproj',
158 | scheme: 'MyScheme',
159 | },
160 | mockExecutor,
161 | );
162 |
163 | expect(result).toEqual({
164 | content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }],
165 | isError: true,
166 | });
167 | });
168 | });
169 |
170 | describe('XOR Validation', () => {
171 | it('should error when neither projectPath nor workspacePath provided', async () => {
172 | const result = await plugin.handler({
173 | scheme: 'MyScheme',
174 | });
175 |
176 | expect(result.isError).toBe(true);
177 | expect(result.content[0].text).toContain('Missing required session defaults');
178 | expect(result.content[0].text).toContain('Provide a project or workspace');
179 | });
180 |
181 | it('should error when both projectPath and workspacePath provided', async () => {
182 | const result = await plugin.handler({
183 | projectPath: '/path/project.xcodeproj',
184 | workspacePath: '/path/workspace.xcworkspace',
185 | scheme: 'MyScheme',
186 | });
187 |
188 | expect(result.isError).toBe(true);
189 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
190 | });
191 |
192 | it('should work with projectPath only', async () => {
193 | const mockExecutor = createMockExecutor({
194 | success: true,
195 | output: 'Mock build settings output',
196 | });
197 |
198 | const result = await showBuildSettingsLogic(
199 | { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' },
200 | mockExecutor,
201 | );
202 |
203 | expect(result.isError).toBe(false);
204 | expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:');
205 | });
206 |
207 | it('should work with workspacePath only', async () => {
208 | const mockExecutor = createMockExecutor({
209 | success: true,
210 | output: 'Mock build settings output',
211 | });
212 |
213 | const result = await showBuildSettingsLogic(
214 | { workspacePath: '/valid/path.xcworkspace', scheme: 'MyScheme' },
215 | mockExecutor,
216 | );
217 |
218 | expect(result.isError).toBe(false);
219 | expect(result.content[0].text).toContain('✅ Build settings retrieved successfully');
220 | });
221 | });
222 |
223 | describe('Session requirement handling', () => {
224 | it('should require scheme when not provided', async () => {
225 | const result = await plugin.handler({
226 | projectPath: '/path/to/MyProject.xcodeproj',
227 | } as any);
228 |
229 | expect(result.isError).toBe(true);
230 | expect(result.content[0].text).toContain('Missing required session defaults');
231 | expect(result.content[0].text).toContain('scheme is required');
232 | });
233 |
234 | it('should surface project/workspace requirement even with scheme default', async () => {
235 | sessionStore.setDefaults({ scheme: 'MyScheme' });
236 |
237 | const result = await plugin.handler({});
238 |
239 | expect(result.isError).toBe(true);
240 | expect(result.content[0].text).toContain('Missing required session defaults');
241 | expect(result.content[0].text).toContain('Provide a project or workspace');
242 | });
243 | });
244 |
245 | describe('showBuildSettingsLogic function', () => {
246 | it('should return success with build settings', async () => {
247 | const calls: any[] = [];
248 | const mockExecutor = createMockExecutor({
249 | success: true,
250 | output: `Build settings from command line:
251 | ARCHS = arm64
252 | BUILD_DIR = /Users/dev/Build/Products
253 | CONFIGURATION = Debug
254 | DEVELOPMENT_TEAM = ABC123DEF4
255 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
256 | PRODUCT_NAME = MyApp
257 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
258 | error: undefined,
259 | process: { pid: 12345 },
260 | });
261 |
262 | // Wrap mockExecutor to track calls
263 | const wrappedExecutor: CommandExecutor = (...args) => {
264 | calls.push(args);
265 | return mockExecutor(...args);
266 | };
267 |
268 | const result = await showBuildSettingsLogic(
269 | {
270 | projectPath: '/path/to/MyProject.xcodeproj',
271 | scheme: 'MyScheme',
272 | },
273 | wrappedExecutor,
274 | );
275 |
276 | expect(calls).toHaveLength(1);
277 | expect(calls[0]).toEqual([
278 | [
279 | 'xcodebuild',
280 | '-showBuildSettings',
281 | '-project',
282 | '/path/to/MyProject.xcodeproj',
283 | '-scheme',
284 | 'MyScheme',
285 | ],
286 | 'Show Build Settings',
287 | true,
288 | ]);
289 |
290 | expect(result).toEqual({
291 | content: [
292 | {
293 | type: 'text',
294 | text: '✅ Build settings for scheme MyScheme:',
295 | },
296 | {
297 | type: 'text',
298 | text: `Build settings from command line:
299 | ARCHS = arm64
300 | BUILD_DIR = /Users/dev/Build/Products
301 | CONFIGURATION = Debug
302 | DEVELOPMENT_TEAM = ABC123DEF4
303 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
304 | PRODUCT_NAME = MyApp
305 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
306 | },
307 | ],
308 | isError: false,
309 | });
310 | });
311 |
312 | it('should return error when command fails', async () => {
313 | const mockExecutor = createMockExecutor({
314 | success: false,
315 | output: '',
316 | error: 'Scheme not found',
317 | process: { pid: 12345 },
318 | });
319 |
320 | const result = await showBuildSettingsLogic(
321 | {
322 | projectPath: '/path/to/MyProject.xcodeproj',
323 | scheme: 'InvalidScheme',
324 | },
325 | mockExecutor,
326 | );
327 |
328 | expect(result).toEqual({
329 | content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }],
330 | isError: true,
331 | });
332 | });
333 |
334 | it('should handle Error objects in catch blocks', async () => {
335 | const mockExecutor = async () => {
336 | throw new Error('Command execution failed');
337 | };
338 |
339 | const result = await showBuildSettingsLogic(
340 | {
341 | projectPath: '/path/to/MyProject.xcodeproj',
342 | scheme: 'MyScheme',
343 | },
344 | mockExecutor,
345 | );
346 |
347 | expect(result).toEqual({
348 | content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }],
349 | isError: true,
350 | });
351 | });
352 | });
353 | });
354 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/stop_device_log_cap.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Logging Plugin: Stop Device Log Capture
3 | *
4 | * Stops an active Apple device log capture session and returns the captured logs.
5 | */
6 |
7 | import * as fs from 'fs';
8 | import * as z from 'zod';
9 | import { log } from '../../../utils/logging/index.ts';
10 | import {
11 | activeDeviceLogSessions,
12 | type DeviceLogSession,
13 | } from '../../../utils/log-capture/device-log-sessions.ts';
14 | import { ToolResponse } from '../../../types/common.ts';
15 | import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts';
16 | import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts';
17 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
18 |
19 | // Define schema as ZodObject
20 | const stopDeviceLogCapSchema = z.object({
21 | logSessionId: z.string().describe('The session ID returned by start_device_log_cap.'),
22 | });
23 |
24 | // Use z.infer for type safety
25 | type StopDeviceLogCapParams = z.infer<typeof stopDeviceLogCapSchema>;
26 |
27 | /**
28 | * Business logic for stopping device log capture session
29 | */
30 | export async function stop_device_log_capLogic(
31 | params: StopDeviceLogCapParams,
32 | fileSystemExecutor: FileSystemExecutor,
33 | ): Promise<ToolResponse> {
34 | const { logSessionId } = params;
35 |
36 | const session = activeDeviceLogSessions.get(logSessionId);
37 | if (!session) {
38 | log('warning', `Device log session not found: ${logSessionId}`);
39 | return {
40 | content: [
41 | {
42 | type: 'text',
43 | text: `Failed to stop device log capture session ${logSessionId}: Device log capture session not found: ${logSessionId}`,
44 | },
45 | ],
46 | isError: true,
47 | };
48 | }
49 |
50 | try {
51 | log('info', `Attempting to stop device log capture session: ${logSessionId}`);
52 |
53 | const shouldSignalStop =
54 | !(session.hasEnded ?? false) &&
55 | session.process.killed !== true &&
56 | session.process.exitCode == null;
57 |
58 | if (shouldSignalStop) {
59 | session.process.kill?.('SIGTERM');
60 | }
61 |
62 | await waitForSessionToFinish(session);
63 |
64 | if (session.logStream) {
65 | await ensureStreamClosed(session.logStream);
66 | }
67 |
68 | const logFilePath = session.logFilePath;
69 | activeDeviceLogSessions.delete(logSessionId);
70 |
71 | // Check file access
72 | if (!fileSystemExecutor.existsSync(logFilePath)) {
73 | throw new Error(`Log file not found: ${logFilePath}`);
74 | }
75 |
76 | const fileContent = await fileSystemExecutor.readFile(logFilePath, 'utf-8');
77 | log('info', `Successfully read device log content from ${logFilePath}`);
78 |
79 | log(
80 | 'info',
81 | `Device log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`,
82 | );
83 |
84 | return {
85 | content: [
86 | {
87 | type: 'text',
88 | text: `✅ Device log capture session stopped successfully\n\nSession ID: ${logSessionId}\n\n--- Captured Logs ---\n${fileContent}`,
89 | },
90 | ],
91 | };
92 | } catch (error) {
93 | const message = error instanceof Error ? error.message : String(error);
94 | log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`);
95 | return {
96 | content: [
97 | {
98 | type: 'text',
99 | text: `Failed to stop device log capture session ${logSessionId}: ${message}`,
100 | },
101 | ],
102 | isError: true,
103 | };
104 | }
105 | }
106 |
107 | type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean };
108 |
109 | async function ensureStreamClosed(stream: fs.WriteStream): Promise<void> {
110 | const typedStream = stream as WriteStreamWithClosed;
111 | if (typedStream.destroyed || typedStream.closed) {
112 | return;
113 | }
114 |
115 | await new Promise<void>((resolve) => {
116 | const onClose = (): void => resolve();
117 | typedStream.once('close', onClose);
118 | typedStream.end();
119 | }).catch(() => {
120 | // Ignore cleanup errors – best-effort close
121 | });
122 | }
123 |
124 | async function waitForSessionToFinish(session: DeviceLogSession): Promise<void> {
125 | if (session.hasEnded) {
126 | return;
127 | }
128 |
129 | if (session.process.exitCode != null) {
130 | session.hasEnded = true;
131 | return;
132 | }
133 |
134 | if (typeof session.process.once === 'function') {
135 | await new Promise<void>((resolve) => {
136 | const onClose = (): void => {
137 | clearTimeout(timeout);
138 | session.hasEnded = true;
139 | resolve();
140 | };
141 |
142 | const timeout = setTimeout(() => {
143 | session.process.removeListener?.('close', onClose);
144 | session.hasEnded = true;
145 | resolve();
146 | }, 1000);
147 |
148 | session.process.once('close', onClose);
149 |
150 | if (session.hasEnded || session.process.exitCode != null) {
151 | session.process.removeListener?.('close', onClose);
152 | onClose();
153 | }
154 | });
155 | return;
156 | }
157 |
158 | // Fallback polling for minimal mock processes (primarily in tests)
159 | for (let i = 0; i < 20; i += 1) {
160 | if (session.hasEnded || session.process.exitCode != null) {
161 | session.hasEnded = true;
162 | break;
163 | }
164 | await new Promise((resolve) => setTimeout(resolve, 50));
165 | }
166 | }
167 |
168 | /**
169 | * Type guard to check if an object has fs-like promises interface
170 | */
171 | function hasPromisesInterface(obj: unknown): obj is { promises: typeof fs.promises } {
172 | return typeof obj === 'object' && obj !== null && 'promises' in obj;
173 | }
174 |
175 | /**
176 | * Type guard to check if an object has existsSync method
177 | */
178 | function hasExistsSyncMethod(obj: unknown): obj is { existsSync: typeof fs.existsSync } {
179 | return typeof obj === 'object' && obj !== null && 'existsSync' in obj;
180 | }
181 |
182 | /**
183 | * Type guard to check if an object has createWriteStream method
184 | */
185 | function hasCreateWriteStreamMethod(
186 | obj: unknown,
187 | ): obj is { createWriteStream: typeof fs.createWriteStream } {
188 | return typeof obj === 'object' && obj !== null && 'createWriteStream' in obj;
189 | }
190 |
191 | /**
192 | * Legacy support for backward compatibility
193 | */
194 | export async function stopDeviceLogCapture(
195 | logSessionId: string,
196 | fileSystem?: unknown,
197 | ): Promise<{ logContent: string; error?: string }> {
198 | // For backward compatibility, create a mock FileSystemExecutor from the fileSystem parameter
199 | const fsToUse = fileSystem ?? fs;
200 | const mockFileSystemExecutor: FileSystemExecutor = {
201 | async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
202 | if (hasPromisesInterface(fsToUse)) {
203 | await fsToUse.promises.mkdir(path, options);
204 | } else {
205 | await fs.promises.mkdir(path, options);
206 | }
207 | },
208 | async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
209 | if (hasPromisesInterface(fsToUse)) {
210 | const result = await fsToUse.promises.readFile(path, encoding);
211 | return typeof result === 'string' ? result : (result as Buffer).toString();
212 | } else {
213 | const result = await fs.promises.readFile(path, encoding);
214 | return typeof result === 'string' ? result : (result as Buffer).toString();
215 | }
216 | },
217 | async writeFile(
218 | path: string,
219 | content: string,
220 | encoding: BufferEncoding = 'utf8',
221 | ): Promise<void> {
222 | if (hasPromisesInterface(fsToUse)) {
223 | await fsToUse.promises.writeFile(path, content, encoding);
224 | } else {
225 | await fs.promises.writeFile(path, content, encoding);
226 | }
227 | },
228 | createWriteStream(path: string, options?: { flags?: string }) {
229 | if (hasCreateWriteStreamMethod(fsToUse)) {
230 | return fsToUse.createWriteStream(path, options);
231 | }
232 | return fs.createWriteStream(path, options);
233 | },
234 | async cp(
235 | source: string,
236 | destination: string,
237 | options?: { recursive?: boolean },
238 | ): Promise<void> {
239 | if (hasPromisesInterface(fsToUse)) {
240 | await fsToUse.promises.cp(source, destination, options);
241 | } else {
242 | await fs.promises.cp(source, destination, options);
243 | }
244 | },
245 | async readdir(path: string, options?: { withFileTypes?: boolean }): Promise<unknown[]> {
246 | if (hasPromisesInterface(fsToUse)) {
247 | if (options?.withFileTypes === true) {
248 | const result = await fsToUse.promises.readdir(path, { withFileTypes: true });
249 | return Array.isArray(result) ? result : [];
250 | } else {
251 | const result = await fsToUse.promises.readdir(path);
252 | return Array.isArray(result) ? result : [];
253 | }
254 | } else {
255 | if (options?.withFileTypes === true) {
256 | const result = await fs.promises.readdir(path, { withFileTypes: true });
257 | return Array.isArray(result) ? result : [];
258 | } else {
259 | const result = await fs.promises.readdir(path);
260 | return Array.isArray(result) ? result : [];
261 | }
262 | }
263 | },
264 | async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
265 | if (hasPromisesInterface(fsToUse)) {
266 | await fsToUse.promises.rm(path, options);
267 | } else {
268 | await fs.promises.rm(path, options);
269 | }
270 | },
271 | existsSync(path: string): boolean {
272 | if (hasExistsSyncMethod(fsToUse)) {
273 | return fsToUse.existsSync(path);
274 | } else {
275 | return fs.existsSync(path);
276 | }
277 | },
278 | async stat(path: string): Promise<{ isDirectory(): boolean; mtimeMs: number }> {
279 | if (hasPromisesInterface(fsToUse)) {
280 | const result = await fsToUse.promises.stat(path);
281 | return result as { isDirectory(): boolean; mtimeMs: number };
282 | } else {
283 | const result = await fs.promises.stat(path);
284 | return result as { isDirectory(): boolean; mtimeMs: number };
285 | }
286 | },
287 | async mkdtemp(prefix: string): Promise<string> {
288 | if (hasPromisesInterface(fsToUse)) {
289 | return await fsToUse.promises.mkdtemp(prefix);
290 | } else {
291 | return await fs.promises.mkdtemp(prefix);
292 | }
293 | },
294 | tmpdir(): string {
295 | return '/tmp';
296 | },
297 | };
298 |
299 | const result = await stop_device_log_capLogic({ logSessionId }, mockFileSystemExecutor);
300 |
301 | if (result.isError) {
302 | const errorText = result.content[0]?.text;
303 | const errorMessage =
304 | typeof errorText === 'string'
305 | ? errorText.replace(`Failed to stop device log capture session ${logSessionId}: `, '')
306 | : 'Unknown error occurred';
307 |
308 | return {
309 | logContent: '',
310 | error: errorMessage,
311 | };
312 | }
313 |
314 | // Extract log content from successful response
315 | const successText = result.content[0]?.text;
316 | if (typeof successText !== 'string') {
317 | return {
318 | logContent: '',
319 | error: 'Invalid response format: expected text content',
320 | };
321 | }
322 |
323 | const logContentMatch = successText.match(/--- Captured Logs ---\n([\s\S]*)$/);
324 | const logContent = logContentMatch?.[1] ?? '';
325 |
326 | return { logContent };
327 | }
328 |
329 | export default {
330 | name: 'stop_device_log_cap',
331 | description: 'Stops an active Apple device log capture session and returns the captured logs.',
332 | schema: stopDeviceLogCapSchema.shape, // MCP SDK compatibility
333 | annotations: {
334 | title: 'Stop Device Log Capture',
335 | destructiveHint: true,
336 | },
337 | handler: createTypedTool(
338 | stopDeviceLogCapSchema,
339 | (params: StopDeviceLogCapParams) => {
340 | return stop_device_log_capLogic(params, getDefaultFileSystemExecutor());
341 | },
342 | getDefaultCommandExecutor,
343 | ),
344 | };
345 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Test for get_app_bundle_id plugin - Dependency Injection Architecture
3 | *
4 | * Tests the plugin structure and exported components for get_app_bundle_id tool.
5 | * Uses pure dependency injection with createMockFileSystemExecutor.
6 | * NO VITEST MOCKING ALLOWED - Only createMockFileSystemExecutor
7 | *
8 | * Plugin location: plugins/project-discovery/get_app_bundle_id.ts
9 | */
10 |
11 | import { describe, it, expect } from 'vitest';
12 | import * as z from 'zod';
13 | import plugin, { get_app_bundle_idLogic } from '../get_app_bundle_id.ts';
14 | import {
15 | createMockFileSystemExecutor,
16 | createCommandMatchingMockExecutor,
17 | } from '../../../../test-utils/mock-executors.ts';
18 |
19 | describe('get_app_bundle_id plugin', () => {
20 | // Helper function to create mock executor for command matching
21 | const createMockExecutorForCommands = (results: Record<string, string | Error>) => {
22 | return createCommandMatchingMockExecutor(
23 | Object.fromEntries(
24 | Object.entries(results).map(([command, result]) => [
25 | command,
26 | result instanceof Error
27 | ? { success: false, error: result.message }
28 | : { success: true, output: result },
29 | ]),
30 | ),
31 | );
32 | };
33 |
34 | describe('Export Field Validation (Literal)', () => {
35 | it('should have correct name', () => {
36 | expect(plugin.name).toBe('get_app_bundle_id');
37 | });
38 |
39 | it('should have correct description', () => {
40 | expect(plugin.description).toBe(
41 | "Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS). IMPORTANT: You MUST provide the appPath parameter. Example: get_app_bundle_id({ appPath: '/path/to/your/app.app' })",
42 | );
43 | });
44 |
45 | it('should have handler function', () => {
46 | expect(typeof plugin.handler).toBe('function');
47 | });
48 |
49 | it('should validate schema with valid inputs', () => {
50 | const schema = z.object(plugin.schema);
51 | expect(schema.safeParse({ appPath: '/path/to/MyApp.app' }).success).toBe(true);
52 | expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true);
53 | });
54 |
55 | it('should validate schema with invalid inputs', () => {
56 | const schema = z.object(plugin.schema);
57 | expect(schema.safeParse({}).success).toBe(false);
58 | expect(schema.safeParse({ appPath: 123 }).success).toBe(false);
59 | expect(schema.safeParse({ appPath: null }).success).toBe(false);
60 | expect(schema.safeParse({ appPath: undefined }).success).toBe(false);
61 | });
62 | });
63 |
64 | describe('Handler Behavior (Complete Literal Returns)', () => {
65 | it('should return error when appPath validation fails', async () => {
66 | // Test validation through the handler which uses Zod validation
67 | const result = await plugin.handler({});
68 |
69 | expect(result).toEqual({
70 | content: [
71 | {
72 | type: 'text',
73 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined',
74 | },
75 | ],
76 | isError: true,
77 | });
78 | });
79 |
80 | it('should return error when file exists validation fails', async () => {
81 | const mockExecutor = createMockExecutorForCommands({});
82 | const mockFileSystemExecutor = createMockFileSystemExecutor({
83 | existsSync: () => false,
84 | });
85 |
86 | const result = await get_app_bundle_idLogic(
87 | { appPath: '/path/to/MyApp.app' },
88 | mockExecutor,
89 | mockFileSystemExecutor,
90 | );
91 |
92 | expect(result).toEqual({
93 | content: [
94 | {
95 | type: 'text',
96 | text: "File not found: '/path/to/MyApp.app'. Please check the path and try again.",
97 | },
98 | ],
99 | isError: true,
100 | });
101 | });
102 |
103 | it('should return success with bundle ID using defaults read', async () => {
104 | const mockExecutor = createMockExecutorForCommands({
105 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': 'com.example.MyApp',
106 | });
107 | const mockFileSystemExecutor = createMockFileSystemExecutor({
108 | existsSync: () => true,
109 | });
110 |
111 | const result = await get_app_bundle_idLogic(
112 | { appPath: '/path/to/MyApp.app' },
113 | mockExecutor,
114 | mockFileSystemExecutor,
115 | );
116 |
117 | expect(result).toEqual({
118 | content: [
119 | {
120 | type: 'text',
121 | text: '✅ Bundle ID: com.example.MyApp',
122 | },
123 | {
124 | type: 'text',
125 | text: `Next Steps:
126 | - Simulator: install_app_sim + launch_app_sim
127 | - Device: install_app_device + launch_app_device`,
128 | },
129 | ],
130 | isError: false,
131 | });
132 | });
133 |
134 | it('should fallback to PlistBuddy when defaults read fails', async () => {
135 | const mockExecutor = createMockExecutorForCommands({
136 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
137 | 'defaults read failed',
138 | ),
139 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
140 | 'com.example.MyApp',
141 | });
142 | const mockFileSystemExecutor = createMockFileSystemExecutor({
143 | existsSync: () => true,
144 | });
145 |
146 | const result = await get_app_bundle_idLogic(
147 | { appPath: '/path/to/MyApp.app' },
148 | mockExecutor,
149 | mockFileSystemExecutor,
150 | );
151 |
152 | expect(result).toEqual({
153 | content: [
154 | {
155 | type: 'text',
156 | text: '✅ Bundle ID: com.example.MyApp',
157 | },
158 | {
159 | type: 'text',
160 | text: `Next Steps:
161 | - Simulator: install_app_sim + launch_app_sim
162 | - Device: install_app_device + launch_app_device`,
163 | },
164 | ],
165 | isError: false,
166 | });
167 | });
168 |
169 | it('should return error when both extraction methods fail', async () => {
170 | const mockExecutor = createMockExecutorForCommands({
171 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
172 | 'defaults read failed',
173 | ),
174 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
175 | new Error('Command failed'),
176 | });
177 | const mockFileSystemExecutor = createMockFileSystemExecutor({
178 | existsSync: () => true,
179 | });
180 |
181 | const result = await get_app_bundle_idLogic(
182 | { appPath: '/path/to/MyApp.app' },
183 | mockExecutor,
184 | mockFileSystemExecutor,
185 | );
186 |
187 | expect(result).toEqual({
188 | content: [
189 | {
190 | type: 'text',
191 | text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Command failed',
192 | },
193 | {
194 | type: 'text',
195 | text: 'Make sure the path points to a valid app bundle (.app directory).',
196 | },
197 | ],
198 | isError: true,
199 | });
200 | });
201 |
202 | it('should handle Error objects in catch blocks', async () => {
203 | const mockExecutor = createMockExecutorForCommands({
204 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
205 | 'defaults read failed',
206 | ),
207 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
208 | new Error('Custom error message'),
209 | });
210 | const mockFileSystemExecutor = createMockFileSystemExecutor({
211 | existsSync: () => true,
212 | });
213 |
214 | const result = await get_app_bundle_idLogic(
215 | { appPath: '/path/to/MyApp.app' },
216 | mockExecutor,
217 | mockFileSystemExecutor,
218 | );
219 |
220 | expect(result).toEqual({
221 | content: [
222 | {
223 | type: 'text',
224 | text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Custom error message',
225 | },
226 | {
227 | type: 'text',
228 | text: 'Make sure the path points to a valid app bundle (.app directory).',
229 | },
230 | ],
231 | isError: true,
232 | });
233 | });
234 |
235 | it('should handle string errors in catch blocks', async () => {
236 | const mockExecutor = createMockExecutorForCommands({
237 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
238 | 'defaults read failed',
239 | ),
240 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
241 | new Error('String error'),
242 | });
243 | const mockFileSystemExecutor = createMockFileSystemExecutor({
244 | existsSync: () => true,
245 | });
246 |
247 | const result = await get_app_bundle_idLogic(
248 | { appPath: '/path/to/MyApp.app' },
249 | mockExecutor,
250 | mockFileSystemExecutor,
251 | );
252 |
253 | expect(result).toEqual({
254 | content: [
255 | {
256 | type: 'text',
257 | text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: String error',
258 | },
259 | {
260 | type: 'text',
261 | text: 'Make sure the path points to a valid app bundle (.app directory).',
262 | },
263 | ],
264 | isError: true,
265 | });
266 | });
267 |
268 | it('should handle schema validation error when appPath is null', async () => {
269 | // Test validation through the handler which uses Zod validation
270 | const result = await plugin.handler({ appPath: null });
271 |
272 | expect(result).toEqual({
273 | content: [
274 | {
275 | type: 'text',
276 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received null',
277 | },
278 | ],
279 | isError: true,
280 | });
281 | });
282 |
283 | it('should handle schema validation with missing appPath', async () => {
284 | // Test validation through the handler which uses Zod validation
285 | const result = await plugin.handler({});
286 |
287 | expect(result).toEqual({
288 | content: [
289 | {
290 | type: 'text',
291 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined',
292 | },
293 | ],
294 | isError: true,
295 | });
296 | });
297 |
298 | it('should handle schema validation with undefined appPath', async () => {
299 | // Test validation through the handler which uses Zod validation
300 | const result = await plugin.handler({ appPath: undefined });
301 |
302 | expect(result).toEqual({
303 | content: [
304 | {
305 | type: 'text',
306 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined',
307 | },
308 | ],
309 | isError: true,
310 | });
311 | });
312 |
313 | it('should handle schema validation with number type appPath', async () => {
314 | // Test validation through the handler which uses Zod validation
315 | const result = await plugin.handler({ appPath: 123 });
316 |
317 | expect(result).toEqual({
318 | content: [
319 | {
320 | type: 'text',
321 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received number',
322 | },
323 | ],
324 | isError: true,
325 | });
326 | });
327 | });
328 | });
329 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/test_device.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Device Shared Plugin: Test Device (Unified)
3 | *
4 | * Runs tests for an Apple project or workspace on a physical device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro)
5 | * using xcodebuild test and parses xcresult output. Accepts mutually exclusive `projectPath` or `workspacePath`.
6 | */
7 |
8 | import * as z from 'zod';
9 | import { join } from 'path';
10 | import { ToolResponse, XcodePlatform } from '../../../types/common.ts';
11 | import { log } from '../../../utils/logging/index.ts';
12 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts';
13 | import { createTextResponse } from '../../../utils/responses/index.ts';
14 | import { normalizeTestRunnerEnv } from '../../../utils/environment.ts';
15 | import type {
16 | CommandExecutor,
17 | FileSystemExecutor,
18 | CommandExecOptions,
19 | } from '../../../utils/execution/index.ts';
20 | import {
21 | getDefaultCommandExecutor,
22 | getDefaultFileSystemExecutor,
23 | } from '../../../utils/execution/index.ts';
24 | import {
25 | createSessionAwareTool,
26 | getSessionAwareToolSchemaShape,
27 | } from '../../../utils/typed-tool-factory.ts';
28 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
29 |
30 | // Unified schema: XOR between projectPath and workspacePath
31 | const baseSchemaObject = z.object({
32 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'),
33 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'),
34 | scheme: z.string().describe('The scheme to test'),
35 | deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
36 | configuration: z.string().optional().describe('Build configuration (Debug, Release)'),
37 | derivedDataPath: z.string().optional().describe('Path to derived data directory'),
38 | extraArgs: z.array(z.string()).optional().describe('Additional arguments to pass to xcodebuild'),
39 | preferXcodebuild: z.boolean().optional().describe('Prefer xcodebuild over faster alternatives'),
40 | platform: z
41 | .enum(['iOS', 'watchOS', 'tvOS', 'visionOS'])
42 | .optional()
43 | .describe('Target platform (defaults to iOS)'),
44 | testRunnerEnv: z
45 | .record(z.string(), z.string())
46 | .optional()
47 | .describe(
48 | 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)',
49 | ),
50 | });
51 |
52 | const testDeviceSchema = z.preprocess(
53 | nullifyEmptyStrings,
54 | baseSchemaObject
55 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, {
56 | message: 'Either projectPath or workspacePath is required.',
57 | })
58 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), {
59 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.',
60 | }),
61 | );
62 |
63 | export type TestDeviceParams = z.infer<typeof testDeviceSchema>;
64 |
65 | const publicSchemaObject = baseSchemaObject.omit({
66 | projectPath: true,
67 | workspacePath: true,
68 | scheme: true,
69 | deviceId: true,
70 | configuration: true,
71 | } as const);
72 |
73 | /**
74 | * Type definition for test summary structure from xcresulttool
75 | * (JavaScript implementation - no actual interface, this is just documentation)
76 | */
77 |
78 | /**
79 | * Parse xcresult bundle using xcrun xcresulttool
80 | */
81 | async function parseXcresultBundle(
82 | resultBundlePath: string,
83 | executor: CommandExecutor = getDefaultCommandExecutor(),
84 | ): Promise<string> {
85 | try {
86 | // Use injected executor for testing
87 | const result = await executor(
88 | ['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundlePath],
89 | 'Parse xcresult bundle',
90 | );
91 | if (!result.success) {
92 | throw new Error(result.error ?? 'Failed to execute xcresulttool');
93 | }
94 | if (!result.output || result.output.trim().length === 0) {
95 | throw new Error('xcresulttool returned no output');
96 | }
97 |
98 | // Parse JSON response and format as human-readable
99 | const summaryData = JSON.parse(result.output) as Record<string, unknown>;
100 | return formatTestSummary(summaryData);
101 | } catch (error) {
102 | const errorMessage = error instanceof Error ? error.message : String(error);
103 | log('error', `Error parsing xcresult bundle: ${errorMessage}`);
104 | throw error;
105 | }
106 | }
107 |
108 | /**
109 | * Format test summary JSON into human-readable text
110 | */
111 | function formatTestSummary(summary: Record<string, unknown>): string {
112 | const lines = [];
113 |
114 | lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`);
115 | lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`);
116 | lines.push('');
117 |
118 | lines.push('Test Counts:');
119 | lines.push(` Total: ${summary.totalTestCount ?? 0}`);
120 | lines.push(` Passed: ${summary.passedTests ?? 0}`);
121 | lines.push(` Failed: ${summary.failedTests ?? 0}`);
122 | lines.push(` Skipped: ${summary.skippedTests ?? 0}`);
123 | lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`);
124 | lines.push('');
125 |
126 | if (summary.environmentDescription) {
127 | lines.push(`Environment: ${summary.environmentDescription}`);
128 | lines.push('');
129 | }
130 |
131 | if (
132 | summary.devicesAndConfigurations &&
133 | Array.isArray(summary.devicesAndConfigurations) &&
134 | summary.devicesAndConfigurations.length > 0
135 | ) {
136 | const deviceConfig = summary.devicesAndConfigurations[0] as Record<string, unknown>;
137 | const device = deviceConfig.device as Record<string, unknown> | undefined;
138 | if (device) {
139 | lines.push(
140 | `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`,
141 | );
142 | lines.push('');
143 | }
144 | }
145 |
146 | if (
147 | summary.testFailures &&
148 | Array.isArray(summary.testFailures) &&
149 | summary.testFailures.length > 0
150 | ) {
151 | lines.push('Test Failures:');
152 | summary.testFailures.forEach((failureItem, index) => {
153 | const failure = failureItem as Record<string, unknown>;
154 | lines.push(
155 | ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`,
156 | );
157 | if (failure.failureText) {
158 | lines.push(` ${failure.failureText}`);
159 | }
160 | });
161 | lines.push('');
162 | }
163 |
164 | if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) {
165 | lines.push('Insights:');
166 | summary.topInsights.forEach((insightItem, index) => {
167 | const insight = insightItem as Record<string, unknown>;
168 | lines.push(
169 | ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`,
170 | );
171 | });
172 | }
173 |
174 | return lines.join('\n');
175 | }
176 |
177 | /**
178 | * Business logic for running tests with platform-specific handling.
179 | * Exported for direct testing and reuse.
180 | */
181 | export async function testDeviceLogic(
182 | params: TestDeviceParams,
183 | executor: CommandExecutor = getDefaultCommandExecutor(),
184 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
185 | ): Promise<ToolResponse> {
186 | log(
187 | 'info',
188 | `Starting test run for scheme ${params.scheme} on platform ${params.platform ?? 'iOS'} (internal)`,
189 | );
190 |
191 | let tempDir: string | undefined;
192 | const cleanup = async (): Promise<void> => {
193 | if (!tempDir) return;
194 | try {
195 | await fileSystemExecutor.rm(tempDir, { recursive: true, force: true });
196 | } catch (cleanupError) {
197 | log('warn', `Failed to clean up temporary directory: ${cleanupError}`);
198 | }
199 | };
200 |
201 | try {
202 | // Create temporary directory for xcresult bundle
203 | tempDir = await fileSystemExecutor.mkdtemp(
204 | join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'),
205 | );
206 | const resultBundlePath = join(tempDir, 'TestResults.xcresult');
207 |
208 | // Add resultBundlePath to extraArgs
209 | const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath];
210 |
211 | // Prepare execution options with TEST_RUNNER_ environment variables
212 | const execOpts: CommandExecOptions | undefined = params.testRunnerEnv
213 | ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) }
214 | : undefined;
215 |
216 | // Run the test command
217 | const testResult = await executeXcodeBuildCommand(
218 | {
219 | projectPath: params.projectPath,
220 | workspacePath: params.workspacePath,
221 | scheme: params.scheme,
222 | configuration: params.configuration ?? 'Debug',
223 | derivedDataPath: params.derivedDataPath,
224 | extraArgs,
225 | },
226 | {
227 | platform: (params.platform as XcodePlatform) || XcodePlatform.iOS,
228 | simulatorName: undefined,
229 | simulatorId: undefined,
230 | deviceId: params.deviceId,
231 | useLatestOS: false,
232 | logPrefix: 'Test Run',
233 | },
234 | params.preferXcodebuild,
235 | 'test',
236 | executor,
237 | execOpts,
238 | );
239 |
240 | // Parse xcresult bundle if it exists, regardless of whether tests passed or failed
241 | // Test failures are expected and should not prevent xcresult parsing
242 | try {
243 | log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`);
244 |
245 | // Check if the file exists
246 | try {
247 | await fileSystemExecutor.stat(resultBundlePath);
248 | log('info', `xcresult bundle exists at: ${resultBundlePath}`);
249 | } catch {
250 | log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`);
251 | throw new Error(`xcresult bundle not found at ${resultBundlePath}`);
252 | }
253 |
254 | const testSummary = await parseXcresultBundle(resultBundlePath, executor);
255 | log('info', 'Successfully parsed xcresult bundle');
256 |
257 | // Clean up temporary directory
258 | await cleanup();
259 |
260 | // Return combined result - preserve isError from testResult (test failures should be marked as errors)
261 | return {
262 | content: [
263 | ...(testResult.content || []),
264 | {
265 | type: 'text',
266 | text: '\nTest Results Summary:\n' + testSummary,
267 | },
268 | ],
269 | isError: testResult.isError,
270 | };
271 | } catch (parseError) {
272 | // If parsing fails, return original test result
273 | log('warn', `Failed to parse xcresult bundle: ${parseError}`);
274 |
275 | await cleanup();
276 |
277 | return testResult;
278 | }
279 | } catch (error) {
280 | const errorMessage = error instanceof Error ? error.message : String(error);
281 | log('error', `Error during test run: ${errorMessage}`);
282 | return createTextResponse(`Error during test run: ${errorMessage}`, true);
283 | } finally {
284 | await cleanup();
285 | }
286 | }
287 |
288 | export default {
289 | name: 'test_device',
290 | description: 'Runs tests on a physical Apple device.',
291 | schema: getSessionAwareToolSchemaShape({
292 | sessionAware: publicSchemaObject,
293 | legacy: baseSchemaObject,
294 | }),
295 | annotations: {
296 | title: 'Test Device',
297 | destructiveHint: true,
298 | },
299 | handler: createSessionAwareTool<TestDeviceParams>({
300 | internalSchema: testDeviceSchema as unknown as z.ZodType<TestDeviceParams, unknown>,
301 | logicFunction: (params: TestDeviceParams, executor: CommandExecutor) =>
302 | testDeviceLogic(
303 | {
304 | ...params,
305 | platform: params.platform ?? 'iOS',
306 | },
307 | executor,
308 | getDefaultFileSystemExecutor(),
309 | ),
310 | getExecutor: getDefaultCommandExecutor,
311 | requirements: [
312 | { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' },
313 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
314 | ],
315 | exclusivePairs: [['projectPath', 'workspacePath']],
316 | }),
317 | };
318 |
```