This is page 6 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .axe-version
├── .claude
│ └── agents
│ └── xcodebuild-mcp-qa-tester.md
├── .cursor
│ ├── BUGBOT.md
│ └── environment.json
├── .cursorrules
├── .github
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ └── feature_request.yml
│ └── workflows
│ ├── ci.yml
│ ├── claude-code-review.yml
│ ├── claude-dispatch.yml
│ ├── claude.yml
│ ├── droid-code-review.yml
│ ├── README.md
│ ├── release.yml
│ └── sentry.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ ├── mcp.json
│ ├── settings.json
│ └── tasks.json
├── AGENTS.md
├── banner.png
├── build-plugins
│ ├── plugin-discovery.js
│ ├── plugin-discovery.ts
│ └── tsconfig.json
├── CHANGELOG.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── Dockerfile
├── docs
│ ├── ARCHITECTURE.md
│ ├── CODE_QUALITY.md
│ ├── CONTRIBUTING.md
│ ├── ESLINT_TYPE_SAFETY.md
│ ├── MANUAL_TESTING.md
│ ├── NODEJS_2025.md
│ ├── PLUGIN_DEVELOPMENT.md
│ ├── RELEASE_PROCESS.md
│ ├── RELOADEROO_FOR_XCODEBUILDMCP.md
│ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
│ ├── RELOADEROO.md
│ ├── session_management_plan.md
│ ├── session-aware-migration-todo.md
│ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
│ ├── TESTING.md
│ └── TOOLS.md
├── eslint.config.js
├── example_projects
│ ├── .vscode
│ │ └── launch.json
│ ├── iOS
│ │ ├── .cursor
│ │ │ └── rules
│ │ │ └── errors.mdc
│ │ ├── .vscode
│ │ │ └── settings.json
│ │ ├── Makefile
│ │ ├── MCPTest
│ │ │ ├── Assets.xcassets
│ │ │ │ ├── AccentColor.colorset
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── ContentView.swift
│ │ │ ├── MCPTestApp.swift
│ │ │ └── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ │ ├── MCPTest.xcodeproj
│ │ │ ├── project.pbxproj
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── MCPTest.xcscheme
│ │ └── MCPTestUITests
│ │ └── MCPTestUITests.swift
│ ├── iOS_Calculator
│ │ ├── CalculatorApp
│ │ │ ├── Assets.xcassets
│ │ │ │ ├── AccentColor.colorset
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── CalculatorApp.swift
│ │ │ └── CalculatorApp.xctestplan
│ │ ├── CalculatorApp.xcodeproj
│ │ │ ├── project.pbxproj
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── CalculatorApp.xcscheme
│ │ ├── CalculatorApp.xcworkspace
│ │ │ └── contents.xcworkspacedata
│ │ ├── CalculatorAppPackage
│ │ │ ├── .gitignore
│ │ │ ├── Package.swift
│ │ │ ├── Sources
│ │ │ │ └── CalculatorAppFeature
│ │ │ │ ├── BackgroundEffect.swift
│ │ │ │ ├── CalculatorButton.swift
│ │ │ │ ├── CalculatorDisplay.swift
│ │ │ │ ├── CalculatorInputHandler.swift
│ │ │ │ ├── CalculatorService.swift
│ │ │ │ └── ContentView.swift
│ │ │ └── Tests
│ │ │ └── CalculatorAppFeatureTests
│ │ │ └── CalculatorServiceTests.swift
│ │ ├── CalculatorAppTests
│ │ │ └── CalculatorAppTests.swift
│ │ └── Config
│ │ ├── Debug.xcconfig
│ │ ├── Release.xcconfig
│ │ ├── Shared.xcconfig
│ │ └── Tests.xcconfig
│ ├── macOS
│ │ ├── MCPTest
│ │ │ ├── Assets.xcassets
│ │ │ │ ├── AccentColor.colorset
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── AppIcon.appiconset
│ │ │ │ │ └── Contents.json
│ │ │ │ └── Contents.json
│ │ │ ├── ContentView.swift
│ │ │ ├── MCPTest.entitlements
│ │ │ ├── MCPTestApp.swift
│ │ │ └── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ │ └── MCPTest.xcodeproj
│ │ ├── project.pbxproj
│ │ └── xcshareddata
│ │ └── xcschemes
│ │ └── MCPTest.xcscheme
│ └── spm
│ ├── .gitignore
│ ├── Package.resolved
│ ├── Package.swift
│ ├── Sources
│ │ ├── long-server
│ │ │ └── main.swift
│ │ ├── quick-task
│ │ │ └── main.swift
│ │ ├── spm
│ │ │ └── main.swift
│ │ └── TestLib
│ │ └── TaskManager.swift
│ └── Tests
│ └── TestLibTests
│ └── SimpleTests.swift
├── LICENSE
├── mcp-install-dark.png
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── analysis
│ │ └── tools-analysis.ts
│ ├── bundle-axe.sh
│ ├── check-code-patterns.js
│ ├── release.sh
│ ├── tools-cli.ts
│ └── update-tools-docs.ts
├── server.json
├── smithery.yaml
├── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── resources.test.ts
│ │ ├── dynamic-tools.ts
│ │ ├── plugin-registry.ts
│ │ ├── plugin-types.ts
│ │ └── resources.ts
│ ├── doctor-cli.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── resources
│ │ │ ├── __tests__
│ │ │ │ ├── devices.test.ts
│ │ │ │ ├── doctor.test.ts
│ │ │ │ └── simulators.test.ts
│ │ │ ├── devices.ts
│ │ │ ├── doctor.ts
│ │ │ └── simulators.ts
│ │ └── tools
│ │ ├── device
│ │ │ ├── __tests__
│ │ │ │ ├── build_device.test.ts
│ │ │ │ ├── get_device_app_path.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── install_app_device.test.ts
│ │ │ │ ├── launch_app_device.test.ts
│ │ │ │ ├── list_devices.test.ts
│ │ │ │ ├── re-exports.test.ts
│ │ │ │ ├── stop_app_device.test.ts
│ │ │ │ └── test_device.test.ts
│ │ │ ├── build_device.ts
│ │ │ ├── clean.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_app_bundle_id.ts
│ │ │ ├── get_device_app_path.ts
│ │ │ ├── index.ts
│ │ │ ├── install_app_device.ts
│ │ │ ├── launch_app_device.ts
│ │ │ ├── list_devices.ts
│ │ │ ├── list_schemes.ts
│ │ │ ├── show_build_settings.ts
│ │ │ ├── start_device_log_cap.ts
│ │ │ ├── stop_app_device.ts
│ │ │ ├── stop_device_log_cap.ts
│ │ │ └── test_device.ts
│ │ ├── discovery
│ │ │ ├── __tests__
│ │ │ │ └── discover_tools.test.ts
│ │ │ ├── discover_tools.ts
│ │ │ └── index.ts
│ │ ├── doctor
│ │ │ ├── __tests__
│ │ │ │ ├── doctor.test.ts
│ │ │ │ └── index.test.ts
│ │ │ ├── doctor.ts
│ │ │ ├── index.ts
│ │ │ └── lib
│ │ │ └── doctor.deps.ts
│ │ ├── logging
│ │ │ ├── __tests__
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── start_device_log_cap.test.ts
│ │ │ │ ├── start_sim_log_cap.test.ts
│ │ │ │ ├── stop_device_log_cap.test.ts
│ │ │ │ └── stop_sim_log_cap.test.ts
│ │ │ ├── index.ts
│ │ │ ├── start_device_log_cap.ts
│ │ │ ├── start_sim_log_cap.ts
│ │ │ ├── stop_device_log_cap.ts
│ │ │ └── stop_sim_log_cap.ts
│ │ ├── macos
│ │ │ ├── __tests__
│ │ │ │ ├── build_macos.test.ts
│ │ │ │ ├── build_run_macos.test.ts
│ │ │ │ ├── get_mac_app_path.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── launch_mac_app.test.ts
│ │ │ │ ├── re-exports.test.ts
│ │ │ │ ├── stop_mac_app.test.ts
│ │ │ │ └── test_macos.test.ts
│ │ │ ├── build_macos.ts
│ │ │ ├── build_run_macos.ts
│ │ │ ├── clean.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_mac_app_path.ts
│ │ │ ├── get_mac_bundle_id.ts
│ │ │ ├── index.ts
│ │ │ ├── launch_mac_app.ts
│ │ │ ├── list_schemes.ts
│ │ │ ├── show_build_settings.ts
│ │ │ ├── stop_mac_app.ts
│ │ │ └── test_macos.ts
│ │ ├── project-discovery
│ │ │ ├── __tests__
│ │ │ │ ├── discover_projs.test.ts
│ │ │ │ ├── get_app_bundle_id.test.ts
│ │ │ │ ├── get_mac_bundle_id.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── list_schemes.test.ts
│ │ │ │ └── show_build_settings.test.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_app_bundle_id.ts
│ │ │ ├── get_mac_bundle_id.ts
│ │ │ ├── index.ts
│ │ │ ├── list_schemes.ts
│ │ │ └── show_build_settings.ts
│ │ ├── project-scaffolding
│ │ │ ├── __tests__
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── scaffold_ios_project.test.ts
│ │ │ │ └── scaffold_macos_project.test.ts
│ │ │ ├── index.ts
│ │ │ ├── scaffold_ios_project.ts
│ │ │ └── scaffold_macos_project.ts
│ │ ├── session-management
│ │ │ ├── __tests__
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── session_clear_defaults.test.ts
│ │ │ │ ├── session_set_defaults.test.ts
│ │ │ │ └── session_show_defaults.test.ts
│ │ │ ├── index.ts
│ │ │ ├── session_clear_defaults.ts
│ │ │ ├── session_set_defaults.ts
│ │ │ └── session_show_defaults.ts
│ │ ├── simulator
│ │ │ ├── __tests__
│ │ │ │ ├── boot_sim.test.ts
│ │ │ │ ├── build_run_sim.test.ts
│ │ │ │ ├── build_sim.test.ts
│ │ │ │ ├── get_sim_app_path.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── install_app_sim.test.ts
│ │ │ │ ├── launch_app_logs_sim.test.ts
│ │ │ │ ├── launch_app_sim.test.ts
│ │ │ │ ├── list_sims.test.ts
│ │ │ │ ├── open_sim.test.ts
│ │ │ │ ├── record_sim_video.test.ts
│ │ │ │ ├── screenshot.test.ts
│ │ │ │ ├── stop_app_sim.test.ts
│ │ │ │ └── test_sim.test.ts
│ │ │ ├── boot_sim.ts
│ │ │ ├── build_run_sim.ts
│ │ │ ├── build_sim.ts
│ │ │ ├── clean.ts
│ │ │ ├── describe_ui.ts
│ │ │ ├── discover_projs.ts
│ │ │ ├── get_app_bundle_id.ts
│ │ │ ├── get_sim_app_path.ts
│ │ │ ├── index.ts
│ │ │ ├── install_app_sim.ts
│ │ │ ├── launch_app_logs_sim.ts
│ │ │ ├── launch_app_sim.ts
│ │ │ ├── list_schemes.ts
│ │ │ ├── list_sims.ts
│ │ │ ├── open_sim.ts
│ │ │ ├── record_sim_video.ts
│ │ │ ├── screenshot.ts
│ │ │ ├── show_build_settings.ts
│ │ │ ├── stop_app_sim.ts
│ │ │ └── test_sim.ts
│ │ ├── simulator-management
│ │ │ ├── __tests__
│ │ │ │ ├── erase_sims.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── reset_sim_location.test.ts
│ │ │ │ ├── set_sim_appearance.test.ts
│ │ │ │ ├── set_sim_location.test.ts
│ │ │ │ └── sim_statusbar.test.ts
│ │ │ ├── boot_sim.ts
│ │ │ ├── erase_sims.ts
│ │ │ ├── index.ts
│ │ │ ├── list_sims.ts
│ │ │ ├── open_sim.ts
│ │ │ ├── reset_sim_location.ts
│ │ │ ├── set_sim_appearance.ts
│ │ │ ├── set_sim_location.ts
│ │ │ └── sim_statusbar.ts
│ │ ├── swift-package
│ │ │ ├── __tests__
│ │ │ │ ├── active-processes.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── swift_package_build.test.ts
│ │ │ │ ├── swift_package_clean.test.ts
│ │ │ │ ├── swift_package_list.test.ts
│ │ │ │ ├── swift_package_run.test.ts
│ │ │ │ ├── swift_package_stop.test.ts
│ │ │ │ └── swift_package_test.test.ts
│ │ │ ├── active-processes.ts
│ │ │ ├── index.ts
│ │ │ ├── swift_package_build.ts
│ │ │ ├── swift_package_clean.ts
│ │ │ ├── swift_package_list.ts
│ │ │ ├── swift_package_run.ts
│ │ │ ├── swift_package_stop.ts
│ │ │ └── swift_package_test.ts
│ │ ├── ui-testing
│ │ │ ├── __tests__
│ │ │ │ ├── button.test.ts
│ │ │ │ ├── describe_ui.test.ts
│ │ │ │ ├── gesture.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── key_press.test.ts
│ │ │ │ ├── key_sequence.test.ts
│ │ │ │ ├── long_press.test.ts
│ │ │ │ ├── screenshot.test.ts
│ │ │ │ ├── swipe.test.ts
│ │ │ │ ├── tap.test.ts
│ │ │ │ ├── touch.test.ts
│ │ │ │ └── type_text.test.ts
│ │ │ ├── button.ts
│ │ │ ├── describe_ui.ts
│ │ │ ├── gesture.ts
│ │ │ ├── index.ts
│ │ │ ├── key_press.ts
│ │ │ ├── key_sequence.ts
│ │ │ ├── long_press.ts
│ │ │ ├── screenshot.ts
│ │ │ ├── swipe.ts
│ │ │ ├── tap.ts
│ │ │ ├── touch.ts
│ │ │ └── type_text.ts
│ │ └── utilities
│ │ ├── __tests__
│ │ │ ├── clean.test.ts
│ │ │ └── index.test.ts
│ │ ├── clean.ts
│ │ └── index.ts
│ ├── server
│ │ └── server.ts
│ ├── test-utils
│ │ └── mock-executors.ts
│ ├── types
│ │ └── common.ts
│ └── utils
│ ├── __tests__
│ │ ├── build-utils.test.ts
│ │ ├── environment.test.ts
│ │ ├── session-aware-tool-factory.test.ts
│ │ ├── session-store.test.ts
│ │ ├── simulator-utils.test.ts
│ │ ├── test-runner-env-integration.test.ts
│ │ └── typed-tool-factory.test.ts
│ ├── axe
│ │ └── index.ts
│ ├── axe-helpers.ts
│ ├── build
│ │ └── index.ts
│ ├── build-utils.ts
│ ├── capabilities.ts
│ ├── command.ts
│ ├── CommandExecutor.ts
│ ├── environment.ts
│ ├── errors.ts
│ ├── execution
│ │ └── index.ts
│ ├── FileSystemExecutor.ts
│ ├── log_capture.ts
│ ├── log-capture
│ │ └── index.ts
│ ├── logger.ts
│ ├── logging
│ │ └── index.ts
│ ├── plugin-registry
│ │ └── index.ts
│ ├── responses
│ │ └── index.ts
│ ├── schema-helpers.ts
│ ├── sentry.ts
│ ├── session-store.ts
│ ├── simulator-utils.ts
│ ├── template
│ │ └── index.ts
│ ├── template-manager.ts
│ ├── test
│ │ └── index.ts
│ ├── test-common.ts
│ ├── tool-registry.ts
│ ├── typed-tool-factory.ts
│ ├── validation
│ │ └── index.ts
│ ├── validation.ts
│ ├── version
│ │ └── index.ts
│ ├── video_capture.ts
│ ├── video-capture
│ │ └── index.ts
│ ├── xcode.ts
│ ├── xcodemake
│ │ └── index.ts
│ └── xcodemake.ts
├── tsconfig.json
├── tsconfig.test.json
├── tsup.config.ts
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/mcp/tools/doctor/lib/doctor.deps.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as os from 'os';
2 | import type { CommandExecutor } from '../../../../utils/execution/index.ts';
3 | import {
4 | loadWorkflowGroups,
5 | loadPlugins,
6 | getEnabledWorkflows,
7 | } from '../../../../utils/plugin-registry/index.ts';
8 | import { areAxeToolsAvailable } from '../../../../utils/axe/index.ts';
9 | import {
10 | isXcodemakeEnabled,
11 | isXcodemakeAvailable,
12 | doesMakefileExist,
13 | } from '../../../../utils/xcodemake/index.ts';
14 | import { getTrackedToolNames } from '../../../../utils/tool-registry.ts';
15 |
16 | export interface BinaryChecker {
17 | checkBinaryAvailability(binary: string): Promise<{ available: boolean; version?: string }>;
18 | }
19 |
20 | export interface XcodeInfoProvider {
21 | getXcodeInfo(): Promise<
22 | | { version: string; path: string; selectedXcode: string; xcrunVersion: string }
23 | | { error: string }
24 | >;
25 | }
26 |
27 | export interface EnvironmentInfoProvider {
28 | getEnvironmentVariables(): Record<string, string | undefined>;
29 | getSystemInfo(): {
30 | platform: string;
31 | release: string;
32 | arch: string;
33 | cpus: string;
34 | memory: string;
35 | hostname: string;
36 | username: string;
37 | homedir: string;
38 | tmpdir: string;
39 | };
40 | getNodeInfo(): {
41 | version: string;
42 | execPath: string;
43 | pid: string;
44 | ppid: string;
45 | platform: string;
46 | arch: string;
47 | cwd: string;
48 | argv: string;
49 | };
50 | }
51 |
52 | export interface PluginInfoProvider {
53 | getPluginSystemInfo(): Promise<
54 | | {
55 | totalPlugins: number;
56 | pluginDirectories: number;
57 | pluginsByDirectory: Record<string, string[]>;
58 | systemMode: string;
59 | }
60 | | { error: string; systemMode: string }
61 | >;
62 | }
63 |
64 | export interface RuntimeInfoProvider {
65 | getRuntimeToolInfo(): Promise<
66 | | {
67 | mode: 'dynamic';
68 | enabledWorkflows: string[];
69 | enabledTools: string[];
70 | totalRegistered: number;
71 | }
72 | | {
73 | mode: 'static';
74 | enabledWorkflows: string[];
75 | enabledTools: string[];
76 | totalRegistered: number;
77 | }
78 | >;
79 | }
80 |
81 | export interface FeatureDetector {
82 | areAxeToolsAvailable(): boolean;
83 | isXcodemakeEnabled(): boolean;
84 | isXcodemakeAvailable(): Promise<boolean>;
85 | doesMakefileExist(path: string): boolean;
86 | }
87 |
88 | export interface DoctorDependencies {
89 | binaryChecker: BinaryChecker;
90 | xcode: XcodeInfoProvider;
91 | env: EnvironmentInfoProvider;
92 | plugins: PluginInfoProvider;
93 | runtime: RuntimeInfoProvider;
94 | features: FeatureDetector;
95 | }
96 |
97 | export function createDoctorDependencies(executor: CommandExecutor): DoctorDependencies {
98 | const binaryChecker: BinaryChecker = {
99 | async checkBinaryAvailability(binary: string) {
100 | // If bundled axe is available, reflect that in dependencies even if not on PATH
101 | if (binary === 'axe' && areAxeToolsAvailable()) {
102 | return { available: true, version: 'Bundled' };
103 | }
104 | try {
105 | const which = await executor(['which', binary], 'Check Binary Availability');
106 | if (!which.success) {
107 | return { available: false };
108 | }
109 | } catch {
110 | return { available: false };
111 | }
112 |
113 | let version: string | undefined;
114 | const versionCommands: Record<string, string> = {
115 | axe: 'axe --version',
116 | mise: 'mise --version',
117 | };
118 |
119 | if (binary in versionCommands) {
120 | try {
121 | const res = await executor(versionCommands[binary]!.split(' '), 'Get Binary Version');
122 | if (res.success && res.output) {
123 | version = res.output.trim();
124 | }
125 | } catch {
126 | // ignore
127 | }
128 | }
129 |
130 | return { available: true, version: version ?? 'Available (version info not available)' };
131 | },
132 | };
133 |
134 | const xcode: XcodeInfoProvider = {
135 | async getXcodeInfo() {
136 | try {
137 | const xcodebuild = await executor(['xcodebuild', '-version'], 'Get Xcode Version');
138 | if (!xcodebuild.success) throw new Error('xcodebuild command failed');
139 | const version = xcodebuild.output.trim().split('\n').slice(0, 2).join(' - ');
140 |
141 | const pathRes = await executor(['xcode-select', '-p'], 'Get Xcode Path');
142 | if (!pathRes.success) throw new Error('xcode-select command failed');
143 | const path = pathRes.output.trim();
144 |
145 | const selected = await executor(['xcrun', '--find', 'xcodebuild'], 'Find Xcodebuild');
146 | if (!selected.success) throw new Error('xcrun --find command failed');
147 | const selectedXcode = selected.output.trim();
148 |
149 | const xcrun = await executor(['xcrun', '--version'], 'Get Xcrun Version');
150 | if (!xcrun.success) throw new Error('xcrun --version command failed');
151 | const xcrunVersion = xcrun.output.trim();
152 |
153 | return { version, path, selectedXcode, xcrunVersion };
154 | } catch (error) {
155 | return { error: error instanceof Error ? error.message : String(error) };
156 | }
157 | },
158 | };
159 |
160 | const env: EnvironmentInfoProvider = {
161 | getEnvironmentVariables() {
162 | const relevantVars = [
163 | 'INCREMENTAL_BUILDS_ENABLED',
164 | 'PATH',
165 | 'DEVELOPER_DIR',
166 | 'HOME',
167 | 'USER',
168 | 'TMPDIR',
169 | 'NODE_ENV',
170 | 'SENTRY_DISABLED',
171 | ];
172 |
173 | const envVars: Record<string, string | undefined> = {};
174 | for (const varName of relevantVars) {
175 | envVars[varName] = process.env[varName];
176 | }
177 |
178 | Object.keys(process.env).forEach((key) => {
179 | if (key.startsWith('XCODEBUILDMCP_')) {
180 | envVars[key] = process.env[key];
181 | }
182 | });
183 |
184 | return envVars;
185 | },
186 |
187 | getSystemInfo() {
188 | return {
189 | platform: os.platform(),
190 | release: os.release(),
191 | arch: os.arch(),
192 | cpus: `${os.cpus().length} x ${os.cpus()[0]?.model ?? 'Unknown'}`,
193 | memory: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`,
194 | hostname: os.hostname(),
195 | username: os.userInfo().username,
196 | homedir: os.homedir(),
197 | tmpdir: os.tmpdir(),
198 | };
199 | },
200 |
201 | getNodeInfo() {
202 | return {
203 | version: process.version,
204 | execPath: process.execPath,
205 | pid: process.pid.toString(),
206 | ppid: process.ppid.toString(),
207 | platform: process.platform,
208 | arch: process.arch,
209 | cwd: process.cwd(),
210 | argv: process.argv.join(' '),
211 | };
212 | },
213 | };
214 |
215 | const plugins: PluginInfoProvider = {
216 | async getPluginSystemInfo() {
217 | try {
218 | const workflows = await loadWorkflowGroups();
219 | const pluginsByDirectory: Record<string, string[]> = {};
220 | let totalPlugins = 0;
221 |
222 | for (const [dirName, wf] of workflows.entries()) {
223 | const toolNames = wf.tools.map((t) => t.name).filter(Boolean) as string[];
224 | totalPlugins += toolNames.length;
225 | pluginsByDirectory[dirName] = toolNames;
226 | }
227 |
228 | return {
229 | totalPlugins,
230 | pluginDirectories: workflows.size,
231 | pluginsByDirectory,
232 | systemMode: 'plugin-based',
233 | };
234 | } catch (error) {
235 | return {
236 | error: `Failed to load plugins: ${error instanceof Error ? error.message : 'Unknown error'}`,
237 | systemMode: 'error',
238 | };
239 | }
240 | },
241 | };
242 |
243 | const runtime: RuntimeInfoProvider = {
244 | async getRuntimeToolInfo() {
245 | const dynamic = process.env.XCODEBUILDMCP_DYNAMIC_TOOLS === 'true';
246 |
247 | if (dynamic) {
248 | const enabledWf = getEnabledWorkflows();
249 | const enabledTools = getTrackedToolNames();
250 | return {
251 | mode: 'dynamic',
252 | enabledWorkflows: enabledWf,
253 | enabledTools,
254 | totalRegistered: enabledTools.length,
255 | };
256 | }
257 |
258 | // Static mode: all tools are registered
259 | const workflows = await loadWorkflowGroups();
260 | const enabledWorkflows = Array.from(workflows.keys());
261 | const plugins = await loadPlugins();
262 | const enabledTools = Array.from(plugins.keys());
263 | return {
264 | mode: 'static',
265 | enabledWorkflows,
266 | enabledTools,
267 | totalRegistered: enabledTools.length,
268 | };
269 | },
270 | };
271 |
272 | const features: FeatureDetector = {
273 | areAxeToolsAvailable,
274 | isXcodemakeEnabled,
275 | isXcodemakeAvailable,
276 | doesMakefileExist,
277 | };
278 |
279 | return { binaryChecker, xcode, env, plugins, runtime, features };
280 | }
281 |
282 | export type { CommandExecutor };
283 |
284 | export default {} as const;
285 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/macos/build_run_macos.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * macOS Shared Plugin: Build and Run macOS (Unified)
3 | *
4 | * Builds and runs a macOS app from a project or workspace in one step.
5 | * Accepts mutually exclusive `projectPath` or `workspacePath`.
6 | */
7 |
8 | import { z } from 'zod';
9 | import { log } from '../../../utils/logging/index.ts';
10 | import { createTextResponse } from '../../../utils/responses/index.ts';
11 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts';
12 | import { ToolResponse, XcodePlatform } from '../../../types/common.ts';
13 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
14 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
15 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
16 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
17 |
18 | // Unified schema: XOR between projectPath and workspacePath
19 | const baseSchemaObject = z.object({
20 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'),
21 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'),
22 | scheme: z.string().describe('The scheme to use'),
23 | configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'),
24 | derivedDataPath: z
25 | .string()
26 | .optional()
27 | .describe('Path where build products and other derived data will go'),
28 | arch: z
29 | .enum(['arm64', 'x86_64'])
30 | .optional()
31 | .describe('Architecture to build for (arm64 or x86_64). For macOS only.'),
32 | extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'),
33 | preferXcodebuild: z
34 | .boolean()
35 | .optional()
36 | .describe('If true, prefers xcodebuild over the experimental incremental build system'),
37 | });
38 |
39 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject);
40 |
41 | const publicSchemaObject = baseSchemaObject.omit({
42 | projectPath: true,
43 | workspacePath: true,
44 | scheme: true,
45 | configuration: true,
46 | arch: true,
47 | } as const);
48 |
49 | const buildRunMacOSSchema = baseSchema
50 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, {
51 | message: 'Either projectPath or workspacePath is required.',
52 | })
53 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), {
54 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.',
55 | });
56 |
57 | export type BuildRunMacOSParams = z.infer<typeof buildRunMacOSSchema>;
58 |
59 | /**
60 | * Internal logic for building macOS apps.
61 | */
62 | async function _handleMacOSBuildLogic(
63 | params: BuildRunMacOSParams,
64 | executor: CommandExecutor,
65 | ): Promise<ToolResponse> {
66 | log('info', `Starting macOS build for scheme ${params.scheme} (internal)`);
67 |
68 | return executeXcodeBuildCommand(
69 | {
70 | ...params,
71 | configuration: params.configuration ?? 'Debug',
72 | },
73 | {
74 | platform: XcodePlatform.macOS,
75 | arch: params.arch,
76 | logPrefix: 'macOS Build',
77 | },
78 | params.preferXcodebuild ?? false,
79 | 'build',
80 | executor,
81 | );
82 | }
83 |
84 | async function _getAppPathFromBuildSettings(
85 | params: BuildRunMacOSParams,
86 | executor: CommandExecutor,
87 | ): Promise<{ success: true; appPath: string } | { success: false; error: string }> {
88 | try {
89 | // Create the command array for xcodebuild
90 | const command = ['xcodebuild', '-showBuildSettings'];
91 |
92 | // Add the project or workspace
93 | if (params.projectPath) {
94 | command.push('-project', params.projectPath);
95 | } else if (params.workspacePath) {
96 | command.push('-workspace', params.workspacePath);
97 | }
98 |
99 | // Add the scheme and configuration
100 | command.push('-scheme', params.scheme);
101 | command.push('-configuration', params.configuration ?? 'Debug');
102 |
103 | // Add derived data path if provided
104 | if (params.derivedDataPath) {
105 | command.push('-derivedDataPath', params.derivedDataPath);
106 | }
107 |
108 | // Add extra args if provided
109 | if (params.extraArgs && params.extraArgs.length > 0) {
110 | command.push(...params.extraArgs);
111 | }
112 |
113 | // Execute the command directly
114 | const result = await executor(command, 'Get Build Settings for Launch', true, undefined);
115 |
116 | if (!result.success) {
117 | return {
118 | success: false,
119 | error: result.error ?? 'Failed to get build settings',
120 | };
121 | }
122 |
123 | // Parse the output to extract the app path
124 | const buildSettingsOutput = result.output;
125 | const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m);
126 | const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m);
127 |
128 | if (!builtProductsDirMatch || !fullProductNameMatch) {
129 | return { success: false, error: 'Could not extract app path from build settings' };
130 | }
131 |
132 | const appPath = `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`;
133 | return { success: true, appPath };
134 | } catch (error) {
135 | const errorMessage = error instanceof Error ? error.message : String(error);
136 | return { success: false, error: errorMessage };
137 | }
138 | }
139 |
140 | /**
141 | * Business logic for building and running macOS apps.
142 | */
143 | export async function buildRunMacOSLogic(
144 | params: BuildRunMacOSParams,
145 | executor: CommandExecutor,
146 | ): Promise<ToolResponse> {
147 | log('info', 'Handling macOS build & run logic...');
148 |
149 | try {
150 | // First, build the app
151 | const buildResult = await _handleMacOSBuildLogic(params, executor);
152 |
153 | // 1. Check if the build itself failed
154 | if (buildResult.isError) {
155 | return buildResult; // Return build failure directly
156 | }
157 | const buildWarningMessages = buildResult.content?.filter((c) => c.type === 'text') ?? [];
158 |
159 | // 2. Build succeeded, now get the app path using the helper
160 | const appPathResult = await _getAppPathFromBuildSettings(params, executor);
161 |
162 | // 3. Check if getting the app path failed
163 | if (!appPathResult.success) {
164 | log('error', 'Build succeeded, but failed to get app path to launch.');
165 | const response = createTextResponse(
166 | `✅ Build succeeded, but failed to get app path to launch: ${appPathResult.error}`,
167 | false, // Build succeeded, so not a full error
168 | );
169 | if (response.content) {
170 | response.content.unshift(...buildWarningMessages);
171 | }
172 | return response;
173 | }
174 |
175 | const appPath = appPathResult.appPath; // success === true narrows to string
176 | log('info', `App path determined as: ${appPath}`);
177 |
178 | // 4. Launch the app using CommandExecutor
179 | const launchResult = await executor(['open', appPath], 'Launch macOS App', true);
180 |
181 | if (!launchResult.success) {
182 | log('error', `Build succeeded, but failed to launch app ${appPath}: ${launchResult.error}`);
183 | const errorResponse = createTextResponse(
184 | `✅ Build succeeded, but failed to launch app ${appPath}. Error: ${launchResult.error}`,
185 | false, // Build succeeded
186 | );
187 | if (errorResponse.content) {
188 | errorResponse.content.unshift(...buildWarningMessages);
189 | }
190 | return errorResponse;
191 | }
192 |
193 | log('info', `✅ macOS app launched successfully: ${appPath}`);
194 | const successResponse: ToolResponse = {
195 | content: [
196 | ...buildWarningMessages,
197 | {
198 | type: 'text',
199 | text: `✅ macOS build and run succeeded for scheme ${params.scheme}. App launched: ${appPath}`,
200 | },
201 | ],
202 | isError: false,
203 | };
204 | return successResponse;
205 | } catch (error) {
206 | const errorMessage = error instanceof Error ? error.message : String(error);
207 | log('error', `Error during macOS build & run logic: ${errorMessage}`);
208 | const errorResponse = createTextResponse(
209 | `Error during macOS build and run: ${errorMessage}`,
210 | true,
211 | );
212 | return errorResponse;
213 | }
214 | }
215 |
216 | export default {
217 | name: 'build_run_macos',
218 | description: 'Builds and runs a macOS app.',
219 | schema: publicSchemaObject.shape,
220 | handler: createSessionAwareTool<BuildRunMacOSParams>({
221 | internalSchema: buildRunMacOSSchema as unknown as z.ZodType<BuildRunMacOSParams>,
222 | logicFunction: buildRunMacOSLogic,
223 | getExecutor: getDefaultCommandExecutor,
224 | requirements: [
225 | { allOf: ['scheme'], message: 'scheme is required' },
226 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
227 | ],
228 | exclusivePairs: [['projectPath', 'workspacePath']],
229 | }),
230 | };
231 |
```
--------------------------------------------------------------------------------
/scripts/update-tools-docs.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * XcodeBuildMCP Tools Documentation Updater
5 | *
6 | * Automatically updates docs/TOOLS.md with current tool and workflow information
7 | * using static AST analysis. Ensures documentation always reflects the actual codebase.
8 | *
9 | * Usage:
10 | * npx tsx scripts/update-tools-docs.ts [--dry-run] [--verbose]
11 | *
12 | * Options:
13 | * --dry-run, -d Show what would be updated without making changes
14 | * --verbose, -v Show detailed information about the update process
15 | * --help, -h Show this help message
16 | */
17 |
18 | import * as fs from 'fs';
19 | import * as path from 'path';
20 | import { fileURLToPath } from 'url';
21 | import {
22 | getStaticToolAnalysis,
23 | type StaticAnalysisResult,
24 | type WorkflowInfo,
25 | } from './analysis/tools-analysis.js';
26 |
27 | // Get project paths
28 | const __filename = fileURLToPath(import.meta.url);
29 | const __dirname = path.dirname(__filename);
30 | const projectRoot = path.resolve(__dirname, '..');
31 | const docsPath = path.join(projectRoot, 'docs', 'TOOLS.md');
32 |
33 | // CLI options
34 | const args = process.argv.slice(2);
35 | const options = {
36 | dryRun: args.includes('--dry-run') || args.includes('-d'),
37 | verbose: args.includes('--verbose') || args.includes('-v'),
38 | help: args.includes('--help') || args.includes('-h'),
39 | };
40 |
41 | const colors = {
42 | reset: '\x1b[0m',
43 | bright: '\x1b[1m',
44 | red: '\x1b[31m',
45 | green: '\x1b[32m',
46 | yellow: '\x1b[33m',
47 | blue: '\x1b[34m',
48 | cyan: '\x1b[36m',
49 | magenta: '\x1b[35m',
50 | } as const;
51 |
52 | if (options.help) {
53 | console.log(`
54 | ${colors.bright}${colors.blue}XcodeBuildMCP Tools Documentation Updater${colors.reset}
55 |
56 | Automatically updates docs/TOOLS.md with current tool and workflow information.
57 |
58 | ${colors.bright}Usage:${colors.reset}
59 | npx tsx scripts/update-tools-docs.ts [options]
60 |
61 | ${colors.bright}Options:${colors.reset}
62 | --dry-run, -d Show what would be updated without making changes
63 | --verbose, -v Show detailed information about the update process
64 | --help, -h Show this help message
65 |
66 | ${colors.bright}Examples:${colors.reset}
67 | ${colors.cyan}npx tsx scripts/update-tools-docs.ts${colors.reset} # Update docs/TOOLS.md
68 | ${colors.cyan}npx tsx scripts/update-tools-docs.ts --dry-run${colors.reset} # Preview changes
69 | ${colors.cyan}npx tsx scripts/update-tools-docs.ts --verbose${colors.reset} # Show detailed progress
70 | `);
71 | process.exit(0);
72 | }
73 |
74 | /**
75 | * Generate the workflow section content
76 | */
77 | function generateWorkflowSection(workflow: WorkflowInfo): string {
78 | const canonicalTools = workflow.tools.filter((tool) => tool.isCanonical);
79 | const toolCount = canonicalTools.length;
80 |
81 | let content = `### ${workflow.displayName} (\`${workflow.name}\`)\n`;
82 | content += `**Purpose**: ${workflow.description} (${toolCount} tools)\n\n`;
83 |
84 | // List each tool with its description
85 | for (const tool of canonicalTools.sort((a, b) => a.name.localeCompare(b.name))) {
86 | // Clean up the description for documentation
87 | const cleanDescription = tool.description
88 | .replace(/IMPORTANT:.*?Example:.*?\)/g, '') // Remove IMPORTANT sections
89 | .replace(/\s+/g, ' ') // Normalize whitespace
90 | .trim();
91 |
92 | content += `- \`${tool.name}\` - ${cleanDescription}\n`;
93 | }
94 |
95 | return content;
96 | }
97 |
98 | /**
99 | * Generate the complete TOOLS.md content
100 | */
101 | function generateToolsDocumentation(analysis: StaticAnalysisResult): string {
102 | const { workflows, stats } = analysis;
103 |
104 | // Sort workflows by display name for consistent ordering
105 | const sortedWorkflows = workflows.sort((a, b) => a.displayName.localeCompare(b.displayName));
106 |
107 | const content = `# XcodeBuildMCP Tools Reference
108 |
109 | XcodeBuildMCP provides ${stats.canonicalTools} tools organized into ${stats.workflowCount} workflow groups for comprehensive Apple development workflows.
110 |
111 | ## Workflow Groups
112 |
113 | ${sortedWorkflows.map((workflow) => generateWorkflowSection(workflow)).join('')}
114 | ## Summary Statistics
115 |
116 | - **Total Tools**: ${stats.canonicalTools} canonical tools + ${stats.reExportTools} re-exports = ${stats.totalTools} total
117 | - **Workflow Groups**: ${stats.workflowCount}
118 |
119 | ---
120 |
121 | *This documentation is automatically generated by \`scripts/update-tools-docs.ts\` using static analysis. Last updated: ${new Date().toISOString().split('T')[0]}*
122 | `;
123 |
124 | return content;
125 | }
126 |
127 | /**
128 | * Compare old and new content to show what changed
129 | */
130 | function showDiff(oldContent: string, newContent: string): void {
131 | if (!options.verbose) return;
132 |
133 | console.log(`${colors.bright}${colors.cyan}📄 Content Comparison:${colors.reset}`);
134 | console.log('─'.repeat(50));
135 |
136 | const oldLines = oldContent.split('\n');
137 | const newLines = newContent.split('\n');
138 |
139 | const maxLength = Math.max(oldLines.length, newLines.length);
140 | let changes = 0;
141 |
142 | for (let i = 0; i < maxLength; i++) {
143 | const oldLine = oldLines[i] || '';
144 | const newLine = newLines[i] || '';
145 |
146 | if (oldLine !== newLine) {
147 | changes++;
148 | if (changes <= 10) {
149 | // Show first 10 changes
150 | console.log(`${colors.red}- Line ${i + 1}: ${oldLine}${colors.reset}`);
151 | console.log(`${colors.green}+ Line ${i + 1}: ${newLine}${colors.reset}`);
152 | }
153 | }
154 | }
155 |
156 | if (changes > 10) {
157 | console.log(`${colors.yellow}... and ${changes - 10} more changes${colors.reset}`);
158 | }
159 |
160 | console.log(`${colors.blue}Total changes: ${changes} lines${colors.reset}\n`);
161 | }
162 |
163 | /**
164 | * Main execution function
165 | */
166 | async function main(): Promise<void> {
167 | try {
168 | console.log(
169 | `${colors.bright}${colors.blue}🔧 XcodeBuildMCP Tools Documentation Updater${colors.reset}`,
170 | );
171 |
172 | if (options.dryRun) {
173 | console.log(
174 | `${colors.yellow}🔍 Running in dry-run mode - no files will be modified${colors.reset}`,
175 | );
176 | }
177 |
178 | console.log(`${colors.cyan}📊 Analyzing tools...${colors.reset}`);
179 |
180 | // Get current tool analysis
181 | const analysis = await getStaticToolAnalysis();
182 |
183 | if (options.verbose) {
184 | console.log(
185 | `${colors.green}✓ Found ${analysis.stats.canonicalTools} canonical tools in ${analysis.stats.workflowCount} workflows${colors.reset}`,
186 | );
187 | console.log(
188 | `${colors.green}✓ Found ${analysis.stats.reExportTools} re-export files${colors.reset}`,
189 | );
190 | }
191 |
192 | // Generate new documentation content
193 | console.log(`${colors.cyan}📝 Generating documentation...${colors.reset}`);
194 | const newContent = generateToolsDocumentation(analysis);
195 |
196 | // Read current content for comparison
197 | let oldContent = '';
198 | if (fs.existsSync(docsPath)) {
199 | oldContent = fs.readFileSync(docsPath, 'utf-8');
200 | }
201 |
202 | // Check if content has changed
203 | if (oldContent === newContent) {
204 | console.log(`${colors.green}✅ Documentation is already up to date!${colors.reset}`);
205 | return;
206 | }
207 |
208 | // Show differences if verbose
209 | if (oldContent && options.verbose) {
210 | showDiff(oldContent, newContent);
211 | }
212 |
213 | if (options.dryRun) {
214 | console.log(
215 | `${colors.yellow}📋 Dry run completed. Documentation would be updated with:${colors.reset}`,
216 | );
217 | console.log(` - ${analysis.stats.canonicalTools} canonical tools`);
218 | console.log(` - ${analysis.stats.workflowCount} workflow groups`);
219 | console.log(` - ${newContent.split('\n').length} lines total`);
220 |
221 | if (!options.verbose) {
222 | console.log(`\n${colors.cyan}💡 Use --verbose to see detailed changes${colors.reset}`);
223 | }
224 |
225 | return;
226 | }
227 |
228 | // Write new content
229 | console.log(`${colors.cyan}✏️ Writing updated documentation...${colors.reset}`);
230 | fs.writeFileSync(docsPath, newContent, 'utf-8');
231 |
232 | console.log(
233 | `${colors.green}✅ Successfully updated ${path.relative(projectRoot, docsPath)}!${colors.reset}`,
234 | );
235 |
236 | if (options.verbose) {
237 | console.log(`\n${colors.bright}📈 Update Summary:${colors.reset}`);
238 | console.log(
239 | ` Tools: ${analysis.stats.canonicalTools} canonical + ${analysis.stats.reExportTools} re-exports = ${analysis.stats.totalTools} total`,
240 | );
241 | console.log(` Workflows: ${analysis.stats.workflowCount}`);
242 | console.log(` File size: ${(newContent.length / 1024).toFixed(1)}KB`);
243 | console.log(` Lines: ${newContent.split('\n').length}`);
244 | }
245 | } catch (error) {
246 | console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`);
247 | process.exit(1);
248 | }
249 | }
250 |
251 | // Run the updater
252 | main();
253 |
```
--------------------------------------------------------------------------------
/docs/CODE_QUALITY.md:
--------------------------------------------------------------------------------
```markdown
1 | # XcodeBuildMCP Code Quality Guide
2 |
3 | This guide consolidates all code quality, linting, and architectural compliance information for the XcodeBuildMCP project.
4 |
5 | ## Table of Contents
6 |
7 | 1. [Overview](#overview)
8 | 2. [ESLint Configuration](#eslint-configuration)
9 | 3. [Architectural Rules](#architectural-rules)
10 | 4. [Development Scripts](#development-scripts)
11 | 5. [Code Pattern Violations](#code-pattern-violations)
12 | 6. [Type Safety Migration](#type-safety-migration)
13 | 7. [Best Practices](#best-practices)
14 |
15 | ## Overview
16 |
17 | XcodeBuildMCP enforces code quality through multiple layers:
18 |
19 | 1. **ESLint**: Handles general code quality, TypeScript rules, and stylistic consistency
20 | 2. **TypeScript**: Enforces type safety with strict mode
21 | 3. **Pattern Checker**: Enforces XcodeBuildMCP-specific architectural rules
22 | 4. **Migration Scripts**: Track progress on type safety improvements
23 |
24 | ## ESLint Configuration
25 |
26 | ### Current Configuration
27 |
28 | The project uses a comprehensive ESLint setup that covers:
29 |
30 | - TypeScript type safety rules
31 | - Code style consistency
32 | - Import ordering
33 | - Unused variable detection
34 | - Testing best practices
35 |
36 | ### ESLint Rules
37 |
38 | For detailed ESLint rules and rationale, see [ESLINT_RULES.md](./ESLINT_RULES.md).
39 |
40 | ### Running ESLint
41 |
42 | ```bash
43 | # Check for linting issues
44 | npm run lint
45 |
46 | # Auto-fix linting issues
47 | npm run lint:fix
48 | ```
49 |
50 | ## Architectural Rules
51 |
52 | XcodeBuildMCP enforces several architectural patterns that cannot be expressed through ESLint:
53 |
54 | ### 1. Dependency Injection Pattern
55 |
56 | **Rule**: All tools must use dependency injection for external interactions.
57 |
58 | ✅ **Allowed**:
59 | - `createMockExecutor()` for command execution mocking
60 | - `createMockFileSystemExecutor()` for file system mocking
61 | - Logic functions accepting `executor?: CommandExecutor` parameter
62 |
63 | ❌ **Forbidden**:
64 | - Direct use of `vi.mock()`, `vi.fn()`, or any Vitest mocking
65 | - Direct calls to `execSync`, `spawn`, or `exec` in production code
66 | - Testing handler functions directly
67 |
68 | ### 2. Handler Signature Compliance
69 |
70 | **Rule**: MCP handlers must have exact signatures as required by the SDK.
71 |
72 | ✅ **Tool Handler Signature**:
73 | ```typescript
74 | async handler(args: Record<string, unknown>): Promise<ToolResponse>
75 | ```
76 |
77 | ✅ **Resource Handler Signature**:
78 | ```typescript
79 | async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }>
80 | ```
81 |
82 | ❌ **Forbidden**:
83 | - Multiple parameters in handlers
84 | - Optional parameters
85 | - Dependency injection parameters in handlers
86 |
87 | ### 3. Testing Architecture
88 |
89 | **Rule**: Tests must only call logic functions, never handlers directly.
90 |
91 | ✅ **Correct Pattern**:
92 | ```typescript
93 | const result = await myToolLogic(params, mockExecutor);
94 | ```
95 |
96 | ❌ **Forbidden Pattern**:
97 | ```typescript
98 | const result = await myTool.handler(params);
99 | ```
100 |
101 | ### 4. Server Type Safety
102 |
103 | **Rule**: MCP server instances must use proper SDK types, not generic casts.
104 |
105 | ✅ **Correct Pattern**:
106 | ```typescript
107 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
108 | const server = (globalThis as { mcpServer?: McpServer }).mcpServer;
109 | server.server.createMessage({...});
110 | ```
111 |
112 | ❌ **Forbidden Pattern**:
113 | ```typescript
114 | const server = (globalThis as { mcpServer?: Record<string, unknown> }).mcpServer;
115 | const serverInstance = (server.server ?? server) as Record<string, unknown> & {...};
116 | ```
117 |
118 | ## Development Scripts
119 |
120 | ### Core Scripts
121 |
122 | ```bash
123 | # Build the project
124 | npm run build
125 |
126 | # Run type checking
127 | npm run typecheck
128 |
129 | # Run tests
130 | npm run test
131 |
132 | # Check code patterns (architectural compliance)
133 | node scripts/check-code-patterns.js
134 |
135 | # Check type safety migration progress
136 | npm run check-migration
137 | ```
138 |
139 | ### Pattern Checker Usage
140 |
141 | The pattern checker enforces XcodeBuildMCP-specific architectural rules:
142 |
143 | ```bash
144 | # Check all patterns
145 | node scripts/check-code-patterns.js
146 |
147 | # Check specific pattern type
148 | node scripts/check-code-patterns.js --pattern=vitest
149 | node scripts/check-code-patterns.js --pattern=execsync
150 | node scripts/check-code-patterns.js --pattern=handler
151 | node scripts/check-code-patterns.js --pattern=handler-testing
152 | node scripts/check-code-patterns.js --pattern=server-typing
153 |
154 | # Get help
155 | node scripts/check-code-patterns.js --help
156 | ```
157 |
158 | ### Tool Summary Scripts
159 |
160 | ```bash
161 | # Show tool and resource summary
162 | npm run tools
163 |
164 | # List all tools
165 | npm run tools:list
166 |
167 | # List both tools and resources
168 | npm run tools:all
169 | ```
170 |
171 | ## Code Pattern Violations
172 |
173 | The pattern checker identifies the following violations:
174 |
175 | ### 1. Vitest Mocking Violations
176 |
177 | **What**: Any use of Vitest mocking functions
178 | **Why**: Breaks dependency injection architecture
179 | **Fix**: Use `createMockExecutor()` instead
180 |
181 | ### 2. ExecSync Violations
182 |
183 | **What**: Direct use of Node.js child_process functions in production code
184 | **Why**: Bypasses CommandExecutor dependency injection
185 | **Fix**: Accept `CommandExecutor` parameter and use it
186 |
187 | ### 3. Handler Signature Violations
188 |
189 | **What**: Handlers with incorrect parameter signatures
190 | **Why**: MCP SDK requires exact signatures
191 | **Fix**: Move dependencies inside handler body
192 |
193 | ### 4. Handler Testing Violations
194 |
195 | **What**: Tests calling `.handler()` directly
196 | **Why**: Violates dependency injection principle
197 | **Fix**: Test logic functions instead
198 |
199 | ### 5. Improper Server Typing Violations
200 |
201 | **What**: Casting MCP server instances to `Record<string, unknown>` or using custom interfaces instead of SDK types
202 | **Why**: Breaks type safety and prevents proper API usage
203 | **Fix**: Import `McpServer` from SDK and use proper typing instead of generic casts
204 |
205 | ## Type Safety Migration
206 |
207 | The project is migrating to improved type safety using the `createTypedTool` factory:
208 |
209 | ### Check Migration Status
210 |
211 | ```bash
212 | # Show summary
213 | npm run check-migration
214 |
215 | # Show detailed analysis
216 | npm run check-migration:verbose
217 |
218 | # Show only unmigrated tools
219 | npm run check-migration:unfixed
220 | ```
221 |
222 | ### Migration Benefits
223 |
224 | 1. **Compile-time type safety** for tool parameters
225 | 2. **Automatic Zod schema validation**
226 | 3. **Better IDE support** and autocomplete
227 | 4. **Consistent error handling**
228 |
229 | ## Best Practices
230 |
231 | ### 1. Before Committing
232 |
233 | Always run these checks before committing:
234 |
235 | ```bash
236 | npm run build # Ensure code compiles
237 | npm run typecheck # Check TypeScript types
238 | npm run lint # Check linting rules
239 | npm run test # Run tests
240 | node scripts/check-code-patterns.js # Check architectural compliance
241 | ```
242 |
243 | ### 2. Adding New Tools
244 |
245 | 1. Use dependency injection pattern
246 | 2. Follow handler signature requirements
247 | 3. Create comprehensive tests (test logic, not handlers)
248 | 4. Use `createTypedTool` factory for type safety
249 | 5. Document parameter schemas clearly
250 |
251 | ### 3. Writing Tests
252 |
253 | 1. Import the logic function, not the default export
254 | 2. Use `createMockExecutor()` for mocking
255 | 3. Test three dimensions: validation, command generation, output processing
256 | 4. Never test handlers directly
257 |
258 | ### 4. Code Organization
259 |
260 | 1. Keep tools in appropriate workflow directories
261 | 2. Share common tools via `-shared` directories
262 | 3. Re-export shared tools, don't duplicate
263 | 4. Follow naming conventions for tools
264 |
265 | ## Automated Enforcement
266 |
267 | The project uses multiple layers of automated enforcement:
268 |
269 | 1. **Pre-commit**: ESLint and TypeScript checks (if configured)
270 | 2. **CI Pipeline**: All checks run on every PR
271 | 3. **PR Blocking**: Checks must pass before merge
272 | 4. **Code Review**: Automated and manual review processes
273 |
274 | ## Troubleshooting
275 |
276 | ### ESLint False Positives
277 |
278 | If ESLint reports false positives in test files, check that:
279 | 1. Test files are properly configured in `.eslintrc.json`
280 | 2. Test-specific rules are applied correctly
281 | 3. File patterns match your test file locations
282 |
283 | ### Pattern Checker Issues
284 |
285 | If the pattern checker reports unexpected violations:
286 | 1. Check if it's a legitimate architectural violation
287 | 2. Verify the file is in the correct directory
288 | 3. Ensure you're using the latest pattern definitions
289 |
290 | ### Type Safety Migration
291 |
292 | If migration tooling reports incorrect status:
293 | 1. Ensure the tool exports follow standard patterns
294 | 2. Check that schema definitions are properly typed
295 | 3. Verify the handler uses the schema correctly
296 |
297 | ## Future Improvements
298 |
299 | 1. **Automated Fixes**: Add auto-fix capability to pattern checker
300 | 2. **IDE Integration**: Create VS Code extension for real-time checking
301 | 3. **Performance Metrics**: Add build and test performance tracking
302 | 4. **Complexity Analysis**: Add code complexity metrics
303 | 5. **Documentation Linting**: Add documentation quality checks
```
--------------------------------------------------------------------------------
/.github/workflows/droid-code-review.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Droid Code Review
2 |
3 | on:
4 | pull_request:
5 | types: [opened, synchronize, reopened, ready_for_review]
6 |
7 | concurrency:
8 | group: droid-review-${{ github.event.pull_request.number }}
9 | cancel-in-progress: true
10 |
11 | permissions:
12 | pull-requests: write
13 | contents: read
14 | issues: write
15 |
16 | jobs:
17 | code-review:
18 | runs-on: ubuntu-latest
19 | timeout-minutes: 15
20 | # Skip automated code review for draft PRs
21 | if: github.event.pull_request.draft == false
22 |
23 | steps:
24 | - name: Checkout repository
25 | uses: actions/checkout@v4
26 | with:
27 | fetch-depth: 0
28 | ref: ${{ github.event.pull_request.head.sha }}
29 |
30 | - name: Install Droid CLI
31 | run: |
32 | curl -fsSL https://app.factory.ai/cli | sh
33 | echo "$HOME/.local/bin" >> $GITHUB_PATH
34 | "$HOME/.local/bin/droid" --version
35 |
36 | - name: Configure git identity
37 | run: |
38 | git config user.name "Droid Agent"
39 | git config user.email "[email protected]"
40 |
41 | - name: Prepare review context
42 | run: |
43 | # Get the PR diff
44 | git fetch origin ${{ github.event.pull_request.base.ref }}
45 | git diff origin/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.sha }} > diff.txt
46 |
47 | # Get existing comments using GitHub API
48 | curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
49 | -H "Accept: application/vnd.github.v3+json" \
50 | "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
51 | > existing_comments.json
52 |
53 | # Get changed files with patches for positioning
54 | curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
55 | -H "Accept: application/vnd.github.v3+json" \
56 | "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
57 | | jq '[.[] | {filename: .filename, patch: .patch}]' > files.json
58 |
59 | - name: Perform automated code review
60 | env:
61 | FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
62 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63 | run: |
64 | cat > prompt.txt << 'EOF'
65 | You are an automated code review system. Review the PR diff and identify clear issues that need to be fixed.
66 |
67 | Input files (already in current directory):
68 | - diff.txt: the code changes to review
69 | - files.json: file patches with line numbers for positioning comments
70 | - existing_comments.json: skip issues already mentioned here
71 |
72 | Task: Create a file called comments.json with this exact format:
73 | [{ "path": "path/to/file.js", "position": 42, "body": "Your comment here" }]
74 |
75 | Focus on these types of issues:
76 | - Dead/unreachable code (if (false), while (false), code after return/throw/break)
77 | - Broken control flow (missing break in switch, fallthrough bugs)
78 | - Async/await mistakes (missing await, .then without return, unhandled promise rejections)
79 | - Array/object mutations in React components or reducers
80 | - UseEffect dependency array problems (missing deps, incorrect deps)
81 | - Incorrect operator usage (== vs ===, && vs ||, = in conditions)
82 | - Off-by-one errors in loops or array indexing
83 | - Integer overflow/underflow in calculations
84 | - Regex catastrophic backtracking vulnerabilities
85 | - Missing base cases in recursive functions
86 | - Incorrect type coercion that changes behavior
87 | - Environment variable access without defaults or validation
88 | - Null/undefined dereferences
89 | - Resource leaks (unclosed files or connections)
90 | - SQL/XSS injection vulnerabilities
91 | - Concurrency/race conditions
92 | - Missing error handling for critical operations
93 |
94 | Comment format:
95 | - Clearly describe the issue: "This code block is unreachable due to the if (false) condition"
96 | - Provide a concrete fix: "Remove this entire if block as it will never execute"
97 | - When possible, suggest the exact code change:
98 | ```suggestion
99 | // Remove the unreachable code
100 | ```
101 | - Be specific about why it's a problem: "This will cause a TypeError if input is null"
102 | - No emojis, just clear technical language
103 |
104 | Skip commenting on:
105 | - Code style, formatting, or naming conventions
106 | - Minor performance optimizations
107 | - Architectural decisions or design patterns
108 | - Features or functionality (unless broken)
109 | - Test coverage (unless tests are clearly broken)
110 |
111 | Position calculation:
112 | - Use the "position" field from files.json patches
113 | - This is the line number in the diff, not the file
114 | - Comments must align with exact changed lines only
115 |
116 | Output:
117 | - Empty array [] if no issues found
118 | - Otherwise array of comment objects with path, position, body
119 | - Each comment should be actionable and clear about what needs to be fixed
120 | - Maximum 10 comments total; prioritize the most critical issues
121 | EOF
122 |
123 | # Run droid exec with the prompt
124 | echo "Running code review analysis..."
125 | droid exec --auto high -f prompt.txt
126 |
127 | # Check if comments.json was created
128 | if [ ! -f comments.json ]; then
129 | echo "❌ ERROR: droid exec did not create comments.json"
130 | echo "This usually indicates the review run failed (e.g. missing FACTORY_API_KEY or runtime error)."
131 | exit 1
132 | fi
133 |
134 | echo "=== Review Results ==="
135 | cat comments.json
136 |
137 | - name: Submit inline review comments
138 | uses: actions/github-script@v7
139 | with:
140 | script: |
141 | const fs = require('fs');
142 | const prNumber = context.payload.pull_request.number;
143 |
144 | if (!fs.existsSync('comments.json')) {
145 | core.info('comments.json missing; skipping review submission');
146 | return;
147 | }
148 |
149 | const comments = JSON.parse(fs.readFileSync('comments.json', 'utf8'));
150 |
151 | if (!Array.isArray(comments) || comments.length === 0) {
152 | // Check if we already have a "no issues" comment
153 | const existing = await github.paginate(github.rest.issues.listComments, {
154 | owner: context.repo.owner,
155 | repo: context.repo.repo,
156 | issue_number: prNumber,
157 | per_page: 100
158 | });
159 |
160 | const hasNoIssuesComment = existing.some(c =>
161 | c.user.login.includes('[bot]') &&
162 | /no issues found|lgtm|✅/i.test(c.body || '')
163 | );
164 |
165 | if (!hasNoIssuesComment) {
166 | await github.rest.pulls.createReview({
167 | owner: context.repo.owner,
168 | repo: context.repo.repo,
169 | pull_number: prNumber,
170 | event: 'COMMENT',
171 | body: '✅ No issues found in the current changes.'
172 | });
173 | }
174 | return;
175 | }
176 |
177 | // Submit review with inline comments
178 | const summary = `Found ${comments.length} potential issue${comments.length === 1 ? '' : 's'} that should be addressed.`;
179 |
180 | await github.rest.pulls.createReview({
181 | owner: context.repo.owner,
182 | repo: context.repo.repo,
183 | pull_number: prNumber,
184 | event: 'COMMENT',
185 | body: summary,
186 | comments: comments
187 | });
188 |
189 | core.info(`Submitted review with ${comments.length} inline comments`);
190 |
191 | - name: Upload debug artifacts on failure
192 | if: ${{ failure() }}
193 | uses: actions/upload-artifact@v4
194 | with:
195 | name: droid-review-debug-${{ github.run_id }}
196 | path: |
197 | diff.txt
198 | files.json
199 | existing_comments.json
200 | prompt.txt
201 | comments.json
202 | ${{ runner.home }}/.factory/logs/droid-log-single.log
203 | ${{ runner.home }}/.factory/logs/console.log
204 | if-no-files-found: ignore
205 | retention-days: 7
206 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/stop_app_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for stop_app_device plugin (device-shared)
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import { z } from 'zod';
9 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
10 | import stopAppDevice, { stop_app_deviceLogic } from '../stop_app_device.ts';
11 | import { sessionStore } from '../../../../utils/session-store.ts';
12 |
13 | describe('stop_app_device plugin', () => {
14 | beforeEach(() => {
15 | sessionStore.clear();
16 | });
17 |
18 | describe('Export Field Validation (Literal)', () => {
19 | it('should have correct name', () => {
20 | expect(stopAppDevice.name).toBe('stop_app_device');
21 | });
22 |
23 | it('should have correct description', () => {
24 | expect(stopAppDevice.description).toBe('Stops a running app on a connected device.');
25 | });
26 |
27 | it('should have handler function', () => {
28 | expect(typeof stopAppDevice.handler).toBe('function');
29 | });
30 |
31 | it('should require processId in public schema', () => {
32 | const schema = z.object(stopAppDevice.schema).strict();
33 | expect(schema.safeParse({ processId: 12345 }).success).toBe(true);
34 | expect(schema.safeParse({}).success).toBe(false);
35 | expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false);
36 |
37 | expect(Object.keys(stopAppDevice.schema)).toEqual(['processId']);
38 | });
39 | });
40 |
41 | describe('Handler Requirements', () => {
42 | it('should require deviceId when not provided', async () => {
43 | const result = await stopAppDevice.handler({ processId: 12345 });
44 |
45 | expect(result.isError).toBe(true);
46 | expect(result.content[0].text).toContain('deviceId is required');
47 | });
48 | });
49 |
50 | describe('Command Generation', () => {
51 | it('should generate correct devicectl command with basic parameters', async () => {
52 | let capturedCommand: unknown[] = [];
53 | let capturedDescription: string = '';
54 | let capturedUseShell: boolean = false;
55 | let capturedEnv: unknown = undefined;
56 |
57 | const mockExecutor = createMockExecutor({
58 | success: true,
59 | output: 'App terminated successfully',
60 | process: { pid: 12345 },
61 | });
62 |
63 | const trackingExecutor = async (
64 | command: unknown[],
65 | description: string,
66 | useShell: boolean,
67 | env: unknown,
68 | ) => {
69 | capturedCommand = command;
70 | capturedDescription = description;
71 | capturedUseShell = useShell;
72 | capturedEnv = env;
73 | return mockExecutor(command, description, useShell, env);
74 | };
75 |
76 | await stop_app_deviceLogic(
77 | {
78 | deviceId: 'test-device-123',
79 | processId: 12345,
80 | },
81 | trackingExecutor,
82 | );
83 |
84 | expect(capturedCommand).toEqual([
85 | 'xcrun',
86 | 'devicectl',
87 | 'device',
88 | 'process',
89 | 'terminate',
90 | '--device',
91 | 'test-device-123',
92 | '--pid',
93 | '12345',
94 | ]);
95 | expect(capturedDescription).toBe('Stop app on device');
96 | expect(capturedUseShell).toBe(true);
97 | expect(capturedEnv).toBe(undefined);
98 | });
99 |
100 | it('should generate correct command with different device ID and process ID', async () => {
101 | let capturedCommand: unknown[] = [];
102 |
103 | const mockExecutor = createMockExecutor({
104 | success: true,
105 | output: 'Process terminated',
106 | process: { pid: 12345 },
107 | });
108 |
109 | const trackingExecutor = async (command: unknown[]) => {
110 | capturedCommand = command;
111 | return mockExecutor(command);
112 | };
113 |
114 | await stop_app_deviceLogic(
115 | {
116 | deviceId: 'different-device-uuid',
117 | processId: 99999,
118 | },
119 | trackingExecutor,
120 | );
121 |
122 | expect(capturedCommand).toEqual([
123 | 'xcrun',
124 | 'devicectl',
125 | 'device',
126 | 'process',
127 | 'terminate',
128 | '--device',
129 | 'different-device-uuid',
130 | '--pid',
131 | '99999',
132 | ]);
133 | });
134 |
135 | it('should generate correct command with large process ID', async () => {
136 | let capturedCommand: unknown[] = [];
137 |
138 | const mockExecutor = createMockExecutor({
139 | success: true,
140 | output: 'Process terminated',
141 | process: { pid: 12345 },
142 | });
143 |
144 | const trackingExecutor = async (command: unknown[]) => {
145 | capturedCommand = command;
146 | return mockExecutor(command);
147 | };
148 |
149 | await stop_app_deviceLogic(
150 | {
151 | deviceId: 'test-device-123',
152 | processId: 2147483647,
153 | },
154 | trackingExecutor,
155 | );
156 |
157 | expect(capturedCommand).toEqual([
158 | 'xcrun',
159 | 'devicectl',
160 | 'device',
161 | 'process',
162 | 'terminate',
163 | '--device',
164 | 'test-device-123',
165 | '--pid',
166 | '2147483647',
167 | ]);
168 | });
169 | });
170 |
171 | describe('Success Path Tests', () => {
172 | it('should return successful stop response', async () => {
173 | const mockExecutor = createMockExecutor({
174 | success: true,
175 | output: 'App terminated successfully',
176 | });
177 |
178 | const result = await stop_app_deviceLogic(
179 | {
180 | deviceId: 'test-device-123',
181 | processId: 12345,
182 | },
183 | mockExecutor,
184 | );
185 |
186 | expect(result).toEqual({
187 | content: [
188 | {
189 | type: 'text',
190 | text: '✅ App stopped successfully\n\nApp terminated successfully',
191 | },
192 | ],
193 | });
194 | });
195 |
196 | it('should return successful stop with detailed output', async () => {
197 | const mockExecutor = createMockExecutor({
198 | success: true,
199 | output: 'Terminating process...\nProcess ID: 12345\nTermination completed successfully',
200 | });
201 |
202 | const result = await stop_app_deviceLogic(
203 | {
204 | deviceId: 'device-456',
205 | processId: 67890,
206 | },
207 | mockExecutor,
208 | );
209 |
210 | expect(result).toEqual({
211 | content: [
212 | {
213 | type: 'text',
214 | text: '✅ App stopped successfully\n\nTerminating process...\nProcess ID: 12345\nTermination completed successfully',
215 | },
216 | ],
217 | });
218 | });
219 |
220 | it('should return successful stop with empty output', async () => {
221 | const mockExecutor = createMockExecutor({
222 | success: true,
223 | output: '',
224 | });
225 |
226 | const result = await stop_app_deviceLogic(
227 | {
228 | deviceId: 'empty-output-device',
229 | processId: 54321,
230 | },
231 | mockExecutor,
232 | );
233 |
234 | expect(result).toEqual({
235 | content: [
236 | {
237 | type: 'text',
238 | text: '✅ App stopped successfully\n\n',
239 | },
240 | ],
241 | });
242 | });
243 | });
244 |
245 | describe('Error Handling', () => {
246 | it('should return stop failure response', async () => {
247 | const mockExecutor = createMockExecutor({
248 | success: false,
249 | error: 'Terminate failed: Process not found',
250 | });
251 |
252 | const result = await stop_app_deviceLogic(
253 | {
254 | deviceId: 'test-device-123',
255 | processId: 99999,
256 | },
257 | mockExecutor,
258 | );
259 |
260 | expect(result).toEqual({
261 | content: [
262 | {
263 | type: 'text',
264 | text: 'Failed to stop app: Terminate failed: Process not found',
265 | },
266 | ],
267 | isError: true,
268 | });
269 | });
270 |
271 | it('should return exception handling response', async () => {
272 | const mockExecutor = createMockExecutor(new Error('Network error'));
273 |
274 | const result = await stop_app_deviceLogic(
275 | {
276 | deviceId: 'test-device-123',
277 | processId: 12345,
278 | },
279 | mockExecutor,
280 | );
281 |
282 | expect(result).toEqual({
283 | content: [
284 | {
285 | type: 'text',
286 | text: 'Failed to stop app on device: Network error',
287 | },
288 | ],
289 | isError: true,
290 | });
291 | });
292 |
293 | it('should return string error handling response', async () => {
294 | const mockExecutor = createMockExecutor('String error');
295 |
296 | const result = await stop_app_deviceLogic(
297 | {
298 | deviceId: 'test-device-123',
299 | processId: 12345,
300 | },
301 | mockExecutor,
302 | );
303 |
304 | expect(result).toEqual({
305 | content: [
306 | {
307 | type: 'text',
308 | text: 'Failed to stop app on device: String error',
309 | },
310 | ],
311 | isError: true,
312 | });
313 | });
314 | });
315 | });
316 |
```
--------------------------------------------------------------------------------
/src/utils/test-common.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Common Test Utilities - Shared logic for test tools
3 | *
4 | * This module provides shared functionality for all test-related tools across different platforms.
5 | * It includes common test execution logic, xcresult parsing, and utility functions used by
6 | * platform-specific test tools.
7 | *
8 | * Responsibilities:
9 | * - Parsing xcresult bundles into human-readable format
10 | * - Shared test execution logic with platform-specific handling
11 | * - Common error handling and cleanup for test operations
12 | * - Temporary directory management for xcresult files
13 | */
14 |
15 | import { promisify } from 'util';
16 | import { exec } from 'child_process';
17 | import { mkdtemp, rm } from 'fs/promises';
18 | import { tmpdir } from 'os';
19 | import { join } from 'path';
20 | import { log } from './logger.ts';
21 | import { XcodePlatform } from './xcode.ts';
22 | import { executeXcodeBuildCommand } from './build/index.ts';
23 | import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts';
24 | import { normalizeTestRunnerEnv } from './environment.ts';
25 | import { ToolResponse } from '../types/common.ts';
26 | import { CommandExecutor, CommandExecOptions, getDefaultCommandExecutor } from './command.ts';
27 |
28 | /**
29 | * Type definition for test summary structure from xcresulttool
30 | */
31 | interface TestSummary {
32 | title?: string;
33 | result?: string;
34 | totalTestCount?: number;
35 | passedTests?: number;
36 | failedTests?: number;
37 | skippedTests?: number;
38 | expectedFailures?: number;
39 | environmentDescription?: string;
40 | devicesAndConfigurations?: Array<{
41 | device?: {
42 | deviceName?: string;
43 | platform?: string;
44 | osVersion?: string;
45 | };
46 | }>;
47 | testFailures?: Array<{
48 | testName?: string;
49 | targetName?: string;
50 | failureText?: string;
51 | }>;
52 | topInsights?: Array<{
53 | impact?: string;
54 | text?: string;
55 | }>;
56 | }
57 |
58 | /**
59 | * Parse xcresult bundle using xcrun xcresulttool
60 | */
61 | export async function parseXcresultBundle(resultBundlePath: string): Promise<string> {
62 | try {
63 | const execAsync = promisify(exec);
64 | const { stdout } = await execAsync(
65 | `xcrun xcresulttool get test-results summary --path "${resultBundlePath}"`,
66 | );
67 |
68 | // Parse JSON response and format as human-readable
69 | const summary = JSON.parse(stdout) as TestSummary;
70 | return formatTestSummary(summary);
71 | } catch (error) {
72 | const errorMessage = error instanceof Error ? error.message : String(error);
73 | log('error', `Error parsing xcresult bundle: ${errorMessage}`);
74 | throw error;
75 | }
76 | }
77 |
78 | /**
79 | * Format test summary JSON into human-readable text
80 | */
81 | function formatTestSummary(summary: TestSummary): string {
82 | const lines: string[] = [];
83 |
84 | lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`);
85 | lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`);
86 | lines.push('');
87 |
88 | lines.push('Test Counts:');
89 | lines.push(` Total: ${summary.totalTestCount ?? 0}`);
90 | lines.push(` Passed: ${summary.passedTests ?? 0}`);
91 | lines.push(` Failed: ${summary.failedTests ?? 0}`);
92 | lines.push(` Skipped: ${summary.skippedTests ?? 0}`);
93 | lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`);
94 | lines.push('');
95 |
96 | if (summary.environmentDescription) {
97 | lines.push(`Environment: ${summary.environmentDescription}`);
98 | lines.push('');
99 | }
100 |
101 | if (
102 | summary.devicesAndConfigurations &&
103 | Array.isArray(summary.devicesAndConfigurations) &&
104 | summary.devicesAndConfigurations.length > 0
105 | ) {
106 | const device = summary.devicesAndConfigurations[0].device;
107 | if (device) {
108 | lines.push(
109 | `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`,
110 | );
111 | lines.push('');
112 | }
113 | }
114 |
115 | if (
116 | summary.testFailures &&
117 | Array.isArray(summary.testFailures) &&
118 | summary.testFailures.length > 0
119 | ) {
120 | lines.push('Test Failures:');
121 | summary.testFailures.forEach((failure, index: number) => {
122 | lines.push(
123 | ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`,
124 | );
125 | if (failure.failureText) {
126 | lines.push(` ${failure.failureText}`);
127 | }
128 | });
129 | lines.push('');
130 | }
131 |
132 | if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) {
133 | lines.push('Insights:');
134 | summary.topInsights.forEach((insight, index: number) => {
135 | lines.push(
136 | ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`,
137 | );
138 | });
139 | }
140 |
141 | return lines.join('\n');
142 | }
143 |
144 | /**
145 | * Internal logic for running tests with platform-specific handling
146 | */
147 | export async function handleTestLogic(
148 | params: {
149 | workspacePath?: string;
150 | projectPath?: string;
151 | scheme: string;
152 | configuration: string;
153 | simulatorName?: string;
154 | simulatorId?: string;
155 | deviceId?: string;
156 | useLatestOS?: boolean;
157 | derivedDataPath?: string;
158 | extraArgs?: string[];
159 | preferXcodebuild?: boolean;
160 | platform: XcodePlatform;
161 | testRunnerEnv?: Record<string, string>;
162 | },
163 | executor?: CommandExecutor,
164 | ): Promise<ToolResponse> {
165 | log(
166 | 'info',
167 | `Starting test run for scheme ${params.scheme} on platform ${params.platform} (internal)`,
168 | );
169 |
170 | try {
171 | // Create temporary directory for xcresult bundle
172 | const tempDir = await mkdtemp(join(tmpdir(), 'xcodebuild-test-'));
173 | const resultBundlePath = join(tempDir, 'TestResults.xcresult');
174 |
175 | // Add resultBundlePath to extraArgs
176 | const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath];
177 |
178 | // Prepare execution options with TEST_RUNNER_ environment variables
179 | const execOpts: CommandExecOptions | undefined = params.testRunnerEnv
180 | ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) }
181 | : undefined;
182 |
183 | // Run the test command
184 | const testResult = await executeXcodeBuildCommand(
185 | {
186 | ...params,
187 | extraArgs,
188 | },
189 | {
190 | platform: params.platform,
191 | simulatorName: params.simulatorName,
192 | simulatorId: params.simulatorId,
193 | deviceId: params.deviceId,
194 | useLatestOS: params.useLatestOS,
195 | logPrefix: 'Test Run',
196 | },
197 | params.preferXcodebuild,
198 | 'test',
199 | executor ?? getDefaultCommandExecutor(),
200 | execOpts,
201 | );
202 |
203 | // Parse xcresult bundle if it exists, regardless of whether tests passed or failed
204 | // Test failures are expected and should not prevent xcresult parsing
205 | try {
206 | log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`);
207 |
208 | // Check if the file exists
209 | try {
210 | const { stat } = await import('fs/promises');
211 | await stat(resultBundlePath);
212 | log('info', `xcresult bundle exists at: ${resultBundlePath}`);
213 | } catch {
214 | log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`);
215 | throw new Error(`xcresult bundle not found at ${resultBundlePath}`);
216 | }
217 |
218 | const testSummary = await parseXcresultBundle(resultBundlePath);
219 | log('info', 'Successfully parsed xcresult bundle');
220 |
221 | // Clean up temporary directory
222 | await rm(tempDir, { recursive: true, force: true });
223 |
224 | // Return combined result - preserve isError from testResult (test failures should be marked as errors)
225 | const combinedResponse: ToolResponse = {
226 | content: [
227 | ...(testResult.content || []),
228 | {
229 | type: 'text',
230 | text: '\nTest Results Summary:\n' + testSummary,
231 | },
232 | ],
233 | isError: testResult.isError,
234 | };
235 |
236 | // Apply Claude Code workaround if enabled
237 | return consolidateContentForClaudeCode(combinedResponse);
238 | } catch (parseError) {
239 | // If parsing fails, return original test result
240 | log('warn', `Failed to parse xcresult bundle: ${parseError}`);
241 |
242 | // Clean up temporary directory even if parsing fails
243 | try {
244 | await rm(tempDir, { recursive: true, force: true });
245 | } catch (cleanupError) {
246 | log('warn', `Failed to clean up temporary directory: ${cleanupError}`);
247 | }
248 |
249 | return consolidateContentForClaudeCode(testResult);
250 | }
251 | } catch (error) {
252 | const errorMessage = error instanceof Error ? error.message : String(error);
253 | log('error', `Error during test run: ${errorMessage}`);
254 | return consolidateContentForClaudeCode(
255 | createTextResponse(`Error during test run: ${errorMessage}`, true),
256 | );
257 | }
258 | }
259 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for swift_package_test plugin
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect } from 'vitest';
8 | import {
9 | createMockExecutor,
10 | createMockFileSystemExecutor,
11 | createNoopExecutor,
12 | } from '../../../../test-utils/mock-executors.ts';
13 | import swiftPackageTest, { swift_package_testLogic } from '../swift_package_test.ts';
14 |
15 | describe('swift_package_test plugin', () => {
16 | describe('Export Field Validation (Literal)', () => {
17 | it('should have correct name', () => {
18 | expect(swiftPackageTest.name).toBe('swift_package_test');
19 | });
20 |
21 | it('should have correct description', () => {
22 | expect(swiftPackageTest.description).toBe('Runs tests for a Swift Package with swift test');
23 | });
24 |
25 | it('should have handler function', () => {
26 | expect(typeof swiftPackageTest.handler).toBe('function');
27 | });
28 |
29 | it('should validate schema correctly', () => {
30 | // Test required fields
31 | expect(swiftPackageTest.schema.packagePath.safeParse('/test/package').success).toBe(true);
32 | expect(swiftPackageTest.schema.packagePath.safeParse('').success).toBe(true);
33 |
34 | // Test optional fields
35 | expect(swiftPackageTest.schema.testProduct.safeParse('MyTests').success).toBe(true);
36 | expect(swiftPackageTest.schema.testProduct.safeParse(undefined).success).toBe(true);
37 | expect(swiftPackageTest.schema.filter.safeParse('Test.*').success).toBe(true);
38 | expect(swiftPackageTest.schema.filter.safeParse(undefined).success).toBe(true);
39 | expect(swiftPackageTest.schema.configuration.safeParse('debug').success).toBe(true);
40 | expect(swiftPackageTest.schema.configuration.safeParse('release').success).toBe(true);
41 | expect(swiftPackageTest.schema.configuration.safeParse(undefined).success).toBe(true);
42 | expect(swiftPackageTest.schema.parallel.safeParse(true).success).toBe(true);
43 | expect(swiftPackageTest.schema.parallel.safeParse(undefined).success).toBe(true);
44 | expect(swiftPackageTest.schema.showCodecov.safeParse(true).success).toBe(true);
45 | expect(swiftPackageTest.schema.showCodecov.safeParse(undefined).success).toBe(true);
46 | expect(swiftPackageTest.schema.parseAsLibrary.safeParse(true).success).toBe(true);
47 | expect(swiftPackageTest.schema.parseAsLibrary.safeParse(undefined).success).toBe(true);
48 |
49 | // Test invalid inputs
50 | expect(swiftPackageTest.schema.packagePath.safeParse(null).success).toBe(false);
51 | expect(swiftPackageTest.schema.configuration.safeParse('invalid').success).toBe(false);
52 | expect(swiftPackageTest.schema.parallel.safeParse('yes').success).toBe(false);
53 | expect(swiftPackageTest.schema.showCodecov.safeParse('yes').success).toBe(false);
54 | expect(swiftPackageTest.schema.parseAsLibrary.safeParse('yes').success).toBe(false);
55 | });
56 | });
57 |
58 | describe('Command Generation Testing', () => {
59 | it('should build correct command for basic test', async () => {
60 | const calls: any[] = [];
61 | const mockExecutor = async (
62 | args: string[],
63 | name: string,
64 | hideOutput: boolean,
65 | workingDir: string | undefined,
66 | ) => {
67 | calls.push({ args, name, hideOutput, workingDir });
68 | return {
69 | success: true,
70 | output: 'Test Passed',
71 | error: undefined,
72 | process: { pid: 12345 },
73 | };
74 | };
75 |
76 | await swift_package_testLogic(
77 | {
78 | packagePath: '/test/package',
79 | },
80 | mockExecutor,
81 | );
82 |
83 | expect(calls).toHaveLength(1);
84 | expect(calls[0]).toEqual({
85 | args: ['swift', 'test', '--package-path', '/test/package'],
86 | name: 'Swift Package Test',
87 | hideOutput: true,
88 | workingDir: undefined,
89 | });
90 | });
91 |
92 | it('should build correct command with all parameters', async () => {
93 | const calls: any[] = [];
94 | const mockExecutor = async (
95 | args: string[],
96 | name: string,
97 | hideOutput: boolean,
98 | workingDir: string | undefined,
99 | ) => {
100 | calls.push({ args, name, hideOutput, workingDir });
101 | return {
102 | success: true,
103 | output: 'Tests completed',
104 | error: undefined,
105 | process: { pid: 12345 },
106 | };
107 | };
108 |
109 | await swift_package_testLogic(
110 | {
111 | packagePath: '/test/package',
112 | testProduct: 'MyTests',
113 | filter: 'Test.*',
114 | configuration: 'release',
115 | parallel: false,
116 | showCodecov: true,
117 | parseAsLibrary: true,
118 | },
119 | mockExecutor,
120 | );
121 |
122 | expect(calls).toHaveLength(1);
123 | expect(calls[0]).toEqual({
124 | args: [
125 | 'swift',
126 | 'test',
127 | '--package-path',
128 | '/test/package',
129 | '-c',
130 | 'release',
131 | '--test-product',
132 | 'MyTests',
133 | '--filter',
134 | 'Test.*',
135 | '--no-parallel',
136 | '--show-code-coverage',
137 | '-Xswiftc',
138 | '-parse-as-library',
139 | ],
140 | name: 'Swift Package Test',
141 | hideOutput: true,
142 | workingDir: undefined,
143 | });
144 | });
145 | });
146 |
147 | describe('Response Logic Testing', () => {
148 | it('should handle empty packagePath parameter', async () => {
149 | // When packagePath is empty, the function should still process it
150 | // but the command execution may fail, which is handled by the executor
151 | const mockExecutor = createMockExecutor({
152 | success: true,
153 | output: 'Tests completed with empty path',
154 | });
155 |
156 | const result = await swift_package_testLogic({ packagePath: '' }, mockExecutor);
157 |
158 | expect(result.isError).toBe(false);
159 | expect(result.content[0].text).toBe('✅ Swift package tests completed.');
160 | });
161 |
162 | it('should return successful test response', async () => {
163 | const mockExecutor = createMockExecutor({
164 | success: true,
165 | output: 'All tests passed.',
166 | });
167 |
168 | const result = await swift_package_testLogic(
169 | {
170 | packagePath: '/test/package',
171 | },
172 | mockExecutor,
173 | );
174 |
175 | expect(result).toEqual({
176 | content: [
177 | { type: 'text', text: '✅ Swift package tests completed.' },
178 | {
179 | type: 'text',
180 | text: '💡 Next: Execute your app with swift_package_run if tests passed',
181 | },
182 | { type: 'text', text: 'All tests passed.' },
183 | ],
184 | isError: false,
185 | });
186 | });
187 |
188 | it('should return error response for test failure', async () => {
189 | const mockExecutor = createMockExecutor({
190 | success: false,
191 | error: '2 tests failed',
192 | });
193 |
194 | const result = await swift_package_testLogic(
195 | {
196 | packagePath: '/test/package',
197 | },
198 | mockExecutor,
199 | );
200 |
201 | expect(result).toEqual({
202 | content: [
203 | {
204 | type: 'text',
205 | text: 'Error: Swift package tests failed\nDetails: 2 tests failed',
206 | },
207 | ],
208 | isError: true,
209 | });
210 | });
211 |
212 | it('should handle spawn error', async () => {
213 | const mockExecutor = async () => {
214 | throw new Error('spawn ENOENT');
215 | };
216 |
217 | const result = await swift_package_testLogic(
218 | {
219 | packagePath: '/test/package',
220 | },
221 | mockExecutor,
222 | );
223 |
224 | expect(result).toEqual({
225 | content: [
226 | {
227 | type: 'text',
228 | text: 'Error: Failed to execute swift test\nDetails: spawn ENOENT',
229 | },
230 | ],
231 | isError: true,
232 | });
233 | });
234 |
235 | it('should handle successful test with parameters', async () => {
236 | const mockExecutor = createMockExecutor({
237 | success: true,
238 | output: 'Tests completed.',
239 | });
240 |
241 | const result = await swift_package_testLogic(
242 | {
243 | packagePath: '/test/package',
244 | testProduct: 'MyTests',
245 | filter: 'Test.*',
246 | configuration: 'release',
247 | parallel: false,
248 | showCodecov: true,
249 | parseAsLibrary: true,
250 | },
251 | mockExecutor,
252 | );
253 |
254 | expect(result).toEqual({
255 | content: [
256 | { type: 'text', text: '✅ Swift package tests completed.' },
257 | {
258 | type: 'text',
259 | text: '💡 Next: Execute your app with swift_package_run if tests passed',
260 | },
261 | { type: 'text', text: 'Tests completed.' },
262 | ],
263 | isError: false,
264 | });
265 | });
266 | });
267 | });
268 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import { z } from 'zod';
3 | import {
4 | createMockExecutor,
5 | createMockFileSystemExecutor,
6 | createNoopExecutor,
7 | } from '../../../../test-utils/mock-executors.ts';
8 | import { sessionStore } from '../../../../utils/session-store.ts';
9 | import installAppSim, { install_app_simLogic } from '../install_app_sim.ts';
10 |
11 | describe('install_app_sim tool', () => {
12 | beforeEach(() => {
13 | sessionStore.clear();
14 | });
15 |
16 | describe('Export Field Validation (Literal)', () => {
17 | it('should have correct name', () => {
18 | expect(installAppSim.name).toBe('install_app_sim');
19 | });
20 |
21 | it('should have concise description', () => {
22 | expect(installAppSim.description).toBe('Installs an app in an iOS simulator.');
23 | });
24 |
25 | it('should expose public schema with only appPath', () => {
26 | const schema = z.object(installAppSim.schema);
27 |
28 | expect(schema.safeParse({ appPath: '/path/to/app.app' }).success).toBe(true);
29 | expect(schema.safeParse({ appPath: 42 }).success).toBe(false);
30 | expect(schema.safeParse({}).success).toBe(false);
31 |
32 | expect(Object.keys(installAppSim.schema)).toEqual(['appPath']);
33 | });
34 | });
35 |
36 | describe('Handler Requirements', () => {
37 | it('should require simulatorId when not provided', async () => {
38 | const result = await installAppSim.handler({ appPath: '/path/to/app.app' });
39 |
40 | expect(result.isError).toBe(true);
41 | expect(result.content[0].text).toContain('Missing required session defaults');
42 | expect(result.content[0].text).toContain('simulatorId is required');
43 | expect(result.content[0].text).toContain('session-set-defaults');
44 | });
45 |
46 | it('should validate appPath when simulatorId default exists', async () => {
47 | sessionStore.setDefaults({ simulatorId: 'SIM-UUID' });
48 |
49 | const result = await installAppSim.handler({});
50 |
51 | expect(result.isError).toBe(true);
52 | expect(result.content[0].text).toContain('Parameter validation failed');
53 | expect(result.content[0].text).toContain('appPath: Required');
54 | expect(result.content[0].text).toContain(
55 | 'Tip: set session defaults via session-set-defaults',
56 | );
57 | });
58 | });
59 |
60 | describe('Command Generation', () => {
61 | it('should generate correct simctl install command', async () => {
62 | const executorCalls: unknown[] = [];
63 | const mockExecutor = (...args: unknown[]) => {
64 | executorCalls.push(args);
65 | return Promise.resolve({
66 | success: true,
67 | output: 'App installed',
68 | error: undefined,
69 | process: { pid: 12345 },
70 | });
71 | };
72 |
73 | const mockFileSystem = createMockFileSystemExecutor({
74 | existsSync: () => true,
75 | });
76 |
77 | await install_app_simLogic(
78 | {
79 | simulatorId: 'test-uuid-123',
80 | appPath: '/path/to/app.app',
81 | },
82 | mockExecutor,
83 | mockFileSystem,
84 | );
85 |
86 | expect(executorCalls).toEqual([
87 | [
88 | ['xcrun', 'simctl', 'install', 'test-uuid-123', '/path/to/app.app'],
89 | 'Install App in Simulator',
90 | true,
91 | undefined,
92 | ],
93 | [
94 | ['defaults', 'read', '/path/to/app.app/Info', 'CFBundleIdentifier'],
95 | 'Extract Bundle ID',
96 | false,
97 | undefined,
98 | ],
99 | ]);
100 | });
101 |
102 | it('should generate command with different simulator identifier', async () => {
103 | const executorCalls: unknown[] = [];
104 | const mockExecutor = (...args: unknown[]) => {
105 | executorCalls.push(args);
106 | return Promise.resolve({
107 | success: true,
108 | output: 'App installed',
109 | error: undefined,
110 | process: { pid: 12345 },
111 | });
112 | };
113 |
114 | const mockFileSystem = createMockFileSystemExecutor({
115 | existsSync: () => true,
116 | });
117 |
118 | await install_app_simLogic(
119 | {
120 | simulatorId: 'different-uuid-456',
121 | appPath: '/different/path/MyApp.app',
122 | },
123 | mockExecutor,
124 | mockFileSystem,
125 | );
126 |
127 | expect(executorCalls).toEqual([
128 | [
129 | ['xcrun', 'simctl', 'install', 'different-uuid-456', '/different/path/MyApp.app'],
130 | 'Install App in Simulator',
131 | true,
132 | undefined,
133 | ],
134 | [
135 | ['defaults', 'read', '/different/path/MyApp.app/Info', 'CFBundleIdentifier'],
136 | 'Extract Bundle ID',
137 | false,
138 | undefined,
139 | ],
140 | ]);
141 | });
142 | });
143 |
144 | describe('Logic Behavior (Literal Returns)', () => {
145 | it('should handle file does not exist', async () => {
146 | const mockFileSystem = createMockFileSystemExecutor({
147 | existsSync: () => false,
148 | });
149 |
150 | const result = await install_app_simLogic(
151 | {
152 | simulatorId: 'test-uuid-123',
153 | appPath: '/path/to/app.app',
154 | },
155 | createNoopExecutor(),
156 | mockFileSystem,
157 | );
158 |
159 | expect(result).toEqual({
160 | content: [
161 | {
162 | type: 'text',
163 | text: "File not found: '/path/to/app.app'. Please check the path and try again.",
164 | },
165 | ],
166 | isError: true,
167 | });
168 | });
169 |
170 | it('should handle successful install', async () => {
171 | let callCount = 0;
172 | const mockExecutor = () => {
173 | callCount++;
174 | if (callCount === 1) {
175 | return Promise.resolve({
176 | success: true,
177 | output: 'App installed',
178 | error: undefined,
179 | process: { pid: 12345 },
180 | });
181 | }
182 | return Promise.resolve({
183 | success: true,
184 | output: 'com.example.myapp',
185 | error: undefined,
186 | process: { pid: 12345 },
187 | });
188 | };
189 |
190 | const mockFileSystem = createMockFileSystemExecutor({
191 | existsSync: () => true,
192 | });
193 |
194 | const result = await install_app_simLogic(
195 | {
196 | simulatorId: 'test-uuid-123',
197 | appPath: '/path/to/app.app',
198 | },
199 | mockExecutor,
200 | mockFileSystem,
201 | );
202 |
203 | expect(result).toEqual({
204 | content: [
205 | {
206 | type: 'text',
207 | text: 'App installed successfully in simulator test-uuid-123',
208 | },
209 | {
210 | type: 'text',
211 | text: `Next Steps:
212 | 1. Open the Simulator app: open_sim({})
213 | 2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "com.example.myapp" })`,
214 | },
215 | ],
216 | });
217 | });
218 |
219 | it('should handle command failure', async () => {
220 | const mockExecutor = () =>
221 | Promise.resolve({
222 | success: false,
223 | output: '',
224 | error: 'Install failed',
225 | process: { pid: 12345 },
226 | });
227 |
228 | const mockFileSystem = createMockFileSystemExecutor({
229 | existsSync: () => true,
230 | });
231 |
232 | const result = await install_app_simLogic(
233 | {
234 | simulatorId: 'test-uuid-123',
235 | appPath: '/path/to/app.app',
236 | },
237 | mockExecutor,
238 | mockFileSystem,
239 | );
240 |
241 | expect(result).toEqual({
242 | content: [
243 | {
244 | type: 'text',
245 | text: 'Install app in simulator operation failed: Install failed',
246 | },
247 | ],
248 | });
249 | });
250 |
251 | it('should handle exception with Error object', async () => {
252 | const mockExecutor = () => Promise.reject(new Error('Command execution failed'));
253 |
254 | const mockFileSystem = createMockFileSystemExecutor({
255 | existsSync: () => true,
256 | });
257 |
258 | const result = await install_app_simLogic(
259 | {
260 | simulatorId: 'test-uuid-123',
261 | appPath: '/path/to/app.app',
262 | },
263 | mockExecutor,
264 | mockFileSystem,
265 | );
266 |
267 | expect(result).toEqual({
268 | content: [
269 | {
270 | type: 'text',
271 | text: 'Install app in simulator operation failed: Command execution failed',
272 | },
273 | ],
274 | });
275 | });
276 |
277 | it('should handle exception with string error', async () => {
278 | const mockExecutor = () => Promise.reject('String error');
279 |
280 | const mockFileSystem = createMockFileSystemExecutor({
281 | existsSync: () => true,
282 | });
283 |
284 | const result = await install_app_simLogic(
285 | {
286 | simulatorId: 'test-uuid-123',
287 | appPath: '/path/to/app.app',
288 | },
289 | mockExecutor,
290 | mockFileSystem,
291 | );
292 |
293 | expect(result).toEqual({
294 | content: [
295 | {
296 | type: 'text',
297 | text: 'Install app in simulator operation failed: String error',
298 | },
299 | ],
300 | });
301 | });
302 | });
303 | });
304 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/swift-package/swift_package_run.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import path from 'node:path';
3 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts';
4 | import { log } from '../../../utils/logging/index.ts';
5 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
6 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
7 | import { ToolResponse, createTextContent } from '../../../types/common.ts';
8 | import { addProcess } from './active-processes.ts';
9 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
10 |
11 | // Define schema as ZodObject
12 | const swiftPackageRunSchema = z.object({
13 | packagePath: z.string().describe('Path to the Swift package root (Required)'),
14 | executableName: z
15 | .string()
16 | .optional()
17 | .describe('Name of executable to run (defaults to package name)'),
18 | arguments: z.array(z.string()).optional().describe('Arguments to pass to the executable'),
19 | configuration: z
20 | .enum(['debug', 'release'])
21 | .optional()
22 | .describe("Build configuration: 'debug' (default) or 'release'"),
23 | timeout: z.number().optional().describe('Timeout in seconds (default: 30, max: 300)'),
24 | background: z
25 | .boolean()
26 | .optional()
27 | .describe('Run in background and return immediately (default: false)'),
28 | parseAsLibrary: z
29 | .boolean()
30 | .optional()
31 | .describe('Add -parse-as-library flag for @main support (default: false)'),
32 | });
33 |
34 | // Use z.infer for type safety
35 | type SwiftPackageRunParams = z.infer<typeof swiftPackageRunSchema>;
36 |
37 | export async function swift_package_runLogic(
38 | params: SwiftPackageRunParams,
39 | executor: CommandExecutor,
40 | ): Promise<ToolResponse> {
41 | const resolvedPath = path.resolve(params.packagePath);
42 | const timeout = Math.min(params.timeout ?? 30, 300) * 1000; // Convert to ms, max 5 minutes
43 |
44 | // Detect test environment to prevent real spawn calls during testing
45 | const isTestEnvironment = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
46 |
47 | const swiftArgs = ['run', '--package-path', resolvedPath];
48 |
49 | if (params.configuration && params.configuration.toLowerCase() === 'release') {
50 | swiftArgs.push('-c', 'release');
51 | } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') {
52 | return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true);
53 | }
54 |
55 | if (params.parseAsLibrary) {
56 | swiftArgs.push('-Xswiftc', '-parse-as-library');
57 | }
58 |
59 | if (params.executableName) {
60 | swiftArgs.push(params.executableName);
61 | }
62 |
63 | // Add double dash before executable arguments
64 | if (params.arguments && params.arguments.length > 0) {
65 | swiftArgs.push('--');
66 | swiftArgs.push(...params.arguments);
67 | }
68 |
69 | log('info', `Running swift ${swiftArgs.join(' ')}`);
70 |
71 | try {
72 | if (params.background) {
73 | // Background mode: Use CommandExecutor but don't wait for completion
74 | if (isTestEnvironment) {
75 | // In test environment, return mock response without real process
76 | const mockPid = 12345;
77 | return {
78 | content: [
79 | createTextContent(
80 | `🚀 Started executable in background (PID: ${mockPid})\n` +
81 | `💡 Process is running independently. Use swift_package_stop with PID ${mockPid} to terminate when needed.`,
82 | ),
83 | ],
84 | };
85 | } else {
86 | // Production: use CommandExecutor to start the process
87 | const command = ['swift', ...swiftArgs];
88 | // Filter out undefined values from process.env
89 | const cleanEnv = Object.fromEntries(
90 | Object.entries(process.env).filter(([, value]) => value !== undefined),
91 | ) as Record<string, string>;
92 | const result = await executor(
93 | command,
94 | 'Swift Package Run (Background)',
95 | true,
96 | cleanEnv,
97 | true,
98 | );
99 |
100 | // Store the process in active processes system if available
101 | if (result.process?.pid) {
102 | addProcess(result.process.pid, {
103 | process: {
104 | kill: (signal?: string) => {
105 | // Adapt string signal to NodeJS.Signals
106 | if (result.process) {
107 | result.process.kill(signal as NodeJS.Signals);
108 | }
109 | },
110 | on: (event: string, callback: () => void) => {
111 | if (result.process) {
112 | result.process.on(event, callback);
113 | }
114 | },
115 | pid: result.process.pid,
116 | },
117 | startedAt: new Date(),
118 | });
119 |
120 | return {
121 | content: [
122 | createTextContent(
123 | `🚀 Started executable in background (PID: ${result.process.pid})\n` +
124 | `💡 Process is running independently. Use swift_package_stop with PID ${result.process.pid} to terminate when needed.`,
125 | ),
126 | ],
127 | };
128 | } else {
129 | return {
130 | content: [
131 | createTextContent(
132 | `🚀 Started executable in background\n` +
133 | `💡 Process is running independently. PID not available for this execution.`,
134 | ),
135 | ],
136 | };
137 | }
138 | }
139 | } else {
140 | // Foreground mode: use CommandExecutor but handle long-running processes
141 | const command = ['swift', ...swiftArgs];
142 |
143 | // Create a promise that will either complete with the command result or timeout
144 | const commandPromise = executor(command, 'Swift Package Run', true, undefined);
145 |
146 | const timeoutPromise = new Promise<{
147 | success: boolean;
148 | output: string;
149 | error: string;
150 | timedOut: boolean;
151 | }>((resolve) => {
152 | setTimeout(() => {
153 | resolve({
154 | success: false,
155 | output: '',
156 | error: `Process timed out after ${timeout / 1000} seconds`,
157 | timedOut: true,
158 | });
159 | }, timeout);
160 | });
161 |
162 | // Race between command completion and timeout
163 | const result = await Promise.race([commandPromise, timeoutPromise]);
164 |
165 | if ('timedOut' in result && result.timedOut) {
166 | // For timeout case, the process may still be running - provide timeout response
167 | if (isTestEnvironment) {
168 | // In test environment, return mock response
169 | const mockPid = 12345;
170 | return {
171 | content: [
172 | createTextContent(
173 | `⏱️ Process timed out after ${timeout / 1000} seconds but may continue running.`,
174 | ),
175 | createTextContent(`PID: ${mockPid} (mock)`),
176 | createTextContent(
177 | `💡 Process may still be running. Use swift_package_stop with PID ${mockPid} to terminate when needed.`,
178 | ),
179 | createTextContent(result.output || '(no output so far)'),
180 | ],
181 | };
182 | } else {
183 | // Production: timeout occurred, but we don't start a new process
184 | return {
185 | content: [
186 | createTextContent(`⏱️ Process timed out after ${timeout / 1000} seconds.`),
187 | createTextContent(
188 | `💡 Process execution exceeded the timeout limit. Consider using background mode for long-running executables.`,
189 | ),
190 | createTextContent(result.output || '(no output so far)'),
191 | ],
192 | };
193 | }
194 | }
195 |
196 | if (result.success) {
197 | return {
198 | content: [
199 | createTextContent('✅ Swift executable completed successfully.'),
200 | createTextContent('💡 Process finished cleanly. Check output for results.'),
201 | createTextContent(result.output || '(no output)'),
202 | ],
203 | };
204 | } else {
205 | const content = [
206 | createTextContent('❌ Swift executable failed.'),
207 | createTextContent(result.output || '(no output)'),
208 | ];
209 | if (result.error) {
210 | content.push(createTextContent(`Errors:\n${result.error}`));
211 | }
212 | return { content };
213 | }
214 | }
215 | } catch (error) {
216 | const message = error instanceof Error ? error.message : String(error);
217 | log('error', `Swift run failed: ${message}`);
218 | return createErrorResponse('Failed to execute swift run', message);
219 | }
220 | }
221 |
222 | export default {
223 | name: 'swift_package_run',
224 | description: 'Runs an executable target from a Swift Package with swift run',
225 | schema: swiftPackageRunSchema.shape, // MCP SDK compatibility
226 | handler: createTypedTool(
227 | swiftPackageRunSchema,
228 | swift_package_runLogic,
229 | getDefaultCommandExecutor,
230 | ),
231 | };
232 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/install_app_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for install_app_device plugin (device-shared)
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import { z } from 'zod';
9 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
10 | import installAppDevice, { install_app_deviceLogic } from '../install_app_device.ts';
11 | import { sessionStore } from '../../../../utils/session-store.ts';
12 |
13 | describe('install_app_device plugin', () => {
14 | beforeEach(() => {
15 | sessionStore.clear();
16 | });
17 |
18 | describe('Handler Requirements', () => {
19 | it('should require deviceId when session defaults are missing', async () => {
20 | const result = await installAppDevice.handler({
21 | appPath: '/path/to/test.app',
22 | });
23 |
24 | expect(result.isError).toBe(true);
25 | expect(result.content[0].text).toContain('deviceId is required');
26 | });
27 | });
28 |
29 | describe('Export Field Validation (Literal)', () => {
30 | it('should have correct name', () => {
31 | expect(installAppDevice.name).toBe('install_app_device');
32 | });
33 |
34 | it('should have correct description', () => {
35 | expect(installAppDevice.description).toBe('Installs an app on a connected device.');
36 | });
37 |
38 | it('should have handler function', () => {
39 | expect(typeof installAppDevice.handler).toBe('function');
40 | });
41 |
42 | it('should require appPath in public schema', () => {
43 | const schema = z.object(installAppDevice.schema).strict();
44 | expect(schema.safeParse({ appPath: '/path/to/test.app' }).success).toBe(true);
45 | expect(schema.safeParse({}).success).toBe(false);
46 | expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false);
47 |
48 | expect(Object.keys(installAppDevice.schema)).toEqual(['appPath']);
49 | });
50 | });
51 |
52 | describe('Command Generation', () => {
53 | it('should generate correct devicectl command with basic parameters', async () => {
54 | let capturedCommand: unknown[] = [];
55 | let capturedDescription: string = '';
56 | let capturedUseShell: boolean = false;
57 | let capturedEnv: unknown = undefined;
58 |
59 | const mockExecutor = createMockExecutor({
60 | success: true,
61 | output: 'App installation successful',
62 | process: { pid: 12345 },
63 | });
64 |
65 | const trackingExecutor = async (
66 | command: unknown[],
67 | description: string,
68 | useShell: boolean,
69 | env: unknown,
70 | ) => {
71 | capturedCommand = command;
72 | capturedDescription = description;
73 | capturedUseShell = useShell;
74 | capturedEnv = env;
75 | return mockExecutor(command, description, useShell, env);
76 | };
77 |
78 | await install_app_deviceLogic(
79 | {
80 | deviceId: 'test-device-123',
81 | appPath: '/path/to/test.app',
82 | },
83 | trackingExecutor,
84 | );
85 |
86 | expect(capturedCommand).toEqual([
87 | 'xcrun',
88 | 'devicectl',
89 | 'device',
90 | 'install',
91 | 'app',
92 | '--device',
93 | 'test-device-123',
94 | '/path/to/test.app',
95 | ]);
96 | expect(capturedDescription).toBe('Install app on device');
97 | expect(capturedUseShell).toBe(true);
98 | expect(capturedEnv).toBe(undefined);
99 | });
100 |
101 | it('should generate correct command with different device ID', async () => {
102 | let capturedCommand: unknown[] = [];
103 |
104 | const mockExecutor = createMockExecutor({
105 | success: true,
106 | output: 'App installation successful',
107 | process: { pid: 12345 },
108 | });
109 |
110 | const trackingExecutor = async (command: unknown[]) => {
111 | capturedCommand = command;
112 | return mockExecutor(command);
113 | };
114 |
115 | await install_app_deviceLogic(
116 | {
117 | deviceId: 'different-device-uuid',
118 | appPath: '/apps/MyApp.app',
119 | },
120 | trackingExecutor,
121 | );
122 |
123 | expect(capturedCommand).toEqual([
124 | 'xcrun',
125 | 'devicectl',
126 | 'device',
127 | 'install',
128 | 'app',
129 | '--device',
130 | 'different-device-uuid',
131 | '/apps/MyApp.app',
132 | ]);
133 | });
134 |
135 | it('should generate correct command with paths containing spaces', async () => {
136 | let capturedCommand: unknown[] = [];
137 |
138 | const mockExecutor = createMockExecutor({
139 | success: true,
140 | output: 'App installation successful',
141 | process: { pid: 12345 },
142 | });
143 |
144 | const trackingExecutor = async (command: unknown[]) => {
145 | capturedCommand = command;
146 | return mockExecutor(command);
147 | };
148 |
149 | await install_app_deviceLogic(
150 | {
151 | deviceId: 'test-device-123',
152 | appPath: '/path/to/My App.app',
153 | },
154 | trackingExecutor,
155 | );
156 |
157 | expect(capturedCommand).toEqual([
158 | 'xcrun',
159 | 'devicectl',
160 | 'device',
161 | 'install',
162 | 'app',
163 | '--device',
164 | 'test-device-123',
165 | '/path/to/My App.app',
166 | ]);
167 | });
168 | });
169 |
170 | describe('Success Path Tests', () => {
171 | it('should return successful installation response', async () => {
172 | const mockExecutor = createMockExecutor({
173 | success: true,
174 | output: 'App installation successful',
175 | });
176 |
177 | const result = await install_app_deviceLogic(
178 | {
179 | deviceId: 'test-device-123',
180 | appPath: '/path/to/test.app',
181 | },
182 | mockExecutor,
183 | );
184 |
185 | expect(result).toEqual({
186 | content: [
187 | {
188 | type: 'text',
189 | text: '✅ App installed successfully on device test-device-123\n\nApp installation successful',
190 | },
191 | ],
192 | });
193 | });
194 |
195 | it('should return successful installation with detailed output', async () => {
196 | const mockExecutor = createMockExecutor({
197 | success: true,
198 | output:
199 | 'Installing app...\nApp bundle: /path/to/test.app\nInstallation completed successfully',
200 | });
201 |
202 | const result = await install_app_deviceLogic(
203 | {
204 | deviceId: 'device-456',
205 | appPath: '/apps/TestApp.app',
206 | },
207 | mockExecutor,
208 | );
209 |
210 | expect(result).toEqual({
211 | content: [
212 | {
213 | type: 'text',
214 | text: '✅ App installed successfully on device device-456\n\nInstalling app...\nApp bundle: /path/to/test.app\nInstallation completed successfully',
215 | },
216 | ],
217 | });
218 | });
219 |
220 | it('should return successful installation with empty output', async () => {
221 | const mockExecutor = createMockExecutor({
222 | success: true,
223 | output: '',
224 | });
225 |
226 | const result = await install_app_deviceLogic(
227 | {
228 | deviceId: 'empty-output-device',
229 | appPath: '/path/to/app.app',
230 | },
231 | mockExecutor,
232 | );
233 |
234 | expect(result).toEqual({
235 | content: [
236 | {
237 | type: 'text',
238 | text: '✅ App installed successfully on device empty-output-device\n\n',
239 | },
240 | ],
241 | });
242 | });
243 | });
244 |
245 | describe('Error Handling', () => {
246 | it('should return installation failure response', async () => {
247 | const mockExecutor = createMockExecutor({
248 | success: false,
249 | error: 'Installation failed: App not found',
250 | });
251 |
252 | const result = await install_app_deviceLogic(
253 | {
254 | deviceId: 'test-device-123',
255 | appPath: '/path/to/nonexistent.app',
256 | },
257 | mockExecutor,
258 | );
259 |
260 | expect(result).toEqual({
261 | content: [
262 | {
263 | type: 'text',
264 | text: 'Failed to install app: Installation failed: App not found',
265 | },
266 | ],
267 | isError: true,
268 | });
269 | });
270 |
271 | it('should return exception handling response', async () => {
272 | const mockExecutor = createMockExecutor(new Error('Network error'));
273 |
274 | const result = await install_app_deviceLogic(
275 | {
276 | deviceId: 'test-device-123',
277 | appPath: '/path/to/test.app',
278 | },
279 | mockExecutor,
280 | );
281 |
282 | expect(result).toEqual({
283 | content: [
284 | {
285 | type: 'text',
286 | text: 'Failed to install app on device: Network error',
287 | },
288 | ],
289 | isError: true,
290 | });
291 | });
292 |
293 | it('should return string error handling response', async () => {
294 | const mockExecutor = createMockExecutor('String error');
295 |
296 | const result = await install_app_deviceLogic(
297 | {
298 | deviceId: 'test-device-123',
299 | appPath: '/path/to/test.app',
300 | },
301 | mockExecutor,
302 | );
303 |
304 | expect(result).toEqual({
305 | content: [
306 | {
307 | type: 'text',
308 | text: 'Failed to install app on device: String error',
309 | },
310 | ],
311 | isError: true,
312 | });
313 | });
314 | });
315 | });
316 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for describe_ui tool plugin
3 | */
4 |
5 | import { describe, it, expect, beforeEach } from 'vitest';
6 | import { z } from 'zod';
7 | import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts';
8 | import describeUIPlugin, { describe_uiLogic } from '../describe_ui.ts';
9 |
10 | describe('Describe UI Plugin', () => {
11 | let mockCalls: any[] = [];
12 |
13 | mockCalls = [];
14 |
15 | describe('Export Field Validation (Literal)', () => {
16 | it('should have correct name', () => {
17 | expect(describeUIPlugin.name).toBe('describe_ui');
18 | });
19 |
20 | it('should have correct description', () => {
21 | expect(describeUIPlugin.description).toBe(
22 | 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.',
23 | );
24 | });
25 |
26 | it('should have handler function', () => {
27 | expect(typeof describeUIPlugin.handler).toBe('function');
28 | });
29 |
30 | it('should validate schema fields with safeParse', () => {
31 | const schema = z.object(describeUIPlugin.schema);
32 |
33 | // Valid case
34 | expect(
35 | schema.safeParse({
36 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
37 | }).success,
38 | ).toBe(true);
39 |
40 | // Invalid simulatorUuid
41 | expect(
42 | schema.safeParse({
43 | simulatorUuid: 'invalid-uuid',
44 | }).success,
45 | ).toBe(false);
46 |
47 | // Missing simulatorUuid
48 | expect(schema.safeParse({}).success).toBe(false);
49 | });
50 | });
51 |
52 | describe('Handler Behavior (Complete Literal Returns)', () => {
53 | it('should handle missing simulatorUuid via schema validation', async () => {
54 | // Test the actual handler (not just the logic function)
55 | // This demonstrates that Zod validation catches missing parameters
56 | const result = await describeUIPlugin.handler({});
57 |
58 | expect(result.isError).toBe(true);
59 | expect(result.content[0].text).toContain('Parameter validation failed');
60 | expect(result.content[0].text).toContain('simulatorUuid: Required');
61 | });
62 |
63 | it('should handle invalid simulatorUuid format via schema validation', async () => {
64 | // Test the actual handler with invalid UUID format
65 | const result = await describeUIPlugin.handler({
66 | simulatorUuid: 'invalid-uuid-format',
67 | });
68 |
69 | expect(result.isError).toBe(true);
70 | expect(result.content[0].text).toContain('Parameter validation failed');
71 | expect(result.content[0].text).toContain('Invalid Simulator UUID format');
72 | });
73 |
74 | it('should return success for valid describe_ui execution', async () => {
75 | const uiHierarchy =
76 | '{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}';
77 |
78 | const mockExecutor = createMockExecutor({
79 | success: true,
80 | output: uiHierarchy,
81 | error: undefined,
82 | process: { pid: 12345 },
83 | });
84 |
85 | // Create mock axe helpers
86 | const mockAxeHelpers = {
87 | getAxePath: () => '/usr/local/bin/axe',
88 | getBundledAxeEnvironment: () => ({}),
89 | };
90 |
91 | // Wrap executor to track calls
92 | const executorCalls: any[] = [];
93 | const trackingExecutor = async (...args: any[]) => {
94 | executorCalls.push(args);
95 | return mockExecutor(...args);
96 | };
97 |
98 | const result = await describe_uiLogic(
99 | {
100 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
101 | },
102 | trackingExecutor,
103 | mockAxeHelpers,
104 | );
105 |
106 | expect(executorCalls[0]).toEqual([
107 | ['/usr/local/bin/axe', 'describe-ui', '--udid', '12345678-1234-1234-1234-123456789012'],
108 | '[AXe]: describe-ui',
109 | false,
110 | {},
111 | ]);
112 |
113 | expect(result).toEqual({
114 | content: [
115 | {
116 | type: 'text',
117 | text: 'Accessibility hierarchy retrieved successfully:\n```json\n{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}\n```',
118 | },
119 | {
120 | type: 'text',
121 | text: `Next Steps:
122 | - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)
123 | - Re-run describe_ui after layout changes
124 | - Screenshots are for visual verification only`,
125 | },
126 | ],
127 | });
128 | });
129 |
130 | it('should handle DependencyError when axe is not available', async () => {
131 | // Create mock axe helpers that return null for axe path
132 | const mockAxeHelpers = {
133 | getAxePath: () => null,
134 | getBundledAxeEnvironment: () => ({}),
135 | createAxeNotAvailableResponse: () => ({
136 | content: [
137 | {
138 | type: 'text',
139 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.',
140 | },
141 | ],
142 | isError: true,
143 | }),
144 | };
145 |
146 | const result = await describe_uiLogic(
147 | {
148 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
149 | },
150 | createNoopExecutor(),
151 | mockAxeHelpers,
152 | );
153 |
154 | expect(result).toEqual({
155 | content: [
156 | {
157 | type: 'text',
158 | text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.',
159 | },
160 | ],
161 | isError: true,
162 | });
163 | });
164 |
165 | it('should handle AxeError from failed command execution', async () => {
166 | const mockExecutor = createMockExecutor({
167 | success: false,
168 | output: '',
169 | error: 'axe command failed',
170 | process: { pid: 12345 },
171 | });
172 |
173 | // Create mock axe helpers
174 | const mockAxeHelpers = {
175 | getAxePath: () => '/usr/local/bin/axe',
176 | getBundledAxeEnvironment: () => ({}),
177 | };
178 |
179 | const result = await describe_uiLogic(
180 | {
181 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
182 | },
183 | mockExecutor,
184 | mockAxeHelpers,
185 | );
186 |
187 | expect(result).toEqual({
188 | content: [
189 | {
190 | type: 'text',
191 | text: "Error: Failed to get accessibility hierarchy: axe command 'describe-ui' failed.\nDetails: axe command failed",
192 | },
193 | ],
194 | isError: true,
195 | });
196 | });
197 |
198 | it('should handle SystemError from command execution', async () => {
199 | const mockExecutor = createMockExecutor(new Error('ENOENT: no such file or directory'));
200 |
201 | // Create mock axe helpers
202 | const mockAxeHelpers = {
203 | getAxePath: () => '/usr/local/bin/axe',
204 | getBundledAxeEnvironment: () => ({}),
205 | };
206 |
207 | const result = await describe_uiLogic(
208 | {
209 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
210 | },
211 | mockExecutor,
212 | mockAxeHelpers,
213 | );
214 |
215 | expect(result).toEqual({
216 | content: [
217 | {
218 | type: 'text',
219 | text: expect.stringContaining(
220 | 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory',
221 | ),
222 | },
223 | ],
224 | isError: true,
225 | });
226 | });
227 |
228 | it('should handle unexpected Error objects', async () => {
229 | const mockExecutor = createMockExecutor(new Error('Unexpected error'));
230 |
231 | // Create mock axe helpers
232 | const mockAxeHelpers = {
233 | getAxePath: () => '/usr/local/bin/axe',
234 | getBundledAxeEnvironment: () => ({}),
235 | };
236 |
237 | const result = await describe_uiLogic(
238 | {
239 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
240 | },
241 | mockExecutor,
242 | mockAxeHelpers,
243 | );
244 |
245 | expect(result).toEqual({
246 | content: [
247 | {
248 | type: 'text',
249 | text: expect.stringContaining(
250 | 'Error: System error executing axe: Failed to execute axe command: Unexpected error',
251 | ),
252 | },
253 | ],
254 | isError: true,
255 | });
256 | });
257 |
258 | it('should handle unexpected string errors', async () => {
259 | const mockExecutor = createMockExecutor('String error');
260 |
261 | // Create mock axe helpers
262 | const mockAxeHelpers = {
263 | getAxePath: () => '/usr/local/bin/axe',
264 | getBundledAxeEnvironment: () => ({}),
265 | };
266 |
267 | const result = await describe_uiLogic(
268 | {
269 | simulatorUuid: '12345678-1234-1234-1234-123456789012',
270 | },
271 | mockExecutor,
272 | mockAxeHelpers,
273 | );
274 |
275 | expect(result).toEqual({
276 | content: [
277 | {
278 | type: 'text',
279 | text: 'Error: System error executing axe: Failed to execute axe command: String error',
280 | },
281 | ],
282 | isError: true,
283 | });
284 | });
285 | });
286 | });
287 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for swift_package_build plugin
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import {
9 | createMockExecutor,
10 | createMockFileSystemExecutor,
11 | createNoopExecutor,
12 | } from '../../../../test-utils/mock-executors.ts';
13 | import swiftPackageBuild, { swift_package_buildLogic } from '../swift_package_build.ts';
14 |
15 | describe('swift_package_build plugin', () => {
16 | describe('Export Field Validation (Literal)', () => {
17 | it('should have correct name', () => {
18 | expect(swiftPackageBuild.name).toBe('swift_package_build');
19 | });
20 |
21 | it('should have correct description', () => {
22 | expect(swiftPackageBuild.description).toBe('Builds a Swift Package with swift build');
23 | });
24 |
25 | it('should have handler function', () => {
26 | expect(typeof swiftPackageBuild.handler).toBe('function');
27 | });
28 |
29 | it('should validate schema correctly', () => {
30 | // Test required fields
31 | expect(swiftPackageBuild.schema.packagePath.safeParse('/test/package').success).toBe(true);
32 | expect(swiftPackageBuild.schema.packagePath.safeParse('').success).toBe(true);
33 |
34 | // Test optional fields
35 | expect(swiftPackageBuild.schema.targetName.safeParse('MyTarget').success).toBe(true);
36 | expect(swiftPackageBuild.schema.targetName.safeParse(undefined).success).toBe(true);
37 | expect(swiftPackageBuild.schema.configuration.safeParse('debug').success).toBe(true);
38 | expect(swiftPackageBuild.schema.configuration.safeParse('release').success).toBe(true);
39 | expect(swiftPackageBuild.schema.configuration.safeParse(undefined).success).toBe(true);
40 | expect(swiftPackageBuild.schema.architectures.safeParse(['arm64']).success).toBe(true);
41 | expect(swiftPackageBuild.schema.architectures.safeParse(undefined).success).toBe(true);
42 | expect(swiftPackageBuild.schema.parseAsLibrary.safeParse(true).success).toBe(true);
43 | expect(swiftPackageBuild.schema.parseAsLibrary.safeParse(undefined).success).toBe(true);
44 |
45 | // Test invalid inputs
46 | expect(swiftPackageBuild.schema.packagePath.safeParse(null).success).toBe(false);
47 | expect(swiftPackageBuild.schema.configuration.safeParse('invalid').success).toBe(false);
48 | expect(swiftPackageBuild.schema.architectures.safeParse('not-array').success).toBe(false);
49 | expect(swiftPackageBuild.schema.parseAsLibrary.safeParse('yes').success).toBe(false);
50 | });
51 | });
52 |
53 | let executorCalls: any[] = [];
54 |
55 | beforeEach(() => {
56 | executorCalls = [];
57 | });
58 |
59 | describe('Command Generation Testing', () => {
60 | it('should build correct command for basic build', async () => {
61 | const executor = async (args: any, description: any, useShell: any, cwd: any) => {
62 | executorCalls.push({ args, description, useShell, cwd });
63 | return {
64 | success: true,
65 | output: 'Build succeeded',
66 | error: undefined,
67 | process: { pid: 12345 },
68 | };
69 | };
70 |
71 | await swift_package_buildLogic(
72 | {
73 | packagePath: '/test/package',
74 | },
75 | executor,
76 | );
77 |
78 | expect(executorCalls).toEqual([
79 | {
80 | args: ['swift', 'build', '--package-path', '/test/package'],
81 | description: 'Swift Package Build',
82 | useShell: true,
83 | cwd: undefined,
84 | },
85 | ]);
86 | });
87 |
88 | it('should build correct command with release configuration', async () => {
89 | const executor = async (args: any, description: any, useShell: any, cwd: any) => {
90 | executorCalls.push({ args, description, useShell, cwd });
91 | return {
92 | success: true,
93 | output: 'Build succeeded',
94 | error: undefined,
95 | process: { pid: 12345 },
96 | };
97 | };
98 |
99 | await swift_package_buildLogic(
100 | {
101 | packagePath: '/test/package',
102 | configuration: 'release',
103 | },
104 | executor,
105 | );
106 |
107 | expect(executorCalls).toEqual([
108 | {
109 | args: ['swift', 'build', '--package-path', '/test/package', '-c', 'release'],
110 | description: 'Swift Package Build',
111 | useShell: true,
112 | cwd: undefined,
113 | },
114 | ]);
115 | });
116 |
117 | it('should build correct command with all parameters', async () => {
118 | const executor = async (args: any, description: any, useShell: any, cwd: any) => {
119 | executorCalls.push({ args, description, useShell, cwd });
120 | return {
121 | success: true,
122 | output: 'Build succeeded',
123 | error: undefined,
124 | process: { pid: 12345 },
125 | };
126 | };
127 |
128 | await swift_package_buildLogic(
129 | {
130 | packagePath: '/test/package',
131 | targetName: 'MyTarget',
132 | configuration: 'release',
133 | architectures: ['arm64', 'x86_64'],
134 | parseAsLibrary: true,
135 | },
136 | executor,
137 | );
138 |
139 | expect(executorCalls).toEqual([
140 | {
141 | args: [
142 | 'swift',
143 | 'build',
144 | '--package-path',
145 | '/test/package',
146 | '-c',
147 | 'release',
148 | '--target',
149 | 'MyTarget',
150 | '--arch',
151 | 'arm64',
152 | '--arch',
153 | 'x86_64',
154 | '-Xswiftc',
155 | '-parse-as-library',
156 | ],
157 | description: 'Swift Package Build',
158 | useShell: true,
159 | cwd: undefined,
160 | },
161 | ]);
162 | });
163 | });
164 |
165 | describe('Response Logic Testing', () => {
166 | it('should handle missing packagePath parameter (Zod handles validation)', async () => {
167 | // Note: With createTypedTool, Zod validation happens before the logic function is called
168 | // So we test with a valid but minimal parameter set since validation is handled upstream
169 | const executor = createMockExecutor({
170 | success: true,
171 | output: 'Build succeeded',
172 | });
173 |
174 | const result = await swift_package_buildLogic({ packagePath: '/test/package' }, executor);
175 |
176 | // The logic function should execute normally with valid parameters
177 | // Zod validation errors are handled by createTypedTool wrapper
178 | expect(result.isError).toBe(false);
179 | });
180 |
181 | it('should return successful build response', async () => {
182 | const executor = createMockExecutor({
183 | success: true,
184 | output: 'Build complete.',
185 | });
186 |
187 | const result = await swift_package_buildLogic(
188 | {
189 | packagePath: '/test/package',
190 | },
191 | executor,
192 | );
193 |
194 | expect(result).toEqual({
195 | content: [
196 | { type: 'text', text: '✅ Swift package build succeeded.' },
197 | {
198 | type: 'text',
199 | text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run',
200 | },
201 | { type: 'text', text: 'Build complete.' },
202 | ],
203 | isError: false,
204 | });
205 | });
206 |
207 | it('should return error response for build failure', async () => {
208 | const executor = createMockExecutor({
209 | success: false,
210 | error: 'Compilation failed: error in main.swift',
211 | });
212 |
213 | const result = await swift_package_buildLogic(
214 | {
215 | packagePath: '/test/package',
216 | },
217 | executor,
218 | );
219 |
220 | expect(result).toEqual({
221 | content: [
222 | {
223 | type: 'text',
224 | text: 'Error: Swift package build failed\nDetails: Compilation failed: error in main.swift',
225 | },
226 | ],
227 | isError: true,
228 | });
229 | });
230 |
231 | it('should handle spawn error', async () => {
232 | const executor = async () => {
233 | throw new Error('spawn ENOENT');
234 | };
235 |
236 | const result = await swift_package_buildLogic(
237 | {
238 | packagePath: '/test/package',
239 | },
240 | executor,
241 | );
242 |
243 | expect(result).toEqual({
244 | content: [
245 | {
246 | type: 'text',
247 | text: 'Error: Failed to execute swift build\nDetails: spawn ENOENT',
248 | },
249 | ],
250 | isError: true,
251 | });
252 | });
253 |
254 | it('should handle successful build with parameters', async () => {
255 | const executor = createMockExecutor({
256 | success: true,
257 | output: 'Build complete.',
258 | });
259 |
260 | const result = await swift_package_buildLogic(
261 | {
262 | packagePath: '/test/package',
263 | targetName: 'MyTarget',
264 | configuration: 'release',
265 | architectures: ['arm64', 'x86_64'],
266 | parseAsLibrary: true,
267 | },
268 | executor,
269 | );
270 |
271 | expect(result).toEqual({
272 | content: [
273 | { type: 'text', text: '✅ Swift package build succeeded.' },
274 | {
275 | type: 'text',
276 | text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run',
277 | },
278 | { type: 'text', text: 'Build complete.' },
279 | ],
280 | isError: false,
281 | });
282 | });
283 | });
284 | });
285 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/doctor/__tests__/doctor.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for doctor plugin
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import { z } from 'zod';
9 | import doctor, { runDoctor, type DoctorDependencies } from '../doctor.ts';
10 |
11 | function createDeps(overrides?: Partial<DoctorDependencies>): DoctorDependencies {
12 | const base: DoctorDependencies = {
13 | binaryChecker: {
14 | async checkBinaryAvailability(binary: string) {
15 | // default: all available with generic version
16 | return { available: true, version: `${binary} version 1.0.0` };
17 | },
18 | },
19 | xcode: {
20 | async getXcodeInfo() {
21 | return {
22 | version: 'Xcode 15.0 - Build version 15A240d',
23 | path: '/Applications/Xcode.app/Contents/Developer',
24 | selectedXcode: '/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild',
25 | xcrunVersion: 'xcrun version 65',
26 | };
27 | },
28 | },
29 | env: {
30 | getEnvironmentVariables() {
31 | const x: Record<string, string | undefined> = {
32 | XCODEBUILDMCP_DEBUG: 'true',
33 | INCREMENTAL_BUILDS_ENABLED: '1',
34 | PATH: '/usr/local/bin:/usr/bin:/bin',
35 | DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
36 | HOME: '/Users/testuser',
37 | USER: 'testuser',
38 | TMPDIR: '/tmp',
39 | NODE_ENV: 'test',
40 | SENTRY_DISABLED: 'false',
41 | };
42 | return x;
43 | },
44 | getSystemInfo() {
45 | return {
46 | platform: 'darwin',
47 | release: '25.0.0',
48 | arch: 'arm64',
49 | cpus: '10 x Apple M3',
50 | memory: '32 GB',
51 | hostname: 'localhost',
52 | username: 'testuser',
53 | homedir: '/Users/testuser',
54 | tmpdir: '/tmp',
55 | };
56 | },
57 | getNodeInfo() {
58 | return {
59 | version: 'v22.0.0',
60 | execPath: '/usr/local/bin/node',
61 | pid: '123',
62 | ppid: '1',
63 | platform: 'darwin',
64 | arch: 'arm64',
65 | cwd: '/',
66 | argv: 'node build/index.js',
67 | };
68 | },
69 | },
70 | plugins: {
71 | async getPluginSystemInfo() {
72 | return {
73 | totalPlugins: 1,
74 | pluginDirectories: 1,
75 | pluginsByDirectory: { doctor: ['doctor'] },
76 | systemMode: 'plugin-based',
77 | };
78 | },
79 | },
80 | features: {
81 | areAxeToolsAvailable: () => true,
82 | isXcodemakeEnabled: () => true,
83 | isXcodemakeAvailable: async () => true,
84 | doesMakefileExist: () => true,
85 | },
86 | runtime: {
87 | async getRuntimeToolInfo() {
88 | return {
89 | mode: 'static' as const,
90 | enabledWorkflows: ['doctor', 'discovery'],
91 | enabledTools: ['doctor', 'discover_tools'],
92 | totalRegistered: 2,
93 | };
94 | },
95 | },
96 | };
97 |
98 | return {
99 | ...base,
100 | ...overrides,
101 | binaryChecker: {
102 | ...base.binaryChecker,
103 | ...(overrides?.binaryChecker ?? {}),
104 | },
105 | xcode: {
106 | ...base.xcode,
107 | ...(overrides?.xcode ?? {}),
108 | },
109 | env: {
110 | ...base.env,
111 | ...(overrides?.env ?? {}),
112 | },
113 | plugins: {
114 | ...base.plugins,
115 | ...(overrides?.plugins ?? {}),
116 | },
117 | features: {
118 | ...base.features,
119 | ...(overrides?.features ?? {}),
120 | },
121 | };
122 | }
123 |
124 | describe('doctor tool', () => {
125 | // Reset any state if needed
126 |
127 | describe('Export Field Validation (Literal)', () => {
128 | it('should have correct name', () => {
129 | expect(doctor.name).toBe('doctor');
130 | });
131 |
132 | it('should have correct description', () => {
133 | expect(doctor.description).toBe(
134 | 'Provides comprehensive information about the MCP server environment, available dependencies, and configuration status.',
135 | );
136 | });
137 |
138 | it('should have handler function', () => {
139 | expect(typeof doctor.handler).toBe('function');
140 | });
141 |
142 | it('should have correct schema with enabled boolean field', () => {
143 | const schema = z.object(doctor.schema);
144 |
145 | // Valid inputs
146 | expect(schema.safeParse({ enabled: true }).success).toBe(true);
147 | expect(schema.safeParse({ enabled: false }).success).toBe(true);
148 | expect(schema.safeParse({}).success).toBe(true); // enabled is optional
149 |
150 | // Invalid inputs
151 | expect(schema.safeParse({ enabled: 'true' }).success).toBe(false);
152 | expect(schema.safeParse({ enabled: 1 }).success).toBe(false);
153 | expect(schema.safeParse({ enabled: null }).success).toBe(false);
154 | });
155 | });
156 |
157 | describe('Handler Behavior (Complete Literal Returns)', () => {
158 | it('should handle successful doctor execution', async () => {
159 | const deps = createDeps();
160 | const result = await runDoctor({ enabled: true }, deps);
161 |
162 | expect(result.content).toEqual([
163 | {
164 | type: 'text',
165 | text: result.content[0].text,
166 | },
167 | ]);
168 | expect(typeof result.content[0].text).toBe('string');
169 | });
170 |
171 | it('should handle plugin loading failure', async () => {
172 | const deps = createDeps({
173 | plugins: {
174 | async getPluginSystemInfo() {
175 | return { error: 'Plugin loading failed', systemMode: 'error' };
176 | },
177 | },
178 | });
179 |
180 | const result = await runDoctor({ enabled: true }, deps);
181 |
182 | expect(result.content).toEqual([
183 | {
184 | type: 'text',
185 | text: result.content[0].text,
186 | },
187 | ]);
188 | expect(typeof result.content[0].text).toBe('string');
189 | });
190 |
191 | it('should handle xcode command failure', async () => {
192 | const deps = createDeps({
193 | xcode: {
194 | async getXcodeInfo() {
195 | return { error: 'Xcode not found' };
196 | },
197 | },
198 | });
199 | const result = await runDoctor({ enabled: true }, deps);
200 |
201 | expect(result.content).toEqual([
202 | {
203 | type: 'text',
204 | text: result.content[0].text,
205 | },
206 | ]);
207 | expect(typeof result.content[0].text).toBe('string');
208 | });
209 |
210 | it('should handle xcodemake check failure', async () => {
211 | const deps = createDeps({
212 | features: {
213 | areAxeToolsAvailable: () => true,
214 | isXcodemakeEnabled: () => true,
215 | isXcodemakeAvailable: async () => false,
216 | doesMakefileExist: () => true,
217 | },
218 | binaryChecker: {
219 | async checkBinaryAvailability(binary: string) {
220 | if (binary === 'xcodemake') return { available: false };
221 | return { available: true, version: `${binary} version 1.0.0` };
222 | },
223 | },
224 | });
225 | const result = await runDoctor({ enabled: true }, deps);
226 |
227 | expect(result.content).toEqual([
228 | {
229 | type: 'text',
230 | text: result.content[0].text,
231 | },
232 | ]);
233 | expect(typeof result.content[0].text).toBe('string');
234 | });
235 |
236 | it('should handle axe tools not available', async () => {
237 | const deps = createDeps({
238 | features: {
239 | areAxeToolsAvailable: () => false,
240 | isXcodemakeEnabled: () => false,
241 | isXcodemakeAvailable: async () => false,
242 | doesMakefileExist: () => false,
243 | },
244 | binaryChecker: {
245 | async checkBinaryAvailability(binary: string) {
246 | if (binary === 'axe') return { available: false };
247 | if (binary === 'xcodemake') return { available: false };
248 | if (binary === 'mise') return { available: true, version: 'mise 1.0.0' };
249 | return { available: true };
250 | },
251 | },
252 | env: {
253 | getEnvironmentVariables() {
254 | const x: Record<string, string | undefined> = {
255 | XCODEBUILDMCP_DEBUG: 'true',
256 | INCREMENTAL_BUILDS_ENABLED: '0',
257 | PATH: '/usr/local/bin:/usr/bin:/bin',
258 | DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
259 | HOME: '/Users/testuser',
260 | USER: 'testuser',
261 | TMPDIR: '/tmp',
262 | NODE_ENV: 'test',
263 | SENTRY_DISABLED: 'true',
264 | };
265 | return x;
266 | },
267 | getSystemInfo: () => ({
268 | platform: 'darwin',
269 | release: '25.0.0',
270 | arch: 'arm64',
271 | cpus: '10 x Apple M3',
272 | memory: '32 GB',
273 | hostname: 'localhost',
274 | username: 'testuser',
275 | homedir: '/Users/testuser',
276 | tmpdir: '/tmp',
277 | }),
278 | getNodeInfo: () => ({
279 | version: 'v22.0.0',
280 | execPath: '/usr/local/bin/node',
281 | pid: '123',
282 | ppid: '1',
283 | platform: 'darwin',
284 | arch: 'arm64',
285 | cwd: '/',
286 | argv: 'node build/index.js',
287 | }),
288 | },
289 | });
290 |
291 | const result = await runDoctor({ enabled: true }, deps);
292 |
293 | expect(result.content).toEqual([
294 | {
295 | type: 'text',
296 | text: result.content[0].text,
297 | },
298 | ]);
299 | expect(typeof result.content[0].text).toBe('string');
300 | });
301 | });
302 | });
303 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Pure dependency injection test for launch_mac_app plugin
3 | *
4 | * Tests plugin structure and macOS app launching functionality including parameter validation,
5 | * command generation, file validation, and response formatting.
6 | *
7 | * Uses manual call tracking and createMockFileSystemExecutor for file operations.
8 | */
9 |
10 | import { describe, it, expect } from 'vitest';
11 | import { z } from 'zod';
12 | import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts';
13 | import launchMacApp, { launch_mac_appLogic } from '../launch_mac_app.ts';
14 |
15 | describe('launch_mac_app plugin', () => {
16 | describe('Export Field Validation (Literal)', () => {
17 | it('should have correct name', () => {
18 | expect(launchMacApp.name).toBe('launch_mac_app');
19 | });
20 |
21 | it('should have correct description', () => {
22 | expect(launchMacApp.description).toBe(
23 | "Launches a macOS application. IMPORTANT: You MUST provide the appPath parameter. Example: launch_mac_app({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app.",
24 | );
25 | });
26 |
27 | it('should have handler function', () => {
28 | expect(typeof launchMacApp.handler).toBe('function');
29 | });
30 |
31 | it('should validate schema with valid inputs', () => {
32 | const schema = z.object(launchMacApp.schema);
33 | expect(
34 | schema.safeParse({
35 | appPath: '/path/to/MyApp.app',
36 | }).success,
37 | ).toBe(true);
38 | expect(
39 | schema.safeParse({
40 | appPath: '/Applications/Calculator.app',
41 | args: ['--debug'],
42 | }).success,
43 | ).toBe(true);
44 | expect(
45 | schema.safeParse({
46 | appPath: '/path/to/MyApp.app',
47 | args: ['--debug', '--verbose'],
48 | }).success,
49 | ).toBe(true);
50 | });
51 |
52 | it('should validate schema with invalid inputs', () => {
53 | const schema = z.object(launchMacApp.schema);
54 | expect(schema.safeParse({}).success).toBe(false);
55 | expect(schema.safeParse({ appPath: null }).success).toBe(false);
56 | expect(schema.safeParse({ appPath: 123 }).success).toBe(false);
57 | expect(schema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success).toBe(
58 | false,
59 | );
60 | });
61 | });
62 |
63 | describe('Input Validation', () => {
64 | it('should handle non-existent app path', async () => {
65 | const mockExecutor = async () => Promise.resolve({ stdout: '', stderr: '' });
66 | const mockFileSystem = createMockFileSystemExecutor({
67 | existsSync: () => false,
68 | });
69 |
70 | const result = await launch_mac_appLogic(
71 | {
72 | appPath: '/path/to/NonExistent.app',
73 | },
74 | mockExecutor,
75 | mockFileSystem,
76 | );
77 |
78 | expect(result).toEqual({
79 | content: [
80 | {
81 | type: 'text',
82 | text: "File not found: '/path/to/NonExistent.app'. Please check the path and try again.",
83 | },
84 | ],
85 | isError: true,
86 | });
87 | });
88 | });
89 |
90 | describe('Command Generation', () => {
91 | it('should generate correct command with minimal parameters', async () => {
92 | const calls: any[] = [];
93 | const mockExecutor = async (command: string[]) => {
94 | calls.push({ command });
95 | return { stdout: '', stderr: '' };
96 | };
97 |
98 | const mockFileSystem = createMockFileSystemExecutor({
99 | existsSync: () => true,
100 | });
101 |
102 | await launch_mac_appLogic(
103 | {
104 | appPath: '/path/to/MyApp.app',
105 | },
106 | mockExecutor,
107 | mockFileSystem,
108 | );
109 |
110 | expect(calls).toHaveLength(1);
111 | expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']);
112 | });
113 |
114 | it('should generate correct command with args parameter', async () => {
115 | const calls: any[] = [];
116 | const mockExecutor = async (command: string[]) => {
117 | calls.push({ command });
118 | return { stdout: '', stderr: '' };
119 | };
120 |
121 | const mockFileSystem = createMockFileSystemExecutor({
122 | existsSync: () => true,
123 | });
124 |
125 | await launch_mac_appLogic(
126 | {
127 | appPath: '/path/to/MyApp.app',
128 | args: ['--debug', '--verbose'],
129 | },
130 | mockExecutor,
131 | mockFileSystem,
132 | );
133 |
134 | expect(calls).toHaveLength(1);
135 | expect(calls[0].command).toEqual([
136 | 'open',
137 | '/path/to/MyApp.app',
138 | '--args',
139 | '--debug',
140 | '--verbose',
141 | ]);
142 | });
143 |
144 | it('should generate correct command with empty args array', async () => {
145 | const calls: any[] = [];
146 | const mockExecutor = async (command: string[]) => {
147 | calls.push({ command });
148 | return { stdout: '', stderr: '' };
149 | };
150 |
151 | const mockFileSystem = createMockFileSystemExecutor({
152 | existsSync: () => true,
153 | });
154 |
155 | await launch_mac_appLogic(
156 | {
157 | appPath: '/path/to/MyApp.app',
158 | args: [],
159 | },
160 | mockExecutor,
161 | mockFileSystem,
162 | );
163 |
164 | expect(calls).toHaveLength(1);
165 | expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']);
166 | });
167 |
168 | it('should handle paths with spaces correctly', async () => {
169 | const calls: any[] = [];
170 | const mockExecutor = async (command: string[]) => {
171 | calls.push({ command });
172 | return { stdout: '', stderr: '' };
173 | };
174 |
175 | const mockFileSystem = createMockFileSystemExecutor({
176 | existsSync: () => true,
177 | });
178 |
179 | await launch_mac_appLogic(
180 | {
181 | appPath: '/Applications/My App.app',
182 | },
183 | mockExecutor,
184 | mockFileSystem,
185 | );
186 |
187 | expect(calls).toHaveLength(1);
188 | expect(calls[0].command).toEqual(['open', '/Applications/My App.app']);
189 | });
190 | });
191 |
192 | describe('Response Processing', () => {
193 | it('should return successful launch response', async () => {
194 | const mockExecutor = async () => Promise.resolve({ stdout: '', stderr: '' });
195 |
196 | const mockFileSystem = createMockFileSystemExecutor({
197 | existsSync: () => true,
198 | });
199 |
200 | const result = await launch_mac_appLogic(
201 | {
202 | appPath: '/path/to/MyApp.app',
203 | },
204 | mockExecutor,
205 | mockFileSystem,
206 | );
207 |
208 | expect(result).toEqual({
209 | content: [
210 | {
211 | type: 'text',
212 | text: '✅ macOS app launched successfully: /path/to/MyApp.app',
213 | },
214 | ],
215 | });
216 | });
217 |
218 | it('should return successful launch response with args', async () => {
219 | const mockExecutor = async () => Promise.resolve({ stdout: '', stderr: '' });
220 |
221 | const mockFileSystem = createMockFileSystemExecutor({
222 | existsSync: () => true,
223 | });
224 |
225 | const result = await launch_mac_appLogic(
226 | {
227 | appPath: '/path/to/MyApp.app',
228 | args: ['--debug', '--verbose'],
229 | },
230 | mockExecutor,
231 | mockFileSystem,
232 | );
233 |
234 | expect(result).toEqual({
235 | content: [
236 | {
237 | type: 'text',
238 | text: '✅ macOS app launched successfully: /path/to/MyApp.app',
239 | },
240 | ],
241 | });
242 | });
243 |
244 | it('should handle launch failure with Error object', async () => {
245 | const mockExecutor = async () => {
246 | throw new Error('App not found');
247 | };
248 |
249 | const mockFileSystem = createMockFileSystemExecutor({
250 | existsSync: () => true,
251 | });
252 |
253 | const result = await launch_mac_appLogic(
254 | {
255 | appPath: '/path/to/MyApp.app',
256 | },
257 | mockExecutor,
258 | mockFileSystem,
259 | );
260 |
261 | expect(result).toEqual({
262 | content: [
263 | {
264 | type: 'text',
265 | text: '❌ Launch macOS app operation failed: App not found',
266 | },
267 | ],
268 | isError: true,
269 | });
270 | });
271 |
272 | it('should handle launch failure with string error', async () => {
273 | const mockExecutor = async () => {
274 | throw 'Permission denied';
275 | };
276 |
277 | const mockFileSystem = createMockFileSystemExecutor({
278 | existsSync: () => true,
279 | });
280 |
281 | const result = await launch_mac_appLogic(
282 | {
283 | appPath: '/path/to/MyApp.app',
284 | },
285 | mockExecutor,
286 | mockFileSystem,
287 | );
288 |
289 | expect(result).toEqual({
290 | content: [
291 | {
292 | type: 'text',
293 | text: '❌ Launch macOS app operation failed: Permission denied',
294 | },
295 | ],
296 | isError: true,
297 | });
298 | });
299 |
300 | it('should handle launch failure with unknown error type', async () => {
301 | const mockExecutor = async () => {
302 | throw 123;
303 | };
304 |
305 | const mockFileSystem = createMockFileSystemExecutor({
306 | existsSync: () => true,
307 | });
308 |
309 | const result = await launch_mac_appLogic(
310 | {
311 | appPath: '/path/to/MyApp.app',
312 | },
313 | mockExecutor,
314 | mockFileSystem,
315 | );
316 |
317 | expect(result).toEqual({
318 | content: [
319 | {
320 | type: 'text',
321 | text: '❌ Launch macOS app operation failed: 123',
322 | },
323 | ],
324 | isError: true,
325 | });
326 | });
327 | });
328 | });
329 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import { z } from 'zod';
3 | import plugin, { get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts';
4 | import {
5 | createMockFileSystemExecutor,
6 | createCommandMatchingMockExecutor,
7 | } from '../../../../test-utils/mock-executors.ts';
8 |
9 | describe('get_mac_bundle_id plugin', () => {
10 | // Helper function to create mock executor for command matching
11 | const createMockExecutorForCommands = (results: Record<string, string | Error>) => {
12 | return createCommandMatchingMockExecutor(
13 | Object.fromEntries(
14 | Object.entries(results).map(([command, result]) => [
15 | command,
16 | result instanceof Error
17 | ? { success: false, error: result.message }
18 | : { success: true, output: result },
19 | ]),
20 | ),
21 | );
22 | };
23 |
24 | describe('Export Field Validation (Literal)', () => {
25 | it('should have correct name', () => {
26 | expect(plugin.name).toBe('get_mac_bundle_id');
27 | });
28 |
29 | it('should have correct description', () => {
30 | expect(plugin.description).toBe(
31 | "Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_mac_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.",
32 | );
33 | });
34 |
35 | it('should have handler function', () => {
36 | expect(typeof plugin.handler).toBe('function');
37 | });
38 |
39 | it('should validate schema with valid inputs', () => {
40 | const schema = z.object(plugin.schema);
41 | expect(schema.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true);
42 | expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true);
43 | });
44 |
45 | it('should validate schema with invalid inputs', () => {
46 | const schema = z.object(plugin.schema);
47 | expect(schema.safeParse({}).success).toBe(false);
48 | expect(schema.safeParse({ appPath: 123 }).success).toBe(false);
49 | expect(schema.safeParse({ appPath: null }).success).toBe(false);
50 | expect(schema.safeParse({ appPath: undefined }).success).toBe(false);
51 | });
52 | });
53 |
54 | describe('Handler Behavior (Complete Literal Returns)', () => {
55 | // Note: appPath validation is now handled by Zod schema validation in createTypedTool
56 | // This test would not reach the logic function as Zod validation occurs before it
57 |
58 | it('should return error when file exists validation fails', async () => {
59 | const mockExecutor = createMockExecutorForCommands({});
60 | const mockFileSystemExecutor = createMockFileSystemExecutor({
61 | existsSync: () => false,
62 | });
63 |
64 | const result = await get_mac_bundle_idLogic(
65 | { appPath: '/Applications/MyApp.app' },
66 | mockExecutor,
67 | mockFileSystemExecutor,
68 | );
69 |
70 | expect(result).toEqual({
71 | content: [
72 | {
73 | type: 'text',
74 | text: "File not found: '/Applications/MyApp.app'. Please check the path and try again.",
75 | },
76 | ],
77 | isError: true,
78 | });
79 | });
80 |
81 | it('should return success with bundle ID using defaults read', async () => {
82 | const mockExecutor = createMockExecutorForCommands({
83 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier':
84 | 'com.example.MyMacApp',
85 | });
86 | const mockFileSystemExecutor = createMockFileSystemExecutor({
87 | existsSync: () => true,
88 | });
89 |
90 | const result = await get_mac_bundle_idLogic(
91 | { appPath: '/Applications/MyApp.app' },
92 | mockExecutor,
93 | mockFileSystemExecutor,
94 | );
95 |
96 | expect(result).toEqual({
97 | content: [
98 | {
99 | type: 'text',
100 | text: '✅ Bundle ID: com.example.MyMacApp',
101 | },
102 | {
103 | type: 'text',
104 | text: `Next Steps:
105 | - Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" })
106 | - Build again: build_macos({ scheme: "SCHEME_NAME" })`,
107 | },
108 | ],
109 | isError: false,
110 | });
111 | });
112 |
113 | it('should fallback to PlistBuddy when defaults read fails', async () => {
114 | const mockExecutor = createMockExecutorForCommands({
115 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
116 | 'defaults read failed',
117 | ),
118 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
119 | 'com.example.MyMacApp',
120 | });
121 | const mockFileSystemExecutor = createMockFileSystemExecutor({
122 | existsSync: () => true,
123 | });
124 |
125 | const result = await get_mac_bundle_idLogic(
126 | { appPath: '/Applications/MyApp.app' },
127 | mockExecutor,
128 | mockFileSystemExecutor,
129 | );
130 |
131 | expect(result).toEqual({
132 | content: [
133 | {
134 | type: 'text',
135 | text: '✅ Bundle ID: com.example.MyMacApp',
136 | },
137 | {
138 | type: 'text',
139 | text: `Next Steps:
140 | - Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" })
141 | - Build again: build_macos({ scheme: "SCHEME_NAME" })`,
142 | },
143 | ],
144 | isError: false,
145 | });
146 | });
147 |
148 | it('should return error when both extraction methods fail', async () => {
149 | const mockExecutor = createMockExecutorForCommands({
150 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
151 | 'Command failed',
152 | ),
153 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
154 | new Error('Command failed'),
155 | });
156 | const mockFileSystemExecutor = createMockFileSystemExecutor({
157 | existsSync: () => true,
158 | });
159 |
160 | const result = await get_mac_bundle_idLogic(
161 | { appPath: '/Applications/MyApp.app' },
162 | mockExecutor,
163 | mockFileSystemExecutor,
164 | );
165 |
166 | expect(result.isError).toBe(true);
167 | expect(result.content).toHaveLength(2);
168 | expect(result.content[0].type).toBe('text');
169 | expect(result.content[0].text).toContain('Error extracting macOS bundle ID');
170 | expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist');
171 | expect(result.content[0].text).toContain('Command failed');
172 | expect(result.content[1].type).toBe('text');
173 | expect(result.content[1].text).toBe(
174 | 'Make sure the path points to a valid macOS app bundle (.app directory).',
175 | );
176 | });
177 |
178 | it('should handle Error objects in catch blocks', async () => {
179 | const mockExecutor = createMockExecutorForCommands({
180 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
181 | 'Custom error message',
182 | ),
183 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
184 | new Error('Custom error message'),
185 | });
186 | const mockFileSystemExecutor = createMockFileSystemExecutor({
187 | existsSync: () => true,
188 | });
189 |
190 | const result = await get_mac_bundle_idLogic(
191 | { appPath: '/Applications/MyApp.app' },
192 | mockExecutor,
193 | mockFileSystemExecutor,
194 | );
195 |
196 | expect(result.isError).toBe(true);
197 | expect(result.content).toHaveLength(2);
198 | expect(result.content[0].type).toBe('text');
199 | expect(result.content[0].text).toContain('Error extracting macOS bundle ID');
200 | expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist');
201 | expect(result.content[0].text).toContain('Custom error message');
202 | expect(result.content[1].type).toBe('text');
203 | expect(result.content[1].text).toBe(
204 | 'Make sure the path points to a valid macOS app bundle (.app directory).',
205 | );
206 | });
207 |
208 | it('should handle string errors in catch blocks', async () => {
209 | const mockExecutor = createMockExecutorForCommands({
210 | 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error(
211 | 'String error',
212 | ),
213 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"':
214 | new Error('String error'),
215 | });
216 | const mockFileSystemExecutor = createMockFileSystemExecutor({
217 | existsSync: () => true,
218 | });
219 |
220 | const result = await get_mac_bundle_idLogic(
221 | { appPath: '/Applications/MyApp.app' },
222 | mockExecutor,
223 | mockFileSystemExecutor,
224 | );
225 |
226 | expect(result.isError).toBe(true);
227 | expect(result.content).toHaveLength(2);
228 | expect(result.content[0].type).toBe('text');
229 | expect(result.content[0].text).toContain('Error extracting macOS bundle ID');
230 | expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist');
231 | expect(result.content[0].text).toContain('String error');
232 | expect(result.content[1].type).toBe('text');
233 | expect(result.content[1].text).toBe(
234 | 'Make sure the path points to a valid macOS app bundle (.app directory).',
235 | );
236 | });
237 | });
238 | });
239 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/discover_projs.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Project Discovery Plugin: Discover Projects
3 | *
4 | * Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj)
5 | * and workspace (.xcworkspace) files.
6 | */
7 |
8 | import { z } from 'zod';
9 | import * as path from 'node:path';
10 | import { log } from '../../../utils/logging/index.ts';
11 | import { ToolResponse, createTextContent } from '../../../types/common.ts';
12 | import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts';
13 | import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts';
14 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
15 |
16 | // Constants
17 | const DEFAULT_MAX_DEPTH = 5;
18 | const SKIPPED_DIRS = new Set(['build', 'DerivedData', 'Pods', '.git', 'node_modules']);
19 |
20 | // Type definition for Dirent-like objects returned by readdir with withFileTypes: true
21 | interface DirentLike {
22 | name: string;
23 | isDirectory(): boolean;
24 | isSymbolicLink(): boolean;
25 | }
26 |
27 | /**
28 | * Recursively scans directories to find Xcode projects and workspaces.
29 | */
30 | async function _findProjectsRecursive(
31 | currentDirAbs: string,
32 | workspaceRootAbs: string,
33 | currentDepth: number,
34 | maxDepth: number,
35 | results: { projects: string[]; workspaces: string[] },
36 | fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
37 | ): Promise<void> {
38 | // Explicit depth check (now simplified as maxDepth is always non-negative)
39 | if (currentDepth >= maxDepth) {
40 | log('debug', `Max depth ${maxDepth} reached at ${currentDirAbs}, stopping recursion.`);
41 | return;
42 | }
43 |
44 | log('debug', `Scanning directory: ${currentDirAbs} at depth ${currentDepth}`);
45 | const normalizedWorkspaceRoot = path.normalize(workspaceRootAbs);
46 |
47 | try {
48 | // Use the injected fileSystemExecutor
49 | const entries = await fileSystemExecutor.readdir(currentDirAbs, { withFileTypes: true });
50 | for (const rawEntry of entries) {
51 | // Cast the unknown entry to DirentLike interface for type safety
52 | const entry = rawEntry as DirentLike;
53 | const absoluteEntryPath = path.join(currentDirAbs, entry.name);
54 | const relativePath = path.relative(workspaceRootAbs, absoluteEntryPath);
55 |
56 | // --- Skip conditions ---
57 | if (entry.isSymbolicLink()) {
58 | log('debug', `Skipping symbolic link: ${relativePath}`);
59 | continue;
60 | }
61 |
62 | // Skip common build/dependency directories by name
63 | if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) {
64 | log('debug', `Skipping standard directory: ${relativePath}`);
65 | continue;
66 | }
67 |
68 | // Ensure entry is within the workspace root (security/sanity check)
69 | if (!path.normalize(absoluteEntryPath).startsWith(normalizedWorkspaceRoot)) {
70 | log(
71 | 'warn',
72 | `Skipping entry outside workspace root: ${absoluteEntryPath} (Workspace: ${workspaceRootAbs})`,
73 | );
74 | continue;
75 | }
76 |
77 | // --- Process entries ---
78 | if (entry.isDirectory()) {
79 | let isXcodeBundle = false;
80 |
81 | if (entry.name.endsWith('.xcodeproj')) {
82 | results.projects.push(absoluteEntryPath); // Use absolute path
83 | log('debug', `Found project: ${absoluteEntryPath}`);
84 | isXcodeBundle = true;
85 | } else if (entry.name.endsWith('.xcworkspace')) {
86 | results.workspaces.push(absoluteEntryPath); // Use absolute path
87 | log('debug', `Found workspace: ${absoluteEntryPath}`);
88 | isXcodeBundle = true;
89 | }
90 |
91 | // Recurse into regular directories, but not into found project/workspace bundles
92 | if (!isXcodeBundle) {
93 | await _findProjectsRecursive(
94 | absoluteEntryPath,
95 | workspaceRootAbs,
96 | currentDepth + 1,
97 | maxDepth,
98 | results,
99 | fileSystemExecutor,
100 | );
101 | }
102 | }
103 | }
104 | } catch (error) {
105 | let code;
106 | let message = 'Unknown error';
107 |
108 | if (error instanceof Error) {
109 | message = error.message;
110 | if ('code' in error) {
111 | code = error.code;
112 | }
113 | } else if (typeof error === 'object' && error !== null) {
114 | if ('message' in error && typeof error.message === 'string') {
115 | message = error.message;
116 | }
117 | if ('code' in error && typeof error.code === 'string') {
118 | code = error.code;
119 | }
120 | } else {
121 | message = String(error);
122 | }
123 |
124 | if (code === 'EPERM' || code === 'EACCES') {
125 | log('debug', `Permission denied scanning directory: ${currentDirAbs}`);
126 | } else {
127 | log(
128 | 'warning',
129 | `Error scanning directory ${currentDirAbs}: ${message} (Code: ${code ?? 'N/A'})`,
130 | );
131 | }
132 | }
133 | }
134 |
135 | // Define schema as ZodObject
136 | const discoverProjsSchema = z.object({
137 | workspaceRoot: z.string().describe('The absolute path of the workspace root to scan within.'),
138 | scanPath: z
139 | .string()
140 | .optional()
141 | .describe('Optional: Path relative to workspace root to scan. Defaults to workspace root.'),
142 | maxDepth: z
143 | .number()
144 | .int()
145 | .nonnegative()
146 | .optional()
147 | .describe(`Optional: Maximum directory depth to scan. Defaults to ${DEFAULT_MAX_DEPTH}.`),
148 | });
149 |
150 | // Use z.infer for type safety
151 | type DiscoverProjsParams = z.infer<typeof discoverProjsSchema>;
152 |
153 | /**
154 | * Business logic for discovering projects.
155 | * Exported for testing purposes.
156 | */
157 | export async function discover_projsLogic(
158 | params: DiscoverProjsParams,
159 | fileSystemExecutor: FileSystemExecutor,
160 | ): Promise<ToolResponse> {
161 | // Apply defaults
162 | const scanPath = params.scanPath ?? '.';
163 | const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH;
164 | const workspaceRoot = params.workspaceRoot;
165 |
166 | const relativeScanPath = scanPath;
167 |
168 | // Calculate and validate the absolute scan path
169 | const requestedScanPath = path.resolve(workspaceRoot, relativeScanPath ?? '.');
170 | let absoluteScanPath = requestedScanPath;
171 | const normalizedWorkspaceRoot = path.normalize(workspaceRoot);
172 | if (!path.normalize(absoluteScanPath).startsWith(normalizedWorkspaceRoot)) {
173 | log(
174 | 'warn',
175 | `Requested scan path '${relativeScanPath}' resolved outside workspace root '${workspaceRoot}'. Defaulting scan to workspace root.`,
176 | );
177 | absoluteScanPath = normalizedWorkspaceRoot;
178 | }
179 |
180 | const results = { projects: [], workspaces: [] };
181 |
182 | log(
183 | 'info',
184 | `Starting project discovery request: path=${absoluteScanPath}, maxDepth=${maxDepth}, workspace=${workspaceRoot}`,
185 | );
186 |
187 | try {
188 | // Ensure the scan path exists and is a directory
189 | const stats = await fileSystemExecutor.stat(absoluteScanPath);
190 | if (!stats.isDirectory()) {
191 | const errorMsg = `Scan path is not a directory: ${absoluteScanPath}`;
192 | log('error', errorMsg);
193 | // Return ToolResponse error format
194 | return {
195 | content: [createTextContent(errorMsg)],
196 | isError: true,
197 | };
198 | }
199 | } catch (error) {
200 | let code;
201 | let message = 'Unknown error accessing scan path';
202 |
203 | // Type guards - refined
204 | if (error instanceof Error) {
205 | message = error.message;
206 | // Check for code property specific to Node.js fs errors
207 | if ('code' in error) {
208 | code = error.code;
209 | }
210 | } else if (typeof error === 'object' && error !== null) {
211 | if ('message' in error && typeof error.message === 'string') {
212 | message = error.message;
213 | }
214 | if ('code' in error && typeof error.code === 'string') {
215 | code = error.code;
216 | }
217 | } else {
218 | message = String(error);
219 | }
220 |
221 | const errorMsg = `Failed to access scan path: ${absoluteScanPath}. Error: ${message}`;
222 | log('error', `${errorMsg} - Code: ${code ?? 'N/A'}`);
223 | return {
224 | content: [createTextContent(errorMsg)],
225 | isError: true,
226 | };
227 | }
228 |
229 | // Start the recursive scan from the validated absolute path
230 | await _findProjectsRecursive(
231 | absoluteScanPath,
232 | workspaceRoot,
233 | 0,
234 | maxDepth,
235 | results,
236 | fileSystemExecutor,
237 | );
238 |
239 | log(
240 | 'info',
241 | `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`,
242 | );
243 |
244 | const responseContent = [
245 | createTextContent(
246 | `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`,
247 | ),
248 | ];
249 |
250 | // Sort results for consistent output
251 | results.projects.sort();
252 | results.workspaces.sort();
253 |
254 | if (results.projects.length > 0) {
255 | responseContent.push(
256 | createTextContent(`Projects found:\n - ${results.projects.join('\n - ')}`),
257 | );
258 | }
259 |
260 | if (results.workspaces.length > 0) {
261 | responseContent.push(
262 | createTextContent(`Workspaces found:\n - ${results.workspaces.join('\n - ')}`),
263 | );
264 | }
265 |
266 | return {
267 | content: responseContent,
268 | isError: false,
269 | };
270 | }
271 |
272 | export default {
273 | name: 'discover_projs',
274 | description:
275 | 'Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.',
276 | schema: discoverProjsSchema.shape, // MCP SDK compatibility
277 | handler: createTypedTool(
278 | discoverProjsSchema,
279 | (params: DiscoverProjsParams) => {
280 | return discover_projsLogic(params, getDefaultFileSystemExecutor());
281 | },
282 | getDefaultCommandExecutor,
283 | ),
284 | };
285 |
```