This is page 9 of 12. Use http://codebase.md/cameroncooke/xcodebuildmcp?page={x} to view the full context.
# Directory Structure
```
├── .axe-version
├── .claude
│ └── agents
│ └── xcodebuild-mcp-qa-tester.md
├── .cursor
│ ├── BUGBOT.md
│ └── environment.json
├── .cursorrules
├── .github
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ └── feature_request.yml
│ └── workflows
│ ├── ci.yml
│ ├── README.md
│ ├── release.yml
│ ├── sentry.yml
│ └── stale.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ ├── mcp.json
│ ├── settings.json
│ └── tasks.json
├── AGENTS.md
├── banner.png
├── build-plugins
│ ├── plugin-discovery.js
│ ├── plugin-discovery.ts
│ └── tsconfig.json
├── CHANGELOG.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── docs
│ ├── CONFIGURATION.md
│ ├── DAP_BACKEND_IMPLEMENTATION_PLAN.md
│ ├── DEBUGGING_ARCHITECTURE.md
│ ├── DEMOS.md
│ ├── dev
│ │ ├── ARCHITECTURE.md
│ │ ├── CODE_QUALITY.md
│ │ ├── CONTRIBUTING.md
│ │ ├── ESLINT_TYPE_SAFETY.md
│ │ ├── MANUAL_TESTING.md
│ │ ├── NODEJS_2025.md
│ │ ├── PLUGIN_DEVELOPMENT.md
│ │ ├── README.md
│ │ ├── RELEASE_PROCESS.md
│ │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md
│ │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
│ │ ├── RELOADEROO.md
│ │ ├── session_management_plan.md
│ │ ├── session-aware-migration-todo.md
│ │ ├── SMITHERY.md
│ │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
│ │ ├── TESTING.md
│ │ └── ZOD_MIGRATION_GUIDE.md
│ ├── DEVICE_CODE_SIGNING.md
│ ├── GETTING_STARTED.md
│ ├── investigations
│ │ ├── issue-154-screenshot-downscaling.md
│ │ ├── issue-163.md
│ │ ├── issue-debugger-attach-stopped.md
│ │ └── issue-describe-ui-empty-after-debugger-resume.md
│ ├── OVERVIEW.md
│ ├── PRIVACY.md
│ ├── README.md
│ ├── SESSION_DEFAULTS.md
│ ├── TOOLS.md
│ └── TROUBLESHOOTING.md
├── eslint.config.js
├── example_projects
│ ├── .vscode
│ │ └── launch.json
│ ├── iOS
│ │ ├── .cursor
│ │ │ └── rules
│ │ │ └── errors.mdc
│ │ ├── .vscode
│ │ │ └── settings.json
│ │ ├── Makefile
│ │ ├── MCPTest
│ │ │ ├── Assets.xcassets
│ │ │ │ ├── AccentColor.colorset
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── ContentView.swift
│ │ │ ├── MCPTestApp.swift
│ │ │ └── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ │ ├── MCPTest.xcodeproj
│ │ │ ├── project.pbxproj
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── MCPTest.xcscheme
│ │ └── MCPTestUITests
│ │ └── MCPTestUITests.swift
│ ├── iOS_Calculator
│ │ ├── .gitignore
│ │ ├── CalculatorApp
│ │ │ ├── Assets.xcassets
│ │ │ │ ├── AccentColor.colorset
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── CalculatorApp.swift
│ │ │ └── CalculatorApp.xctestplan
│ │ ├── CalculatorApp.xcodeproj
│ │ │ ├── project.pbxproj
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── CalculatorApp.xcscheme
│ │ ├── CalculatorApp.xcworkspace
│ │ │ └── contents.xcworkspacedata
│ │ ├── CalculatorAppPackage
│ │ │ ├── .gitignore
│ │ │ ├── Package.swift
│ │ │ ├── Sources
│ │ │ │ └── CalculatorAppFeature
│ │ │ │ ├── BackgroundEffect.swift
│ │ │ │ ├── CalculatorButton.swift
│ │ │ │ ├── CalculatorDisplay.swift
│ │ │ │ ├── CalculatorInputHandler.swift
│ │ │ │ ├── CalculatorService.swift
│ │ │ │ └── ContentView.swift
│ │ │ └── Tests
│ │ │ └── CalculatorAppFeatureTests
│ │ │ └── CalculatorServiceTests.swift
│ │ ├── CalculatorAppTests
│ │ │ └── CalculatorAppTests.swift
│ │ └── Config
│ │ ├── Debug.xcconfig
│ │ ├── Release.xcconfig
│ │ ├── Shared.xcconfig
│ │ └── Tests.xcconfig
│ ├── macOS
│ │ ├── MCPTest
│ │ │ ├── Assets.xcassets
│ │ │ │ ├── AccentColor.colorset
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── ContentView.swift
│ │ │ ├── MCPTest.entitlements
│ │ │ ├── MCPTestApp.swift
│ │ │ └── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ │ ├── MCPTest.xcodeproj
│ │ │ ├── project.pbxproj
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── MCPTest.xcscheme
│ │ └── MCPTestTests
│ │ └── MCPTestTests.swift
│ └── spm
│ ├── .gitignore
│ ├── Package.resolved
│ ├── Package.swift
│ ├── Sources
│ │ ├── long-server
│ │ │ └── main.swift
│ │ ├── quick-task
│ │ │ └── main.swift
│ │ ├── spm
│ │ │ └── main.swift
│ │ └── TestLib
│ │ └── TaskManager.swift
│ └── Tests
│ └── TestLibTests
│ └── SimpleTests.swift
├── LICENSE
├── mcp-install-dark.png
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── analysis
│ │ └── tools-analysis.ts
│ ├── bundle-axe.sh
│ ├── check-code-patterns.js
│ ├── generate-loaders.ts
│ ├── generate-version.ts
│ ├── release.sh
│ ├── tools-cli.ts
│ ├── update-tools-docs.ts
│ └── verify-smithery-bundle.sh
├── server.json
├── smithery.config.js
├── smithery.yaml
├── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── resources.test.ts
│ │ ├── generated-plugins.ts
│ │ ├── generated-resources.ts
│ │ ├── plugin-registry.ts
│ │ ├── plugin-types.ts
│ │ └── resources.ts
│ ├── doctor-cli.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── resources
│ │ │ ├── __tests__
│ │ │ │ ├── devices.test.ts
│ │ │ │ ├── doctor.test.ts
│ │ │ │ ├── session-status.test.ts
│ │ │ │ └── simulators.test.ts
│ │ │ ├── devices.ts
│ │ │ ├── doctor.ts
│ │ │ ├── session-status.ts
│ │ │ └── simulators.ts
│ │ └── tools
│ │ ├── debugging
│ │ │ ├── debug_attach_sim.ts
│ │ │ ├── debug_breakpoint_add.ts
│ │ │ ├── debug_breakpoint_remove.ts
│ │ │ ├── debug_continue.ts
│ │ │ ├── debug_detach.ts
│ │ │ ├── debug_lldb_command.ts
│ │ │ ├── debug_stack.ts
│ │ │ ├── debug_variables.ts
│ │ │ └── index.ts
│ │ ├── device
│ │ │ ├── __tests__
│ │ │ │ ├── build_device.test.ts
│ │ │ │ ├── get_device_app_path.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── install_app_device.test.ts
│ │ │ │ ├── launch_app_device.test.ts
│ │ │ │ ├── list_devices.test.ts
│ │ │ │ ├── re-exports.test.ts
│ │ │ │ ├── stop_app_device.test.ts
│ │ │ │ └── test_device.test.ts
│ │ │ ├── build_device.ts
│ │ │ ├── clean.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_app_bundle_id.ts
│ │ │ ├── get_device_app_path.ts
│ │ │ ├── index.ts
│ │ │ ├── install_app_device.ts
│ │ │ ├── launch_app_device.ts
│ │ │ ├── list_devices.ts
│ │ │ ├── list_schemes.ts
│ │ │ ├── show_build_settings.ts
│ │ │ ├── start_device_log_cap.ts
│ │ │ ├── stop_app_device.ts
│ │ │ ├── stop_device_log_cap.ts
│ │ │ └── test_device.ts
│ │ ├── doctor
│ │ │ ├── __tests__
│ │ │ │ ├── doctor.test.ts
│ │ │ │ └── index.test.ts
│ │ │ ├── doctor.ts
│ │ │ ├── index.ts
│ │ │ └── lib
│ │ │ └── doctor.deps.ts
│ │ ├── logging
│ │ │ ├── __tests__
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── start_device_log_cap.test.ts
│ │ │ │ ├── start_sim_log_cap.test.ts
│ │ │ │ ├── stop_device_log_cap.test.ts
│ │ │ │ └── stop_sim_log_cap.test.ts
│ │ │ ├── index.ts
│ │ │ ├── start_device_log_cap.ts
│ │ │ ├── start_sim_log_cap.ts
│ │ │ ├── stop_device_log_cap.ts
│ │ │ └── stop_sim_log_cap.ts
│ │ ├── macos
│ │ │ ├── __tests__
│ │ │ │ ├── build_macos.test.ts
│ │ │ │ ├── build_run_macos.test.ts
│ │ │ │ ├── get_mac_app_path.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── launch_mac_app.test.ts
│ │ │ │ ├── re-exports.test.ts
│ │ │ │ ├── stop_mac_app.test.ts
│ │ │ │ └── test_macos.test.ts
│ │ │ ├── build_macos.ts
│ │ │ ├── build_run_macos.ts
│ │ │ ├── clean.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_mac_app_path.ts
│ │ │ ├── get_mac_bundle_id.ts
│ │ │ ├── index.ts
│ │ │ ├── launch_mac_app.ts
│ │ │ ├── list_schemes.ts
│ │ │ ├── show_build_settings.ts
│ │ │ ├── stop_mac_app.ts
│ │ │ └── test_macos.ts
│ │ ├── project-discovery
│ │ │ ├── __tests__
│ │ │ │ ├── discover_projs.test.ts
│ │ │ │ ├── get_app_bundle_id.test.ts
│ │ │ │ ├── get_mac_bundle_id.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── list_schemes.test.ts
│ │ │ │ └── show_build_settings.test.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_app_bundle_id.ts
│ │ │ ├── get_mac_bundle_id.ts
│ │ │ ├── index.ts
│ │ │ ├── list_schemes.ts
│ │ │ └── show_build_settings.ts
│ │ ├── project-scaffolding
│ │ │ ├── __tests__
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── scaffold_ios_project.test.ts
│ │ │ │ └── scaffold_macos_project.test.ts
│ │ │ ├── index.ts
│ │ │ ├── scaffold_ios_project.ts
│ │ │ └── scaffold_macos_project.ts
│ │ ├── session-management
│ │ │ ├── __tests__
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── session_clear_defaults.test.ts
│ │ │ │ ├── session_set_defaults.test.ts
│ │ │ │ └── session_show_defaults.test.ts
│ │ │ ├── index.ts
│ │ │ ├── session_clear_defaults.ts
│ │ │ ├── session_set_defaults.ts
│ │ │ └── session_show_defaults.ts
│ │ ├── simulator
│ │ │ ├── __tests__
│ │ │ │ ├── boot_sim.test.ts
│ │ │ │ ├── build_run_sim.test.ts
│ │ │ │ ├── build_sim.test.ts
│ │ │ │ ├── get_sim_app_path.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── install_app_sim.test.ts
│ │ │ │ ├── launch_app_logs_sim.test.ts
│ │ │ │ ├── launch_app_sim.test.ts
│ │ │ │ ├── list_sims.test.ts
│ │ │ │ ├── open_sim.test.ts
│ │ │ │ ├── record_sim_video.test.ts
│ │ │ │ ├── screenshot.test.ts
│ │ │ │ ├── stop_app_sim.test.ts
│ │ │ │ └── test_sim.test.ts
│ │ │ ├── boot_sim.ts
│ │ │ ├── build_run_sim.ts
│ │ │ ├── build_sim.ts
│ │ │ ├── clean.ts
│ │ │ ├── describe_ui.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_app_bundle_id.ts
│ │ │ ├── get_sim_app_path.ts
│ │ │ ├── index.ts
│ │ │ ├── install_app_sim.ts
│ │ │ ├── launch_app_logs_sim.ts
│ │ │ ├── launch_app_sim.ts
│ │ │ ├── list_schemes.ts
│ │ │ ├── list_sims.ts
│ │ │ ├── open_sim.ts
│ │ │ ├── record_sim_video.ts
│ │ │ ├── screenshot.ts
│ │ │ ├── show_build_settings.ts
│ │ │ ├── stop_app_sim.ts
│ │ │ └── test_sim.ts
│ │ ├── simulator-management
│ │ │ ├── __tests__
│ │ │ │ ├── erase_sims.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── reset_sim_location.test.ts
│ │ │ │ ├── set_sim_appearance.test.ts
│ │ │ │ ├── set_sim_location.test.ts
│ │ │ │ └── sim_statusbar.test.ts
│ │ │ ├── boot_sim.ts
│ │ │ ├── erase_sims.ts
│ │ │ ├── index.ts
│ │ │ ├── list_sims.ts
│ │ │ ├── open_sim.ts
│ │ │ ├── reset_sim_location.ts
│ │ │ ├── set_sim_appearance.ts
│ │ │ ├── set_sim_location.ts
│ │ │ └── sim_statusbar.ts
│ │ ├── swift-package
│ │ │ ├── __tests__
│ │ │ │ ├── active-processes.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── swift_package_build.test.ts
│ │ │ │ ├── swift_package_clean.test.ts
│ │ │ │ ├── swift_package_list.test.ts
│ │ │ │ ├── swift_package_run.test.ts
│ │ │ │ ├── swift_package_stop.test.ts
│ │ │ │ └── swift_package_test.test.ts
│ │ │ ├── active-processes.ts
│ │ │ ├── index.ts
│ │ │ ├── swift_package_build.ts
│ │ │ ├── swift_package_clean.ts
│ │ │ ├── swift_package_list.ts
│ │ │ ├── swift_package_run.ts
│ │ │ ├── swift_package_stop.ts
│ │ │ └── swift_package_test.ts
│ │ ├── ui-testing
│ │ │ ├── __tests__
│ │ │ │ ├── button.test.ts
│ │ │ │ ├── describe_ui.test.ts
│ │ │ │ ├── gesture.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── key_press.test.ts
│ │ │ │ ├── key_sequence.test.ts
│ │ │ │ ├── long_press.test.ts
│ │ │ │ ├── screenshot.test.ts
│ │ │ │ ├── swipe.test.ts
│ │ │ │ ├── tap.test.ts
│ │ │ │ ├── touch.test.ts
│ │ │ │ └── type_text.test.ts
│ │ │ ├── button.ts
│ │ │ ├── describe_ui.ts
│ │ │ ├── gesture.ts
│ │ │ ├── index.ts
│ │ │ ├── key_press.ts
│ │ │ ├── key_sequence.ts
│ │ │ ├── long_press.ts
│ │ │ ├── screenshot.ts
│ │ │ ├── swipe.ts
│ │ │ ├── tap.ts
│ │ │ ├── touch.ts
│ │ │ └── type_text.ts
│ │ └── utilities
│ │ ├── __tests__
│ │ │ ├── clean.test.ts
│ │ │ └── index.test.ts
│ │ ├── clean.ts
│ │ └── index.ts
│ ├── server
│ │ ├── bootstrap.ts
│ │ └── server.ts
│ ├── smithery.ts
│ ├── test-utils
│ │ └── mock-executors.ts
│ ├── types
│ │ └── common.ts
│ ├── utils
│ │ ├── __tests__
│ │ │ ├── build-utils-suppress-warnings.test.ts
│ │ │ ├── build-utils.test.ts
│ │ │ ├── debugger-simctl.test.ts
│ │ │ ├── environment.test.ts
│ │ │ ├── session-aware-tool-factory.test.ts
│ │ │ ├── session-store.test.ts
│ │ │ ├── simulator-utils.test.ts
│ │ │ ├── test-runner-env-integration.test.ts
│ │ │ ├── typed-tool-factory.test.ts
│ │ │ └── workflow-selection.test.ts
│ │ ├── axe
│ │ │ └── index.ts
│ │ ├── axe-helpers.ts
│ │ ├── build
│ │ │ └── index.ts
│ │ ├── build-utils.ts
│ │ ├── capabilities.ts
│ │ ├── command.ts
│ │ ├── CommandExecutor.ts
│ │ ├── debugger
│ │ │ ├── __tests__
│ │ │ │ └── debugger-manager-dap.test.ts
│ │ │ ├── backends
│ │ │ │ ├── __tests__
│ │ │ │ │ └── dap-backend.test.ts
│ │ │ │ ├── dap-backend.ts
│ │ │ │ ├── DebuggerBackend.ts
│ │ │ │ └── lldb-cli-backend.ts
│ │ │ ├── dap
│ │ │ │ ├── __tests__
│ │ │ │ │ └── transport-framing.test.ts
│ │ │ │ ├── adapter-discovery.ts
│ │ │ │ ├── transport.ts
│ │ │ │ └── types.ts
│ │ │ ├── debugger-manager.ts
│ │ │ ├── index.ts
│ │ │ ├── simctl.ts
│ │ │ ├── tool-context.ts
│ │ │ ├── types.ts
│ │ │ └── ui-automation-guard.ts
│ │ ├── environment.ts
│ │ ├── errors.ts
│ │ ├── execution
│ │ │ ├── index.ts
│ │ │ └── interactive-process.ts
│ │ ├── FileSystemExecutor.ts
│ │ ├── log_capture.ts
│ │ ├── log-capture
│ │ │ ├── device-log-sessions.ts
│ │ │ └── index.ts
│ │ ├── logger.ts
│ │ ├── logging
│ │ │ └── index.ts
│ │ ├── plugin-registry
│ │ │ └── index.ts
│ │ ├── responses
│ │ │ └── index.ts
│ │ ├── runtime-registry.ts
│ │ ├── schema-helpers.ts
│ │ ├── sentry.ts
│ │ ├── session-status.ts
│ │ ├── session-store.ts
│ │ ├── simulator-utils.ts
│ │ ├── template
│ │ │ └── index.ts
│ │ ├── template-manager.ts
│ │ ├── test
│ │ │ └── index.ts
│ │ ├── test-common.ts
│ │ ├── tool-registry.ts
│ │ ├── typed-tool-factory.ts
│ │ ├── validation
│ │ │ └── index.ts
│ │ ├── validation.ts
│ │ ├── version
│ │ │ └── index.ts
│ │ ├── video_capture.ts
│ │ ├── video-capture
│ │ │ └── index.ts
│ │ ├── workflow-selection.ts
│ │ ├── xcode.ts
│ │ ├── xcodemake
│ │ │ └── index.ts
│ │ └── xcodemake.ts
│ └── version.ts
├── tsconfig.json
├── tsconfig.test.json
├── tsconfig.tests.json
├── tsup.config.ts
├── vitest.config.ts
└── XcodeBuildMCP.code-workspace
```
# Files
--------------------------------------------------------------------------------
/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for get_mac_app_path plugin (unified project/workspace)
* Following CLAUDE.md testing standards with literal validation
* Using dependency injection for deterministic testing
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockCommandResponse,
createMockExecutor,
type CommandExecutor,
} from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import getMacAppPath, { get_mac_app_pathLogic } from '../get_mac_app_path.ts';
describe('get_mac_app_path plugin', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(getMacAppPath.name).toBe('get_mac_app_path');
});
it('should have correct description', () => {
expect(getMacAppPath.description).toBe('Retrieves the built macOS app bundle path.');
});
it('should have handler function', () => {
expect(typeof getMacAppPath.handler).toBe('function');
});
it('should validate schema correctly', () => {
const schema = z.object(getMacAppPath.schema);
expect(schema.safeParse({}).success).toBe(true);
expect(
schema.safeParse({
derivedDataPath: '/path/to/derived',
extraArgs: ['--verbose'],
}).success,
).toBe(true);
expect(schema.safeParse({ derivedDataPath: 7 }).success).toBe(false);
expect(schema.safeParse({ extraArgs: ['--bad', 1] }).success).toBe(false);
const schemaKeys = Object.keys(getMacAppPath.schema).sort();
expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs'].sort());
});
});
describe('Handler Requirements', () => {
it('should require scheme before running', async () => {
const result = await getMacAppPath.handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('scheme is required');
});
it('should require project or workspace when scheme default exists', async () => {
sessionStore.setDefaults({ scheme: 'MyScheme' });
const result = await getMacAppPath.handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a project or workspace');
});
it('should reject when both projectPath and workspacePath provided explicitly', async () => {
sessionStore.setDefaults({ scheme: 'MyScheme' });
const result = await getMacAppPath.handler({
projectPath: '/path/to/project.xcodeproj',
workspacePath: '/path/to/workspace.xcworkspace',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
});
});
describe('XOR Validation', () => {
it('should error when neither projectPath nor workspacePath provided', async () => {
const result = await getMacAppPath.handler({
scheme: 'MyScheme',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a project or workspace');
});
it('should error when both projectPath and workspacePath provided', async () => {
const result = await getMacAppPath.handler({
projectPath: '/path/to/project.xcodeproj',
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
});
});
describe('Command Generation', () => {
it('should generate correct command with workspace minimal parameters', async () => {
// Manual call tracking for command verification
const calls: any[] = [];
const mockExecutor: CommandExecutor = async (...args) => {
calls.push(args);
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: undefined,
});
};
const args = {
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
};
await get_mac_app_pathLogic(args, mockExecutor);
// Verify command generation with manual call tracking
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
[
'xcodebuild',
'-showBuildSettings',
'-workspace',
'/path/to/MyProject.xcworkspace',
'-scheme',
'MyScheme',
'-configuration',
'Debug',
],
'Get App Path',
true,
undefined,
]);
});
it('should generate correct command with project minimal parameters', async () => {
// Manual call tracking for command verification
const calls: any[] = [];
const mockExecutor: CommandExecutor = async (...args) => {
calls.push(args);
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: undefined,
});
};
const args = {
projectPath: '/path/to/MyProject.xcodeproj',
scheme: 'MyScheme',
};
await get_mac_app_pathLogic(args, mockExecutor);
// Verify command generation with manual call tracking
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
[
'xcodebuild',
'-showBuildSettings',
'-project',
'/path/to/MyProject.xcodeproj',
'-scheme',
'MyScheme',
'-configuration',
'Debug',
],
'Get App Path',
true,
undefined,
]);
});
it('should generate correct command with workspace all parameters', async () => {
// Manual call tracking for command verification
const calls: any[] = [];
const mockExecutor: CommandExecutor = async (...args) => {
calls.push(args);
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: undefined,
});
};
const args = {
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
configuration: 'Release',
arch: 'arm64' as const,
};
await get_mac_app_pathLogic(args, mockExecutor);
// Verify command generation with manual call tracking
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
[
'xcodebuild',
'-showBuildSettings',
'-workspace',
'/path/to/MyProject.xcworkspace',
'-scheme',
'MyScheme',
'-configuration',
'Release',
'-destination',
'platform=macOS,arch=arm64',
],
'Get App Path',
true,
undefined,
]);
});
it('should generate correct command with x86_64 architecture', async () => {
// Manual call tracking for command verification
const calls: any[] = [];
const mockExecutor: CommandExecutor = async (...args) => {
calls.push(args);
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: undefined,
});
};
const args = {
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
configuration: 'Debug',
arch: 'x86_64' as const,
};
await get_mac_app_pathLogic(args, mockExecutor);
// Verify command generation with manual call tracking
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
[
'xcodebuild',
'-showBuildSettings',
'-workspace',
'/path/to/MyProject.xcworkspace',
'-scheme',
'MyScheme',
'-configuration',
'Debug',
'-destination',
'platform=macOS,arch=x86_64',
],
'Get App Path',
true,
undefined,
]);
});
it('should generate correct command with project all parameters', async () => {
// Manual call tracking for command verification
const calls: any[] = [];
const mockExecutor: CommandExecutor = async (...args) => {
calls.push(args);
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: undefined,
});
};
const args = {
projectPath: '/path/to/MyProject.xcodeproj',
scheme: 'MyScheme',
configuration: 'Release',
derivedDataPath: '/path/to/derived',
extraArgs: ['--verbose'],
};
await get_mac_app_pathLogic(args, mockExecutor);
// Verify command generation with manual call tracking
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
[
'xcodebuild',
'-showBuildSettings',
'-project',
'/path/to/MyProject.xcodeproj',
'-scheme',
'MyScheme',
'-configuration',
'Release',
'-derivedDataPath',
'/path/to/derived',
'--verbose',
],
'Get App Path',
true,
undefined,
]);
});
it('should use default configuration when not provided', async () => {
// Manual call tracking for command verification
const calls: any[] = [];
const mockExecutor: CommandExecutor = async (...args) => {
calls.push(args);
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: undefined,
});
};
const args = {
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
arch: 'arm64' as const,
};
await get_mac_app_pathLogic(args, mockExecutor);
// Verify command generation with manual call tracking
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
[
'xcodebuild',
'-showBuildSettings',
'-workspace',
'/path/to/MyProject.xcworkspace',
'-scheme',
'MyScheme',
'-configuration',
'Debug',
'-destination',
'platform=macOS,arch=arm64',
],
'Get App Path',
true,
undefined,
]);
});
});
describe('Handler Behavior (Complete Literal Returns)', () => {
it('should return Zod validation error for missing scheme', async () => {
const result = await getMacAppPath.handler({
workspacePath: '/path/to/MyProject.xcworkspace',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('scheme is required');
expect(result.content[0].text).toContain('session-set-defaults');
});
it('should return exact successful app path response with workspace', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: `
BUILT_PRODUCTS_DIR = /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug
FULL_PRODUCT_NAME = MyApp.app
`,
});
const result = await get_mac_app_pathLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
},
{
type: 'text',
text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })',
},
],
});
});
it('should return exact successful app path response with project', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: `
BUILT_PRODUCTS_DIR = /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug
FULL_PRODUCT_NAME = MyApp.app
`,
});
const result = await get_mac_app_pathLogic(
{
projectPath: '/path/to/MyProject.xcodeproj',
scheme: 'MyScheme',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
},
{
type: 'text',
text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })',
},
],
});
});
it('should return exact build settings failure response', async () => {
const mockExecutor = createMockExecutor({
success: false,
error: 'error: No such scheme',
});
const result = await get_mac_app_pathLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Failed to get macOS app path\nDetails: error: No such scheme',
},
],
isError: true,
});
});
it('should return exact missing build settings response', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'OTHER_SETTING = value',
});
const result = await get_mac_app_pathLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings',
},
],
isError: true,
});
});
it('should return exact exception handling response', async () => {
const mockExecutor = async () => {
throw new Error('Network error');
};
const result = await get_mac_app_pathLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Failed to get macOS app path\nDetails: Network error',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/__tests__/gesture.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for gesture tool plugin
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockExecutor,
createMockFileSystemExecutor,
createNoopExecutor,
mockProcess,
} from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import gesturePlugin, { gestureLogic } from '../gesture.ts';
describe('Gesture Plugin', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(gesturePlugin.name).toBe('gesture');
});
it('should have correct description', () => {
expect(gesturePlugin.description).toBe(
'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',
);
});
it('should have handler function', () => {
expect(typeof gesturePlugin.handler).toBe('function');
});
it('should expose public schema without simulatorId field', () => {
const schema = z.object(gesturePlugin.schema);
expect(schema.safeParse({ preset: 'scroll-up' }).success).toBe(true);
expect(
schema.safeParse({
preset: 'scroll-up',
screenWidth: 375,
screenHeight: 667,
duration: 1.5,
delta: 100,
preDelay: 0.5,
postDelay: 0.2,
}).success,
).toBe(true);
expect(schema.safeParse({ preset: 'invalid-preset' }).success).toBe(false);
expect(schema.safeParse({ preset: 'scroll-up', screenWidth: 0 }).success).toBe(false);
expect(schema.safeParse({ preset: 'scroll-up', duration: -1 }).success).toBe(false);
const withSimId = schema.safeParse({
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
});
expect(withSimId.success).toBe(true);
expect('simulatorId' in (withSimId.data as any)).toBe(false);
});
});
describe('Handler Requirements', () => {
it('should require simulatorId session default when not provided', async () => {
const result = await gesturePlugin.handler({ preset: 'scroll-up' });
expect(result.isError).toBe(true);
const message = result.content[0].text;
expect(message).toContain('Missing required session defaults');
expect(message).toContain('simulatorId is required');
expect(message).toContain('session-set-defaults');
});
it('should surface validation errors once simulator defaults exist', async () => {
sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' });
const result = await gesturePlugin.handler({});
expect(result.isError).toBe(true);
const message = result.content[0].text;
expect(message).toContain('Parameter validation failed');
expect(message).toContain(
'preset: Invalid option: expected one of "scroll-up"|"scroll-down"|"scroll-left"|"scroll-right"|"swipe-from-left-edge"|"swipe-from-right-edge"|"swipe-from-top-edge"|"swipe-from-bottom-edge"',
);
});
});
describe('Command Generation', () => {
it('should generate correct axe command for basic gesture', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'gesture completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'gesture',
'scroll-up',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command for gesture with screen dimensions', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'gesture completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'swipe-from-left-edge',
screenWidth: 375,
screenHeight: 667,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'gesture',
'swipe-from-left-edge',
'--screen-width',
'375',
'--screen-height',
'667',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command for gesture with all parameters', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'gesture completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-down',
screenWidth: 414,
screenHeight: 896,
duration: 2.0,
delta: 150,
preDelay: 0.5,
postDelay: 0.3,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'gesture',
'scroll-down',
'--screen-width',
'414',
'--screen-height',
'896',
'--duration',
'2',
'--delta',
'150',
'--pre-delay',
'0.5',
'--post-delay',
'0.3',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command with different gesture presets', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'gesture completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'swipe-from-bottom-edge',
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'gesture',
'swipe-from-bottom-edge',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
});
describe('Handler Behavior (Complete Literal Returns)', () => {
// Note: Parameter validation is now handled by Zod schema validation in createTypedTool,
// so invalid parameters never reach gestureLogic. The schema validation tests above
// cover parameter validation scenarios.
it('should return success for valid gesture execution', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'gesture completed',
error: undefined,
process: mockProcess,
});
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [{ type: 'text' as const, text: "Gesture 'scroll-up' executed successfully." }],
isError: false,
});
});
it('should return success for gesture execution with all optional parameters', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'gesture completed',
error: undefined,
process: mockProcess,
});
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'swipe-from-left-edge',
screenWidth: 375,
screenHeight: 667,
duration: 1.0,
delta: 50,
preDelay: 0.1,
postDelay: 0.2,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{ type: 'text' as const, text: "Gesture 'swipe-from-left-edge' executed successfully." },
],
isError: false,
});
});
it('should handle DependencyError when axe is not available', async () => {
const mockAxeHelpers = {
getAxePath: () => null,
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
createNoopExecutor(),
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
});
});
it('should handle AxeError from failed command execution', async () => {
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'axe command failed',
process: mockProcess,
});
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: "Error: Failed to execute gesture 'scroll-up': axe command 'gesture' failed.\nDetails: axe command failed",
},
],
isError: true,
});
});
it('should handle SystemError from command execution', async () => {
const mockExecutor = createMockExecutor(new Error('ENOENT: no such file or directory'));
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
mockExecutor,
mockAxeHelpers,
);
expect(result.content[0].text).toMatch(
/^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/,
);
expect(result.isError).toBe(true);
});
it('should handle unexpected Error objects', async () => {
const mockExecutor = createMockExecutor(new Error('Unexpected error'));
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
mockExecutor,
mockAxeHelpers,
);
expect(result.content[0].text).toMatch(
/^Error: System error executing axe: Failed to execute axe command: Unexpected error/,
);
expect(result.isError).toBe(true);
});
it('should handle unexpected string errors', async () => {
const mockExecutor = createMockExecutor('String error');
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe CLI is not available.' }],
isError: true,
}),
};
const result = await gestureLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
preset: 'scroll-up',
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'Error: System error executing axe: Failed to execute axe command: String error',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/__tests__/swipe.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for swipe tool plugin
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockExecutor,
createNoopExecutor,
mockProcess,
} from '../../../../test-utils/mock-executors.ts';
import { SystemError, DependencyError } from '../../../../utils/responses/index.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
// Import the plugin module to test
import swipePlugin, { AxeHelpers, swipeLogic, SwipeParams } from '../swipe.ts';
// Helper function to create mock axe helpers
function createMockAxeHelpers(): AxeHelpers {
return {
getAxePath: () => '/mocked/axe/path',
getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
}
// Helper function to create mock axe helpers with null path (for dependency error tests)
function createMockAxeHelpersWithNullPath(): AxeHelpers {
return {
getAxePath: () => null,
getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
}
describe('Swipe Plugin', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(swipePlugin.name).toBe('swipe');
});
it('should have correct description', () => {
expect(swipePlugin.description).toBe(
"Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.",
);
});
it('should have handler function', () => {
expect(typeof swipePlugin.handler).toBe('function');
});
it('should validate schema fields with safeParse', () => {
const schema = z.object(swipePlugin.schema);
expect(
schema.safeParse({
x1: 100,
y1: 200,
x2: 300,
y2: 400,
}).success,
).toBe(true);
expect(
schema.safeParse({
x1: 100.5,
y1: 200,
x2: 300,
y2: 400,
}).success,
).toBe(false);
expect(
schema.safeParse({
x1: 100,
y1: 200,
x2: 300,
y2: 400,
duration: -1,
}).success,
).toBe(false);
expect(
schema.safeParse({
x1: 100,
y1: 200,
x2: 300,
y2: 400,
duration: 1.5,
delta: 10,
preDelay: 0.5,
postDelay: 0.2,
}).success,
).toBe(true);
const withSimId = schema.safeParse({
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
});
expect(withSimId.success).toBe(true);
expect('simulatorId' in (withSimId.data as Record<string, unknown>)).toBe(false);
});
});
describe('Command Generation', () => {
it('should generate correct axe command for basic swipe', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'swipe completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = createMockAxeHelpers();
await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/mocked/axe/path',
'swipe',
'--start-x',
'100',
'--start-y',
'200',
'--end-x',
'300',
'--end-y',
'400',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command for swipe with duration', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'swipe completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = createMockAxeHelpers();
await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 50,
y1: 75,
x2: 250,
y2: 350,
duration: 1.5,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/mocked/axe/path',
'swipe',
'--start-x',
'50',
'--start-y',
'75',
'--end-x',
'250',
'--end-y',
'350',
'--duration',
'1.5',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command for swipe with all optional parameters', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'swipe completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = createMockAxeHelpers();
await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 0,
y1: 0,
x2: 500,
y2: 800,
duration: 2.0,
delta: 10,
preDelay: 0.5,
postDelay: 0.3,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/mocked/axe/path',
'swipe',
'--start-x',
'0',
'--start-y',
'0',
'--end-x',
'500',
'--end-y',
'800',
'--duration',
'2',
'--delta',
'10',
'--pre-delay',
'0.5',
'--post-delay',
'0.3',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command with bundled axe path', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return {
success: true,
output: 'swipe completed',
error: undefined,
process: mockProcess,
};
};
const mockAxeHelpers = {
getAxePath: () => '/path/to/bundled/axe',
getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }),
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text' as const, text: 'AXe tools not available' }],
isError: true,
}),
};
await swipeLogic(
{
simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF',
x1: 150,
y1: 250,
x2: 400,
y2: 600,
delta: 5,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/path/to/bundled/axe',
'swipe',
'--start-x',
'150',
'--start-y',
'250',
'--end-x',
'400',
'--end-y',
'600',
'--delta',
'5',
'--udid',
'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF',
]);
});
});
describe('Handler Behavior (Complete Literal Returns)', () => {
it('should return error for missing simulatorId via handler', async () => {
const result = await swipePlugin.handler({ x1: 100, y1: 200, x2: 300, y2: 400 });
expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain('Missing required session defaults');
expect(result.content[0].text).toContain('simulatorId is required');
expect(result.content[0].text).toContain('session-set-defaults');
});
it('should return validation error for missing x1 once simulator default exists', async () => {
sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' });
const result = await swipePlugin.handler({
y1: 200,
x2: 300,
y2: 400,
});
expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain('Parameter validation failed');
expect(result.content[0].text).toContain(
'x1: Invalid input: expected number, received undefined',
);
});
it('should return success for valid swipe execution', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'swipe completed',
error: '',
});
const mockAxeHelpers = createMockAxeHelpers();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'Swipe from (100, 200) to (300, 400) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.',
},
],
isError: false,
});
});
it('should return success for swipe with duration', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'swipe completed',
error: '',
});
const mockAxeHelpers = createMockAxeHelpers();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
duration: 1.5,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'Swipe from (100, 200) to (300, 400) duration=1.5s simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.',
},
],
isError: false,
});
});
it('should handle DependencyError when axe is not available', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'swipe completed',
error: '',
});
const mockAxeHelpers = createMockAxeHelpersWithNullPath();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
});
});
it('should handle AxeError from failed command execution', async () => {
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'axe command failed',
});
const mockAxeHelpers = createMockAxeHelpers();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: "Error: Failed to simulate swipe: axe command 'swipe' failed.\nDetails: axe command failed",
},
],
isError: true,
});
});
it('should handle SystemError from command execution', async () => {
// Override the executor to throw SystemError for this test
const systemErrorExecutor = async () => {
throw new SystemError('System error occurred');
};
const mockAxeHelpers = createMockAxeHelpers();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
systemErrorExecutor,
mockAxeHelpers,
);
expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain(
'Error: System error executing axe: Failed to execute axe command: System error occurred',
);
expect(result.content[0].text).toContain('Details: SystemError: System error occurred');
});
it('should handle unexpected Error objects', async () => {
// Override the executor to throw an unexpected Error for this test
const unexpectedErrorExecutor = async () => {
throw new Error('Unexpected error');
};
const mockAxeHelpers = createMockAxeHelpers();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
unexpectedErrorExecutor,
mockAxeHelpers,
);
expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain(
'Error: System error executing axe: Failed to execute axe command: Unexpected error',
);
expect(result.content[0].text).toContain('Details: Error: Unexpected error');
});
it('should handle unexpected string errors', async () => {
// Override the executor to throw a string error for this test
const stringErrorExecutor = async () => {
throw 'String error';
};
const mockAxeHelpers = createMockAxeHelpers();
const result = await swipeLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
x1: 100,
y1: 200,
x2: 300,
y2: 400,
},
stringErrorExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'Error: System error executing axe: Failed to execute axe command: String error',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for start_device_log_cap plugin
* Following CLAUDE.md testing standards with pure dependency injection
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { Readable } from 'stream';
import type { ChildProcess } from 'child_process';
import * as z from 'zod';
import {
createMockExecutor,
createMockFileSystemExecutor,
} from '../../../../test-utils/mock-executors.ts';
import plugin, { start_device_log_capLogic } from '../start_device_log_cap.ts';
import { activeDeviceLogSessions } from '../../../../utils/log-capture/device-log-sessions.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type MockChildProcess = Mutable<ChildProcess> & {
stdout: Readable;
stderr: Readable;
};
describe('start_device_log_cap plugin', () => {
// Mock state tracking
let commandCalls: Array<{
command: string[];
logPrefix?: string;
useShell?: boolean;
env?: Record<string, string>;
}> = [];
let mkdirCalls: string[] = [];
let writeFileCalls: Array<{ path: string; content: string }> = [];
const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS;
beforeEach(() => {
sessionStore.clear();
activeDeviceLogSessions.clear();
process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25';
});
afterEach(() => {
if (originalJsonWaitEnv === undefined) {
delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS;
} else {
process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv;
}
});
describe('Plugin Structure', () => {
it('should export an object with required properties', () => {
expect(plugin).toHaveProperty('name');
expect(plugin).toHaveProperty('description');
expect(plugin).toHaveProperty('schema');
expect(plugin).toHaveProperty('handler');
});
it('should have correct tool name', () => {
expect(plugin.name).toBe('start_device_log_cap');
});
it('should have correct description', () => {
expect(plugin.description).toBe('Starts log capture on a connected device.');
});
it('should have correct schema structure', () => {
// Schema should be a plain object for MCP protocol compliance
expect(typeof plugin.schema).toBe('object');
expect(Object.keys(plugin.schema)).toEqual(['bundleId']);
// Validate that schema fields are Zod types that can be used for validation
const schema = z.strictObject(plugin.schema);
expect(schema.safeParse({ bundleId: 'com.test.app' }).success).toBe(true);
expect(schema.safeParse({}).success).toBe(false);
});
it('should have handler as a function', () => {
expect(typeof plugin.handler).toBe('function');
});
});
describe('Handler Requirements', () => {
it('should require deviceId when not provided', async () => {
const result = await plugin.handler({ bundleId: 'com.example.MyApp' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('deviceId is required');
});
});
describe('Handler Functionality', () => {
it('should start log capture successfully', async () => {
// Mock successful command execution
const mockExecutor = createMockExecutor({
success: true,
output: 'App launched successfully',
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async (path: string) => {
mkdirCalls.push(path);
},
writeFile: async (path: string, content: string) => {
writeFileCalls.push({ path, content });
},
});
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content[0].text).toMatch(/✅ Device log capture started successfully/);
expect(result.content[0].text).toMatch(/Session ID: [a-f0-9-]{36}/);
expect(result.isError ?? false).toBe(false);
});
it('should include next steps in success response', async () => {
// Mock successful command execution
const mockExecutor = createMockExecutor({
success: true,
output: 'App launched successfully',
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async (path: string) => {
mkdirCalls.push(path);
},
writeFile: async (path: string, content: string) => {
writeFileCalls.push({ path, content });
},
});
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content[0].text).toContain('Next Steps:');
expect(result.content[0].text).toContain('Use stop_device_log_cap');
});
it('should surface early launch failures when process exits immediately', async () => {
const failingProcess = new EventEmitter() as MockChildProcess;
const stubOutput = new Readable({
read() {},
});
const stubError = new Readable({
read() {},
});
failingProcess.stdout = stubOutput;
failingProcess.stderr = stubError;
failingProcess.exitCode = null;
failingProcess.killed = false;
failingProcess.kill = () => {
failingProcess.killed = true;
failingProcess.exitCode = 0;
failingProcess.emit('close', 0, null);
return true;
};
const mockExecutor = createMockExecutor({
success: true,
output: '',
process: failingProcess,
});
let createdLogPath = '';
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async () => {},
writeFile: async (path: string, content: string) => {
createdLogPath = path;
writeFileCalls.push({ path, content });
},
});
const resultPromise = start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.invalid.App',
},
mockExecutor,
mockFileSystemExecutor,
);
setTimeout(() => {
stubError.emit(
'data',
'ERROR: The application failed to launch. (com.apple.dt.CoreDeviceError error 10002)\nNSLocalizedRecoverySuggestion = Provide a valid bundle identifier.\n',
);
failingProcess.exitCode = 70;
failingProcess.emit('close', 70, null);
}, 10);
const result = await resultPromise;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a valid bundle identifier');
expect(activeDeviceLogSessions.size).toBe(0);
expect(createdLogPath).not.toBe('');
});
it('should surface JSON-reported failures when launch cannot start', async () => {
const jsonFailure = {
error: {
domain: 'com.apple.dt.CoreDeviceError',
code: 10002,
localizedDescription: 'The application failed to launch.',
userInfo: {
NSLocalizedRecoverySuggestion: 'Provide a valid bundle identifier.',
NSLocalizedFailureReason: 'The requested application com.invalid.App is not installed.',
BundleIdentifier: 'com.invalid.App',
},
},
};
const failingProcess = new EventEmitter() as MockChildProcess;
const stubOutput = new Readable({
read() {},
});
const stubError = new Readable({
read() {},
});
failingProcess.stdout = stubOutput;
failingProcess.stderr = stubError;
failingProcess.exitCode = null;
failingProcess.killed = false;
failingProcess.kill = () => {
failingProcess.killed = true;
return true;
};
const mockExecutor = createMockExecutor({
success: true,
output: '',
process: failingProcess,
});
let jsonPathSeen = '';
let removedJsonPath = '';
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async () => {},
writeFile: async () => {},
existsSync: (filePath: string): boolean => {
if (filePath.includes('devicectl-launch-')) {
jsonPathSeen = filePath;
return true;
}
return false;
},
readFile: async (filePath: string): Promise<string> => {
if (filePath.includes('devicectl-launch-')) {
jsonPathSeen = filePath;
return JSON.stringify(jsonFailure);
}
return '';
},
rm: async (filePath: string) => {
if (filePath.includes('devicectl-launch-')) {
removedJsonPath = filePath;
}
},
});
setTimeout(() => {
failingProcess.exitCode = 0;
failingProcess.emit('close', 0, null);
}, 5);
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.invalid.App',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a valid bundle identifier');
expect(jsonPathSeen).not.toBe('');
expect(removedJsonPath).toBe(jsonPathSeen);
expect(activeDeviceLogSessions.size).toBe(0);
expect(failingProcess.killed).toBe(true);
});
it('should treat JSON success payload as confirmation of launch', async () => {
const jsonSuccess = {
result: {
process: {
processIdentifier: 4321,
},
},
};
const runningProcess = new EventEmitter() as MockChildProcess;
const stubOutput = new Readable({
read() {},
});
const stubError = new Readable({
read() {},
});
runningProcess.stdout = stubOutput;
runningProcess.stderr = stubError;
runningProcess.exitCode = null;
runningProcess.killed = false;
runningProcess.kill = () => {
runningProcess.killed = true;
runningProcess.emit('close', 0, null);
return true;
};
const mockExecutor = createMockExecutor({
success: true,
output: '',
process: runningProcess,
});
let jsonPathSeen = '';
let removedJsonPath = '';
let jsonRemoved = false;
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async () => {},
writeFile: async () => {},
existsSync: (filePath: string): boolean => {
if (filePath.includes('devicectl-launch-')) {
jsonPathSeen = filePath;
return !jsonRemoved;
}
return false;
},
readFile: async (filePath: string): Promise<string> => {
if (filePath.includes('devicectl-launch-')) {
jsonPathSeen = filePath;
return JSON.stringify(jsonSuccess);
}
return '';
},
rm: async (filePath: string) => {
if (filePath.includes('devicectl-launch-')) {
jsonRemoved = true;
removedJsonPath = filePath;
}
},
});
setTimeout(() => {
runningProcess.emit('close', 0, null);
}, 5);
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content[0].text).toContain('Device log capture started successfully');
expect(result.isError ?? false).toBe(false);
expect(jsonPathSeen).not.toBe('');
expect(removedJsonPath).toBe(jsonPathSeen);
expect(activeDeviceLogSessions.size).toBe(1);
});
it('should handle directory creation failure', async () => {
// Mock mkdir to fail
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'Command failed',
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async (path: string) => {
mkdirCalls.push(path);
throw new Error('Permission denied');
},
});
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to start device log capture: Permission denied',
},
],
isError: true,
});
});
it('should handle file write failure', async () => {
// Mock writeFile to fail
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'Command failed',
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async (path: string) => {
mkdirCalls.push(path);
},
writeFile: async (path: string, content: string) => {
writeFileCalls.push({ path, content });
throw new Error('Disk full');
},
});
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to start device log capture: Disk full',
},
],
isError: true,
});
});
it('should handle spawn process error', async () => {
// Mock spawn to throw error
const mockExecutor = createMockExecutor(new Error('Command not found'));
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async (path: string) => {
mkdirCalls.push(path);
},
writeFile: async (path: string, content: string) => {
writeFileCalls.push({ path, content });
},
});
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to start device log capture: Command not found',
},
],
isError: true,
});
});
it('should handle string error objects', async () => {
// Mock mkdir to fail with string error
const mockExecutor = createMockExecutor('String error message');
const mockFileSystemExecutor = createMockFileSystemExecutor({
mkdir: async (path: string) => {
mkdirCalls.push(path);
},
writeFile: async (path: string, content: string) => {
writeFileCalls.push({ path, content });
},
});
const result = await start_device_log_capLogic(
{
deviceId: '00008110-001A2C3D4E5F',
bundleId: 'com.example.MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to start device log capture: String error message',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/docs/dev/RELOADEROO_XCODEBUILDMCP_PRIMER.md:
--------------------------------------------------------------------------------
```markdown
# Reloaderoo + XcodeBuildMCP: Curated CLI Primer
Use this primer to drive XcodeBuildMCP entirely through Reloaderoo—treating it like a CLI. It is designed to be included in your agent’s context to show exactly how to invoke the specific tools your project needs.
Why this file:
- XcodeBuildMCP exposes many tools. Dumping the full tool surface into the context wastes tokens.
- Instead, copy this file into your project and delete everything you don’t need. Keep only the commands relevant to your workflow (e.g., just Simulator tools).
- Your trimmed version becomes a small, project‑specific reference that tells your agent precisely which Reloaderoo tool calls to make.
How to use this primer:
1. Copy this file into your repo (e.g., docs/xcodebuildmcp_primer.md or AGENTS.md).
2. Remove all sections and commands you don’t use. Keep it minimal.
3. Replace placeholders with your real values (paths, schemes, simulator UUIDs/Names, bundle IDs, etc.).
4. Use the quiet (-q) examples to reduce noise; pipe output to jq when you only need the content.
5. Include your curated file in the agent context whenever you want it to call XcodeBuildMCP via Reloaderoo.
Conventions in the examples:
- Calls use: npx reloaderoo@latest inspect … -q -- npx xcodebuildmcp@latest
- Parameters are passed as JSON via --params.
- Resources are read with read-resource (e.g., xcodebuildmcp://simulators).
- Use jq -r '.contents[].text' to extract the textual results when needed.
Keep it small. The smaller your curated primer, the less context your agent needs—and the cheaper, faster, and more reliable your interactions will be.
## Installation
Reloaderoo is available via npm and can be used with npx for universal compatibility.
```bash
# Use npx to run reloaderoo
npx reloaderoo@latest --help
```
## Hint
Use jq to parse the output to get just the content response:
```bash
npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -q -- npx xcodebuildmcp@latest | jq -r '.contents[].text'
```
**Example Tool Calls:**
## iOS Device Development
- **`build_device`**: Builds an app for a physical device.
```bash
npx reloaderoo@latest inspect -q call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
- **`get_device_app_path`**: Gets the `.app` bundle path for a device build.
```bash
npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
- **`install_app_device`**: Installs an app on a physical device.
```bash
npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest
```
- **`launch_app_device`**: Launches an app on a physical device.
```bash
npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest
```
- **`list_devices`**: Lists connected physical devices.
```bash
npx reloaderoo@latest inspect call-tool list_devices --params '{}' -q -- npx xcodebuildmcp@latest
```
- **`stop_app_device`**: Stops an app on a physical device.
```bash
npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -q -- npx xcodebuildmcp@latest
```
- **`test_device`**: Runs tests on a physical device.
```bash
npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -q -- npx xcodebuildmcp@latest
```
## iOS Simulator Development
- **`boot_sim`**: Boots a simulator.
```bash
npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest
```
- **`build_run_sim`**: Builds and runs an app on a simulator.
```bash
npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest
```
- **`build_sim`**: Builds an app for a simulator.
```bash
npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest
```
- **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build.
```bash
npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest
```
- **`install_app_sim`**: Installs an app on a simulator.
```bash
npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest
```
- **`launch_app_logs_sim`**: Launches an app on a simulator with log capture.
```bash
npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest
```
- **`launch_app_sim`**: Launches an app on a simulator.
```bash
npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest
```
- **`list_sims`**: Lists available simulators.
```bash
npx reloaderoo@latest inspect call-tool list_sims --params '{}' -q -- npx xcodebuildmcp@latest
```
- **`open_sim`**: Opens the Simulator application.
```bash
npx reloaderoo@latest inspect call-tool open_sim --params '{}' -q -- npx xcodebuildmcp@latest
```
- **`stop_app_sim`**: Stops an app on a simulator.
```bash
npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest
```
- **`test_sim`**: Runs tests on a simulator.
```bash
npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest
```
## Log Capture & Management
- **`start_device_log_cap`**: Starts log capture for a physical device.
```bash
npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest
```
- **`start_sim_log_cap`**: Starts log capture for a simulator.
```bash
npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest
```
- **`stop_device_log_cap`**: Stops log capture for a physical device.
```bash
npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -q -- npx xcodebuildmcp@latest
```
- **`stop_sim_log_cap`**: Stops log capture for a simulator.
```bash
npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -q -- npx xcodebuildmcp@latest
```
## macOS Development
- **`build_macos`**: Builds a macOS app.
```bash
npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
- **`build_run_macos`**: Builds and runs a macOS app.
```bash
npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
- **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build.
```bash
npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
- **`launch_mac_app`**: Launches a macOS app.
```bash
npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -q -- npx xcodebuildmcp@latest
```
- **`stop_mac_app`**: Stops a macOS app.
```bash
npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -q -- npx xcodebuildmcp@latest
```
- **`test_macos`**: Runs tests for a macOS project.
```bash
npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
## Project Discovery
- **`discover_projs`**: Discovers Xcode projects and workspaces.
```bash
npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -q -- npx xcodebuildmcp@latest
```
- **`get_app_bundle_id`**: Gets an app's bundle identifier.
```bash
npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest
```
- **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier.
```bash
npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -q -- npx xcodebuildmcp@latest
```
- **`list_schemes`**: Lists schemes in a project or workspace.
```bash
npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -q -- npx xcodebuildmcp@latest
```
- **`show_build_settings`**: Shows build settings for a scheme.
```bash
npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
## Project Scaffolding
- **`scaffold_ios_project`**: Scaffolds a new iOS project.
```bash
npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -q -- npx xcodebuildmcp@latest
```
- **`scaffold_macos_project`**: Scaffolds a new macOS project.
```bash
npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -q -- npx xcodebuildmcp@latest
```
## Project Utilities
- **`clean`**: Cleans build artifacts.
```bash
# For a project
npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -q -- npx xcodebuildmcp@latest
# For a workspace
npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest
```
## Simulator Management
- **`reset_sim_location`**: Resets a simulator's location.
```bash
npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest
```
- **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode).
```bash
npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -q -- npx xcodebuildmcp@latest
```
- **`set_sim_location`**: Sets a simulator's GPS location.
```bash
npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -q -- npx xcodebuildmcp@latest
```
- **`sim_statusbar`**: Overrides a simulator's status bar.
```bash
npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -q -- npx xcodebuildmcp@latest
```
## Swift Package Manager
- **`swift_package_build`**: Builds a Swift package.
```bash
npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest
```
- **`swift_package_clean`**: Cleans a Swift package.
```bash
npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest
```
- **`swift_package_list`**: Lists running Swift package processes.
```bash
npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -q -- npx xcodebuildmcp@latest
```
- **`swift_package_run`**: Runs a Swift package executable.
```bash
npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest
```
- **`swift_package_stop`**: Stops a running Swift package process.
```bash
npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -q -- npx xcodebuildmcp@latest
```
- **`swift_package_test`**: Tests a Swift package.
```bash
npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest
```
## System Doctor
- **`doctor`**: Runs system diagnostics.
```bash
npx reloaderoo@latest inspect call-tool doctor --params '{}' -q -- npx xcodebuildmcp@latest
```
## UI Testing & Automation
- **`button`**: Simulates a hardware button press.
```bash
npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -q -- npx xcodebuildmcp@latest
```
- **`describe_ui`**: Gets the UI hierarchy of the current screen.
```bash
npx reloaderoo@latest inspect call-tool describe_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest
```
- **`gesture`**: Performs a pre-defined gesture.
```bash
npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -q -- npx xcodebuildmcp@latest
```
- **`key_press`**: Simulates a key press.
```bash
npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -q -- npx xcodebuildmcp@latest
```
- **`key_sequence`**: Simulates a sequence of key presses.
```bash
npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -q -- npx xcodebuildmcp@latest
```
- **`long_press`**: Performs a long press at coordinates.
```bash
npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -q -- npx xcodebuildmcp@latest
```
- **`screenshot`**: Takes a screenshot.
```bash
npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest
```
- **`swipe`**: Performs a swipe gesture.
```bash
npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -q -- npx xcodebuildmcp@latest
```
- **`tap`**: Performs a tap at coordinates.
```bash
npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -q -- npx xcodebuildmcp@latest
```
- **`touch`**: Simulates a touch down or up event.
```bash
npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -q -- npx xcodebuildmcp@latest
```
- **`type_text`**: Types text into the focused element.
```bash
npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -q -- npx xcodebuildmcp@latest
```
## Resources
- **Read devices resource**:
```bash
npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -q -- npx xcodebuildmcp@latest
```
- **Read simulators resource**:
```bash
npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -q -- npx xcodebuildmcp@latest
```
- **Read doctor resource**:
```bash
npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -q -- npx xcodebuildmcp@latest
```
```
--------------------------------------------------------------------------------
/src/mcp/tools/macos/__tests__/build_run_macos.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import tool, { buildRunMacOSLogic } from '../build_run_macos.ts';
describe('build_run_macos', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should export the correct name', () => {
expect(tool.name).toBe('build_run_macos');
});
it('should export the correct description', () => {
expect(tool.description).toBe('Builds and runs a macOS app.');
});
it('should export a handler function', () => {
expect(typeof tool.handler).toBe('function');
});
it('should expose only non-session fields in schema', () => {
const schema = z.object(tool.schema);
expect(schema.safeParse({}).success).toBe(true);
expect(
schema.safeParse({
derivedDataPath: '/tmp/derived',
extraArgs: ['--verbose'],
preferXcodebuild: true,
}).success,
).toBe(true);
expect(schema.safeParse({ derivedDataPath: 1 }).success).toBe(false);
expect(schema.safeParse({ extraArgs: ['--ok', 2] }).success).toBe(false);
expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);
const schemaKeys = Object.keys(tool.schema).sort();
expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort());
});
});
describe('Handler Requirements', () => {
it('should require scheme before executing', async () => {
const result = await tool.handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('scheme is required');
});
it('should require project or workspace once scheme is set', async () => {
sessionStore.setDefaults({ scheme: 'MyApp' });
const result = await tool.handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a project or workspace');
});
it('should fail when both project and workspace provided explicitly', async () => {
sessionStore.setDefaults({ scheme: 'MyApp' });
const result = await tool.handler({
projectPath: '/path/to/project.xcodeproj',
workspacePath: '/path/to/workspace.xcworkspace',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
});
});
describe('Command Generation and Response Logic', () => {
it('should successfully build and run macOS app from project', async () => {
// Track executor calls manually
let callCount = 0;
const executorCalls: any[] = [];
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
callCount++;
executorCalls.push({ command, description, logOutput, opts });
void detached;
if (callCount === 1) {
// First call for build
return Promise.resolve({
success: true,
output: 'BUILD SUCCEEDED',
error: '',
process: mockProcess,
});
} else if (callCount === 2) {
// Second call for build settings
return Promise.resolve({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: '',
process: mockProcess,
});
}
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
};
const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
const result = await buildRunMacOSLogic(args, mockExecutor);
// Verify build command was called
expect(executorCalls[0]).toEqual({
command: [
'xcodebuild',
'-project',
'/path/to/project.xcodeproj',
'-scheme',
'MyApp',
'-configuration',
'Debug',
'-skipMacroValidation',
'-destination',
'platform=macOS',
'build',
],
description: 'macOS Build',
logOutput: true,
opts: { cwd: '/path/to' },
});
// Verify build settings command was called
expect(executorCalls[1]).toEqual({
command: [
'xcodebuild',
'-showBuildSettings',
'-project',
'/path/to/project.xcodeproj',
'-scheme',
'MyApp',
'-configuration',
'Debug',
],
description: 'Get Build Settings for Launch',
logOutput: true,
opts: undefined,
});
expect(result).toEqual({
content: [
{
type: 'text',
text: '✅ macOS Build build succeeded for scheme MyApp.',
},
{
type: 'text',
text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })",
},
{
type: 'text',
text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app',
},
],
isError: false,
});
});
it('should successfully build and run macOS app from workspace', async () => {
// Track executor calls manually
let callCount = 0;
const executorCalls: any[] = [];
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
callCount++;
executorCalls.push({ command, description, logOutput, opts });
void detached;
if (callCount === 1) {
// First call for build
return Promise.resolve({
success: true,
output: 'BUILD SUCCEEDED',
error: '',
process: mockProcess,
});
} else if (callCount === 2) {
// Second call for build settings
return Promise.resolve({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: '',
process: mockProcess,
});
}
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
};
const args = {
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
const result = await buildRunMacOSLogic(args, mockExecutor);
// Verify build command was called
expect(executorCalls[0]).toEqual({
command: [
'xcodebuild',
'-workspace',
'/path/to/workspace.xcworkspace',
'-scheme',
'MyApp',
'-configuration',
'Debug',
'-skipMacroValidation',
'-destination',
'platform=macOS',
'build',
],
description: 'macOS Build',
logOutput: true,
opts: { cwd: '/path/to' },
});
// Verify build settings command was called
expect(executorCalls[1]).toEqual({
command: [
'xcodebuild',
'-showBuildSettings',
'-workspace',
'/path/to/workspace.xcworkspace',
'-scheme',
'MyApp',
'-configuration',
'Debug',
],
description: 'Get Build Settings for Launch',
logOutput: true,
opts: undefined,
});
expect(result).toEqual({
content: [
{
type: 'text',
text: '✅ macOS Build build succeeded for scheme MyApp.',
},
{
type: 'text',
text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })",
},
{
type: 'text',
text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app',
},
],
isError: false,
});
});
it('should handle build failure', async () => {
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'error: Build failed',
});
const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
const result = await buildRunMacOSLogic(args, mockExecutor);
expect(result).toEqual({
content: [
{ type: 'text', text: '❌ [stderr] error: Build failed' },
{ type: 'text', text: '❌ macOS Build build failed for scheme MyApp.' },
],
isError: true,
});
});
it('should handle build settings failure', async () => {
// Track executor calls manually
let callCount = 0;
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
callCount++;
void detached;
if (callCount === 1) {
// First call for build succeeds
return Promise.resolve({
success: true,
output: 'BUILD SUCCEEDED',
error: '',
process: mockProcess,
});
} else if (callCount === 2) {
// Second call for build settings fails
return Promise.resolve({
success: false,
output: '',
error: 'error: Failed to get settings',
process: mockProcess,
});
}
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
};
const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
const result = await buildRunMacOSLogic(args, mockExecutor);
expect(result).toEqual({
content: [
{
type: 'text',
text: '✅ macOS Build build succeeded for scheme MyApp.',
},
{
type: 'text',
text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })",
},
{
type: 'text',
text: '✅ Build succeeded, but failed to get app path to launch: error: Failed to get settings',
},
],
isError: false,
});
});
it('should handle app launch failure', async () => {
// Track executor calls manually
let callCount = 0;
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
callCount++;
void detached;
if (callCount === 1) {
// First call for build succeeds
return Promise.resolve({
success: true,
output: 'BUILD SUCCEEDED',
error: '',
process: mockProcess,
});
} else if (callCount === 2) {
// Second call for build settings succeeds
return Promise.resolve({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: '',
process: mockProcess,
});
} else if (callCount === 3) {
// Third call for open command fails
return Promise.resolve({
success: false,
output: '',
error: 'Failed to launch',
process: mockProcess,
});
}
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
};
const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
const result = await buildRunMacOSLogic(args, mockExecutor);
expect(result).toEqual({
content: [
{
type: 'text',
text: '✅ macOS Build build succeeded for scheme MyApp.',
},
{
type: 'text',
text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })",
},
{
type: 'text',
text: '✅ Build succeeded, but failed to launch app /path/to/build/MyApp.app. Error: Failed to launch',
},
],
isError: false,
});
});
it('should handle spawn error', async () => {
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
void command;
void description;
void logOutput;
void opts;
void detached;
return Promise.reject(new Error('spawn xcodebuild ENOENT'));
};
const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
const result = await buildRunMacOSLogic(args, mockExecutor);
expect(result).toEqual({
content: [
{ type: 'text', text: 'Error during macOS Build build: spawn xcodebuild ENOENT' },
],
isError: true,
});
});
it('should use default configuration when not provided', async () => {
// Track executor calls manually
let callCount = 0;
const executorCalls: any[] = [];
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
callCount++;
executorCalls.push({ command, description, logOutput, opts });
void detached;
if (callCount === 1) {
// First call for build
return Promise.resolve({
success: true,
output: 'BUILD SUCCEEDED',
error: '',
process: mockProcess,
});
} else if (callCount === 2) {
// Second call for build settings
return Promise.resolve({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: '',
process: mockProcess,
});
}
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
};
const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
};
await buildRunMacOSLogic(args, mockExecutor);
expect(executorCalls[0]).toEqual({
command: [
'xcodebuild',
'-project',
'/path/to/project.xcodeproj',
'-scheme',
'MyApp',
'-configuration',
'Debug',
'-skipMacroValidation',
'-destination',
'platform=macOS',
'build',
],
description: 'macOS Build',
logOutput: true,
opts: { cwd: '/path/to' },
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/screenshot.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for screenshot plugin
* Following CLAUDE.md testing standards with literal validation
* Using pure dependency injection for deterministic testing
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockExecutor,
createMockFileSystemExecutor,
createCommandMatchingMockExecutor,
} from '../../../../test-utils/mock-executors.ts';
import type { CommandExecutor } from '../../../../utils/execution/index.ts';
import { SystemError } from '../../../../utils/responses/index.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import screenshotPlugin, { screenshotLogic } from '../../ui-testing/screenshot.ts';
describe('screenshot plugin', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name field', () => {
expect(screenshotPlugin.name).toBe('screenshot');
});
it('should have correct description field', () => {
expect(screenshotPlugin.description).toBe(
"Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).",
);
});
it('should have handler function', () => {
expect(typeof screenshotPlugin.handler).toBe('function');
});
it('should have correct schema validation', () => {
const schema = z.object(screenshotPlugin.schema);
expect(schema.safeParse({}).success).toBe(true);
const withSimId = schema.safeParse({
simulatorId: '550e8400-e29b-41d4-a716-446655440000',
});
expect(withSimId.success).toBe(true);
expect('simulatorId' in (withSimId.data as Record<string, unknown>)).toBe(false);
});
});
describe('Command Generation', () => {
it('should generate correct simctl and sips commands', async () => {
const capturedCommands: string[][] = [];
const mockExecutor = createCommandMatchingMockExecutor({
'xcrun simctl': { success: true, output: 'Screenshot saved' },
sips: { success: true, output: 'Image optimized' },
});
// Wrap to capture both commands
const capturingExecutor = async (command: string[], ...args: any[]) => {
capturedCommands.push(command);
return mockExecutor(command, ...args);
};
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => 'fake-image-data',
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
await screenshotLogic(
{
simulatorId: 'test-uuid',
},
capturingExecutor,
mockFileSystemExecutor,
mockPathDeps,
mockUuidDeps,
);
// Should execute both commands in sequence
expect(capturedCommands).toHaveLength(2);
// First command: xcrun simctl screenshot
expect(capturedCommands[0]).toEqual([
'xcrun',
'simctl',
'io',
'test-uuid',
'screenshot',
'/tmp/screenshot_mock-uuid-123.png',
]);
// Second command: sips optimization
expect(capturedCommands[1]).toEqual([
'sips',
'-Z',
'800',
'-s',
'format',
'jpeg',
'-s',
'formatOptions',
'75',
'/tmp/screenshot_mock-uuid-123.png',
'--out',
'/tmp/screenshot_optimized_mock-uuid-123.jpg',
]);
});
it('should generate correct path with different uuid', async () => {
const capturedCommands: string[][] = [];
const mockExecutor = createCommandMatchingMockExecutor({
'xcrun simctl': { success: true, output: 'Screenshot saved' },
sips: { success: true, output: 'Image optimized' },
});
// Wrap to capture both commands
const capturingExecutor = async (command: string[], ...args: any[]) => {
capturedCommands.push(command);
return mockExecutor(command, ...args);
};
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => 'fake-image-data',
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'different-uuid-456',
};
await screenshotLogic(
{
simulatorId: 'another-uuid',
},
capturingExecutor,
mockFileSystemExecutor,
mockPathDeps,
mockUuidDeps,
);
// Should execute both commands in sequence
expect(capturedCommands).toHaveLength(2);
// First command: xcrun simctl screenshot
expect(capturedCommands[0]).toEqual([
'xcrun',
'simctl',
'io',
'another-uuid',
'screenshot',
'/tmp/screenshot_different-uuid-456.png',
]);
// Second command: sips optimization
expect(capturedCommands[1]).toEqual([
'sips',
'-Z',
'800',
'-s',
'format',
'jpeg',
'-s',
'formatOptions',
'75',
'/tmp/screenshot_different-uuid-456.png',
'--out',
'/tmp/screenshot_optimized_different-uuid-456.jpg',
]);
});
it('should use default dependencies when not provided', async () => {
const capturedCommands: string[][] = [];
const mockExecutor = createCommandMatchingMockExecutor({
'xcrun simctl': { success: true, output: 'Screenshot saved' },
sips: { success: true, output: 'Image optimized' },
});
// Wrap to capture both commands
const capturingExecutor = async (command: string[], ...args: any[]) => {
capturedCommands.push(command);
return mockExecutor(command, ...args);
};
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => 'fake-image-data',
});
await screenshotLogic(
{
simulatorId: 'test-uuid',
},
capturingExecutor,
mockFileSystemExecutor,
);
// Should execute both commands in sequence
expect(capturedCommands).toHaveLength(2);
// First command should be generated with real os.tmpdir, path.join, and uuidv4
const firstCommand = capturedCommands[0];
expect(firstCommand).toHaveLength(6);
expect(firstCommand[0]).toBe('xcrun');
expect(firstCommand[1]).toBe('simctl');
expect(firstCommand[2]).toBe('io');
expect(firstCommand[3]).toBe('test-uuid');
expect(firstCommand[4]).toBe('screenshot');
expect(firstCommand[5]).toMatch(/\/.*\/screenshot_.*\.png/);
// Second command should be sips optimization
const secondCommand = capturedCommands[1];
expect(secondCommand[0]).toBe('sips');
expect(secondCommand[1]).toBe('-Z');
expect(secondCommand[2]).toBe('800');
// Should have proper PNG input and JPG output paths
expect(secondCommand[secondCommand.length - 3]).toMatch(/\/.*\/screenshot_.*\.png/);
expect(secondCommand[secondCommand.length - 1]).toMatch(/\/.*\/screenshot_optimized_.*\.jpg/);
});
});
describe('Response Processing', () => {
it('should capture screenshot successfully', async () => {
const mockImageBuffer = Buffer.from('fake-image-data');
// Mock both commands: screenshot + optimization
const mockExecutor = createCommandMatchingMockExecutor({
'xcrun simctl': { success: true, output: 'Screenshot saved' },
sips: { success: true, output: 'Image optimized' },
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => mockImageBuffer.toString('base64'), // Return base64 directly
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
mockFileSystemExecutor,
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'image',
data: mockImageBuffer.toString('base64'),
mimeType: 'image/jpeg', // Now JPEG after optimization
},
],
isError: false,
});
});
it('should handle missing simulatorId via handler', async () => {
const result = await screenshotPlugin.handler({});
expect(result.isError).toBe(true);
const message = result.content[0].text;
expect(message).toContain('Missing required session defaults');
expect(message).toContain('simulatorId is required');
expect(message).toContain('session-set-defaults');
});
it('should handle command failure', async () => {
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'Command failed',
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
createMockFileSystemExecutor(),
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: System error executing screenshot: Failed to capture screenshot: Command failed',
},
],
isError: true,
});
});
it('should handle file read failure', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: '',
error: undefined,
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => {
throw new Error('File not found');
},
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
mockFileSystemExecutor,
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Screenshot captured but failed to process image file: File not found',
},
],
isError: true,
});
});
it('should call correct command with direct execution', async () => {
const capturedArgs: any[][] = [];
const mockExecutor = createCommandMatchingMockExecutor({
'xcrun simctl': { success: true, output: 'Screenshot saved' },
sips: { success: true, output: 'Image optimized' },
});
// Wrap to capture both command executions
const capturingExecutor: CommandExecutor = async (...args) => {
capturedArgs.push(args);
return mockExecutor(...args);
};
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => 'fake-image-data',
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
await screenshotLogic(
{
simulatorId: 'test-uuid',
},
capturingExecutor,
mockFileSystemExecutor,
mockPathDeps,
mockUuidDeps,
);
// Should capture both command executions
expect(capturedArgs).toHaveLength(2);
// First call: xcrun simctl screenshot (3 args: command, logPrefix, useShell)
expect(capturedArgs[0]).toEqual([
['xcrun', 'simctl', 'io', 'test-uuid', 'screenshot', '/tmp/screenshot_mock-uuid-123.png'],
'[Screenshot]: screenshot',
false,
]);
// Second call: sips optimization (3 args: command, logPrefix, useShell)
expect(capturedArgs[1]).toEqual([
[
'sips',
'-Z',
'800',
'-s',
'format',
'jpeg',
'-s',
'formatOptions',
'75',
'/tmp/screenshot_mock-uuid-123.png',
'--out',
'/tmp/screenshot_optimized_mock-uuid-123.jpg',
],
'[Screenshot]: optimize image',
false,
]);
});
it('should handle SystemError exceptions', async () => {
const mockExecutor = createMockExecutor(new SystemError('System error occurred'));
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
createMockFileSystemExecutor(),
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: System error executing screenshot: System error occurred',
},
],
isError: true,
});
});
it('should handle unexpected Error objects', async () => {
const mockExecutor = createMockExecutor(new Error('Unexpected error'));
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
createMockFileSystemExecutor(),
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: An unexpected error occurred: Unexpected error',
},
],
isError: true,
});
});
it('should handle unexpected string errors', async () => {
const mockExecutor = createMockExecutor('String error');
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
createMockFileSystemExecutor(),
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: An unexpected error occurred: String error',
},
],
isError: true,
});
});
it('should handle file read error with fileSystemExecutor', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: '',
error: undefined,
});
const mockFileSystemExecutor = createMockFileSystemExecutor({
readFile: async () => {
throw 'File system error';
},
});
const mockPathDeps = {
tmpdir: () => '/tmp',
join: (...paths: string[]) => paths.join('/'),
};
const mockUuidDeps = {
v4: () => 'mock-uuid-123',
};
const result = await screenshotLogic(
{
simulatorId: 'test-uuid',
},
mockExecutor,
mockFileSystemExecutor,
mockPathDeps,
mockUuidDeps,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Screenshot captured but failed to process image file: File system error',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/macos/__tests__/test_macos.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for test_macos plugin (unified project/workspace)
* Following CLAUDE.md testing standards with literal validation
* Using dependency injection for deterministic testing
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockCommandResponse,
createMockExecutor,
createMockFileSystemExecutor,
type FileSystemExecutor,
} from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import testMacos, { testMacosLogic } from '../test_macos.ts';
const createTestFileSystemExecutor = (overrides: Partial<FileSystemExecutor> = {}) =>
createMockFileSystemExecutor({
mkdtemp: async () => '/tmp/test-123',
rm: async () => {},
tmpdir: () => '/tmp',
stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
...overrides,
});
describe('test_macos plugin (unified)', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(testMacos.name).toBe('test_macos');
});
it('should have correct description', () => {
expect(testMacos.description).toBe('Runs tests for a macOS target.');
});
it('should have handler function', () => {
expect(typeof testMacos.handler).toBe('function');
});
it('should validate schema correctly', () => {
const schema = z.object(testMacos.schema);
expect(schema.safeParse({}).success).toBe(true);
expect(
schema.safeParse({
derivedDataPath: '/path/to/derived-data',
extraArgs: ['--arg1', '--arg2'],
preferXcodebuild: true,
testRunnerEnv: { FOO: 'BAR' },
}).success,
).toBe(true);
expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false);
expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false);
expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);
expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false);
const schemaKeys = Object.keys(testMacos.schema).sort();
expect(schemaKeys).toEqual(
['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(),
);
});
});
describe('Handler Requirements', () => {
it('should require scheme before running', async () => {
const result = await testMacos.handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('scheme is required');
});
it('should require project or workspace when scheme default exists', async () => {
sessionStore.setDefaults({ scheme: 'MyScheme' });
const result = await testMacos.handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a project or workspace');
});
it('should reject when both projectPath and workspacePath provided explicitly', async () => {
sessionStore.setDefaults({ scheme: 'MyScheme' });
const result = await testMacos.handler({
projectPath: '/path/to/project.xcodeproj',
workspacePath: '/path/to/workspace.xcworkspace',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
});
});
describe('XOR Parameter Validation', () => {
it('should validate that either projectPath or workspacePath is provided', async () => {
// Should return error response when neither is provided
const result = await testMacos.handler({
scheme: 'MyScheme',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a project or workspace');
});
it('should validate that both projectPath and workspacePath cannot be provided', async () => {
// Should return error response when both are provided
const result = await testMacos.handler({
projectPath: '/path/to/project.xcodeproj',
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
});
it('should allow only projectPath', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyScheme',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
it('should allow only workspacePath', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
});
describe('Handler Behavior (Complete Literal Returns)', () => {
it('should return successful test response with workspace when xcodebuild succeeds', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
configuration: 'Debug',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
it('should return successful test response with project when xcodebuild succeeds', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyScheme',
configuration: 'Debug',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
it('should use default configuration when not provided', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
it('should handle optional parameters correctly', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
configuration: 'Release',
derivedDataPath: '/custom/derived',
extraArgs: ['--verbose'],
preferXcodebuild: true,
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
it('should handle successful test execution with minimal parameters', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Suite All Tests passed',
});
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor();
const result = await testMacosLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyApp',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toBeDefined();
expect(Array.isArray(result.content)).toBe(true);
expect(result.isError).toBeUndefined();
});
it('should return exact successful test response', async () => {
// Track command execution calls
const commandCalls: any[] = [];
// Mock executor for successful test
const mockExecutor = async (
command: string[],
logPrefix?: string,
useShell?: boolean,
opts?: { env?: Record<string, string> },
detached?: boolean,
) => {
commandCalls.push({ command, logPrefix, useShell, env: opts?.env });
void detached;
// Handle xcresulttool command
if (command.includes('xcresulttool')) {
return createMockCommandResponse({
success: true,
output: JSON.stringify({
title: 'Test Results',
result: 'SUCCEEDED',
totalTestCount: 5,
passedTests: 5,
failedTests: 0,
skippedTests: 0,
expectedFailures: 0,
}),
error: undefined,
});
}
return createMockCommandResponse({
success: true,
output: 'Test Succeeded',
error: undefined,
});
};
// Mock file system dependencies using approved utility
const mockFileSystemExecutor = createTestFileSystemExecutor({
mkdtemp: async () => '/tmp/xcodebuild-test-abc123',
});
const result = await testMacosLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
mockFileSystemExecutor,
);
// Verify commands were called with correct parameters
expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool
expect(commandCalls[0].command).toEqual([
'xcodebuild',
'-workspace',
'/path/to/MyProject.xcworkspace',
'-scheme',
'MyScheme',
'-configuration',
'Debug',
'-skipMacroValidation',
'-destination',
'platform=macOS',
'-resultBundlePath',
'/tmp/xcodebuild-test-abc123/TestResults.xcresult',
'test',
]);
expect(commandCalls[0].logPrefix).toBe('Test Run');
expect(commandCalls[0].useShell).toBe(true);
// Verify xcresulttool was called
expect(commandCalls[1].command).toEqual([
'xcrun',
'xcresulttool',
'get',
'test-results',
'summary',
'--path',
'/tmp/xcodebuild-test-abc123/TestResults.xcresult',
]);
expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle');
expect(result.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: '✅ Test Run test succeeded for scheme MyScheme.',
}),
]),
);
});
it('should return exact test failure response', async () => {
// Track command execution calls
let callCount = 0;
const mockExecutor = async (
command: string[],
logPrefix?: string,
useShell?: boolean,
opts?: { env?: Record<string, string> },
detached?: boolean,
) => {
callCount++;
void logPrefix;
void useShell;
void opts;
void detached;
// First call is xcodebuild test - fails
if (callCount === 1) {
return createMockCommandResponse({
success: false,
output: '',
error: 'error: Test failed',
});
}
// Second call is xcresulttool
if (command.includes('xcresulttool')) {
return createMockCommandResponse({
success: true,
output: JSON.stringify({
title: 'Test Results',
result: 'FAILED',
totalTestCount: 5,
passedTests: 3,
failedTests: 2,
skippedTests: 0,
expectedFailures: 0,
}),
error: undefined,
});
}
return createMockCommandResponse({ success: true, output: '', error: undefined });
};
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor({
mkdtemp: async () => '/tmp/xcodebuild-test-abc123',
});
const result = await testMacosLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: '❌ Test Run test failed for scheme MyScheme.',
}),
]),
);
expect(result.isError).toBe(true);
});
it('should return exact successful test response with optional parameters', async () => {
// Track command execution calls
const commandCalls: any[] = [];
// Mock executor for successful test with optional parameters
const mockExecutor = async (
command: string[],
logPrefix?: string,
useShell?: boolean,
opts?: { env?: Record<string, string> },
detached?: boolean,
) => {
commandCalls.push({ command, logPrefix, useShell, env: opts?.env });
void detached;
// Handle xcresulttool command
if (command.includes('xcresulttool')) {
return createMockCommandResponse({
success: true,
output: JSON.stringify({
title: 'Test Results',
result: 'SUCCEEDED',
totalTestCount: 5,
passedTests: 5,
failedTests: 0,
skippedTests: 0,
expectedFailures: 0,
}),
error: undefined,
});
}
return createMockCommandResponse({
success: true,
output: 'Test Succeeded',
error: undefined,
});
};
// Mock file system dependencies
const mockFileSystemExecutor = createTestFileSystemExecutor({
mkdtemp: async () => '/tmp/xcodebuild-test-abc123',
});
const result = await testMacosLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
configuration: 'Release',
derivedDataPath: '/path/to/derived-data',
extraArgs: ['--verbose'],
preferXcodebuild: true,
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: '✅ Test Run test succeeded for scheme MyScheme.',
}),
]),
);
});
it('should return exact exception handling response', async () => {
// Mock executor (won't be called due to mkdtemp failure)
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
// Mock file system dependencies - mkdtemp fails
const mockFileSystemExecutor = createTestFileSystemExecutor({
mkdtemp: async () => {
throw new Error('Network error');
},
});
const result = await testMacosLogic(
{
workspacePath: '/path/to/MyProject.xcworkspace',
scheme: 'MyScheme',
},
mockExecutor,
mockFileSystemExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error during test run: Network error',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/list_devices.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Device Workspace Plugin: 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.
*/
import * as z from 'zod';
import type { ToolResponse } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
import type { CommandExecutor } from '../../../utils/execution/index.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Define schema as ZodObject (empty schema since this tool takes no parameters)
const listDevicesSchema = z.object({});
// Use z.infer for type safety
type ListDevicesParams = z.infer<typeof listDevicesSchema>;
/**
* Business logic for listing connected devices
*/
export async function list_devicesLogic(
params: ListDevicesParams,
executor: CommandExecutor,
pathDeps?: { tmpdir?: () => string; join?: (...paths: string[]) => string },
fsDeps?: {
readFile?: (path: string, encoding?: string) => Promise<string>;
unlink?: (path: string) => Promise<void>;
},
): Promise<ToolResponse> {
log('info', 'Starting device discovery');
try {
// Try modern devicectl with JSON output first (iOS 17+, Xcode 15+)
const tempDir = pathDeps?.tmpdir ? pathDeps.tmpdir() : tmpdir();
const timestamp = pathDeps?.join ? '123' : Date.now(); // Use fixed timestamp for tests
const tempJsonPath = pathDeps?.join
? pathDeps.join(tempDir, `devicectl-${timestamp}.json`)
: join(tempDir, `devicectl-${timestamp}.json`);
const devices = [];
let useDevicectl = false;
try {
const result = await executor(
['xcrun', 'devicectl', 'list', 'devices', '--json-output', tempJsonPath],
'List Devices (devicectl with JSON)',
true,
undefined,
);
if (result.success) {
useDevicectl = true;
// Read and parse the JSON file
const jsonContent = fsDeps?.readFile
? await fsDeps.readFile(tempJsonPath, 'utf8')
: await fs.readFile(tempJsonPath, 'utf8');
const deviceCtlData: unknown = JSON.parse(jsonContent);
// Type guard to validate the device data structure
const isValidDeviceData = (data: unknown): data is { result?: { devices?: unknown[] } } => {
return (
typeof data === 'object' &&
data !== null &&
'result' in data &&
typeof (data as { result?: unknown }).result === 'object' &&
(data as { result?: unknown }).result !== null &&
'devices' in ((data as { result?: unknown }).result as { devices?: unknown }) &&
Array.isArray(
((data as { result?: unknown }).result as { devices?: unknown[] }).devices,
)
);
};
if (isValidDeviceData(deviceCtlData) && deviceCtlData.result?.devices) {
for (const deviceRaw of deviceCtlData.result.devices) {
// Type guard for device object
const isValidDevice = (
device: unknown,
): device is {
visibilityClass?: string;
connectionProperties?: {
pairingState?: string;
tunnelState?: string;
transportType?: string;
};
deviceProperties?: {
platformIdentifier?: string;
name?: string;
osVersionNumber?: string;
developerModeStatus?: string;
marketingName?: string;
};
hardwareProperties?: {
productType?: string;
cpuType?: { name?: string };
};
identifier?: string;
} => {
if (typeof device !== 'object' || device === null) {
return false;
}
const dev = device as Record<string, unknown>;
// Check if identifier exists and is a string (most critical property)
if (typeof dev.identifier !== 'string' && dev.identifier !== undefined) {
return false;
}
// Check visibilityClass if present
if (dev.visibilityClass !== undefined && typeof dev.visibilityClass !== 'string') {
return false;
}
// Check connectionProperties structure if present
if (dev.connectionProperties !== undefined) {
if (
typeof dev.connectionProperties !== 'object' ||
dev.connectionProperties === null
) {
return false;
}
const connProps = dev.connectionProperties as Record<string, unknown>;
if (
connProps.pairingState !== undefined &&
typeof connProps.pairingState !== 'string'
) {
return false;
}
if (
connProps.tunnelState !== undefined &&
typeof connProps.tunnelState !== 'string'
) {
return false;
}
if (
connProps.transportType !== undefined &&
typeof connProps.transportType !== 'string'
) {
return false;
}
}
// Check deviceProperties structure if present
if (dev.deviceProperties !== undefined) {
if (typeof dev.deviceProperties !== 'object' || dev.deviceProperties === null) {
return false;
}
const devProps = dev.deviceProperties as Record<string, unknown>;
if (
devProps.platformIdentifier !== undefined &&
typeof devProps.platformIdentifier !== 'string'
) {
return false;
}
if (devProps.name !== undefined && typeof devProps.name !== 'string') {
return false;
}
if (
devProps.osVersionNumber !== undefined &&
typeof devProps.osVersionNumber !== 'string'
) {
return false;
}
if (
devProps.developerModeStatus !== undefined &&
typeof devProps.developerModeStatus !== 'string'
) {
return false;
}
if (
devProps.marketingName !== undefined &&
typeof devProps.marketingName !== 'string'
) {
return false;
}
}
// Check hardwareProperties structure if present
if (dev.hardwareProperties !== undefined) {
if (typeof dev.hardwareProperties !== 'object' || dev.hardwareProperties === null) {
return false;
}
const hwProps = dev.hardwareProperties as Record<string, unknown>;
if (hwProps.productType !== undefined && typeof hwProps.productType !== 'string') {
return false;
}
if (hwProps.cpuType !== undefined) {
if (typeof hwProps.cpuType !== 'object' || hwProps.cpuType === null) {
return false;
}
const cpuType = hwProps.cpuType as Record<string, unknown>;
if (cpuType.name !== undefined && typeof cpuType.name !== 'string') {
return false;
}
}
}
return true;
};
if (!isValidDevice(deviceRaw)) continue;
const device = deviceRaw;
// Skip simulators or unavailable devices
if (
device.visibilityClass === 'Simulator' ||
!device.connectionProperties?.pairingState
) {
continue;
}
// Determine platform from platformIdentifier
let platform = 'Unknown';
const platformId = device.deviceProperties?.platformIdentifier?.toLowerCase() ?? '';
if (typeof platformId === 'string') {
if (platformId.includes('ios') || platformId.includes('iphone')) {
platform = 'iOS';
} else if (platformId.includes('ipad')) {
platform = 'iPadOS';
} else if (platformId.includes('watch')) {
platform = 'watchOS';
} else if (platformId.includes('tv') || platformId.includes('apple tv')) {
platform = 'tvOS';
} else if (platformId.includes('vision')) {
platform = 'visionOS';
}
}
// Determine connection state
const pairingState = device.connectionProperties?.pairingState ?? '';
const tunnelState = device.connectionProperties?.tunnelState ?? '';
const transportType = device.connectionProperties?.transportType ?? '';
let state = 'Unknown';
// Consider a device available if it's paired, regardless of tunnel state
// This allows WiFi-connected devices to be used even if tunnelState isn't "connected"
if (pairingState === 'paired') {
if (tunnelState === 'connected') {
state = 'Available';
} else {
// Device is paired but tunnel state may be different for WiFi connections
// Still mark as available since devicectl commands can work with paired devices
state = 'Available (WiFi)';
}
} else {
state = 'Unpaired';
}
devices.push({
name: device.deviceProperties?.name ?? 'Unknown Device',
identifier: device.identifier ?? 'Unknown',
platform: platform,
model:
device.deviceProperties?.marketingName ?? device.hardwareProperties?.productType,
osVersion: device.deviceProperties?.osVersionNumber,
state: state,
connectionType: transportType,
trustState: pairingState,
developerModeStatus: device.deviceProperties?.developerModeStatus,
productType: device.hardwareProperties?.productType,
cpuArchitecture: device.hardwareProperties?.cpuType?.name,
});
}
}
}
} catch {
log('info', 'devicectl with JSON failed, trying xctrace fallback');
} finally {
// Clean up temp file
try {
if (fsDeps?.unlink) {
await fsDeps.unlink(tempJsonPath);
} else {
await fs.unlink(tempJsonPath);
}
} catch {
// Ignore cleanup errors
}
}
// If devicectl failed or returned no devices, fallback to xctrace
if (!useDevicectl || devices.length === 0) {
const result = await executor(
['xcrun', 'xctrace', 'list', 'devices'],
'List Devices (xctrace)',
true,
undefined,
);
if (!result.success) {
return {
content: [
{
type: 'text',
text: `Failed to list devices: ${result.error}\n\nMake sure Xcode is installed and devices are connected and trusted.`,
},
],
isError: true,
};
}
// Return raw xctrace output without parsing
return {
content: [
{
type: 'text',
text: `Device listing (xctrace output):\n\n${result.output}\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.`,
},
],
};
}
// Format the response
let responseText = 'Connected Devices:\n\n';
// Filter out duplicates
const uniqueDevices = devices.filter(
(device, index, self) => index === self.findIndex((d) => d.identifier === device.identifier),
);
if (uniqueDevices.length === 0) {
responseText += 'No physical Apple devices found.\n\n';
responseText += 'Make sure:\n';
responseText += '1. Devices are connected via USB or WiFi\n';
responseText += '2. Devices are unlocked and trusted\n';
responseText += '3. "Trust this computer" has been accepted on the device\n';
responseText += '4. Developer mode is enabled on the device (iOS 16+)\n';
responseText += '5. Xcode is properly installed\n\n';
responseText += 'For simulators, use the list_sims tool instead.\n';
} else {
// Group devices by availability status
const availableDevices = uniqueDevices.filter(
(d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected',
);
const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)');
const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired');
if (availableDevices.length > 0) {
responseText += '✅ Available Devices:\n';
for (const device of availableDevices) {
responseText += `\n📱 ${device.name}\n`;
responseText += ` UDID: ${device.identifier}\n`;
responseText += ` Model: ${device.model ?? 'Unknown'}\n`;
if (device.productType) {
responseText += ` Product Type: ${device.productType}\n`;
}
responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`;
if (device.cpuArchitecture) {
responseText += ` CPU Architecture: ${device.cpuArchitecture}\n`;
}
responseText += ` Connection: ${device.connectionType ?? 'Unknown'}\n`;
if (device.developerModeStatus) {
responseText += ` Developer Mode: ${device.developerModeStatus}\n`;
}
}
responseText += '\n';
}
if (pairedDevices.length > 0) {
responseText += '🔗 Paired but Not Connected:\n';
for (const device of pairedDevices) {
responseText += `\n📱 ${device.name}\n`;
responseText += ` UDID: ${device.identifier}\n`;
responseText += ` Model: ${device.model ?? 'Unknown'}\n`;
responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`;
}
responseText += '\n';
}
if (unpairedDevices.length > 0) {
responseText += '❌ Unpaired Devices:\n';
for (const device of unpairedDevices) {
responseText += `- ${device.name} (${device.identifier})\n`;
}
responseText += '\n';
}
}
// Add next steps
const availableDevicesExist = uniqueDevices.some(
(d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected',
);
if (availableDevicesExist) {
responseText += 'Next Steps:\n';
responseText +=
"1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n";
responseText += "2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n";
responseText += "3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\n";
responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n';
responseText +=
"Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n";
} else if (uniqueDevices.length > 0) {
responseText +=
'Note: No devices are currently available for testing. Make sure devices are:\n';
responseText += '- Connected via USB\n';
responseText += '- Unlocked and trusted\n';
responseText += '- Have developer mode enabled (iOS 16+)\n';
}
return {
content: [
{
type: 'text',
text: responseText,
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log('error', `Error listing devices: ${errorMessage}`);
return {
content: [
{
type: 'text',
text: `Failed to list devices: ${errorMessage}`,
},
],
isError: true,
};
}
}
export default {
name: 'list_devices',
description:
'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.',
schema: listDevicesSchema.shape, // MCP SDK compatibility
annotations: {
title: 'List Devices',
readOnlyHint: true,
},
handler: createTypedTool(listDevicesSchema, list_devicesLogic, getDefaultCommandExecutor),
};
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/__tests__/key_press.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for key_press tool plugin
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockCommandResponse,
createMockExecutor,
createMockFileSystemExecutor,
createNoopExecutor,
mockProcess,
} from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import keyPressPlugin, { key_pressLogic } from '../key_press.ts';
describe('Key Press Plugin', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(keyPressPlugin.name).toBe('key_press');
});
it('should have correct description', () => {
expect(keyPressPlugin.description).toBe(
'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.',
);
});
it('should have handler function', () => {
expect(typeof keyPressPlugin.handler).toBe('function');
});
it('should expose public schema without simulatorId field', () => {
const schema = z.object(keyPressPlugin.schema);
expect(schema.safeParse({ keyCode: 40 }).success).toBe(true);
expect(schema.safeParse({ keyCode: 40, duration: 1.5 }).success).toBe(true);
expect(schema.safeParse({ keyCode: 'invalid' }).success).toBe(false);
expect(schema.safeParse({ keyCode: -1 }).success).toBe(false);
expect(schema.safeParse({ keyCode: 256 }).success).toBe(false);
const withSimId = schema.safeParse({
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
});
expect(withSimId.success).toBe(true);
expect('simulatorId' in (withSimId.data as any)).toBe(false);
expect(schema.safeParse({}).success).toBe(false);
});
});
describe('Handler Requirements', () => {
it('should require simulatorId session default when not provided', async () => {
const result = await keyPressPlugin.handler({ keyCode: 40 });
expect(result.isError).toBe(true);
const message = result.content[0].text;
expect(message).toContain('Missing required session defaults');
expect(message).toContain('simulatorId is required');
expect(message).toContain('session-set-defaults');
});
it('should surface validation errors once simulator default exists', async () => {
sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' });
const result = await keyPressPlugin.handler({});
expect(result.isError).toBe(true);
const message = result.content[0].text;
expect(message).toContain('Parameter validation failed');
expect(message).toContain('keyCode: Invalid input: expected number, received undefined');
});
});
describe('Command Generation', () => {
it('should generate correct axe command for basic key press', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return createMockCommandResponse({
success: true,
output: 'key press completed',
error: undefined,
process: mockProcess,
});
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'key',
'40',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command for key press with duration', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return createMockCommandResponse({
success: true,
output: 'key press completed',
error: undefined,
process: mockProcess,
});
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 42,
duration: 1.5,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'key',
'42',
'--duration',
'1.5',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command for different key codes', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return createMockCommandResponse({
success: true,
output: 'key press completed',
error: undefined,
process: mockProcess,
});
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 255,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/usr/local/bin/axe',
'key',
'255',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
it('should generate correct axe command with bundled axe path', async () => {
let capturedCommand: string[] = [];
const trackingExecutor = async (command: string[]) => {
capturedCommand = command;
return createMockCommandResponse({
success: true,
output: 'key press completed',
error: undefined,
process: mockProcess,
});
};
const mockAxeHelpers = {
getAxePath: () => '/path/to/bundled/axe',
getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 44,
},
trackingExecutor,
mockAxeHelpers,
);
expect(capturedCommand).toEqual([
'/path/to/bundled/axe',
'key',
'44',
'--udid',
'12345678-1234-4234-8234-123456789012',
]);
});
});
describe('Handler Behavior (Complete Literal Returns)', () => {
// Note: Parameter validation is now handled by Zod schema validation in createTypedTool wrapper.
// The key_pressLogic function expects valid parameters and focuses on business logic testing.
it('should return success for valid key press execution', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'key press completed',
error: '',
});
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [{ type: 'text' as const, text: 'Key press (code: 40) simulated successfully.' }],
isError: false,
});
});
it('should return success for key press with duration', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'key press completed',
error: '',
});
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 42,
duration: 1.5,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [{ type: 'text' as const, text: 'Key press (code: 42) simulated successfully.' }],
isError: false,
});
});
it('should handle DependencyError when axe is not available', async () => {
const mockAxeHelpers = {
getAxePath: () => null,
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
createNoopExecutor(),
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
});
});
it('should handle AxeError from failed command execution', async () => {
const mockExecutor = createMockExecutor({
success: false,
output: '',
error: 'axe command failed',
});
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed",
},
],
isError: true,
});
});
it('should handle SystemError from command execution', async () => {
const mockExecutor = () => {
throw new Error('System error occurred');
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
mockExecutor,
mockAxeHelpers,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain(
'Error: System error executing axe: Failed to execute axe command: System error occurred',
);
});
it('should handle unexpected Error objects', async () => {
const mockExecutor = () => {
throw new Error('Unexpected error');
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
mockExecutor,
mockAxeHelpers,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain(
'Error: System error executing axe: Failed to execute axe command: Unexpected error',
);
});
it('should handle unexpected string errors', async () => {
const mockExecutor = () => {
throw 'String error';
};
const mockAxeHelpers = {
getAxePath: () => '/usr/local/bin/axe',
getBundledAxeEnvironment: () => ({}),
createAxeNotAvailableResponse: () => ({
content: [
{
type: 'text' as const,
text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
},
],
isError: true,
}),
};
const result = await key_pressLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
keyCode: 40,
},
mockExecutor,
mockAxeHelpers,
);
expect(result).toEqual({
content: [
{
type: 'text' as const,
text: 'Error: System error executing axe: Failed to execute axe command: String error',
},
],
isError: true,
});
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Utilities Plugin: Scaffold iOS Project
*
* Scaffold a new iOS project from templates.
*/
import * as z from 'zod';
import { join, dirname, basename } from 'path';
import { log } from '../../../utils/logging/index.ts';
import { ValidationError } from '../../../utils/responses/index.ts';
import { TemplateManager } from '../../../utils/template/index.ts';
import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts';
import {
getDefaultCommandExecutor,
getDefaultFileSystemExecutor,
} from '../../../utils/execution/index.ts';
import { ToolResponse } from '../../../types/common.ts';
// Common base schema for both iOS and macOS
const BaseScaffoldSchema = z.object({
projectName: z.string().min(1).describe('Name of the new project'),
outputPath: z.string().describe('Path where the project should be created'),
bundleIdentifier: z
.string()
.optional()
.describe(
'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname',
),
displayName: z
.string()
.optional()
.describe(
'App display name (shown on home screen/dock). If not provided, will use projectName',
),
marketingVersion: z
.string()
.optional()
.describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'),
currentProjectVersion: z
.string()
.optional()
.describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'),
customizeNames: z
.boolean()
.default(true)
.describe('Whether to customize project names and identifiers. Default is true.'),
});
// iOS-specific schema
const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({
deploymentTarget: z
.string()
.optional()
.describe('iOS deployment target (e.g., 18.4, 17.0). If not provided, will use 18.4'),
targetedDeviceFamily: z
.array(z.enum(['iphone', 'ipad', 'universal']))
.optional()
.describe('Targeted device families'),
supportedOrientations: z
.array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down']))
.optional()
.describe('Supported orientations for iPhone'),
supportedOrientationsIpad: z
.array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down']))
.optional()
.describe('Supported orientations for iPad'),
});
/**
* Convert orientation enum to iOS constant
*/
function orientationToIOSConstant(orientation: string): string {
switch (orientation) {
case 'Portrait':
return 'UIInterfaceOrientationPortrait';
case 'PortraitUpsideDown':
return 'UIInterfaceOrientationPortraitUpsideDown';
case 'LandscapeLeft':
return 'UIInterfaceOrientationLandscapeLeft';
case 'LandscapeRight':
return 'UIInterfaceOrientationLandscapeRight';
default:
return orientation;
}
}
/**
* Convert device family enum to numeric value
*/
function deviceFamilyToNumeric(family: string): string {
switch (family) {
case 'iPhone':
return '1';
case 'iPad':
return '2';
case 'iPhone+iPad':
return '1,2';
default:
return '1,2';
}
}
/**
* Update Package.swift file with deployment target
*/
function updatePackageSwiftFile(content: string, params: Record<string, unknown>): string {
let result = content;
const projectName = params.projectName as string;
const platform = params.platform as string;
const deploymentTarget = params.deploymentTarget as string | undefined;
// Update ALL target name references in Package.swift
const featureName = `${projectName}Feature`;
const testName = `${projectName}FeatureTests`;
// Replace ALL occurrences of MyProjectFeatureTests first (more specific)
result = result.replace(/MyProjectFeatureTests/g, testName);
// Then replace ALL occurrences of MyProjectFeature (less specific, so comes after)
result = result.replace(/MyProjectFeature/g, featureName);
// Update deployment targets based on platform
if (platform === 'iOS') {
if (deploymentTarget) {
// Extract major version (e.g., "17.0" -> "17")
const majorVersion = deploymentTarget.split('.')[0];
result = result.replace(/\.iOS\(\.v\d+\)/, `.iOS(.v${majorVersion})`);
}
}
return result;
}
/**
* Update XCConfig file with scaffold parameters
*/
function updateXCConfigFile(content: string, params: Record<string, unknown>): string {
let result = content;
const projectName = params.projectName as string;
const displayName = params.displayName as string | undefined;
const bundleIdentifier = params.bundleIdentifier as string | undefined;
const marketingVersion = params.marketingVersion as string | undefined;
const currentProjectVersion = params.currentProjectVersion as string | undefined;
const platform = params.platform as string;
const deploymentTarget = params.deploymentTarget as string | undefined;
const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined;
const supportedOrientations = params.supportedOrientations as string[] | undefined;
const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined;
// Update project identity settings
result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`);
result = result.replace(
/PRODUCT_DISPLAY_NAME = .+/g,
`PRODUCT_DISPLAY_NAME = ${displayName ?? projectName}`,
);
result = result.replace(
/PRODUCT_BUNDLE_IDENTIFIER = .+/g,
`PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`,
);
result = result.replace(
/MARKETING_VERSION = .+/g,
`MARKETING_VERSION = ${marketingVersion ?? '1.0'}`,
);
result = result.replace(
/CURRENT_PROJECT_VERSION = .+/g,
`CURRENT_PROJECT_VERSION = ${currentProjectVersion ?? '1'}`,
);
// Platform-specific updates
if (platform === 'iOS') {
// iOS deployment target
if (deploymentTarget) {
result = result.replace(
/IPHONEOS_DEPLOYMENT_TARGET = .+/g,
`IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}`,
);
}
// Device family
if (targetedDeviceFamily) {
const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily);
result = result.replace(
/TARGETED_DEVICE_FAMILY = .+/g,
`TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`,
);
}
// iPhone orientations
if (supportedOrientations && supportedOrientations.length > 0) {
// Filter out any empty strings and validate
const validOrientations = supportedOrientations.filter((o: string) => o && o.trim() !== '');
if (validOrientations.length > 0) {
const orientations = validOrientations.map(orientationToIOSConstant).join(' ');
result = result.replace(
/INFOPLIST_KEY_UISupportedInterfaceOrientations = .+/g,
`INFOPLIST_KEY_UISupportedInterfaceOrientations = ${orientations}`,
);
}
}
// iPad orientations
if (supportedOrientationsIpad && supportedOrientationsIpad.length > 0) {
// Filter out any empty strings and validate
const validOrientations = supportedOrientationsIpad.filter(
(o: string) => o && o.trim() !== '',
);
if (validOrientations.length > 0) {
const orientations = validOrientations.map(orientationToIOSConstant).join(' ');
result = result.replace(
/INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = .+/g,
`INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = ${orientations}`,
);
}
}
// Update entitlements path for iOS
result = result.replace(
/CODE_SIGN_ENTITLEMENTS = .+/g,
`CODE_SIGN_ENTITLEMENTS = Config/${projectName}.entitlements`,
);
}
// Update test bundle identifier and target name
result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${projectName}`);
// Update comments that reference MyProject in entitlements paths
result = result.replace(/Config\/MyProject\.entitlements/g, `Config/${projectName}.entitlements`);
return result;
}
/**
* Replace placeholders in a string (for non-XCConfig files)
*/
function replacePlaceholders(
content: string,
projectName: string,
bundleIdentifier: string,
): string {
let result = content;
// Replace project name
result = result.replace(/MyProject/g, projectName);
// Replace bundle identifier - check for both patterns used in templates
if (bundleIdentifier) {
result = result.replace(/com\.example\.MyProject/g, bundleIdentifier);
result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier);
}
return result;
}
/**
* Process a single file, replacing placeholders if it's a text file
*/
async function processFile(
sourcePath: string,
destPath: string,
params: Record<string, unknown>,
fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
): Promise<void> {
const projectName = params.projectName as string;
const bundleIdentifierParam = params.bundleIdentifier as string | undefined;
const customizeNames = params.customizeNames as boolean | undefined;
// Determine the destination file path
let finalDestPath = destPath;
if (customizeNames) {
// Replace MyProject in file/directory names
const fileName = basename(destPath);
const dirName = dirname(destPath);
const newFileName = fileName.replace(/MyProject/g, projectName);
finalDestPath = join(dirName, newFileName);
}
// Text file extensions that should be processed
const textExtensions = [
'.swift',
'.h',
'.m',
'.mm',
'.cpp',
'.c',
'.pbxproj',
'.plist',
'.xcscheme',
'.xctestplan',
'.xcworkspacedata',
'.xcconfig',
'.json',
'.xml',
'.entitlements',
'.storyboard',
'.xib',
'.md',
];
const ext = sourcePath.toLowerCase();
const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt));
const isXCConfig = sourcePath.endsWith('.xcconfig');
const isPackageSwift = sourcePath.endsWith('Package.swift');
if (isTextFile && customizeNames) {
// Read the file content
const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8');
let processedContent;
if (isXCConfig) {
// Use special XCConfig processing
processedContent = updateXCConfigFile(content, params);
} else if (isPackageSwift) {
// Use special Package.swift processing
processedContent = updatePackageSwiftFile(content, params);
} else {
// Use standard placeholder replacement
const bundleIdentifier =
bundleIdentifierParam ??
`com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
processedContent = replacePlaceholders(content, projectName, bundleIdentifier);
}
await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true });
await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8');
} else {
// Copy binary files as-is
await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true });
await fileSystemExecutor.cp(sourcePath, finalDestPath);
}
}
/**
* Recursively process a directory
*/
async function processDirectory(
sourceDir: string,
destDir: string,
params: Record<string, unknown>,
fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
): Promise<void> {
const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const entryTyped = entry as { name: string; isDirectory: () => boolean; isFile: () => boolean };
const sourcePath = join(sourceDir, entryTyped.name);
let destName = entryTyped.name;
if (params.customizeNames) {
// Replace MyProject in directory names
destName = destName.replace(/MyProject/g, params.projectName as string);
}
const destPath = join(destDir, destName);
if (entryTyped.isDirectory()) {
// Skip certain directories
if (entryTyped.name === '.git' || entryTyped.name === 'xcuserdata') {
continue;
}
await fileSystemExecutor.mkdir(destPath, { recursive: true });
await processDirectory(sourcePath, destPath, params, fileSystemExecutor);
} else if (entryTyped.isFile()) {
// Skip certain files
if (entryTyped.name === '.DS_Store' || entryTyped.name.endsWith('.xcuserstate')) {
continue;
}
await processFile(sourcePath, destPath, params, fileSystemExecutor);
}
}
}
// Use z.infer for type safety
type ScaffoldIOSProjectParams = z.infer<typeof ScaffoldiOSProjectSchema>;
/**
* Logic function for scaffolding iOS projects
*/
export async function scaffold_ios_projectLogic(
params: ScaffoldIOSProjectParams,
commandExecutor: CommandExecutor,
fileSystemExecutor: FileSystemExecutor,
): Promise<ToolResponse> {
try {
const projectParams = { ...params, platform: 'iOS' };
const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor);
const response = {
success: true,
projectPath,
platform: 'iOS',
message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`,
nextSteps: [
`Important: Before working on the project make sure to read the README.md file in the workspace root directory.`,
`Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`,
`Build and run on simulator: build_run_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`,
],
};
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2),
},
],
};
} catch (error) {
log(
'error',
`Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`,
);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
null,
2,
),
},
],
isError: true,
};
}
}
/**
* Scaffold a new iOS or macOS project
*/
async function scaffoldProject(
params: Record<string, unknown>,
commandExecutor?: CommandExecutor,
fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
): Promise<string> {
const projectName = params.projectName as string;
const outputPath = params.outputPath as string;
const platform = params.platform as 'iOS' | 'macOS';
const customizeNames = (params.customizeNames as boolean | undefined) ?? true;
log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`);
// Validate project name
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) {
throw new ValidationError(
'Project name must start with a letter and contain only letters, numbers, and underscores',
);
}
// Get template path from TemplateManager
let templatePath;
try {
// Use the default command executor if not provided
commandExecutor ??= getDefaultCommandExecutor();
templatePath = await TemplateManager.getTemplatePath(
platform,
commandExecutor,
fileSystemExecutor,
);
} catch (error) {
throw new ValidationError(
`Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`,
);
}
// Use outputPath directly as the destination
const projectPath = outputPath;
// Check if the output directory already has Xcode project files
const xcworkspaceExists = fileSystemExecutor.existsSync(
join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`),
);
const xcodeprojExists = fileSystemExecutor.existsSync(
join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`),
);
if (xcworkspaceExists || xcodeprojExists) {
throw new ValidationError(`Xcode project files already exist in ${projectPath}`);
}
try {
// Process the template directly into the output path
await processDirectory(templatePath, projectPath, params, fileSystemExecutor);
return projectPath;
} finally {
// Clean up downloaded template if needed
await TemplateManager.cleanup(templatePath, fileSystemExecutor);
}
}
export default {
name: 'scaffold_ios_project',
description:
'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.',
schema: ScaffoldiOSProjectSchema.shape,
annotations: {
title: 'Scaffold iOS Project',
destructiveHint: true,
},
async handler(args: Record<string, unknown>): Promise<ToolResponse> {
const params = ScaffoldiOSProjectSchema.parse(args);
return scaffold_ios_projectLogic(
params,
getDefaultCommandExecutor(),
getDefaultFileSystemExecutor(),
);
},
};
```