This is page 7 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
--------------------------------------------------------------------------------
/build-plugins/plugin-discovery.js:
--------------------------------------------------------------------------------
```javascript
1 | import { readdirSync, readFileSync, existsSync } from 'fs';
2 | import { join } from 'path';
3 | import path from 'path';
4 |
5 | export function createPluginDiscoveryPlugin() {
6 | return {
7 | name: 'plugin-discovery',
8 | setup(build) {
9 | // Generate the workflow loaders file before build starts
10 | build.onStart(async () => {
11 | try {
12 | await generateWorkflowLoaders();
13 | await generateResourceLoaders();
14 | } catch (error) {
15 | console.error('Failed to generate loaders:', error);
16 | throw error;
17 | }
18 | });
19 | }
20 | };
21 | }
22 |
23 | async function generateWorkflowLoaders() {
24 | const pluginsDir = path.resolve(process.cwd(), 'src/mcp/tools');
25 |
26 | if (!existsSync(pluginsDir)) {
27 | throw new Error(`Plugins directory not found: ${pluginsDir}`);
28 | }
29 |
30 | // Scan for workflow directories
31 | const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true })
32 | .filter(dirent => dirent.isDirectory())
33 | .map(dirent => dirent.name);
34 |
35 | const workflowLoaders = {};
36 | const workflowMetadata = {};
37 |
38 | for (const dirName of workflowDirs) {
39 | const dirPath = join(pluginsDir, dirName);
40 | const indexPath = join(dirPath, 'index.ts');
41 |
42 | // Check if workflow has index.ts file
43 | if (!existsSync(indexPath)) {
44 | console.warn(`Skipping ${dirName}: no index.ts file found`);
45 | continue;
46 | }
47 |
48 | // Try to extract workflow metadata from index.ts
49 | try {
50 | const indexContent = readFileSync(indexPath, 'utf8');
51 | const metadata = extractWorkflowMetadata(indexContent);
52 |
53 | if (metadata) {
54 | // Find all tool files in this workflow directory
55 | const toolFiles = readdirSync(dirPath, { withFileTypes: true })
56 | .filter(dirent => dirent.isFile())
57 | .map(dirent => dirent.name)
58 | .filter(name =>
59 | (name.endsWith('.ts') || name.endsWith('.js')) &&
60 | name !== 'index.ts' &&
61 | name !== 'index.js' &&
62 | !name.endsWith('.test.ts') &&
63 | !name.endsWith('.test.js') &&
64 | name !== 'active-processes.ts' // Special exclusion for swift-package
65 | );
66 |
67 | // Generate dynamic loader function that loads workflow and all its tools
68 | workflowLoaders[dirName] = generateWorkflowLoader(dirName, toolFiles);
69 | workflowMetadata[dirName] = metadata;
70 |
71 | console.log(`✅ Discovered workflow: ${dirName} - ${metadata.name} (${toolFiles.length} tools)`);
72 | } else {
73 | console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`);
74 | }
75 | } catch (error) {
76 | console.warn(`⚠️ Error processing ${dirName}:`, error);
77 | }
78 | }
79 |
80 | // Generate the content for generated-plugins.ts
81 | const generatedContent = generatePluginsFileContent(workflowLoaders, workflowMetadata);
82 |
83 | // Write to the generated file
84 | const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts');
85 |
86 | const fs = await import('fs');
87 | await fs.promises.writeFile(outputPath, generatedContent, 'utf8');
88 |
89 | console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`);
90 | }
91 |
92 | function generateWorkflowLoader(workflowName, toolFiles) {
93 | const toolImports = toolFiles.map((file, index) => {
94 | const toolName = file.replace(/\.(ts|js)$/, '');
95 | return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.js').then(m => m.default)`;
96 | }).join(';\n ');
97 |
98 | const toolExports = toolFiles.map((file, index) => {
99 | const toolName = file.replace(/\.(ts|js)$/, '');
100 | return `'${toolName}': tool_${index}`;
101 | }).join(',\n ');
102 |
103 | return `async () => {
104 | const { workflow } = await import('../mcp/tools/${workflowName}/index.js');
105 | ${toolImports ? toolImports + ';\n ' : ''}
106 | return {
107 | workflow,
108 | ${toolExports ? toolExports : ''}
109 | };
110 | }`;
111 | }
112 |
113 | function extractWorkflowMetadata(content) {
114 | try {
115 | // Simple regex to extract workflow export object
116 | const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/);
117 |
118 | if (!workflowMatch) {
119 | return null;
120 | }
121 |
122 | const workflowObj = workflowMatch[1];
123 |
124 | // Extract name
125 | const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/);
126 | if (!nameMatch) return null;
127 |
128 | // Extract description
129 | const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/);
130 | if (!descMatch) return null;
131 |
132 | // Extract platforms (optional)
133 | const platformsMatch = workflowObj.match(/platforms\s*:\s*\[([^\]]*)\]/);
134 | let platforms;
135 | if (platformsMatch) {
136 | platforms = platformsMatch[1]
137 | .split(',')
138 | .map(p => p.trim().replace(/['"]/g, ''))
139 | .filter(p => p.length > 0);
140 | }
141 |
142 | // Extract targets (optional)
143 | const targetsMatch = workflowObj.match(/targets\s*:\s*\[([^\]]*)\]/);
144 | let targets;
145 | if (targetsMatch) {
146 | targets = targetsMatch[1]
147 | .split(',')
148 | .map(t => t.trim().replace(/['"]/g, ''))
149 | .filter(t => t.length > 0);
150 | }
151 |
152 | // Extract projectTypes (optional)
153 | const projectTypesMatch = workflowObj.match(/projectTypes\s*:\s*\[([^\]]*)\]/);
154 | let projectTypes;
155 | if (projectTypesMatch) {
156 | projectTypes = projectTypesMatch[1]
157 | .split(',')
158 | .map(pt => pt.trim().replace(/['"]/g, ''))
159 | .filter(pt => pt.length > 0);
160 | }
161 |
162 | // Extract capabilities (optional)
163 | const capabilitiesMatch = workflowObj.match(/capabilities\s*:\s*\[([^\]]*)\]/);
164 | let capabilities;
165 | if (capabilitiesMatch) {
166 | capabilities = capabilitiesMatch[1]
167 | .split(',')
168 | .map(c => c.trim().replace(/['"]/g, ''))
169 | .filter(c => c.length > 0);
170 | }
171 |
172 | const result = {
173 | name: nameMatch[1],
174 | description: descMatch[1]
175 | };
176 |
177 | if (platforms) result.platforms = platforms;
178 | if (targets) result.targets = targets;
179 | if (projectTypes) result.projectTypes = projectTypes;
180 | if (capabilities) result.capabilities = capabilities;
181 |
182 | return result;
183 | } catch (error) {
184 | console.warn('Failed to extract workflow metadata:', error);
185 | return null;
186 | }
187 | }
188 |
189 | function generatePluginsFileContent(workflowLoaders, workflowMetadata) {
190 | const loaderEntries = Object.entries(workflowLoaders)
191 | .map(([key, loader]) => {
192 | // Indent the loader function properly
193 | const indentedLoader = loader
194 | .split('\n')
195 | .map((line, index) => index === 0 ? ` '${key}': ${line}` : ` ${line}`)
196 | .join('\n');
197 | return indentedLoader;
198 | })
199 | .join(',\n');
200 |
201 | const metadataEntries = Object.entries(workflowMetadata)
202 | .map(([key, metadata]) => {
203 | const metadataJson = JSON.stringify(metadata, null, 4)
204 | .split('\n')
205 | .map(line => ` ${line}`)
206 | .join('\n');
207 | return ` '${key}': ${metadataJson.trim()}`;
208 | })
209 | .join(',\n');
210 |
211 | return `// AUTO-GENERATED - DO NOT EDIT
212 | // This file is generated by the plugin discovery esbuild plugin
213 |
214 | // Generated based on filesystem scan
215 | export const WORKFLOW_LOADERS = {
216 | ${loaderEntries}
217 | };
218 |
219 | export type WorkflowName = keyof typeof WORKFLOW_LOADERS;
220 |
221 | // Optional: Export workflow metadata for quick access
222 | export const WORKFLOW_METADATA = {
223 | ${metadataEntries}
224 | };
225 | `;
226 | }
227 |
228 | async function generateResourceLoaders() {
229 | const resourcesDir = path.resolve(process.cwd(), 'src/mcp/resources');
230 |
231 | if (!existsSync(resourcesDir)) {
232 | console.log('Resources directory not found, skipping resource generation');
233 | return;
234 | }
235 |
236 | // Scan for resource files
237 | const resourceFiles = readdirSync(resourcesDir, { withFileTypes: true })
238 | .filter(dirent => dirent.isFile())
239 | .map(dirent => dirent.name)
240 | .filter(name =>
241 | (name.endsWith('.ts') || name.endsWith('.js')) &&
242 | !name.endsWith('.test.ts') &&
243 | !name.endsWith('.test.js') &&
244 | !name.startsWith('__') // Exclude test directories
245 | );
246 |
247 | const resourceLoaders = {};
248 |
249 | for (const fileName of resourceFiles) {
250 | const resourceName = fileName.replace(/\.(ts|js)$/, '');
251 |
252 | // Generate dynamic loader for this resource
253 | resourceLoaders[resourceName] = `async () => {
254 | const module = await import('../mcp/resources/${resourceName}.js');
255 | return module.default;
256 | }`;
257 |
258 | console.log(`✅ Discovered resource: ${resourceName}`);
259 | }
260 |
261 | // Generate the content for generated-resources.ts
262 | const generatedContent = generateResourcesFileContent(resourceLoaders);
263 |
264 | // Write to the generated file
265 | const outputPath = path.resolve(process.cwd(), 'src/core/generated-resources.ts');
266 |
267 | const fs = await import('fs');
268 | await fs.promises.writeFile(outputPath, generatedContent, 'utf8');
269 |
270 | console.log(`🔧 Generated resource loaders for ${Object.keys(resourceLoaders).length} resources`);
271 | }
272 |
273 | function generateResourcesFileContent(resourceLoaders) {
274 | const loaderEntries = Object.entries(resourceLoaders)
275 | .map(([key, loader]) => ` '${key}': ${loader}`)
276 | .join(',\n');
277 |
278 | return `// AUTO-GENERATED - DO NOT EDIT
279 | // This file is generated by the plugin discovery esbuild plugin
280 |
281 | export const RESOURCE_LOADERS = {
282 | ${loaderEntries}
283 | };
284 |
285 | export type ResourceName = keyof typeof RESOURCE_LOADERS;
286 | `;
287 | }
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for start_sim_log_cap plugin
3 | */
4 | import { describe, it, expect, beforeEach } from 'vitest';
5 | import { z } from 'zod';
6 | import plugin, { start_sim_log_capLogic } from '../start_sim_log_cap.ts';
7 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
8 |
9 | describe('start_sim_log_cap plugin', () => {
10 | // Reset any test state if needed
11 |
12 | describe('Export Field Validation (Literal)', () => {
13 | it('should export an object with required properties', () => {
14 | expect(plugin).toHaveProperty('name');
15 | expect(plugin).toHaveProperty('description');
16 | expect(plugin).toHaveProperty('schema');
17 | expect(plugin).toHaveProperty('handler');
18 | });
19 |
20 | it('should have correct tool name', () => {
21 | expect(plugin.name).toBe('start_sim_log_cap');
22 | });
23 |
24 | it('should have correct description', () => {
25 | expect(plugin.description).toBe(
26 | 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.',
27 | );
28 | });
29 |
30 | it('should have handler as a function', () => {
31 | expect(typeof plugin.handler).toBe('function');
32 | });
33 |
34 | it('should validate schema with valid parameters', () => {
35 | const schema = z.object(plugin.schema);
36 | expect(
37 | schema.safeParse({ simulatorUuid: 'test-uuid', bundleId: 'com.example.app' }).success,
38 | ).toBe(true);
39 | expect(
40 | schema.safeParse({
41 | simulatorUuid: 'test-uuid',
42 | bundleId: 'com.example.app',
43 | captureConsole: true,
44 | }).success,
45 | ).toBe(true);
46 | expect(
47 | schema.safeParse({
48 | simulatorUuid: 'test-uuid',
49 | bundleId: 'com.example.app',
50 | captureConsole: false,
51 | }).success,
52 | ).toBe(true);
53 | });
54 |
55 | it('should reject invalid schema parameters', () => {
56 | const schema = z.object(plugin.schema);
57 | expect(schema.safeParse({ simulatorUuid: null, bundleId: 'com.example.app' }).success).toBe(
58 | false,
59 | );
60 | expect(
61 | schema.safeParse({ simulatorUuid: undefined, bundleId: 'com.example.app' }).success,
62 | ).toBe(false);
63 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', bundleId: null }).success).toBe(false);
64 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', bundleId: undefined }).success).toBe(
65 | false,
66 | );
67 | expect(
68 | schema.safeParse({
69 | simulatorUuid: 'test-uuid',
70 | bundleId: 'com.example.app',
71 | captureConsole: 'yes',
72 | }).success,
73 | ).toBe(false);
74 | expect(
75 | schema.safeParse({
76 | simulatorUuid: 'test-uuid',
77 | bundleId: 'com.example.app',
78 | captureConsole: 123,
79 | }).success,
80 | ).toBe(false);
81 | });
82 | });
83 |
84 | describe('Handler Behavior (Complete Literal Returns)', () => {
85 | // Note: Parameter validation is now handled by createTypedTool wrapper
86 | // Invalid parameters will not reach the logic function, so we test valid scenarios
87 |
88 | it('should return error when log capture fails', async () => {
89 | const mockExecutor = createMockExecutor({ success: true, output: '' });
90 | const logCaptureStub = (params: any, executor: any) => {
91 | return Promise.resolve({
92 | sessionId: '',
93 | logFilePath: '',
94 | processes: [],
95 | error: 'Permission denied',
96 | });
97 | };
98 |
99 | const result = await start_sim_log_capLogic(
100 | {
101 | simulatorUuid: 'test-uuid',
102 | bundleId: 'com.example.app',
103 | },
104 | mockExecutor,
105 | logCaptureStub,
106 | );
107 |
108 | expect(result.isError).toBe(true);
109 | expect(result.content[0].text).toBe('Error starting log capture: Permission denied');
110 | });
111 |
112 | it('should return success with session ID when log capture starts successfully', async () => {
113 | const mockExecutor = createMockExecutor({ success: true, output: '' });
114 | const logCaptureStub = (params: any, executor: any) => {
115 | return Promise.resolve({
116 | sessionId: 'test-uuid-123',
117 | logFilePath: '/tmp/test.log',
118 | processes: [],
119 | error: undefined,
120 | });
121 | };
122 |
123 | const result = await start_sim_log_capLogic(
124 | {
125 | simulatorUuid: 'test-uuid',
126 | bundleId: 'com.example.app',
127 | },
128 | mockExecutor,
129 | logCaptureStub,
130 | );
131 |
132 | expect(result.isError).toBeUndefined();
133 | expect(result.content[0].text).toBe(
134 | "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Only structured logs are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
135 | );
136 | });
137 |
138 | it('should indicate console capture when captureConsole is true', async () => {
139 | const mockExecutor = createMockExecutor({ success: true, output: '' });
140 | const logCaptureStub = (params: any, executor: any) => {
141 | return Promise.resolve({
142 | sessionId: 'test-uuid-123',
143 | logFilePath: '/tmp/test.log',
144 | processes: [],
145 | error: undefined,
146 | });
147 | };
148 |
149 | const result = await start_sim_log_capLogic(
150 | {
151 | simulatorUuid: 'test-uuid',
152 | bundleId: 'com.example.app',
153 | captureConsole: true,
154 | },
155 | mockExecutor,
156 | logCaptureStub,
157 | );
158 |
159 | expect(result.content[0].text).toBe(
160 | "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Your app was relaunched to capture console output.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
161 | );
162 | });
163 |
164 | it('should create correct spawn commands for console capture', async () => {
165 | const mockExecutor = createMockExecutor({ success: true, output: '' });
166 | const spawnCalls: Array<{
167 | command: string;
168 | args: string[];
169 | }> = [];
170 |
171 | const logCaptureStub = (params: any, executor: any) => {
172 | if (params.captureConsole) {
173 | // Record the console capture spawn call
174 | spawnCalls.push({
175 | command: 'xcrun',
176 | args: [
177 | 'simctl',
178 | 'launch',
179 | '--console-pty',
180 | '--terminate-running-process',
181 | params.simulatorUuid,
182 | params.bundleId,
183 | ],
184 | });
185 | }
186 | // Record the structured log capture spawn call
187 | spawnCalls.push({
188 | command: 'xcrun',
189 | args: [
190 | 'simctl',
191 | 'spawn',
192 | params.simulatorUuid,
193 | 'log',
194 | 'stream',
195 | '--level=debug',
196 | '--predicate',
197 | `subsystem == "${params.bundleId}"`,
198 | ],
199 | });
200 |
201 | return Promise.resolve({
202 | sessionId: 'test-uuid-123',
203 | logFilePath: '/tmp/test.log',
204 | processes: [],
205 | error: undefined,
206 | });
207 | };
208 |
209 | await start_sim_log_capLogic(
210 | {
211 | simulatorUuid: 'test-uuid',
212 | bundleId: 'com.example.app',
213 | captureConsole: true,
214 | },
215 | mockExecutor,
216 | logCaptureStub,
217 | );
218 |
219 | // Should spawn both console capture and structured log capture
220 | expect(spawnCalls).toHaveLength(2);
221 | expect(spawnCalls[0]).toEqual({
222 | command: 'xcrun',
223 | args: [
224 | 'simctl',
225 | 'launch',
226 | '--console-pty',
227 | '--terminate-running-process',
228 | 'test-uuid',
229 | 'com.example.app',
230 | ],
231 | });
232 | expect(spawnCalls[1]).toEqual({
233 | command: 'xcrun',
234 | args: [
235 | 'simctl',
236 | 'spawn',
237 | 'test-uuid',
238 | 'log',
239 | 'stream',
240 | '--level=debug',
241 | '--predicate',
242 | 'subsystem == "com.example.app"',
243 | ],
244 | });
245 | });
246 |
247 | it('should create correct spawn commands for structured logs only', async () => {
248 | const mockExecutor = createMockExecutor({ success: true, output: '' });
249 | const spawnCalls: Array<{
250 | command: string;
251 | args: string[];
252 | }> = [];
253 |
254 | const logCaptureStub = (params: any, executor: any) => {
255 | // Record the structured log capture spawn call only
256 | spawnCalls.push({
257 | command: 'xcrun',
258 | args: [
259 | 'simctl',
260 | 'spawn',
261 | params.simulatorUuid,
262 | 'log',
263 | 'stream',
264 | '--level=debug',
265 | '--predicate',
266 | `subsystem == "${params.bundleId}"`,
267 | ],
268 | });
269 |
270 | return Promise.resolve({
271 | sessionId: 'test-uuid-123',
272 | logFilePath: '/tmp/test.log',
273 | processes: [],
274 | error: undefined,
275 | });
276 | };
277 |
278 | await start_sim_log_capLogic(
279 | {
280 | simulatorUuid: 'test-uuid',
281 | bundleId: 'com.example.app',
282 | captureConsole: false,
283 | },
284 | mockExecutor,
285 | logCaptureStub,
286 | );
287 |
288 | // Should only spawn structured log capture
289 | expect(spawnCalls).toHaveLength(1);
290 | expect(spawnCalls[0]).toEqual({
291 | command: 'xcrun',
292 | args: [
293 | 'simctl',
294 | 'spawn',
295 | 'test-uuid',
296 | 'log',
297 | 'stream',
298 | '--level=debug',
299 | '--predicate',
300 | 'subsystem == "com.example.app"',
301 | ],
302 | });
303 | });
304 | });
305 | });
306 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for sim_statusbar 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 { z } from 'zod';
9 | import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts';
10 | import simStatusbar, { sim_statusbarLogic } from '../sim_statusbar.ts';
11 |
12 | describe('sim_statusbar tool', () => {
13 | describe('Export Field Validation (Literal)', () => {
14 | it('should have correct name', () => {
15 | expect(simStatusbar.name).toBe('sim_statusbar');
16 | });
17 |
18 | it('should have correct description', () => {
19 | expect(simStatusbar.description).toBe(
20 | 'Sets the data network indicator in the iOS simulator status bar. Use "clear" to reset all overrides, or specify a network type (hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc).',
21 | );
22 | });
23 |
24 | it('should have handler function', () => {
25 | expect(typeof simStatusbar.handler).toBe('function');
26 | });
27 |
28 | it('should have correct schema with simulatorUuid string field and dataNetwork enum field', () => {
29 | const schema = z.object(simStatusbar.schema);
30 |
31 | // Valid inputs
32 | expect(
33 | schema.safeParse({ simulatorUuid: 'test-uuid-123', dataNetwork: 'wifi' }).success,
34 | ).toBe(true);
35 | expect(schema.safeParse({ simulatorUuid: 'ABC123-DEF456', dataNetwork: '3g' }).success).toBe(
36 | true,
37 | );
38 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: '4g' }).success).toBe(
39 | true,
40 | );
41 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 'lte' }).success).toBe(
42 | true,
43 | );
44 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 'lte-a' }).success).toBe(
45 | true,
46 | );
47 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 'lte+' }).success).toBe(
48 | true,
49 | );
50 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: '5g' }).success).toBe(
51 | true,
52 | );
53 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: '5g+' }).success).toBe(
54 | true,
55 | );
56 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: '5g-uwb' }).success).toBe(
57 | true,
58 | );
59 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: '5g-uc' }).success).toBe(
60 | true,
61 | );
62 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 'hide' }).success).toBe(
63 | true,
64 | );
65 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 'clear' }).success).toBe(
66 | true,
67 | );
68 |
69 | // Invalid inputs
70 | expect(schema.safeParse({ simulatorUuid: 123, dataNetwork: 'wifi' }).success).toBe(false);
71 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 'invalid' }).success).toBe(
72 | false,
73 | );
74 | expect(schema.safeParse({ simulatorUuid: 'test-uuid', dataNetwork: 123 }).success).toBe(
75 | false,
76 | );
77 | expect(schema.safeParse({ simulatorUuid: null, dataNetwork: 'wifi' }).success).toBe(false);
78 | expect(schema.safeParse({ simulatorUuid: 'test-uuid' }).success).toBe(false);
79 | expect(schema.safeParse({ dataNetwork: 'wifi' }).success).toBe(false);
80 | expect(schema.safeParse({}).success).toBe(false);
81 | });
82 | });
83 |
84 | describe('Handler Behavior (Complete Literal Returns)', () => {
85 | it('should handle successful status bar data network setting', async () => {
86 | const mockExecutor = createMockExecutor({
87 | success: true,
88 | output: 'Status bar set successfully',
89 | });
90 |
91 | const result = await sim_statusbarLogic(
92 | {
93 | simulatorUuid: 'test-uuid-123',
94 | dataNetwork: 'wifi',
95 | },
96 | mockExecutor,
97 | );
98 |
99 | expect(result).toEqual({
100 | content: [
101 | {
102 | type: 'text',
103 | text: 'Successfully set simulator test-uuid-123 status bar data network to wifi',
104 | },
105 | ],
106 | });
107 | });
108 |
109 | it('should handle minimal valid parameters (Zod handles validation)', async () => {
110 | // Note: With createTypedTool, Zod validation happens before the logic function is called
111 | // So we test with a valid minimal parameter set since validation is handled upstream
112 | const mockExecutor = createMockExecutor({
113 | success: true,
114 | output: 'Status bar set successfully',
115 | });
116 |
117 | const result = await sim_statusbarLogic(
118 | {
119 | simulatorUuid: 'test-uuid-123',
120 | dataNetwork: 'wifi',
121 | },
122 | mockExecutor,
123 | );
124 |
125 | // The logic function should execute normally with valid parameters
126 | // Zod validation errors are handled by createTypedTool wrapper
127 | expect(result.isError).toBe(undefined);
128 | expect(result.content[0].text).toContain('Successfully set simulator');
129 | });
130 |
131 | it('should handle command failure', async () => {
132 | const mockExecutor = createMockExecutor({
133 | success: false,
134 | error: 'Simulator not found',
135 | });
136 |
137 | const result = await sim_statusbarLogic(
138 | {
139 | simulatorUuid: 'invalid-uuid',
140 | dataNetwork: '3g',
141 | },
142 | mockExecutor,
143 | );
144 |
145 | expect(result).toEqual({
146 | content: [
147 | {
148 | type: 'text',
149 | text: 'Failed to set status bar: Simulator not found',
150 | },
151 | ],
152 | isError: true,
153 | });
154 | });
155 |
156 | it('should handle exception with Error object', async () => {
157 | const mockExecutor: CommandExecutor = async () => {
158 | throw new Error('Connection failed');
159 | };
160 |
161 | const result = await sim_statusbarLogic(
162 | {
163 | simulatorUuid: 'test-uuid-123',
164 | dataNetwork: '4g',
165 | },
166 | mockExecutor,
167 | );
168 |
169 | expect(result).toEqual({
170 | content: [
171 | {
172 | type: 'text',
173 | text: 'Failed to set status bar: Connection failed',
174 | },
175 | ],
176 | isError: true,
177 | });
178 | });
179 |
180 | it('should handle exception with string error', async () => {
181 | const mockExecutor: CommandExecutor = async () => {
182 | throw 'String error';
183 | };
184 |
185 | const result = await sim_statusbarLogic(
186 | {
187 | simulatorUuid: 'test-uuid-123',
188 | dataNetwork: 'lte',
189 | },
190 | mockExecutor,
191 | );
192 |
193 | expect(result).toEqual({
194 | content: [
195 | {
196 | type: 'text',
197 | text: 'Failed to set status bar: String error',
198 | },
199 | ],
200 | isError: true,
201 | });
202 | });
203 |
204 | it('should verify command generation with mock executor for override', async () => {
205 | const calls: Array<{
206 | command: string[];
207 | operationDescription: string;
208 | keepAlive: boolean;
209 | timeout: number | undefined;
210 | }> = [];
211 |
212 | const mockExecutor: CommandExecutor = async (
213 | command,
214 | operationDescription,
215 | keepAlive,
216 | timeout,
217 | ) => {
218 | calls.push({ command, operationDescription, keepAlive, timeout });
219 | return {
220 | success: true,
221 | output: 'Status bar set successfully',
222 | error: undefined,
223 | process: { pid: 12345 },
224 | };
225 | };
226 |
227 | await sim_statusbarLogic(
228 | {
229 | simulatorUuid: 'test-uuid-123',
230 | dataNetwork: 'wifi',
231 | },
232 | mockExecutor,
233 | );
234 |
235 | expect(calls).toHaveLength(1);
236 | expect(calls[0]).toEqual({
237 | command: [
238 | 'xcrun',
239 | 'simctl',
240 | 'status_bar',
241 | 'test-uuid-123',
242 | 'override',
243 | '--dataNetwork',
244 | 'wifi',
245 | ],
246 | operationDescription: 'Set Status Bar',
247 | keepAlive: true,
248 | timeout: undefined,
249 | });
250 | });
251 |
252 | it('should verify command generation for clear operation', async () => {
253 | const calls: Array<{
254 | command: string[];
255 | operationDescription: string;
256 | keepAlive: boolean;
257 | timeout: number | undefined;
258 | }> = [];
259 |
260 | const mockExecutor: CommandExecutor = async (
261 | command,
262 | operationDescription,
263 | keepAlive,
264 | timeout,
265 | ) => {
266 | calls.push({ command, operationDescription, keepAlive, timeout });
267 | return {
268 | success: true,
269 | output: 'Status bar cleared successfully',
270 | error: undefined,
271 | process: { pid: 12345 },
272 | };
273 | };
274 |
275 | await sim_statusbarLogic(
276 | {
277 | simulatorUuid: 'test-uuid-123',
278 | dataNetwork: 'clear',
279 | },
280 | mockExecutor,
281 | );
282 |
283 | expect(calls).toHaveLength(1);
284 | expect(calls[0]).toEqual({
285 | command: ['xcrun', 'simctl', 'status_bar', 'test-uuid-123', 'clear'],
286 | operationDescription: 'Set Status Bar',
287 | keepAlive: true,
288 | timeout: undefined,
289 | });
290 | });
291 |
292 | it('should handle successful clear operation', async () => {
293 | const mockExecutor = createMockExecutor({
294 | success: true,
295 | output: 'Status bar cleared successfully',
296 | });
297 |
298 | const result = await sim_statusbarLogic(
299 | {
300 | simulatorUuid: 'test-uuid-123',
301 | dataNetwork: 'clear',
302 | },
303 | mockExecutor,
304 | );
305 |
306 | expect(result).toEqual({
307 | content: [
308 | {
309 | type: 'text',
310 | text: 'Successfully cleared status bar overrides for simulator test-uuid-123',
311 | },
312 | ],
313 | });
314 | });
315 | });
316 | });
317 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for list_schemes plugin
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import { z } from 'zod';
9 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
10 | import plugin, { listSchemesLogic } from '../list_schemes.ts';
11 | import { sessionStore } from '../../../../utils/session-store.ts';
12 |
13 | describe('list_schemes plugin', () => {
14 | beforeEach(() => {
15 | sessionStore.clear();
16 | });
17 |
18 | describe('Export Field Validation (Literal)', () => {
19 | it('should have correct name', () => {
20 | expect(plugin.name).toBe('list_schemes');
21 | });
22 |
23 | it('should have correct description', () => {
24 | expect(plugin.description).toBe('Lists schemes for a project or workspace.');
25 | });
26 |
27 | it('should have handler function', () => {
28 | expect(typeof plugin.handler).toBe('function');
29 | });
30 |
31 | it('should expose an empty public schema', () => {
32 | const schema = z.object(plugin.schema).strict();
33 | expect(schema.safeParse({}).success).toBe(true);
34 | expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false);
35 | expect(Object.keys(plugin.schema)).toEqual([]);
36 | });
37 | });
38 |
39 | describe('Handler Behavior (Complete Literal Returns)', () => {
40 | it('should return success with schemes found', async () => {
41 | const mockExecutor = createMockExecutor({
42 | success: true,
43 | output: `Information about project "MyProject":
44 | Targets:
45 | MyProject
46 | MyProjectTests
47 |
48 | Build Configurations:
49 | Debug
50 | Release
51 |
52 | Schemes:
53 | MyProject
54 | MyProjectTests`,
55 | });
56 |
57 | const result = await listSchemesLogic(
58 | { projectPath: '/path/to/MyProject.xcodeproj' },
59 | mockExecutor,
60 | );
61 |
62 | expect(result).toEqual({
63 | content: [
64 | {
65 | type: 'text',
66 | text: '✅ Available schemes:',
67 | },
68 | {
69 | type: 'text',
70 | text: 'MyProject\nMyProjectTests',
71 | },
72 | {
73 | type: 'text',
74 | text: `Next Steps:
75 | 1. Build the app: build_macos({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })
76 | or for iOS: build_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject", simulatorName: "iPhone 16" })
77 | 2. Show build settings: show_build_settings({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })`,
78 | },
79 | ],
80 | isError: false,
81 | });
82 | });
83 |
84 | it('should return error when command fails', async () => {
85 | const mockExecutor = createMockExecutor({
86 | success: false,
87 | error: 'Project not found',
88 | });
89 |
90 | const result = await listSchemesLogic(
91 | { projectPath: '/path/to/MyProject.xcodeproj' },
92 | mockExecutor,
93 | );
94 |
95 | expect(result).toEqual({
96 | content: [{ type: 'text', text: 'Failed to list schemes: Project not found' }],
97 | isError: true,
98 | });
99 | });
100 |
101 | it('should return error when no schemes found in output', async () => {
102 | const mockExecutor = createMockExecutor({
103 | success: true,
104 | output: 'Information about project "MyProject":\n Targets:\n MyProject',
105 | });
106 |
107 | const result = await listSchemesLogic(
108 | { projectPath: '/path/to/MyProject.xcodeproj' },
109 | mockExecutor,
110 | );
111 |
112 | expect(result).toEqual({
113 | content: [{ type: 'text', text: 'No schemes found in the output' }],
114 | isError: true,
115 | });
116 | });
117 |
118 | it('should return success with empty schemes list', async () => {
119 | const mockExecutor = createMockExecutor({
120 | success: true,
121 | output: `Information about project "MinimalProject":
122 | Targets:
123 | MinimalProject
124 |
125 | Build Configurations:
126 | Debug
127 | Release
128 |
129 | Schemes:
130 |
131 | `,
132 | });
133 |
134 | const result = await listSchemesLogic(
135 | { projectPath: '/path/to/MyProject.xcodeproj' },
136 | mockExecutor,
137 | );
138 |
139 | expect(result).toEqual({
140 | content: [
141 | {
142 | type: 'text',
143 | text: '✅ Available schemes:',
144 | },
145 | {
146 | type: 'text',
147 | text: '',
148 | },
149 | {
150 | type: 'text',
151 | text: '',
152 | },
153 | ],
154 | isError: false,
155 | });
156 | });
157 |
158 | it('should handle Error objects in catch blocks', async () => {
159 | const mockExecutor = async () => {
160 | throw new Error('Command execution failed');
161 | };
162 |
163 | const result = await listSchemesLogic(
164 | { projectPath: '/path/to/MyProject.xcodeproj' },
165 | mockExecutor,
166 | );
167 |
168 | expect(result).toEqual({
169 | content: [{ type: 'text', text: 'Error listing schemes: Command execution failed' }],
170 | isError: true,
171 | });
172 | });
173 |
174 | it('should handle string error objects in catch blocks', async () => {
175 | const mockExecutor = async () => {
176 | throw 'String error';
177 | };
178 |
179 | const result = await listSchemesLogic(
180 | { projectPath: '/path/to/MyProject.xcodeproj' },
181 | mockExecutor,
182 | );
183 |
184 | expect(result).toEqual({
185 | content: [{ type: 'text', text: 'Error listing schemes: String error' }],
186 | isError: true,
187 | });
188 | });
189 |
190 | it('should verify command generation with mock executor', async () => {
191 | const calls: any[] = [];
192 | const mockExecutor = async (
193 | command: string[],
194 | action: string,
195 | showOutput: boolean,
196 | workingDir?: string,
197 | ) => {
198 | calls.push([command, action, showOutput, workingDir]);
199 | return {
200 | success: true,
201 | output: `Information about project "MyProject":
202 | Targets:
203 | MyProject
204 |
205 | Build Configurations:
206 | Debug
207 | Release
208 |
209 | Schemes:
210 | MyProject`,
211 | error: undefined,
212 | process: { pid: 12345 },
213 | };
214 | };
215 |
216 | await listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor);
217 |
218 | expect(calls).toEqual([
219 | [
220 | ['xcodebuild', '-list', '-project', '/path/to/MyProject.xcodeproj'],
221 | 'List Schemes',
222 | true,
223 | undefined,
224 | ],
225 | ]);
226 | });
227 |
228 | it('should handle validation when testing with missing projectPath via plugin handler', async () => {
229 | // Note: Direct logic function calls bypass Zod validation, so we test the actual plugin handler
230 | // to verify Zod validation works properly. The createTypedTool wrapper handles validation.
231 | const result = await plugin.handler({});
232 | expect(result.isError).toBe(true);
233 | expect(result.content[0].text).toContain('Missing required session defaults');
234 | expect(result.content[0].text).toContain('Provide a project or workspace');
235 | });
236 | });
237 |
238 | describe('XOR Validation', () => {
239 | it('should error when neither projectPath nor workspacePath provided', async () => {
240 | const result = await plugin.handler({});
241 | expect(result.isError).toBe(true);
242 | expect(result.content[0].text).toContain('Missing required session defaults');
243 | expect(result.content[0].text).toContain('Provide a project or workspace');
244 | });
245 |
246 | it('should error when both projectPath and workspacePath provided', async () => {
247 | const result = await plugin.handler({
248 | projectPath: '/path/to/project.xcodeproj',
249 | workspacePath: '/path/to/workspace.xcworkspace',
250 | });
251 | expect(result.isError).toBe(true);
252 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
253 | });
254 |
255 | it('should handle empty strings as undefined', async () => {
256 | const result = await plugin.handler({
257 | projectPath: '',
258 | workspacePath: '',
259 | });
260 | expect(result.isError).toBe(true);
261 | expect(result.content[0].text).toContain('Missing required session defaults');
262 | expect(result.content[0].text).toContain('Provide a project or workspace');
263 | });
264 | });
265 |
266 | describe('Workspace Support', () => {
267 | it('should list schemes for workspace', async () => {
268 | const mockExecutor = createMockExecutor({
269 | success: true,
270 | output: `Information about workspace "MyWorkspace":
271 | Schemes:
272 | MyApp
273 | MyAppTests`,
274 | });
275 |
276 | const result = await listSchemesLogic(
277 | { workspacePath: '/path/to/MyProject.xcworkspace' },
278 | mockExecutor,
279 | );
280 |
281 | expect(result).toEqual({
282 | content: [
283 | {
284 | type: 'text',
285 | text: '✅ Available schemes:',
286 | },
287 | {
288 | type: 'text',
289 | text: 'MyApp\nMyAppTests',
290 | },
291 | {
292 | type: 'text',
293 | text: `Next Steps:
294 | 1. Build the app: build_macos({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })
295 | or for iOS: build_sim({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp", simulatorName: "iPhone 16" })
296 | 2. Show build settings: show_build_settings({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })`,
297 | },
298 | ],
299 | isError: false,
300 | });
301 | });
302 |
303 | it('should generate correct workspace command', async () => {
304 | const calls: any[] = [];
305 | const mockExecutor = async (
306 | command: string[],
307 | action: string,
308 | showOutput: boolean,
309 | workingDir?: string,
310 | ) => {
311 | calls.push([command, action, showOutput, workingDir]);
312 | return {
313 | success: true,
314 | output: `Information about workspace "MyWorkspace":
315 | Schemes:
316 | MyApp`,
317 | error: undefined,
318 | process: { pid: 12345 },
319 | };
320 | };
321 |
322 | await listSchemesLogic({ workspacePath: '/path/to/MyProject.xcworkspace' }, mockExecutor);
323 |
324 | expect(calls).toEqual([
325 | [
326 | ['xcodebuild', '-list', '-workspace', '/path/to/MyProject.xcworkspace'],
327 | 'List Schemes',
328 | true,
329 | undefined,
330 | ],
331 | ]);
332 | });
333 | });
334 | });
335 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/list_sims.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import { z } from 'zod';
3 | import {
4 | createMockExecutor,
5 | createMockFileSystemExecutor,
6 | } from '../../../../test-utils/mock-executors.ts';
7 |
8 | // Import the plugin and logic function
9 | import listSims, { list_simsLogic } from '../list_sims.ts';
10 |
11 | describe('list_sims tool', () => {
12 | let callHistory: Array<{
13 | command: string[];
14 | logPrefix?: string;
15 | useShell?: boolean;
16 | env?: Record<string, string>;
17 | }>;
18 |
19 | callHistory = [];
20 |
21 | describe('Export Field Validation (Literal)', () => {
22 | it('should have correct name', () => {
23 | expect(listSims.name).toBe('list_sims');
24 | });
25 |
26 | it('should have correct description', () => {
27 | expect(listSims.description).toBe('Lists available iOS simulators with their UUIDs. ');
28 | });
29 |
30 | it('should have handler function', () => {
31 | expect(typeof listSims.handler).toBe('function');
32 | });
33 |
34 | it('should have correct schema with enabled boolean field', () => {
35 | const schema = z.object(listSims.schema);
36 |
37 | // Valid inputs
38 | expect(schema.safeParse({ enabled: true }).success).toBe(true);
39 | expect(schema.safeParse({ enabled: false }).success).toBe(true);
40 | expect(schema.safeParse({ enabled: undefined }).success).toBe(true);
41 | expect(schema.safeParse({}).success).toBe(true);
42 |
43 | // Invalid inputs
44 | expect(schema.safeParse({ enabled: 'yes' }).success).toBe(false);
45 | expect(schema.safeParse({ enabled: 1 }).success).toBe(false);
46 | expect(schema.safeParse({ enabled: null }).success).toBe(false);
47 | });
48 | });
49 |
50 | describe('Handler Behavior (Complete Literal Returns)', () => {
51 | it('should handle successful simulator listing', async () => {
52 | const mockJsonOutput = JSON.stringify({
53 | devices: {
54 | 'iOS 17.0': [
55 | {
56 | name: 'iPhone 15',
57 | udid: 'test-uuid-123',
58 | isAvailable: true,
59 | state: 'Shutdown',
60 | },
61 | ],
62 | },
63 | });
64 |
65 | const mockTextOutput = `== Devices ==
66 | -- iOS 17.0 --
67 | iPhone 15 (test-uuid-123) (Shutdown)`;
68 |
69 | // Create a mock executor that returns different outputs based on command
70 | const mockExecutor = async (
71 | command: string[],
72 | logPrefix?: string,
73 | useShell?: boolean,
74 | env?: Record<string, string>,
75 | ) => {
76 | callHistory.push({ command, logPrefix, useShell, env });
77 |
78 | // Return JSON output for JSON command
79 | if (command.includes('--json')) {
80 | return {
81 | success: true,
82 | output: mockJsonOutput,
83 | error: undefined,
84 | process: { pid: 12345 },
85 | };
86 | }
87 |
88 | // Return text output for text command
89 | return {
90 | success: true,
91 | output: mockTextOutput,
92 | error: undefined,
93 | process: { pid: 12345 },
94 | };
95 | };
96 |
97 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
98 |
99 | // Verify both commands were called
100 | expect(callHistory).toHaveLength(2);
101 | expect(callHistory[0]).toEqual({
102 | command: ['xcrun', 'simctl', 'list', 'devices', '--json'],
103 | logPrefix: 'List Simulators (JSON)',
104 | useShell: true,
105 | env: undefined,
106 | });
107 | expect(callHistory[1]).toEqual({
108 | command: ['xcrun', 'simctl', 'list', 'devices'],
109 | logPrefix: 'List Simulators (Text)',
110 | useShell: true,
111 | env: undefined,
112 | });
113 |
114 | expect(result).toEqual({
115 | content: [
116 | {
117 | type: 'text',
118 | text: `Available iOS Simulators:
119 |
120 | iOS 17.0:
121 | - iPhone 15 (test-uuid-123)
122 |
123 | Next Steps:
124 | 1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
125 | 2. Open the simulator UI: open_sim({})
126 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
127 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
128 | },
129 | ],
130 | });
131 | });
132 |
133 | it('should handle successful listing with booted simulator', async () => {
134 | const mockJsonOutput = JSON.stringify({
135 | devices: {
136 | 'iOS 17.0': [
137 | {
138 | name: 'iPhone 15',
139 | udid: 'test-uuid-123',
140 | isAvailable: true,
141 | state: 'Booted',
142 | },
143 | ],
144 | },
145 | });
146 |
147 | const mockTextOutput = `== Devices ==
148 | -- iOS 17.0 --
149 | iPhone 15 (test-uuid-123) (Booted)`;
150 |
151 | const mockExecutor = async (command: string[]) => {
152 | if (command.includes('--json')) {
153 | return {
154 | success: true,
155 | output: mockJsonOutput,
156 | error: undefined,
157 | process: { pid: 12345 },
158 | };
159 | }
160 | return {
161 | success: true,
162 | output: mockTextOutput,
163 | error: undefined,
164 | process: { pid: 12345 },
165 | };
166 | };
167 |
168 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
169 |
170 | expect(result).toEqual({
171 | content: [
172 | {
173 | type: 'text',
174 | text: `Available iOS Simulators:
175 |
176 | iOS 17.0:
177 | - iPhone 15 (test-uuid-123) [Booted]
178 |
179 | Next Steps:
180 | 1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
181 | 2. Open the simulator UI: open_sim({})
182 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
183 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
184 | },
185 | ],
186 | });
187 | });
188 |
189 | it('should merge devices from text that are missing from JSON', async () => {
190 | const mockJsonOutput = JSON.stringify({
191 | devices: {
192 | 'iOS 18.6': [
193 | {
194 | name: 'iPhone 15',
195 | udid: 'json-uuid-123',
196 | isAvailable: true,
197 | state: 'Shutdown',
198 | },
199 | ],
200 | },
201 | });
202 |
203 | const mockTextOutput = `== Devices ==
204 | -- iOS 18.6 --
205 | iPhone 15 (json-uuid-123) (Shutdown)
206 | -- iOS 26.0 --
207 | iPhone 17 Pro (text-uuid-456) (Shutdown)`;
208 |
209 | const mockExecutor = async (command: string[]) => {
210 | if (command.includes('--json')) {
211 | return {
212 | success: true,
213 | output: mockJsonOutput,
214 | error: undefined,
215 | process: { pid: 12345 },
216 | };
217 | }
218 | return {
219 | success: true,
220 | output: mockTextOutput,
221 | error: undefined,
222 | process: { pid: 12345 },
223 | };
224 | };
225 |
226 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
227 |
228 | // Should contain both iOS 18.6 from JSON and iOS 26.0 from text
229 | expect(result).toEqual({
230 | content: [
231 | {
232 | type: 'text',
233 | text: `Available iOS Simulators:
234 |
235 | iOS 18.6:
236 | - iPhone 15 (json-uuid-123)
237 |
238 | iOS 26.0:
239 | - iPhone 17 Pro (text-uuid-456)
240 |
241 | Next Steps:
242 | 1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
243 | 2. Open the simulator UI: open_sim({})
244 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
245 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
246 | },
247 | ],
248 | });
249 | });
250 |
251 | it('should handle command failure', async () => {
252 | const mockExecutor = createMockExecutor({
253 | success: false,
254 | output: '',
255 | error: 'Command failed',
256 | process: { pid: 12345 },
257 | });
258 |
259 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
260 |
261 | expect(result).toEqual({
262 | content: [
263 | {
264 | type: 'text',
265 | text: 'Failed to list simulators: Command failed',
266 | },
267 | ],
268 | });
269 | });
270 |
271 | it('should handle JSON parse failure and fall back to text parsing', async () => {
272 | const mockTextOutput = `== Devices ==
273 | -- iOS 17.0 --
274 | iPhone 15 (test-uuid-456) (Shutdown)`;
275 |
276 | const mockExecutor = async (command: string[]) => {
277 | // JSON command returns invalid JSON
278 | if (command.includes('--json')) {
279 | return {
280 | success: true,
281 | output: 'invalid json',
282 | error: undefined,
283 | process: { pid: 12345 },
284 | };
285 | }
286 |
287 | // Text command returns valid text output
288 | return {
289 | success: true,
290 | output: mockTextOutput,
291 | error: undefined,
292 | process: { pid: 12345 },
293 | };
294 | };
295 |
296 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
297 |
298 | // Should fall back to text parsing and extract devices
299 | expect(result).toEqual({
300 | content: [
301 | {
302 | type: 'text',
303 | text: `Available iOS Simulators:
304 |
305 | iOS 17.0:
306 | - iPhone 15 (test-uuid-456)
307 |
308 | Next Steps:
309 | 1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
310 | 2. Open the simulator UI: open_sim({})
311 | 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
312 | 4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
313 | },
314 | ],
315 | });
316 | });
317 |
318 | it('should handle exception with Error object', async () => {
319 | const mockExecutor = createMockExecutor(new Error('Command execution failed'));
320 |
321 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
322 |
323 | expect(result).toEqual({
324 | content: [
325 | {
326 | type: 'text',
327 | text: 'Failed to list simulators: Command execution failed',
328 | },
329 | ],
330 | });
331 | });
332 |
333 | it('should handle exception with string error', async () => {
334 | const mockExecutor = createMockExecutor('String error');
335 |
336 | const result = await list_simsLogic({ enabled: true }, mockExecutor);
337 |
338 | expect(result).toEqual({
339 | content: [
340 | {
341 | type: 'text',
342 | text: 'Failed to list simulators: String error',
343 | },
344 | ],
345 | });
346 | });
347 | });
348 | });
349 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import { z } from 'zod';
3 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
4 | import { sessionStore } from '../../../../utils/session-store.ts';
5 | import launchAppSim, { launch_app_simLogic } from '../launch_app_sim.ts';
6 |
7 | describe('launch_app_sim tool', () => {
8 | beforeEach(() => {
9 | sessionStore.clear();
10 | });
11 |
12 | describe('Export Field Validation (Literal)', () => {
13 | it('should expose correct name and description', () => {
14 | expect(launchAppSim.name).toBe('launch_app_sim');
15 | expect(launchAppSim.description).toBe('Launches an app in an iOS simulator.');
16 | });
17 |
18 | it('should expose only non-session fields in public schema', () => {
19 | const schema = z.object(launchAppSim.schema);
20 |
21 | expect(
22 | schema.safeParse({
23 | bundleId: 'com.example.testapp',
24 | }).success,
25 | ).toBe(true);
26 |
27 | expect(
28 | schema.safeParse({
29 | bundleId: 'com.example.testapp',
30 | args: ['--debug'],
31 | }).success,
32 | ).toBe(true);
33 |
34 | expect(schema.safeParse({}).success).toBe(false);
35 | expect(schema.safeParse({ bundleId: 123 }).success).toBe(false);
36 | expect(schema.safeParse({ args: ['--debug'] }).success).toBe(false);
37 |
38 | expect(Object.keys(launchAppSim.schema).sort()).toEqual(['args', 'bundleId'].sort());
39 | });
40 | });
41 |
42 | describe('Handler Requirements', () => {
43 | it('should require simulator identifier when not provided', async () => {
44 | const result = await launchAppSim.handler({ bundleId: 'com.example.testapp' });
45 |
46 | expect(result.isError).toBe(true);
47 | expect(result.content[0].text).toContain('Missing required session defaults');
48 | expect(result.content[0].text).toContain('Provide simulatorId or simulatorName');
49 | expect(result.content[0].text).toContain('session-set-defaults');
50 | });
51 |
52 | it('should validate bundleId when simulatorId default exists', async () => {
53 | sessionStore.setDefaults({ simulatorId: 'SIM-UUID' });
54 |
55 | const result = await launchAppSim.handler({});
56 |
57 | expect(result.isError).toBe(true);
58 | expect(result.content[0].text).toContain('Parameter validation failed');
59 | expect(result.content[0].text).toContain('bundleId: Required');
60 | expect(result.content[0].text).toContain(
61 | 'Tip: set session defaults via session-set-defaults',
62 | );
63 | });
64 |
65 | it('should reject when both simulatorId and simulatorName provided explicitly', async () => {
66 | const result = await launchAppSim.handler({
67 | simulatorId: 'SIM-UUID',
68 | simulatorName: 'iPhone 16',
69 | bundleId: 'com.example.testapp',
70 | });
71 |
72 | expect(result.isError).toBe(true);
73 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
74 | expect(result.content[0].text).toContain('simulatorId');
75 | expect(result.content[0].text).toContain('simulatorName');
76 | });
77 | });
78 |
79 | describe('Logic Behavior (Literal Returns)', () => {
80 | it('should launch app successfully with simulatorId', async () => {
81 | let callCount = 0;
82 | const sequencedExecutor = async (command: string[]) => {
83 | callCount++;
84 | if (callCount === 1) {
85 | return {
86 | success: true,
87 | output: '/path/to/app/container',
88 | error: '',
89 | process: {} as any,
90 | };
91 | }
92 | return {
93 | success: true,
94 | output: 'App launched successfully',
95 | error: '',
96 | process: {} as any,
97 | };
98 | };
99 |
100 | const result = await launch_app_simLogic(
101 | {
102 | simulatorId: 'test-uuid-123',
103 | bundleId: 'com.example.testapp',
104 | },
105 | sequencedExecutor,
106 | );
107 |
108 | expect(result).toEqual({
109 | content: [
110 | {
111 | type: 'text',
112 | text: `✅ App launched successfully in simulator test-uuid-123.
113 |
114 | Next Steps:
115 | 1. To see simulator: open_sim()
116 | 2. Log capture: start_sim_log_cap({ simulatorUuid: "test-uuid-123", bundleId: "com.example.testapp" })
117 | With console: start_sim_log_cap({ simulatorUuid: "test-uuid-123", bundleId: "com.example.testapp", captureConsole: true })
118 | 3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
119 | },
120 | ],
121 | });
122 | });
123 |
124 | it('should append additional arguments when provided', async () => {
125 | let callCount = 0;
126 | const commands: string[][] = [];
127 |
128 | const sequencedExecutor = async (command: string[]) => {
129 | commands.push(command);
130 | callCount++;
131 | if (callCount === 1) {
132 | return {
133 | success: true,
134 | output: '/path/to/app/container',
135 | error: '',
136 | process: {} as any,
137 | };
138 | }
139 | return {
140 | success: true,
141 | output: 'App launched successfully',
142 | error: '',
143 | process: {} as any,
144 | };
145 | };
146 |
147 | await launch_app_simLogic(
148 | {
149 | simulatorId: 'test-uuid-123',
150 | bundleId: 'com.example.testapp',
151 | args: ['--debug', '--verbose'],
152 | },
153 | sequencedExecutor,
154 | );
155 |
156 | expect(commands[1]).toEqual([
157 | 'xcrun',
158 | 'simctl',
159 | 'launch',
160 | 'test-uuid-123',
161 | 'com.example.testapp',
162 | '--debug',
163 | '--verbose',
164 | ]);
165 | });
166 |
167 | it('should surface app-not-installed error', async () => {
168 | const mockExecutor = createMockExecutor({
169 | success: false,
170 | output: '',
171 | error: 'App not found',
172 | });
173 |
174 | const result = await launch_app_simLogic(
175 | {
176 | simulatorId: 'test-uuid-123',
177 | bundleId: 'com.example.testapp',
178 | },
179 | mockExecutor,
180 | );
181 |
182 | expect(result).toEqual({
183 | content: [
184 | {
185 | type: 'text',
186 | text: 'App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.',
187 | },
188 | ],
189 | isError: true,
190 | });
191 | });
192 |
193 | it('should return launch failure message when simctl launch fails', async () => {
194 | let callCount = 0;
195 | const sequencedExecutor = async (command: string[]) => {
196 | callCount++;
197 | if (callCount === 1) {
198 | return {
199 | success: true,
200 | output: '/path/to/app/container',
201 | error: '',
202 | process: {} as any,
203 | };
204 | }
205 | return {
206 | success: false,
207 | output: '',
208 | error: 'Launch failed',
209 | process: {} as any,
210 | };
211 | };
212 |
213 | const result = await launch_app_simLogic(
214 | {
215 | simulatorId: 'test-uuid-123',
216 | bundleId: 'com.example.testapp',
217 | },
218 | sequencedExecutor,
219 | );
220 |
221 | expect(result).toEqual({
222 | content: [
223 | {
224 | type: 'text',
225 | text: 'Launch app in simulator operation failed: Launch failed',
226 | },
227 | ],
228 | });
229 | });
230 |
231 | it('should launch using simulatorName by resolving UUID', async () => {
232 | let callCount = 0;
233 | const sequencedExecutor = async (command: string[]) => {
234 | callCount++;
235 | if (callCount === 1) {
236 | return {
237 | success: true,
238 | output: JSON.stringify({
239 | devices: {
240 | 'iOS 17.0': [
241 | {
242 | name: 'iPhone 16',
243 | udid: 'resolved-uuid',
244 | isAvailable: true,
245 | state: 'Shutdown',
246 | },
247 | ],
248 | },
249 | }),
250 | error: '',
251 | process: {} as any,
252 | };
253 | }
254 | if (callCount === 2) {
255 | return {
256 | success: true,
257 | output: '/path/to/app/container',
258 | error: '',
259 | process: {} as any,
260 | };
261 | }
262 | return {
263 | success: true,
264 | output: 'App launched successfully',
265 | error: '',
266 | process: {} as any,
267 | };
268 | };
269 |
270 | const result = await launch_app_simLogic(
271 | {
272 | simulatorName: 'iPhone 16',
273 | bundleId: 'com.example.testapp',
274 | },
275 | sequencedExecutor,
276 | );
277 |
278 | expect(result).toEqual({
279 | content: [
280 | {
281 | type: 'text',
282 | text: `✅ App launched successfully in simulator "iPhone 16" (resolved-uuid).
283 |
284 | Next Steps:
285 | 1. To see simulator: open_sim()
286 | 2. Log capture: start_sim_log_cap({ simulatorName: "iPhone 16", bundleId: "com.example.testapp" })
287 | With console: start_sim_log_cap({ simulatorName: "iPhone 16", bundleId: "com.example.testapp", captureConsole: true })
288 | 3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
289 | },
290 | ],
291 | });
292 | });
293 |
294 | it('should return error when simulator name is not found', async () => {
295 | const mockListExecutor = async () => ({
296 | success: true,
297 | output: JSON.stringify({ devices: {} }),
298 | error: '',
299 | process: {} as any,
300 | });
301 |
302 | const result = await launch_app_simLogic(
303 | {
304 | simulatorName: 'Missing Simulator',
305 | bundleId: 'com.example.testapp',
306 | },
307 | mockListExecutor,
308 | );
309 |
310 | expect(result).toEqual({
311 | content: [
312 | {
313 | type: 'text',
314 | text: 'Simulator named "Missing Simulator" not found. Use list_sims to see available simulators.',
315 | },
316 | ],
317 | isError: true,
318 | });
319 | });
320 |
321 | it('should return error when simctl list fails', async () => {
322 | const mockExecutor = createMockExecutor({
323 | success: false,
324 | output: '',
325 | error: 'simctl list failed',
326 | });
327 |
328 | const result = await launch_app_simLogic(
329 | {
330 | simulatorName: 'iPhone 16',
331 | bundleId: 'com.example.testapp',
332 | },
333 | mockExecutor,
334 | );
335 |
336 | expect(result).toEqual({
337 | content: [
338 | {
339 | type: 'text',
340 | text: 'Failed to list simulators: simctl list failed',
341 | },
342 | ],
343 | isError: true,
344 | });
345 | });
346 | });
347 | });
348 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for stop_device_log_cap plugin
3 | */
4 | import { describe, it, expect, beforeEach } from 'vitest';
5 | import { EventEmitter } from 'events';
6 | import { z } from 'zod';
7 | import plugin, { stop_device_log_capLogic } from '../stop_device_log_cap.ts';
8 | import { activeDeviceLogSessions, type DeviceLogSession } from '../start_device_log_cap.ts';
9 | import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts';
10 |
11 | // Note: Logger is allowed to execute normally (integration testing pattern)
12 |
13 | describe('stop_device_log_cap plugin', () => {
14 | beforeEach(() => {
15 | // Clear actual active sessions before each test
16 | activeDeviceLogSessions.clear();
17 | });
18 |
19 | describe('Plugin Structure', () => {
20 | it('should export an object with required properties', () => {
21 | expect(plugin).toHaveProperty('name');
22 | expect(plugin).toHaveProperty('description');
23 | expect(plugin).toHaveProperty('schema');
24 | expect(plugin).toHaveProperty('handler');
25 | });
26 |
27 | it('should have correct tool name', () => {
28 | expect(plugin.name).toBe('stop_device_log_cap');
29 | });
30 |
31 | it('should have correct description', () => {
32 | expect(plugin.description).toBe(
33 | 'Stops an active Apple device log capture session and returns the captured logs.',
34 | );
35 | });
36 |
37 | it('should have correct schema structure', () => {
38 | // Schema should be a plain object for MCP protocol compliance
39 | expect(typeof plugin.schema).toBe('object');
40 | expect(plugin.schema).toHaveProperty('logSessionId');
41 |
42 | // Validate that schema fields are Zod types that can be used for validation
43 | const schema = z.object(plugin.schema);
44 | expect(schema.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true);
45 | expect(schema.safeParse({ logSessionId: 123 }).success).toBe(false);
46 | });
47 |
48 | it('should have handler as a function', () => {
49 | expect(typeof plugin.handler).toBe('function');
50 | });
51 | });
52 |
53 | describe('Handler Functionality', () => {
54 | // Helper function to create a test process
55 | function createTestProcess(
56 | options: {
57 | killed?: boolean;
58 | exitCode?: number | null;
59 | } = {},
60 | ) {
61 | const emitter = new EventEmitter();
62 | const processState = {
63 | killed: options.killed ?? false,
64 | exitCode: options.exitCode ?? (options.killed ? 0 : null),
65 | killCalls: [] as string[],
66 | kill(signal?: string) {
67 | if (this.killed) {
68 | return false;
69 | }
70 | this.killCalls.push(signal ?? 'SIGTERM');
71 | this.killed = true;
72 | this.exitCode = 0;
73 | emitter.emit('close', 0);
74 | return true;
75 | },
76 | };
77 |
78 | const testProcess = Object.assign(emitter, processState);
79 | return testProcess as typeof testProcess;
80 | }
81 |
82 | it('should handle stop log capture when session not found', async () => {
83 | const mockFileSystem = createMockFileSystemExecutor();
84 |
85 | const result = await stop_device_log_capLogic(
86 | {
87 | logSessionId: 'device-log-00008110-001A2C3D4E5F-com.example.MyApp',
88 | },
89 | mockFileSystem,
90 | );
91 |
92 | expect(result.content[0].text).toBe(
93 | 'Failed to stop device log capture session device-log-00008110-001A2C3D4E5F-com.example.MyApp: Device log capture session not found: device-log-00008110-001A2C3D4E5F-com.example.MyApp',
94 | );
95 | expect(result.isError).toBe(true);
96 | });
97 |
98 | it('should handle successful log capture stop', async () => {
99 | const testSessionId = 'test-session-123';
100 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-123.log';
101 | const testLogContent = 'Device log content here...';
102 |
103 | // Test active session
104 | const testProcess = createTestProcess({
105 | killed: false,
106 | exitCode: null,
107 | });
108 |
109 | activeDeviceLogSessions.set(testSessionId, {
110 | process: testProcess as unknown as DeviceLogSession['process'],
111 | logFilePath: testLogFilePath,
112 | deviceUuid: '00008110-001A2C3D4E5F',
113 | bundleId: 'com.example.MyApp',
114 | hasEnded: false,
115 | });
116 |
117 | // Configure test file system for successful operation
118 | const mockFileSystem = createMockFileSystemExecutor({
119 | existsSync: () => true,
120 | readFile: async () => testLogContent,
121 | });
122 |
123 | const result = await stop_device_log_capLogic(
124 | {
125 | logSessionId: testSessionId,
126 | },
127 | mockFileSystem,
128 | );
129 |
130 | expect(result).toEqual({
131 | content: [
132 | {
133 | type: 'text',
134 | text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`,
135 | },
136 | ],
137 | });
138 | expect(result.isError).toBeUndefined();
139 | expect(testProcess.killCalls).toEqual(['SIGTERM']);
140 | expect(activeDeviceLogSessions.has(testSessionId)).toBe(false);
141 | });
142 |
143 | it('should handle already killed process', async () => {
144 | const testSessionId = 'test-session-456';
145 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-456.log';
146 | const testLogContent = 'Device log content...';
147 |
148 | // Test active session with already killed process
149 | const testProcess = createTestProcess({
150 | killed: true,
151 | exitCode: 0,
152 | });
153 |
154 | activeDeviceLogSessions.set(testSessionId, {
155 | process: testProcess as unknown as DeviceLogSession['process'],
156 | logFilePath: testLogFilePath,
157 | deviceUuid: '00008110-001A2C3D4E5F',
158 | bundleId: 'com.example.MyApp',
159 | hasEnded: false,
160 | });
161 |
162 | // Configure test file system for successful operation
163 | const mockFileSystem = createMockFileSystemExecutor({
164 | existsSync: () => true,
165 | readFile: async () => testLogContent,
166 | });
167 |
168 | const result = await stop_device_log_capLogic(
169 | {
170 | logSessionId: testSessionId,
171 | },
172 | mockFileSystem,
173 | );
174 |
175 | expect(result).toEqual({
176 | content: [
177 | {
178 | type: 'text',
179 | text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`,
180 | },
181 | ],
182 | });
183 | expect(testProcess.killCalls).toEqual([]); // Should not kill already killed process
184 | });
185 |
186 | it('should handle file access failure', async () => {
187 | const testSessionId = 'test-session-789';
188 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-789.log';
189 |
190 | // Test active session
191 | const testProcess = createTestProcess({
192 | killed: false,
193 | exitCode: null,
194 | });
195 |
196 | activeDeviceLogSessions.set(testSessionId, {
197 | process: testProcess as unknown as DeviceLogSession['process'],
198 | logFilePath: testLogFilePath,
199 | deviceUuid: '00008110-001A2C3D4E5F',
200 | bundleId: 'com.example.MyApp',
201 | hasEnded: false,
202 | });
203 |
204 | // Configure test file system for access failure (file doesn't exist)
205 | const mockFileSystem = createMockFileSystemExecutor({
206 | existsSync: () => false,
207 | });
208 |
209 | const result = await stop_device_log_capLogic(
210 | {
211 | logSessionId: testSessionId,
212 | },
213 | mockFileSystem,
214 | );
215 |
216 | expect(result).toEqual({
217 | content: [
218 | {
219 | type: 'text',
220 | text: `Failed to stop device log capture session ${testSessionId}: Log file not found: ${testLogFilePath}`,
221 | },
222 | ],
223 | isError: true,
224 | });
225 | expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); // Session still removed
226 | });
227 |
228 | it('should handle file read failure', async () => {
229 | const testSessionId = 'test-session-abc';
230 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-abc.log';
231 |
232 | // Test active session
233 | const testProcess = createTestProcess({
234 | killed: false,
235 | exitCode: null,
236 | });
237 |
238 | activeDeviceLogSessions.set(testSessionId, {
239 | process: testProcess as unknown as DeviceLogSession['process'],
240 | logFilePath: testLogFilePath,
241 | deviceUuid: '00008110-001A2C3D4E5F',
242 | bundleId: 'com.example.MyApp',
243 | hasEnded: false,
244 | });
245 |
246 | // Configure test file system for successful access but failed read
247 | const mockFileSystem = createMockFileSystemExecutor({
248 | existsSync: () => true,
249 | readFile: async () => {
250 | throw new Error('Read permission denied');
251 | },
252 | });
253 |
254 | const result = await stop_device_log_capLogic(
255 | {
256 | logSessionId: testSessionId,
257 | },
258 | mockFileSystem,
259 | );
260 |
261 | expect(result).toEqual({
262 | content: [
263 | {
264 | type: 'text',
265 | text: `Failed to stop device log capture session ${testSessionId}: Read permission denied`,
266 | },
267 | ],
268 | isError: true,
269 | });
270 | });
271 |
272 | it('should handle string error objects', async () => {
273 | const testSessionId = 'test-session-def';
274 | const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-def.log';
275 |
276 | // Test active session
277 | const testProcess = createTestProcess({
278 | killed: false,
279 | exitCode: null,
280 | });
281 |
282 | activeDeviceLogSessions.set(testSessionId, {
283 | process: testProcess as unknown as DeviceLogSession['process'],
284 | logFilePath: testLogFilePath,
285 | deviceUuid: '00008110-001A2C3D4E5F',
286 | bundleId: 'com.example.MyApp',
287 | hasEnded: false,
288 | });
289 |
290 | // Configure test file system for access failure with string error
291 | const mockFileSystem = createMockFileSystemExecutor({
292 | existsSync: () => true,
293 | readFile: async () => {
294 | throw 'String error message';
295 | },
296 | });
297 |
298 | const result = await stop_device_log_capLogic(
299 | {
300 | logSessionId: testSessionId,
301 | },
302 | mockFileSystem,
303 | );
304 |
305 | expect(result).toEqual({
306 | content: [
307 | {
308 | type: 'text',
309 | text: `Failed to stop device log capture session ${testSessionId}: String error message`,
310 | },
311 | ],
312 | isError: true,
313 | });
314 | });
315 | });
316 | });
317 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/logging/stop_device_log_cap.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Logging Plugin: Stop Device Log Capture
3 | *
4 | * Stops an active Apple device log capture session and returns the captured logs.
5 | */
6 |
7 | import * as fs from 'fs';
8 | import { z } from 'zod';
9 | import { log } from '../../../utils/logging/index.ts';
10 | import { activeDeviceLogSessions, type DeviceLogSession } from './start_device_log_cap.ts';
11 | import { ToolResponse } 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 | // Define schema as ZodObject
17 | const stopDeviceLogCapSchema = z.object({
18 | logSessionId: z.string().describe('The session ID returned by start_device_log_cap.'),
19 | });
20 |
21 | // Use z.infer for type safety
22 | type StopDeviceLogCapParams = z.infer<typeof stopDeviceLogCapSchema>;
23 |
24 | /**
25 | * Business logic for stopping device log capture session
26 | */
27 | export async function stop_device_log_capLogic(
28 | params: StopDeviceLogCapParams,
29 | fileSystemExecutor: FileSystemExecutor,
30 | ): Promise<ToolResponse> {
31 | const { logSessionId } = params;
32 |
33 | const session = activeDeviceLogSessions.get(logSessionId);
34 | if (!session) {
35 | log('warning', `Device log session not found: ${logSessionId}`);
36 | return {
37 | content: [
38 | {
39 | type: 'text',
40 | text: `Failed to stop device log capture session ${logSessionId}: Device log capture session not found: ${logSessionId}`,
41 | },
42 | ],
43 | isError: true,
44 | };
45 | }
46 |
47 | try {
48 | log('info', `Attempting to stop device log capture session: ${logSessionId}`);
49 |
50 | const shouldSignalStop =
51 | !(session.hasEnded ?? false) &&
52 | session.process.killed !== true &&
53 | session.process.exitCode == null;
54 |
55 | if (shouldSignalStop) {
56 | session.process.kill?.('SIGTERM');
57 | }
58 |
59 | await waitForSessionToFinish(session);
60 |
61 | if (session.logStream) {
62 | await ensureStreamClosed(session.logStream);
63 | }
64 |
65 | const logFilePath = session.logFilePath;
66 | activeDeviceLogSessions.delete(logSessionId);
67 |
68 | // Check file access
69 | if (!fileSystemExecutor.existsSync(logFilePath)) {
70 | throw new Error(`Log file not found: ${logFilePath}`);
71 | }
72 |
73 | const fileContent = await fileSystemExecutor.readFile(logFilePath, 'utf-8');
74 | log('info', `Successfully read device log content from ${logFilePath}`);
75 |
76 | log(
77 | 'info',
78 | `Device log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`,
79 | );
80 |
81 | return {
82 | content: [
83 | {
84 | type: 'text',
85 | text: `✅ Device log capture session stopped successfully\n\nSession ID: ${logSessionId}\n\n--- Captured Logs ---\n${fileContent}`,
86 | },
87 | ],
88 | };
89 | } catch (error) {
90 | const message = error instanceof Error ? error.message : String(error);
91 | log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`);
92 | return {
93 | content: [
94 | {
95 | type: 'text',
96 | text: `Failed to stop device log capture session ${logSessionId}: ${message}`,
97 | },
98 | ],
99 | isError: true,
100 | };
101 | }
102 | }
103 |
104 | type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean };
105 |
106 | async function ensureStreamClosed(stream: fs.WriteStream): Promise<void> {
107 | const typedStream = stream as WriteStreamWithClosed;
108 | if (typedStream.destroyed || typedStream.closed) {
109 | return;
110 | }
111 |
112 | await new Promise<void>((resolve) => {
113 | const onClose = (): void => resolve();
114 | typedStream.once('close', onClose);
115 | typedStream.end();
116 | }).catch(() => {
117 | // Ignore cleanup errors – best-effort close
118 | });
119 | }
120 |
121 | async function waitForSessionToFinish(session: DeviceLogSession): Promise<void> {
122 | if (session.hasEnded) {
123 | return;
124 | }
125 |
126 | if (session.process.exitCode != null) {
127 | session.hasEnded = true;
128 | return;
129 | }
130 |
131 | if (typeof session.process.once === 'function') {
132 | await new Promise<void>((resolve) => {
133 | const onClose = (): void => {
134 | clearTimeout(timeout);
135 | session.hasEnded = true;
136 | resolve();
137 | };
138 |
139 | const timeout = setTimeout(() => {
140 | session.process.removeListener?.('close', onClose);
141 | session.hasEnded = true;
142 | resolve();
143 | }, 1000);
144 |
145 | session.process.once('close', onClose);
146 |
147 | if (session.hasEnded || session.process.exitCode != null) {
148 | session.process.removeListener?.('close', onClose);
149 | onClose();
150 | }
151 | });
152 | return;
153 | }
154 |
155 | // Fallback polling for minimal mock processes (primarily in tests)
156 | for (let i = 0; i < 20; i += 1) {
157 | if (session.hasEnded || session.process.exitCode != null) {
158 | session.hasEnded = true;
159 | break;
160 | }
161 | await new Promise((resolve) => setTimeout(resolve, 50));
162 | }
163 | }
164 |
165 | /**
166 | * Type guard to check if an object has fs-like promises interface
167 | */
168 | function hasPromisesInterface(obj: unknown): obj is { promises: typeof fs.promises } {
169 | return typeof obj === 'object' && obj !== null && 'promises' in obj;
170 | }
171 |
172 | /**
173 | * Type guard to check if an object has existsSync method
174 | */
175 | function hasExistsSyncMethod(obj: unknown): obj is { existsSync: typeof fs.existsSync } {
176 | return typeof obj === 'object' && obj !== null && 'existsSync' in obj;
177 | }
178 |
179 | /**
180 | * Legacy support for backward compatibility
181 | */
182 | export async function stopDeviceLogCapture(
183 | logSessionId: string,
184 | fileSystem?: unknown,
185 | ): Promise<{ logContent: string; error?: string }> {
186 | // For backward compatibility, create a mock FileSystemExecutor from the fileSystem parameter
187 | const fsToUse = fileSystem ?? fs;
188 | const mockFileSystemExecutor: FileSystemExecutor = {
189 | async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
190 | if (hasPromisesInterface(fsToUse)) {
191 | await fsToUse.promises.mkdir(path, options);
192 | } else {
193 | await fs.promises.mkdir(path, options);
194 | }
195 | },
196 | async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
197 | if (hasPromisesInterface(fsToUse)) {
198 | const result = await fsToUse.promises.readFile(path, encoding);
199 | return typeof result === 'string' ? result : (result as Buffer).toString();
200 | } else {
201 | const result = await fs.promises.readFile(path, encoding);
202 | return typeof result === 'string' ? result : (result as Buffer).toString();
203 | }
204 | },
205 | async writeFile(
206 | path: string,
207 | content: string,
208 | encoding: BufferEncoding = 'utf8',
209 | ): Promise<void> {
210 | if (hasPromisesInterface(fsToUse)) {
211 | await fsToUse.promises.writeFile(path, content, encoding);
212 | } else {
213 | await fs.promises.writeFile(path, content, encoding);
214 | }
215 | },
216 | async cp(
217 | source: string,
218 | destination: string,
219 | options?: { recursive?: boolean },
220 | ): Promise<void> {
221 | if (hasPromisesInterface(fsToUse)) {
222 | await fsToUse.promises.cp(source, destination, options);
223 | } else {
224 | await fs.promises.cp(source, destination, options);
225 | }
226 | },
227 | async readdir(path: string, options?: { withFileTypes?: boolean }): Promise<unknown[]> {
228 | if (hasPromisesInterface(fsToUse)) {
229 | if (options?.withFileTypes === true) {
230 | const result = await fsToUse.promises.readdir(path, { withFileTypes: true });
231 | return Array.isArray(result) ? result : [];
232 | } else {
233 | const result = await fsToUse.promises.readdir(path);
234 | return Array.isArray(result) ? result : [];
235 | }
236 | } else {
237 | if (options?.withFileTypes === true) {
238 | const result = await fs.promises.readdir(path, { withFileTypes: true });
239 | return Array.isArray(result) ? result : [];
240 | } else {
241 | const result = await fs.promises.readdir(path);
242 | return Array.isArray(result) ? result : [];
243 | }
244 | }
245 | },
246 | async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
247 | if (hasPromisesInterface(fsToUse)) {
248 | await fsToUse.promises.rm(path, options);
249 | } else {
250 | await fs.promises.rm(path, options);
251 | }
252 | },
253 | existsSync(path: string): boolean {
254 | if (hasExistsSyncMethod(fsToUse)) {
255 | return fsToUse.existsSync(path);
256 | } else {
257 | return fs.existsSync(path);
258 | }
259 | },
260 | async stat(path: string): Promise<{ isDirectory(): boolean }> {
261 | if (hasPromisesInterface(fsToUse)) {
262 | const result = await fsToUse.promises.stat(path);
263 | return result as { isDirectory(): boolean };
264 | } else {
265 | const result = await fs.promises.stat(path);
266 | return result as { isDirectory(): boolean };
267 | }
268 | },
269 | async mkdtemp(prefix: string): Promise<string> {
270 | if (hasPromisesInterface(fsToUse)) {
271 | return await fsToUse.promises.mkdtemp(prefix);
272 | } else {
273 | return await fs.promises.mkdtemp(prefix);
274 | }
275 | },
276 | tmpdir(): string {
277 | return '/tmp';
278 | },
279 | };
280 |
281 | const result = await stop_device_log_capLogic({ logSessionId }, mockFileSystemExecutor);
282 |
283 | if (result.isError) {
284 | const errorText = result.content[0]?.text;
285 | const errorMessage =
286 | typeof errorText === 'string'
287 | ? errorText.replace(`Failed to stop device log capture session ${logSessionId}: `, '')
288 | : 'Unknown error occurred';
289 |
290 | return {
291 | logContent: '',
292 | error: errorMessage,
293 | };
294 | }
295 |
296 | // Extract log content from successful response
297 | const successText = result.content[0]?.text;
298 | if (typeof successText !== 'string') {
299 | return {
300 | logContent: '',
301 | error: 'Invalid response format: expected text content',
302 | };
303 | }
304 |
305 | const logContentMatch = successText.match(/--- Captured Logs ---\n([\s\S]*)$/);
306 | const logContent = logContentMatch?.[1] ?? '';
307 |
308 | return { logContent };
309 | }
310 |
311 | export default {
312 | name: 'stop_device_log_cap',
313 | description: 'Stops an active Apple device log capture session and returns the captured logs.',
314 | schema: stopDeviceLogCapSchema.shape, // MCP SDK compatibility
315 | handler: createTypedTool(
316 | stopDeviceLogCapSchema,
317 | (params: StopDeviceLogCapParams) => {
318 | return stop_device_log_capLogic(params, getDefaultFileSystemExecutor());
319 | },
320 | getDefaultCommandExecutor,
321 | ),
322 | };
323 |
```
--------------------------------------------------------------------------------
/src/test-utils/mock-executors.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Mock Executors for Testing - Dependency Injection Architecture
3 | *
4 | * This module provides mock implementations of CommandExecutor and FileSystemExecutor
5 | * for testing purposes. These mocks are completely isolated from production dependencies
6 | * to avoid import chains that could trigger native module loading issues in test environments.
7 | *
8 | * IMPORTANT: These are EXACT copies of the mock functions originally in utils/command.js
9 | * to ensure zero behavioral changes during the file reorganization.
10 | *
11 | * Responsibilities:
12 | * - Providing mock command execution for tests
13 | * - Providing mock file system operations for tests
14 | * - Maintaining exact behavior compatibility with original implementations
15 | * - Avoiding any dependencies on production logging or instrumentation
16 | */
17 |
18 | import { ChildProcess } from 'child_process';
19 | import { CommandExecutor } from '../utils/CommandExecutor.ts';
20 | import { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
21 |
22 | /**
23 | * Create a mock executor for testing
24 | * @param result Mock command result or error to throw
25 | * @returns Mock executor function
26 | */
27 | export function createMockExecutor(
28 | result:
29 | | {
30 | success?: boolean;
31 | output?: string;
32 | error?: string;
33 | process?: unknown;
34 | exitCode?: number;
35 | shouldThrow?: Error;
36 | }
37 | | Error
38 | | string,
39 | ): CommandExecutor {
40 | // If result is Error or string, return executor that rejects
41 | if (result instanceof Error || typeof result === 'string') {
42 | return async () => {
43 | throw result;
44 | };
45 | }
46 |
47 | // If shouldThrow is specified, return executor that rejects with that error
48 | if (result.shouldThrow) {
49 | return async () => {
50 | throw result.shouldThrow;
51 | };
52 | }
53 |
54 | const mockProcess = {
55 | pid: 12345,
56 | stdout: null,
57 | stderr: null,
58 | stdin: null,
59 | stdio: [null, null, null],
60 | killed: false,
61 | connected: false,
62 | exitCode: result.exitCode ?? (result.success === false ? 1 : 0),
63 | signalCode: null,
64 | spawnargs: [],
65 | spawnfile: 'sh',
66 | } as unknown as ChildProcess;
67 |
68 | return async () => ({
69 | success: result.success ?? true,
70 | output: result.output ?? '',
71 | error: result.error,
72 | process: (result.process ?? mockProcess) as ChildProcess,
73 | exitCode: result.exitCode ?? (result.success === false ? 1 : 0),
74 | });
75 | }
76 |
77 | /**
78 | * Create a no-op executor that throws an error if called
79 | * Use this for tests where an executor is required but should never be called
80 | * @returns CommandExecutor that throws on invocation
81 | */
82 | export function createNoopExecutor(): CommandExecutor {
83 | return async (command) => {
84 | throw new Error(
85 | `🚨 NOOP EXECUTOR CALLED! 🚨\n` +
86 | `Command: ${command.join(' ')}\n` +
87 | `This executor should never be called in this test context.\n` +
88 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
89 | `Either fix the test to avoid this code path, or use createMockExecutor() instead.`,
90 | );
91 | };
92 | }
93 |
94 | /**
95 | * Create a command-matching mock executor for testing multi-command scenarios
96 | * Perfect for tools that execute multiple commands (like screenshot: simctl + sips)
97 | *
98 | * @param commandMap - Map of command patterns to their mock responses
99 | * @returns CommandExecutor that matches commands and returns appropriate responses
100 | *
101 | * @example
102 | * ```typescript
103 | * const mockExecutor = createCommandMatchingMockExecutor({
104 | * 'xcrun simctl': { output: 'Screenshot saved' },
105 | * 'sips': { output: 'Image optimized' }
106 | * });
107 | * ```
108 | */
109 | export function createCommandMatchingMockExecutor(
110 | commandMap: Record<
111 | string,
112 | {
113 | success?: boolean;
114 | output?: string;
115 | error?: string;
116 | process?: unknown;
117 | exitCode?: number;
118 | }
119 | >,
120 | ): CommandExecutor {
121 | return async (command) => {
122 | const commandStr = command.join(' ');
123 |
124 | // Find matching command pattern
125 | const matchedKey = Object.keys(commandMap).find((key) => commandStr.includes(key));
126 |
127 | if (!matchedKey) {
128 | throw new Error(
129 | `🚨 UNEXPECTED COMMAND! 🚨\n` +
130 | `Command: ${commandStr}\n` +
131 | `Expected one of: ${Object.keys(commandMap).join(', ')}\n` +
132 | `Available patterns: ${JSON.stringify(Object.keys(commandMap), null, 2)}`,
133 | );
134 | }
135 |
136 | const result = commandMap[matchedKey];
137 |
138 | const mockProcess = {
139 | pid: 12345,
140 | stdout: null,
141 | stderr: null,
142 | stdin: null,
143 | stdio: [null, null, null],
144 | killed: false,
145 | connected: false,
146 | exitCode: result.exitCode ?? (result.success === false ? 1 : 0),
147 | signalCode: null,
148 | spawnargs: [],
149 | spawnfile: 'sh',
150 | } as unknown as ChildProcess;
151 |
152 | return {
153 | success: result.success ?? true, // Success by default (as discussed)
154 | output: result.output ?? '',
155 | error: result.error,
156 | process: (result.process ?? mockProcess) as ChildProcess,
157 | exitCode: result.exitCode ?? (result.success === false ? 1 : 0),
158 | };
159 | };
160 | }
161 |
162 | /**
163 | * Create a mock file system executor for testing
164 | */
165 | export function createMockFileSystemExecutor(
166 | overrides?: Partial<FileSystemExecutor>,
167 | ): FileSystemExecutor {
168 | return {
169 | mkdir: async (): Promise<void> => {},
170 | readFile: async (): Promise<string> => 'mock file content',
171 | writeFile: async (): Promise<void> => {},
172 | cp: async (): Promise<void> => {},
173 | readdir: async (): Promise<unknown[]> => [],
174 | rm: async (): Promise<void> => {},
175 | existsSync: (): boolean => false,
176 | stat: async (): Promise<{ isDirectory(): boolean }> => ({ isDirectory: (): boolean => true }),
177 | mkdtemp: async (): Promise<string> => '/tmp/mock-temp-123456',
178 | tmpdir: (): string => '/tmp',
179 | ...overrides,
180 | };
181 | }
182 |
183 | /**
184 | * Create a no-op file system executor that throws an error if called
185 | * Use this for tests where an executor is required but should never be called
186 | * @returns CommandExecutor that throws on invocation
187 | */
188 | export function createNoopFileSystemExecutor(): FileSystemExecutor {
189 | return {
190 | mkdir: async (): Promise<void> => {
191 | throw new Error(
192 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
193 | `This executor should never be called in this test context.\n` +
194 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
195 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
196 | );
197 | },
198 | readFile: async (): Promise<string> => {
199 | throw new Error(
200 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
201 | `This executor should never be called in this test context.\n` +
202 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
203 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
204 | );
205 | },
206 | writeFile: async (): Promise<void> => {
207 | throw new Error(
208 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
209 | `This executor should never be called in this test context.\n` +
210 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
211 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
212 | );
213 | },
214 | cp: async (): Promise<void> => {
215 | throw new Error(
216 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
217 | `This executor should never be called in this test context.\n` +
218 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
219 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
220 | );
221 | },
222 | readdir: async (): Promise<unknown[]> => {
223 | throw new Error(
224 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
225 | `This executor should never be called in this test context.\n` +
226 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
227 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
228 | );
229 | },
230 | rm: async (): Promise<void> => {
231 | throw new Error(
232 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
233 | `This executor should never be called in this test context.\n` +
234 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
235 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
236 | );
237 | },
238 | existsSync: (): boolean => {
239 | throw new Error(
240 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
241 | `This executor should never be called in this test context.\n` +
242 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
243 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
244 | );
245 | },
246 | stat: async (): Promise<{ isDirectory(): boolean }> => {
247 | throw new Error(
248 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
249 | `This executor should never be called in this test context.\n` +
250 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
251 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
252 | );
253 | },
254 | mkdtemp: async (): Promise<string> => {
255 | throw new Error(
256 | `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` +
257 | `This executor should never be called in this test context.\n` +
258 | `If you see this error, it means the test is exercising a code path that wasn't expected.\n` +
259 | `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`,
260 | );
261 | },
262 | tmpdir: (): string => '/tmp',
263 | };
264 | }
265 |
266 | /**
267 | * Create a mock environment detector for testing
268 | * @param options Mock options for environment detection
269 | * @returns Mock environment detector
270 | */
271 | export function createMockEnvironmentDetector(
272 | options: {
273 | isRunningUnderClaudeCode?: boolean;
274 | } = {},
275 | ): import('../utils/environment.js').EnvironmentDetector {
276 | return {
277 | isRunningUnderClaudeCode: () => options.isRunningUnderClaudeCode ?? false,
278 | };
279 | }
280 |
```
--------------------------------------------------------------------------------
/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:
--------------------------------------------------------------------------------
```swift
1 | //
2 | // CalculatorAppTests.swift
3 | // CalculatorAppTests
4 | //
5 | // Created by Cameron on 05/06/2025.
6 | //
7 |
8 | import XCTest
9 | import SwiftUI
10 | @testable import CalculatorApp
11 | import CalculatorAppFeature
12 |
13 | final class CalculatorAppTests: XCTestCase {
14 |
15 | override func setUpWithError() throws {
16 | continueAfterFailure = false
17 | }
18 |
19 | override func tearDownWithError() throws {
20 | // Clean up after each test
21 | }
22 | }
23 |
24 | // MARK: - App Lifecycle Tests
25 | extension CalculatorAppTests {
26 |
27 | func testAppLaunch() throws {
28 | // Test that the app launches without crashing
29 | let app = CalculatorApp()
30 | XCTAssertNotNil(app, "App should initialize successfully")
31 | }
32 |
33 | func testContentViewInitialization() throws {
34 | // Test that ContentView initializes properly
35 | let contentView = ContentView()
36 | XCTAssertNotNil(contentView, "ContentView should initialize successfully")
37 | }
38 | }
39 |
40 | // MARK: - Calculator Service Integration Tests
41 | extension CalculatorAppTests {
42 |
43 | func testCalculatorServiceCreation() throws {
44 | let service = CalculatorService()
45 | XCTAssertEqual(service.display, "0", "Calculator should start with display showing 0")
46 | XCTAssertEqual(service.expressionDisplay, "", "Calculator should start with empty expression")
47 | }
48 |
49 | func testCalculatorServiceFailure() throws {
50 | let service = CalculatorService()
51 | // This test is designed to fail to test error reporting
52 | XCTAssertEqual(service.display, "999", "This test should fail - display should be 0, not 999")
53 | }
54 |
55 | func testCalculatorServiceBasicOperation() throws {
56 | let service = CalculatorService()
57 |
58 | // Test basic addition
59 | service.inputNumber("5")
60 | service.setOperation(.add)
61 | service.inputNumber("3")
62 | service.calculate()
63 |
64 | XCTAssertEqual(service.display, "8", "5 + 3 should equal 8")
65 | }
66 |
67 | func testCalculatorServiceChainedOperations() throws {
68 | let service = CalculatorService()
69 |
70 | // Test chained operations: 10 + 5 * 2 = 30 (since calculator evaluates left to right)
71 | service.inputNumber("10")
72 | service.setOperation(.add)
73 | service.inputNumber("5")
74 | service.setOperation(.multiply)
75 | service.inputNumber("2")
76 | service.calculate()
77 |
78 | XCTAssertEqual(service.display, "30", "10 + 5 * 2 should equal 30 (left-to-right evaluation)")
79 | }
80 |
81 | func testCalculatorServiceClear() throws {
82 | let service = CalculatorService()
83 |
84 | // Set up some state
85 | service.inputNumber("123")
86 | service.setOperation(.add)
87 | service.inputNumber("456")
88 |
89 | // Clear should reset everything
90 | service.clear()
91 |
92 | XCTAssertEqual(service.display, "0", "Display should be 0 after clear")
93 | XCTAssertEqual(service.expressionDisplay, "", "Expression should be empty after clear")
94 | }
95 | }
96 |
97 | // MARK: - API Surface Tests
98 | extension CalculatorAppTests {
99 |
100 | func testCalculatorServicePublicInterface() throws {
101 | let service = CalculatorService()
102 |
103 | // Test that all expected public methods are available
104 | XCTAssertNoThrow(service.inputNumber("5"))
105 | XCTAssertNoThrow(service.inputDecimal())
106 | XCTAssertNoThrow(service.setOperation(.add))
107 | XCTAssertNoThrow(service.calculate())
108 | XCTAssertNoThrow(service.toggleSign())
109 | XCTAssertNoThrow(service.percentage())
110 | XCTAssertNoThrow(service.clear())
111 | }
112 |
113 | func testCalculatorServicePublicProperties() throws {
114 | let service = CalculatorService()
115 |
116 | // Test that all expected public properties are accessible
117 | XCTAssertNotNil(service.display)
118 | XCTAssertNotNil(service.expressionDisplay)
119 | XCTAssertEqual(service.hasError, false)
120 |
121 | // Test testing support properties
122 | XCTAssertEqual(service.currentValue, 0)
123 | XCTAssertEqual(service.previousValue, 0)
124 | XCTAssertNil(service.currentOperation)
125 | XCTAssertEqual(service.willResetDisplay, false)
126 | }
127 |
128 | func testCalculatorOperationsEnum() throws {
129 | // Test that all operations are available
130 | XCTAssertEqual(CalculatorService.Operation.add.rawValue, "+")
131 | XCTAssertEqual(CalculatorService.Operation.subtract.rawValue, "-")
132 | XCTAssertEqual(CalculatorService.Operation.multiply.rawValue, "×")
133 | XCTAssertEqual(CalculatorService.Operation.divide.rawValue, "÷")
134 |
135 | // Test operation calculations
136 | XCTAssertEqual(CalculatorService.Operation.add.calculate(5, 3), 8)
137 | XCTAssertEqual(CalculatorService.Operation.subtract.calculate(5, 3), 2)
138 | XCTAssertEqual(CalculatorService.Operation.multiply.calculate(5, 3), 15)
139 | XCTAssertEqual(CalculatorService.Operation.divide.calculate(6, 3), 2)
140 | XCTAssertEqual(CalculatorService.Operation.divide.calculate(5, 0), 0) // Division by zero
141 | }
142 | }
143 |
144 | // MARK: - Edge Case and Error Handling Tests
145 | extension CalculatorAppTests {
146 |
147 | func testDivisionByZero() throws {
148 | let service = CalculatorService()
149 |
150 | service.inputNumber("10")
151 | service.setOperation(.divide)
152 | service.inputNumber("0")
153 | service.calculate()
154 |
155 | XCTAssertEqual(service.display, "0", "Division by zero should return 0")
156 | }
157 |
158 | func testLargeNumbers() throws {
159 | let service = CalculatorService()
160 |
161 | // Test large number input
162 | service.inputNumber("999999999")
163 | XCTAssertEqual(service.display, "999999999", "Should handle large numbers")
164 |
165 | // Test large number calculation
166 | service.setOperation(.multiply)
167 | service.inputNumber("2")
168 | service.calculate()
169 |
170 | // Should handle the result without crashing
171 | XCTAssertNotEqual(service.display, "", "Should display some result for large calculations")
172 | }
173 |
174 | func testRepeatedEquals() throws {
175 | let service = CalculatorService()
176 |
177 | service.inputNumber("5")
178 | service.setOperation(.add)
179 | service.inputNumber("3")
180 | service.calculate() // 5 + 3 = 8
181 |
182 | let firstResult = service.display
183 |
184 | service.calculate() // Should repeat last operation: 8 + 3 = 11
185 | let secondResult = service.display
186 |
187 | XCTAssertEqual(firstResult, "8", "First calculation should be correct")
188 | XCTAssertEqual(secondResult, "11", "Repeated equals should repeat last operation")
189 | }
190 | }
191 |
192 | // MARK: - Performance Tests
193 | extension CalculatorAppTests {
194 |
195 | func testCalculationPerformance() throws {
196 | let service = CalculatorService()
197 |
198 | measure {
199 | // Measure performance of 100 calculations
200 | for i in 1...100 {
201 | service.clear()
202 | service.inputNumber("\(i)")
203 | service.setOperation(.multiply)
204 | service.inputNumber("2")
205 | service.calculate()
206 | }
207 | }
208 | }
209 |
210 | func testLargeNumberInputPerformance() throws {
211 | let service = CalculatorService()
212 |
213 | measure {
214 | // Measure performance of inputting large numbers
215 | service.clear()
216 | for digit in "123456789012345" {
217 | service.inputNumber(String(digit))
218 | }
219 | }
220 | }
221 | }
222 |
223 | // MARK: - State Consistency Tests
224 | extension CalculatorAppTests {
225 |
226 | func testStateConsistencyAfterOperations() throws {
227 | let service = CalculatorService()
228 |
229 | // Perform a series of operations and verify state remains consistent
230 | service.inputNumber("10")
231 | XCTAssertEqual(service.display, "10")
232 |
233 | service.setOperation(.add)
234 | XCTAssertEqual(service.display, "10")
235 | XCTAssertTrue(service.expressionDisplay.contains("10 +"))
236 |
237 | service.inputNumber("5")
238 | XCTAssertEqual(service.display, "5")
239 |
240 | service.calculate()
241 | XCTAssertEqual(service.display, "15")
242 | }
243 |
244 | func testStateConsistencyWithDecimalNumbers() throws {
245 | let service = CalculatorService()
246 |
247 | service.inputNumber("3")
248 | service.inputDecimal()
249 | service.inputNumber("14")
250 | XCTAssertEqual(service.display, "3.14")
251 |
252 | service.setOperation(.multiply)
253 | service.inputNumber("2")
254 | service.calculate()
255 |
256 | XCTAssertEqual(service.display, "6.28")
257 | }
258 |
259 | func testMultipleDecimalPointsHandling() throws {
260 | let service = CalculatorService()
261 |
262 | service.inputNumber("1")
263 | service.inputDecimal()
264 | service.inputNumber("5")
265 | service.inputDecimal() // This should be ignored
266 | service.inputNumber("9")
267 |
268 | XCTAssertEqual(service.display, "1.59", "Multiple decimal points should be ignored")
269 | }
270 | }
271 |
272 | // MARK: - Component Integration Tests
273 | extension CalculatorAppTests {
274 |
275 | func testComplexCalculationWorkflow() throws {
276 | let service = CalculatorService()
277 |
278 | // Test complex workflow through direct service calls
279 | service.inputNumber("2")
280 | service.inputNumber("5")
281 | service.setOperation(.divide)
282 | service.inputNumber("5")
283 | service.calculate()
284 |
285 | XCTAssertEqual(service.display, "5", "Complex workflow should work correctly")
286 |
287 | // Test that we can continue with the result
288 | service.setOperation(.multiply)
289 | service.inputNumber("4")
290 | service.calculate()
291 |
292 | XCTAssertEqual(service.display, "20", "Should be able to continue with previous result")
293 | }
294 |
295 | func testPercentageCalculation() throws {
296 | let service = CalculatorService()
297 |
298 | service.inputNumber("50")
299 | service.percentage()
300 |
301 | XCTAssertEqual(service.display, "0.5", "50% should equal 0.5")
302 | }
303 |
304 | func testSignToggle() throws {
305 | let service = CalculatorService()
306 |
307 | service.inputNumber("42")
308 | service.toggleSign()
309 | XCTAssertEqual(service.display, "-42", "Should toggle to negative")
310 |
311 | service.toggleSign()
312 | XCTAssertEqual(service.display, "42", "Should toggle back to positive")
313 | }
314 | }
315 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/launch_app_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Pure dependency injection test for launch_app_device plugin (device-shared)
3 | *
4 | * Tests plugin structure and app launching functionality including parameter validation,
5 | * command generation, file operations, and response formatting.
6 | *
7 | * Uses createMockExecutor for command execution and manual stubs for file operations.
8 | */
9 |
10 | import { describe, it, expect, beforeEach } from 'vitest';
11 | import { z } from 'zod';
12 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
13 | import launchAppDevice, { launch_app_deviceLogic } from '../launch_app_device.ts';
14 | import { sessionStore } from '../../../../utils/session-store.ts';
15 |
16 | describe('launch_app_device plugin (device-shared)', () => {
17 | beforeEach(() => {
18 | sessionStore.clear();
19 | });
20 |
21 | describe('Export Field Validation (Literal)', () => {
22 | it('should have correct name', () => {
23 | expect(launchAppDevice.name).toBe('launch_app_device');
24 | });
25 |
26 | it('should have correct description', () => {
27 | expect(launchAppDevice.description).toBe('Launches an app on a connected device.');
28 | });
29 |
30 | it('should have handler function', () => {
31 | expect(typeof launchAppDevice.handler).toBe('function');
32 | });
33 |
34 | it('should validate schema with valid inputs', () => {
35 | const schema = z.object(launchAppDevice.schema).strict();
36 | expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
37 | expect(schema.safeParse({}).success).toBe(false);
38 | expect(Object.keys(launchAppDevice.schema)).toEqual(['bundleId']);
39 | });
40 |
41 | it('should validate schema with invalid inputs', () => {
42 | const schema = z.object(launchAppDevice.schema).strict();
43 | expect(schema.safeParse({ bundleId: null }).success).toBe(false);
44 | expect(schema.safeParse({ bundleId: 123 }).success).toBe(false);
45 | });
46 | });
47 |
48 | describe('Handler Requirements', () => {
49 | it('should require deviceId when not provided', async () => {
50 | const result = await launchAppDevice.handler({ bundleId: 'com.example.app' });
51 |
52 | expect(result.isError).toBe(true);
53 | expect(result.content[0].text).toContain('deviceId is required');
54 | });
55 | });
56 |
57 | describe('Command Generation', () => {
58 | it('should generate correct devicectl command with required parameters', async () => {
59 | const calls: any[] = [];
60 | const mockExecutor = createMockExecutor({
61 | success: true,
62 | output: 'App launched successfully',
63 | process: { pid: 12345 },
64 | });
65 |
66 | const trackingExecutor = async (
67 | command: string[],
68 | logPrefix?: string,
69 | useShell?: boolean,
70 | env?: Record<string, string>,
71 | ) => {
72 | calls.push({ command, logPrefix, useShell, env });
73 | return mockExecutor(command, logPrefix, useShell, env);
74 | };
75 |
76 | await launch_app_deviceLogic(
77 | {
78 | deviceId: 'test-device-123',
79 | bundleId: 'com.example.app',
80 | },
81 | trackingExecutor,
82 | );
83 |
84 | expect(calls).toHaveLength(1);
85 | expect(calls[0].command).toEqual([
86 | 'xcrun',
87 | 'devicectl',
88 | 'device',
89 | 'process',
90 | 'launch',
91 | '--device',
92 | 'test-device-123',
93 | '--json-output',
94 | expect.stringMatching(/^\/.*\/launch-\d+\.json$/),
95 | '--terminate-existing',
96 | 'com.example.app',
97 | ]);
98 | expect(calls[0].logPrefix).toBe('Launch app on device');
99 | expect(calls[0].useShell).toBe(true);
100 | expect(calls[0].env).toBeUndefined();
101 | });
102 |
103 | it('should generate command with different device and bundle parameters', async () => {
104 | const calls: any[] = [];
105 | const mockExecutor = createMockExecutor({
106 | success: true,
107 | output: 'Launch successful',
108 | process: { pid: 54321 },
109 | });
110 |
111 | const trackingExecutor = async (command: string[]) => {
112 | calls.push({ command });
113 | return mockExecutor(command);
114 | };
115 |
116 | await launch_app_deviceLogic(
117 | {
118 | deviceId: '00008030-001E14BE2288802E',
119 | bundleId: 'com.apple.mobilesafari',
120 | },
121 | trackingExecutor,
122 | );
123 |
124 | expect(calls[0].command).toEqual([
125 | 'xcrun',
126 | 'devicectl',
127 | 'device',
128 | 'process',
129 | 'launch',
130 | '--device',
131 | '00008030-001E14BE2288802E',
132 | '--json-output',
133 | expect.stringMatching(/^\/.*\/launch-\d+\.json$/),
134 | '--terminate-existing',
135 | 'com.apple.mobilesafari',
136 | ]);
137 | });
138 | });
139 |
140 | describe('Success Path Tests', () => {
141 | it('should return successful launch response without process ID', async () => {
142 | const mockExecutor = createMockExecutor({
143 | success: true,
144 | output: 'App launched successfully',
145 | });
146 |
147 | const result = await launch_app_deviceLogic(
148 | {
149 | deviceId: 'test-device-123',
150 | bundleId: 'com.example.app',
151 | },
152 | mockExecutor,
153 | );
154 |
155 | expect(result).toEqual({
156 | content: [
157 | {
158 | type: 'text',
159 | text: '✅ App launched successfully\n\nApp launched successfully',
160 | },
161 | ],
162 | });
163 | });
164 |
165 | it('should return successful launch response with detailed output', async () => {
166 | const mockExecutor = createMockExecutor({
167 | success: true,
168 | output: 'Launch succeeded with detailed output',
169 | });
170 |
171 | const result = await launch_app_deviceLogic(
172 | {
173 | deviceId: 'test-device-123',
174 | bundleId: 'com.example.app',
175 | },
176 | mockExecutor,
177 | );
178 |
179 | expect(result).toEqual({
180 | content: [
181 | {
182 | type: 'text',
183 | text: '✅ App launched successfully\n\nLaunch succeeded with detailed output',
184 | },
185 | ],
186 | });
187 | });
188 |
189 | it('should handle successful launch with process ID information', async () => {
190 | // Mock fs operations for JSON parsing
191 | const fs = await import('fs');
192 | const originalReadFile = fs.promises.readFile;
193 | const originalUnlink = fs.promises.unlink;
194 |
195 | const mockReadFile = (path: string) => {
196 | if (path.includes('launch-')) {
197 | return Promise.resolve(
198 | JSON.stringify({
199 | result: {
200 | process: {
201 | processIdentifier: 12345,
202 | },
203 | },
204 | }),
205 | );
206 | }
207 | return originalReadFile(path);
208 | };
209 |
210 | const mockUnlink = () => Promise.resolve();
211 |
212 | // Replace fs methods
213 | fs.promises.readFile = mockReadFile;
214 | fs.promises.unlink = mockUnlink;
215 |
216 | const mockExecutor = createMockExecutor({
217 | success: true,
218 | output: 'App launched successfully',
219 | });
220 |
221 | const result = await launch_app_deviceLogic(
222 | {
223 | deviceId: 'test-device-123',
224 | bundleId: 'com.example.app',
225 | },
226 | mockExecutor,
227 | );
228 |
229 | // Restore fs methods
230 | fs.promises.readFile = originalReadFile;
231 | fs.promises.unlink = originalUnlink;
232 |
233 | expect(result).toEqual({
234 | content: [
235 | {
236 | type: 'text',
237 | text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nNext Steps:\n1. Interact with your app on the device\n2. Stop the app: stop_app_device({ deviceId: "test-device-123", processId: 12345 })',
238 | },
239 | ],
240 | });
241 | });
242 |
243 | it('should handle successful launch with command output', async () => {
244 | const mockExecutor = createMockExecutor({
245 | success: true,
246 | output: 'App "com.example.app" launched on device "test-device-123"',
247 | });
248 |
249 | const result = await launch_app_deviceLogic(
250 | {
251 | deviceId: 'test-device-123',
252 | bundleId: 'com.example.app',
253 | },
254 | mockExecutor,
255 | );
256 |
257 | expect(result).toEqual({
258 | content: [
259 | {
260 | type: 'text',
261 | text: '✅ App launched successfully\n\nApp "com.example.app" launched on device "test-device-123"',
262 | },
263 | ],
264 | });
265 | });
266 | });
267 |
268 | describe('Error Handling', () => {
269 | it('should return launch failure response', async () => {
270 | const mockExecutor = createMockExecutor({
271 | success: false,
272 | error: 'Launch failed: App not found',
273 | });
274 |
275 | const result = await launch_app_deviceLogic(
276 | {
277 | deviceId: 'test-device-123',
278 | bundleId: 'com.nonexistent.app',
279 | },
280 | mockExecutor,
281 | );
282 |
283 | expect(result).toEqual({
284 | content: [
285 | {
286 | type: 'text',
287 | text: 'Failed to launch app: Launch failed: App not found',
288 | },
289 | ],
290 | isError: true,
291 | });
292 | });
293 |
294 | it('should return command failure response with specific error', async () => {
295 | const mockExecutor = createMockExecutor({
296 | success: false,
297 | error: 'Device not found: test-device-invalid',
298 | });
299 |
300 | const result = await launch_app_deviceLogic(
301 | {
302 | deviceId: 'test-device-invalid',
303 | bundleId: 'com.example.app',
304 | },
305 | mockExecutor,
306 | );
307 |
308 | expect(result).toEqual({
309 | content: [
310 | {
311 | type: 'text',
312 | text: 'Failed to launch app: Device not found: test-device-invalid',
313 | },
314 | ],
315 | isError: true,
316 | });
317 | });
318 |
319 | it('should handle executor exception with Error object', async () => {
320 | const mockExecutor = createMockExecutor(new Error('Network error'));
321 |
322 | const result = await launch_app_deviceLogic(
323 | {
324 | deviceId: 'test-device-123',
325 | bundleId: 'com.example.app',
326 | },
327 | mockExecutor,
328 | );
329 |
330 | expect(result).toEqual({
331 | content: [
332 | {
333 | type: 'text',
334 | text: 'Failed to launch app on device: Network error',
335 | },
336 | ],
337 | isError: true,
338 | });
339 | });
340 |
341 | it('should handle executor exception with string error', async () => {
342 | const mockExecutor = createMockExecutor('String error');
343 |
344 | const result = await launch_app_deviceLogic(
345 | {
346 | deviceId: 'test-device-123',
347 | bundleId: 'com.example.app',
348 | },
349 | mockExecutor,
350 | );
351 |
352 | expect(result).toEqual({
353 | content: [
354 | {
355 | type: 'text',
356 | text: 'Failed to launch app on device: String error',
357 | },
358 | ],
359 | isError: true,
360 | });
361 | });
362 | });
363 | });
364 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/__tests__/build_device.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for build_device plugin (unified)
3 | * Following CLAUDE.md testing standards with literal validation
4 | * Using dependency injection for deterministic testing
5 | */
6 |
7 | import { describe, it, expect, beforeEach } from 'vitest';
8 | import { z } from 'zod';
9 | import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts';
10 | import buildDevice, { buildDeviceLogic } from '../build_device.ts';
11 | import { sessionStore } from '../../../../utils/session-store.ts';
12 |
13 | describe('build_device plugin', () => {
14 | beforeEach(() => {
15 | sessionStore.clear();
16 | });
17 |
18 | describe('Export Field Validation (Literal)', () => {
19 | it('should have correct name', () => {
20 | expect(buildDevice.name).toBe('build_device');
21 | });
22 |
23 | it('should have correct description', () => {
24 | expect(buildDevice.description).toBe('Builds an app for a connected device.');
25 | });
26 |
27 | it('should have handler function', () => {
28 | expect(typeof buildDevice.handler).toBe('function');
29 | });
30 |
31 | it('should expose only optional build-tuning fields in public schema', () => {
32 | const schema = z.object(buildDevice.schema).strict();
33 | expect(schema.safeParse({}).success).toBe(true);
34 | expect(
35 | schema.safeParse({ derivedDataPath: '/path/to/derived-data', extraArgs: [] }).success,
36 | ).toBe(true);
37 | expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false);
38 |
39 | const schemaKeys = Object.keys(buildDevice.schema).sort();
40 | expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild']);
41 | });
42 | });
43 |
44 | describe('XOR Validation', () => {
45 | it('should error when neither projectPath nor workspacePath provided', async () => {
46 | const result = await buildDevice.handler({
47 | scheme: 'MyScheme',
48 | });
49 |
50 | expect(result.isError).toBe(true);
51 | expect(result.content[0].text).toContain('Missing required session defaults');
52 | expect(result.content[0].text).toContain('Provide a project or workspace');
53 | });
54 |
55 | it('should error when both projectPath and workspacePath provided', async () => {
56 | const result = await buildDevice.handler({
57 | projectPath: '/path/to/MyProject.xcodeproj',
58 | workspacePath: '/path/to/MyProject.xcworkspace',
59 | scheme: 'MyScheme',
60 | });
61 |
62 | expect(result.isError).toBe(true);
63 | expect(result.content[0].text).toContain('Parameter validation failed');
64 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
65 | });
66 | });
67 |
68 | describe('Parameter Validation (via Handler)', () => {
69 | it('should return Zod validation error for missing scheme', async () => {
70 | const result = await buildDevice.handler({
71 | projectPath: '/path/to/MyProject.xcodeproj',
72 | });
73 |
74 | expect(result.isError).toBe(true);
75 | expect(result.content[0].text).toContain('Missing required session defaults');
76 | expect(result.content[0].text).toContain('scheme is required');
77 | });
78 |
79 | it('should return Zod validation error for invalid parameter types', async () => {
80 | const result = await buildDevice.handler({
81 | projectPath: 123, // Should be string
82 | scheme: 'MyScheme',
83 | });
84 |
85 | expect(result.isError).toBe(true);
86 | expect(result.content[0].text).toContain('Parameter validation failed');
87 | expect(result.content[0].text).toContain('projectPath');
88 | expect(result.content[0].text).toContain(
89 | 'Tip: set session defaults via session-set-defaults',
90 | );
91 | });
92 | });
93 |
94 | describe('Handler Behavior (Complete Literal Returns)', () => {
95 | it('should pass validation and execute successfully with valid project parameters', async () => {
96 | const mockExecutor = createMockExecutor({
97 | success: true,
98 | output: 'Build succeeded',
99 | });
100 |
101 | const result = await buildDeviceLogic(
102 | {
103 | projectPath: '/path/to/MyProject.xcodeproj',
104 | scheme: 'MyScheme',
105 | },
106 | mockExecutor,
107 | );
108 |
109 | expect(result.isError).toBeUndefined();
110 | expect(result.content).toHaveLength(2);
111 | expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded');
112 | });
113 |
114 | it('should pass validation and execute successfully with valid workspace parameters', async () => {
115 | const mockExecutor = createMockExecutor({
116 | success: true,
117 | output: 'Build succeeded',
118 | });
119 |
120 | const result = await buildDeviceLogic(
121 | {
122 | workspacePath: '/path/to/MyProject.xcworkspace',
123 | scheme: 'MyScheme',
124 | },
125 | mockExecutor,
126 | );
127 |
128 | expect(result.isError).toBeUndefined();
129 | expect(result.content).toHaveLength(2);
130 | expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded');
131 | });
132 |
133 | it('should verify workspace command generation with mock executor', async () => {
134 | const commandCalls: Array<{
135 | args: string[];
136 | logPrefix: string;
137 | silent: boolean;
138 | timeout: number | undefined;
139 | }> = [];
140 |
141 | const stubExecutor = async (
142 | args: string[],
143 | logPrefix: string,
144 | silent: boolean,
145 | timeout?: number,
146 | ) => {
147 | commandCalls.push({ args, logPrefix, silent, timeout });
148 | return {
149 | success: true,
150 | output: 'Build succeeded',
151 | error: undefined,
152 | process: { pid: 12345 },
153 | };
154 | };
155 |
156 | await buildDeviceLogic(
157 | {
158 | workspacePath: '/path/to/MyProject.xcworkspace',
159 | scheme: 'MyScheme',
160 | },
161 | stubExecutor,
162 | );
163 |
164 | expect(commandCalls).toHaveLength(1);
165 | expect(commandCalls[0]).toEqual({
166 | args: [
167 | 'xcodebuild',
168 | '-workspace',
169 | '/path/to/MyProject.xcworkspace',
170 | '-scheme',
171 | 'MyScheme',
172 | '-configuration',
173 | 'Debug',
174 | '-skipMacroValidation',
175 | '-destination',
176 | 'generic/platform=iOS',
177 | 'build',
178 | ],
179 | logPrefix: 'iOS Device Build',
180 | silent: true,
181 | timeout: undefined,
182 | });
183 | });
184 |
185 | it('should verify command generation with mock executor', async () => {
186 | const commandCalls: Array<{
187 | args: string[];
188 | logPrefix: string;
189 | silent: boolean;
190 | timeout: number | undefined;
191 | }> = [];
192 |
193 | const stubExecutor = async (
194 | args: string[],
195 | logPrefix: string,
196 | silent: boolean,
197 | timeout?: number,
198 | ) => {
199 | commandCalls.push({ args, logPrefix, silent, timeout });
200 | return {
201 | success: true,
202 | output: 'Build succeeded',
203 | error: undefined,
204 | process: { pid: 12345 },
205 | };
206 | };
207 |
208 | await buildDeviceLogic(
209 | {
210 | projectPath: '/path/to/MyProject.xcodeproj',
211 | scheme: 'MyScheme',
212 | },
213 | stubExecutor,
214 | );
215 |
216 | expect(commandCalls).toHaveLength(1);
217 | expect(commandCalls[0]).toEqual({
218 | args: [
219 | 'xcodebuild',
220 | '-project',
221 | '/path/to/MyProject.xcodeproj',
222 | '-scheme',
223 | 'MyScheme',
224 | '-configuration',
225 | 'Debug',
226 | '-skipMacroValidation',
227 | '-destination',
228 | 'generic/platform=iOS',
229 | 'build',
230 | ],
231 | logPrefix: 'iOS Device Build',
232 | silent: true,
233 | timeout: undefined,
234 | });
235 | });
236 |
237 | it('should return exact successful build response', async () => {
238 | const mockExecutor = createMockExecutor({
239 | success: true,
240 | output: 'Build succeeded',
241 | });
242 |
243 | const result = await buildDeviceLogic(
244 | {
245 | projectPath: '/path/to/MyProject.xcodeproj',
246 | scheme: 'MyScheme',
247 | },
248 | mockExecutor,
249 | );
250 |
251 | expect(result).toEqual({
252 | content: [
253 | {
254 | type: 'text',
255 | text: '✅ iOS Device Build build succeeded for scheme MyScheme.',
256 | },
257 | {
258 | type: 'text',
259 | text: "Next Steps:\n1. Get app path: get_device_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })",
260 | },
261 | ],
262 | });
263 | });
264 |
265 | it('should return exact build failure response', async () => {
266 | const mockExecutor = createMockExecutor({
267 | success: false,
268 | error: 'Compilation error',
269 | });
270 |
271 | const result = await buildDeviceLogic(
272 | {
273 | projectPath: '/path/to/MyProject.xcodeproj',
274 | scheme: 'MyScheme',
275 | },
276 | mockExecutor,
277 | );
278 |
279 | expect(result).toEqual({
280 | content: [
281 | {
282 | type: 'text',
283 | text: '❌ [stderr] Compilation error',
284 | },
285 | {
286 | type: 'text',
287 | text: '❌ iOS Device Build build failed for scheme MyScheme.',
288 | },
289 | ],
290 | isError: true,
291 | });
292 | });
293 |
294 | it('should include optional parameters in command', async () => {
295 | const commandCalls: Array<{
296 | args: string[];
297 | logPrefix: string;
298 | silent: boolean;
299 | timeout: number | undefined;
300 | }> = [];
301 |
302 | const stubExecutor = async (
303 | args: string[],
304 | logPrefix: string,
305 | silent: boolean,
306 | timeout?: number,
307 | ) => {
308 | commandCalls.push({ args, logPrefix, silent, timeout });
309 | return {
310 | success: true,
311 | output: 'Build succeeded',
312 | error: undefined,
313 | process: { pid: 12345 },
314 | };
315 | };
316 |
317 | await buildDeviceLogic(
318 | {
319 | projectPath: '/path/to/MyProject.xcodeproj',
320 | scheme: 'MyScheme',
321 | configuration: 'Release',
322 | derivedDataPath: '/tmp/derived-data',
323 | extraArgs: ['--verbose'],
324 | },
325 | stubExecutor,
326 | );
327 |
328 | expect(commandCalls).toHaveLength(1);
329 | expect(commandCalls[0]).toEqual({
330 | args: [
331 | 'xcodebuild',
332 | '-project',
333 | '/path/to/MyProject.xcodeproj',
334 | '-scheme',
335 | 'MyScheme',
336 | '-configuration',
337 | 'Release',
338 | '-skipMacroValidation',
339 | '-destination',
340 | 'generic/platform=iOS',
341 | '-derivedDataPath',
342 | '/tmp/derived-data',
343 | '--verbose',
344 | 'build',
345 | ],
346 | logPrefix: 'iOS Device Build',
347 | silent: true,
348 | timeout: undefined,
349 | });
350 | });
351 | });
352 | });
353 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Test for get_app_bundle_id plugin - Dependency Injection Architecture
3 | *
4 | * Tests the plugin structure and exported components for get_app_bundle_id tool.
5 | * Uses pure dependency injection with createMockFileSystemExecutor.
6 | * NO VITEST MOCKING ALLOWED - Only createMockFileSystemExecutor
7 | *
8 | * Plugin location: plugins/project-discovery/get_app_bundle_id.ts
9 | */
10 |
11 | import { describe, it, expect } from 'vitest';
12 | import { z } from 'zod';
13 | import plugin, { get_app_bundle_idLogic } from '../get_app_bundle_id.ts';
14 | import {
15 | createMockFileSystemExecutor,
16 | createCommandMatchingMockExecutor,
17 | } from '../../../../test-utils/mock-executors.ts';
18 |
19 | describe('get_app_bundle_id plugin', () => {
20 | // Helper function to create mock executor for command matching
21 | const createMockExecutorForCommands = (results: Record<string, string | Error>) => {
22 | return createCommandMatchingMockExecutor(
23 | Object.fromEntries(
24 | Object.entries(results).map(([command, result]) => [
25 | command,
26 | result instanceof Error
27 | ? { success: false, error: result.message }
28 | : { success: true, output: result },
29 | ]),
30 | ),
31 | );
32 | };
33 |
34 | describe('Export Field Validation (Literal)', () => {
35 | it('should have correct name', () => {
36 | expect(plugin.name).toBe('get_app_bundle_id');
37 | });
38 |
39 | it('should have correct description', () => {
40 | expect(plugin.description).toBe(
41 | "Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS). IMPORTANT: You MUST provide the appPath parameter. Example: get_app_bundle_id({ appPath: '/path/to/your/app.app' })",
42 | );
43 | });
44 |
45 | it('should have handler function', () => {
46 | expect(typeof plugin.handler).toBe('function');
47 | });
48 |
49 | it('should validate schema with valid inputs', () => {
50 | const schema = z.object(plugin.schema);
51 | expect(schema.safeParse({ appPath: '/path/to/MyApp.app' }).success).toBe(true);
52 | expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true);
53 | });
54 |
55 | it('should validate schema with invalid inputs', () => {
56 | const schema = z.object(plugin.schema);
57 | expect(schema.safeParse({}).success).toBe(false);
58 | expect(schema.safeParse({ appPath: 123 }).success).toBe(false);
59 | expect(schema.safeParse({ appPath: null }).success).toBe(false);
60 | expect(schema.safeParse({ appPath: undefined }).success).toBe(false);
61 | });
62 | });
63 |
64 | describe('Handler Behavior (Complete Literal Returns)', () => {
65 | it('should return error when appPath validation fails', async () => {
66 | // Test validation through the handler which uses Zod validation
67 | const result = await plugin.handler({});
68 |
69 | expect(result).toEqual({
70 | content: [
71 | {
72 | type: 'text',
73 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required',
74 | },
75 | ],
76 | isError: true,
77 | });
78 | });
79 |
80 | it('should return error when file exists validation fails', async () => {
81 | const mockExecutor = createMockExecutorForCommands({});
82 | const mockFileSystemExecutor = createMockFileSystemExecutor({
83 | existsSync: () => false,
84 | });
85 |
86 | const result = await get_app_bundle_idLogic(
87 | { appPath: '/path/to/MyApp.app' },
88 | mockExecutor,
89 | mockFileSystemExecutor,
90 | );
91 |
92 | expect(result).toEqual({
93 | content: [
94 | {
95 | type: 'text',
96 | text: "File not found: '/path/to/MyApp.app'. Please check the path and try again.",
97 | },
98 | ],
99 | isError: true,
100 | });
101 | });
102 |
103 | it('should return success with bundle ID using defaults read', async () => {
104 | const mockExecutor = createMockExecutorForCommands({
105 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': 'com.example.MyApp',
106 | });
107 | const mockFileSystemExecutor = createMockFileSystemExecutor({
108 | existsSync: () => true,
109 | });
110 |
111 | const result = await get_app_bundle_idLogic(
112 | { appPath: '/path/to/MyApp.app' },
113 | mockExecutor,
114 | mockFileSystemExecutor,
115 | );
116 |
117 | expect(result).toEqual({
118 | content: [
119 | {
120 | type: 'text',
121 | text: '✅ Bundle ID: com.example.MyApp',
122 | },
123 | {
124 | type: 'text',
125 | text: `Next Steps:
126 | - Simulator: install_app_sim + launch_app_sim
127 | - Device: install_app_device + launch_app_device`,
128 | },
129 | ],
130 | isError: false,
131 | });
132 | });
133 |
134 | it('should fallback to PlistBuddy when defaults read fails', async () => {
135 | const mockExecutor = createMockExecutorForCommands({
136 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
137 | 'defaults read failed',
138 | ),
139 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
140 | 'com.example.MyApp',
141 | });
142 | const mockFileSystemExecutor = createMockFileSystemExecutor({
143 | existsSync: () => true,
144 | });
145 |
146 | const result = await get_app_bundle_idLogic(
147 | { appPath: '/path/to/MyApp.app' },
148 | mockExecutor,
149 | mockFileSystemExecutor,
150 | );
151 |
152 | expect(result).toEqual({
153 | content: [
154 | {
155 | type: 'text',
156 | text: '✅ Bundle ID: com.example.MyApp',
157 | },
158 | {
159 | type: 'text',
160 | text: `Next Steps:
161 | - Simulator: install_app_sim + launch_app_sim
162 | - Device: install_app_device + launch_app_device`,
163 | },
164 | ],
165 | isError: false,
166 | });
167 | });
168 |
169 | it('should return error when both extraction methods fail', async () => {
170 | const mockExecutor = createMockExecutorForCommands({
171 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
172 | 'defaults read failed',
173 | ),
174 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
175 | new Error('Command failed'),
176 | });
177 | const mockFileSystemExecutor = createMockFileSystemExecutor({
178 | existsSync: () => true,
179 | });
180 |
181 | const result = await get_app_bundle_idLogic(
182 | { appPath: '/path/to/MyApp.app' },
183 | mockExecutor,
184 | mockFileSystemExecutor,
185 | );
186 |
187 | expect(result).toEqual({
188 | content: [
189 | {
190 | type: 'text',
191 | text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Command failed',
192 | },
193 | {
194 | type: 'text',
195 | text: 'Make sure the path points to a valid app bundle (.app directory).',
196 | },
197 | ],
198 | isError: true,
199 | });
200 | });
201 |
202 | it('should handle Error objects in catch blocks', async () => {
203 | const mockExecutor = createMockExecutorForCommands({
204 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
205 | 'defaults read failed',
206 | ),
207 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
208 | new Error('Custom error message'),
209 | });
210 | const mockFileSystemExecutor = createMockFileSystemExecutor({
211 | existsSync: () => true,
212 | });
213 |
214 | const result = await get_app_bundle_idLogic(
215 | { appPath: '/path/to/MyApp.app' },
216 | mockExecutor,
217 | mockFileSystemExecutor,
218 | );
219 |
220 | expect(result).toEqual({
221 | content: [
222 | {
223 | type: 'text',
224 | text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Custom error message',
225 | },
226 | {
227 | type: 'text',
228 | text: 'Make sure the path points to a valid app bundle (.app directory).',
229 | },
230 | ],
231 | isError: true,
232 | });
233 | });
234 |
235 | it('should handle string errors in catch blocks', async () => {
236 | const mockExecutor = createMockExecutorForCommands({
237 | 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error(
238 | 'defaults read failed',
239 | ),
240 | '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"':
241 | new Error('String error'),
242 | });
243 | const mockFileSystemExecutor = createMockFileSystemExecutor({
244 | existsSync: () => true,
245 | });
246 |
247 | const result = await get_app_bundle_idLogic(
248 | { appPath: '/path/to/MyApp.app' },
249 | mockExecutor,
250 | mockFileSystemExecutor,
251 | );
252 |
253 | expect(result).toEqual({
254 | content: [
255 | {
256 | type: 'text',
257 | text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: String error',
258 | },
259 | {
260 | type: 'text',
261 | text: 'Make sure the path points to a valid app bundle (.app directory).',
262 | },
263 | ],
264 | isError: true,
265 | });
266 | });
267 |
268 | it('should handle schema validation error when appPath is null', async () => {
269 | // Test validation through the handler which uses Zod validation
270 | const result = await plugin.handler({ appPath: null });
271 |
272 | expect(result).toEqual({
273 | content: [
274 | {
275 | type: 'text',
276 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Expected string, received null',
277 | },
278 | ],
279 | isError: true,
280 | });
281 | });
282 |
283 | it('should handle schema validation with missing appPath', async () => {
284 | // Test validation through the handler which uses Zod validation
285 | const result = await plugin.handler({});
286 |
287 | expect(result).toEqual({
288 | content: [
289 | {
290 | type: 'text',
291 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required',
292 | },
293 | ],
294 | isError: true,
295 | });
296 | });
297 |
298 | it('should handle schema validation with undefined appPath', async () => {
299 | // Test validation through the handler which uses Zod validation
300 | const result = await plugin.handler({ appPath: undefined });
301 |
302 | expect(result).toEqual({
303 | content: [
304 | {
305 | type: 'text',
306 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required',
307 | },
308 | ],
309 | isError: true,
310 | });
311 | });
312 |
313 | it('should handle schema validation with number type appPath', async () => {
314 | // Test validation through the handler which uses Zod validation
315 | const result = await plugin.handler({ appPath: 123 });
316 |
317 | expect(result).toEqual({
318 | content: [
319 | {
320 | type: 'text',
321 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Expected string, received number',
322 | },
323 | ],
324 | isError: true,
325 | });
326 | });
327 | });
328 | });
329 |
```
--------------------------------------------------------------------------------
/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import { z } from 'zod';
3 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
4 | import plugin, { showBuildSettingsLogic } from '../show_build_settings.ts';
5 | import { sessionStore } from '../../../../utils/session-store.ts';
6 |
7 | describe('show_build_settings plugin', () => {
8 | beforeEach(() => {
9 | sessionStore.clear();
10 | });
11 | describe('Export Field Validation (Literal)', () => {
12 | it('should have correct name', () => {
13 | expect(plugin.name).toBe('show_build_settings');
14 | });
15 |
16 | it('should have correct description', () => {
17 | expect(plugin.description).toBe('Shows xcodebuild build settings.');
18 | });
19 |
20 | it('should have handler function', () => {
21 | expect(typeof plugin.handler).toBe('function');
22 | });
23 |
24 | it('should expose an empty public schema', () => {
25 | const schema = z.object(plugin.schema).strict();
26 | expect(schema.safeParse({}).success).toBe(true);
27 | expect(schema.safeParse({ projectPath: '/path.xcodeproj' }).success).toBe(false);
28 | expect(schema.safeParse({ scheme: 'App' }).success).toBe(false);
29 | expect(Object.keys(plugin.schema)).toEqual([]);
30 | });
31 | });
32 |
33 | describe('Handler Behavior (Complete Literal Returns)', () => {
34 | it('should execute with valid parameters', async () => {
35 | const mockExecutor = createMockExecutor({
36 | success: true,
37 | output: 'Mock build settings output',
38 | error: undefined,
39 | process: { pid: 12345 },
40 | });
41 |
42 | const result = await showBuildSettingsLogic(
43 | { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' },
44 | mockExecutor,
45 | );
46 | expect(result.isError).toBe(false);
47 | expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:');
48 | });
49 |
50 | it('should test Zod validation through handler', async () => {
51 | // Test the actual tool handler which includes Zod validation
52 | const result = await plugin.handler({
53 | projectPath: null,
54 | scheme: 'MyScheme',
55 | });
56 |
57 | expect(result.isError).toBe(true);
58 | expect(result.content[0].text).toContain('Missing required session defaults');
59 | expect(result.content[0].text).toContain('Provide a project or workspace');
60 | });
61 |
62 | it('should return success with build settings', async () => {
63 | const calls: any[] = [];
64 | const mockExecutor = createMockExecutor({
65 | success: true,
66 | output: `Build settings from command line:
67 | ARCHS = arm64
68 | BUILD_DIR = /Users/dev/Build/Products
69 | CONFIGURATION = Debug
70 | DEVELOPMENT_TEAM = ABC123DEF4
71 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
72 | PRODUCT_NAME = MyApp
73 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
74 | error: undefined,
75 | process: { pid: 12345 },
76 | });
77 |
78 | // Wrap mockExecutor to track calls
79 | const wrappedExecutor = (...args: any[]) => {
80 | calls.push(args);
81 | return mockExecutor(...args);
82 | };
83 |
84 | const result = await showBuildSettingsLogic(
85 | {
86 | projectPath: '/path/to/MyProject.xcodeproj',
87 | scheme: 'MyScheme',
88 | },
89 | wrappedExecutor,
90 | );
91 |
92 | expect(calls).toHaveLength(1);
93 | expect(calls[0]).toEqual([
94 | [
95 | 'xcodebuild',
96 | '-showBuildSettings',
97 | '-project',
98 | '/path/to/MyProject.xcodeproj',
99 | '-scheme',
100 | 'MyScheme',
101 | ],
102 | 'Show Build Settings',
103 | true,
104 | ]);
105 |
106 | expect(result).toEqual({
107 | content: [
108 | {
109 | type: 'text',
110 | text: '✅ Build settings for scheme MyScheme:',
111 | },
112 | {
113 | type: 'text',
114 | text: `Build settings from command line:
115 | ARCHS = arm64
116 | BUILD_DIR = /Users/dev/Build/Products
117 | CONFIGURATION = Debug
118 | DEVELOPMENT_TEAM = ABC123DEF4
119 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
120 | PRODUCT_NAME = MyApp
121 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
122 | },
123 | ],
124 | isError: false,
125 | });
126 | });
127 |
128 | it('should return error when command fails', async () => {
129 | const mockExecutor = createMockExecutor({
130 | success: false,
131 | output: '',
132 | error: 'Scheme not found',
133 | process: { pid: 12345 },
134 | });
135 |
136 | const result = await showBuildSettingsLogic(
137 | {
138 | projectPath: '/path/to/MyProject.xcodeproj',
139 | scheme: 'InvalidScheme',
140 | },
141 | mockExecutor,
142 | );
143 |
144 | expect(result).toEqual({
145 | content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }],
146 | isError: true,
147 | });
148 | });
149 |
150 | it('should handle Error objects in catch blocks', async () => {
151 | const mockExecutor = async () => {
152 | throw new Error('Command execution failed');
153 | };
154 |
155 | const result = await showBuildSettingsLogic(
156 | {
157 | projectPath: '/path/to/MyProject.xcodeproj',
158 | scheme: 'MyScheme',
159 | },
160 | mockExecutor,
161 | );
162 |
163 | expect(result).toEqual({
164 | content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }],
165 | isError: true,
166 | });
167 | });
168 | });
169 |
170 | describe('XOR Validation', () => {
171 | it('should error when neither projectPath nor workspacePath provided', async () => {
172 | const result = await plugin.handler({
173 | scheme: 'MyScheme',
174 | });
175 |
176 | expect(result.isError).toBe(true);
177 | expect(result.content[0].text).toContain('Missing required session defaults');
178 | expect(result.content[0].text).toContain('Provide a project or workspace');
179 | });
180 |
181 | it('should error when both projectPath and workspacePath provided', async () => {
182 | const result = await plugin.handler({
183 | projectPath: '/path/project.xcodeproj',
184 | workspacePath: '/path/workspace.xcworkspace',
185 | scheme: 'MyScheme',
186 | });
187 |
188 | expect(result.isError).toBe(true);
189 | expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
190 | });
191 |
192 | it('should work with projectPath only', async () => {
193 | const mockExecutor = createMockExecutor({
194 | success: true,
195 | output: 'Mock build settings output',
196 | });
197 |
198 | const result = await showBuildSettingsLogic(
199 | { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' },
200 | mockExecutor,
201 | );
202 |
203 | expect(result.isError).toBe(false);
204 | expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:');
205 | });
206 |
207 | it('should work with workspacePath only', async () => {
208 | const mockExecutor = createMockExecutor({
209 | success: true,
210 | output: 'Mock build settings output',
211 | });
212 |
213 | const result = await showBuildSettingsLogic(
214 | { workspacePath: '/valid/path.xcworkspace', scheme: 'MyScheme' },
215 | mockExecutor,
216 | );
217 |
218 | expect(result.isError).toBe(false);
219 | expect(result.content[0].text).toContain('✅ Build settings retrieved successfully');
220 | });
221 | });
222 |
223 | describe('Session requirement handling', () => {
224 | it('should require scheme when not provided', async () => {
225 | const result = await plugin.handler({
226 | projectPath: '/path/to/MyProject.xcodeproj',
227 | } as any);
228 |
229 | expect(result.isError).toBe(true);
230 | expect(result.content[0].text).toContain('Missing required session defaults');
231 | expect(result.content[0].text).toContain('scheme is required');
232 | });
233 |
234 | it('should surface project/workspace requirement even with scheme default', async () => {
235 | sessionStore.setDefaults({ scheme: 'MyScheme' });
236 |
237 | const result = await plugin.handler({});
238 |
239 | expect(result.isError).toBe(true);
240 | expect(result.content[0].text).toContain('Missing required session defaults');
241 | expect(result.content[0].text).toContain('Provide a project or workspace');
242 | });
243 | });
244 |
245 | describe('showBuildSettingsLogic function', () => {
246 | it('should return success with build settings', async () => {
247 | const calls: any[] = [];
248 | const mockExecutor = createMockExecutor({
249 | success: true,
250 | output: `Build settings from command line:
251 | ARCHS = arm64
252 | BUILD_DIR = /Users/dev/Build/Products
253 | CONFIGURATION = Debug
254 | DEVELOPMENT_TEAM = ABC123DEF4
255 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
256 | PRODUCT_NAME = MyApp
257 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
258 | error: undefined,
259 | process: { pid: 12345 },
260 | });
261 |
262 | // Wrap mockExecutor to track calls
263 | const wrappedExecutor = (...args: any[]) => {
264 | calls.push(args);
265 | return mockExecutor(...args);
266 | };
267 |
268 | const result = await showBuildSettingsLogic(
269 | {
270 | projectPath: '/path/to/MyProject.xcodeproj',
271 | scheme: 'MyScheme',
272 | },
273 | wrappedExecutor,
274 | );
275 |
276 | expect(calls).toHaveLength(1);
277 | expect(calls[0]).toEqual([
278 | [
279 | 'xcodebuild',
280 | '-showBuildSettings',
281 | '-project',
282 | '/path/to/MyProject.xcodeproj',
283 | '-scheme',
284 | 'MyScheme',
285 | ],
286 | 'Show Build Settings',
287 | true,
288 | ]);
289 |
290 | expect(result).toEqual({
291 | content: [
292 | {
293 | type: 'text',
294 | text: '✅ Build settings for scheme MyScheme:',
295 | },
296 | {
297 | type: 'text',
298 | text: `Build settings from command line:
299 | ARCHS = arm64
300 | BUILD_DIR = /Users/dev/Build/Products
301 | CONFIGURATION = Debug
302 | DEVELOPMENT_TEAM = ABC123DEF4
303 | PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
304 | PRODUCT_NAME = MyApp
305 | SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
306 | },
307 | ],
308 | isError: false,
309 | });
310 | });
311 |
312 | it('should return error when command fails', async () => {
313 | const mockExecutor = createMockExecutor({
314 | success: false,
315 | output: '',
316 | error: 'Scheme not found',
317 | process: { pid: 12345 },
318 | });
319 |
320 | const result = await showBuildSettingsLogic(
321 | {
322 | projectPath: '/path/to/MyProject.xcodeproj',
323 | scheme: 'InvalidScheme',
324 | },
325 | mockExecutor,
326 | );
327 |
328 | expect(result).toEqual({
329 | content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }],
330 | isError: true,
331 | });
332 | });
333 |
334 | it('should handle Error objects in catch blocks', async () => {
335 | const mockExecutor = async () => {
336 | throw new Error('Command execution failed');
337 | };
338 |
339 | const result = await showBuildSettingsLogic(
340 | {
341 | projectPath: '/path/to/MyProject.xcodeproj',
342 | scheme: 'MyScheme',
343 | },
344 | mockExecutor,
345 | );
346 |
347 | expect(result).toEqual({
348 | content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }],
349 | isError: true,
350 | });
351 | });
352 | });
353 | });
354 |
```