This is page 9 of 11. Use http://codebase.md/cameroncooke/xcodebuildmcp?page={x} to view the full context. # Directory Structure ``` ├── .axe-version ├── .claude │ └── agents │ └── xcodebuild-mcp-qa-tester.md ├── .cursor │ ├── BUGBOT.md │ └── environment.json ├── .cursorrules ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude-dispatch.yml │ ├── claude.yml │ ├── droid-code-review.yml │ ├── README.md │ ├── release.yml │ └── sentry.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── mcp.json │ ├── settings.json │ └── tasks.json ├── AGENTS.md ├── banner.png ├── build-plugins │ ├── plugin-discovery.js │ ├── plugin-discovery.ts │ └── tsconfig.json ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── docs │ ├── ARCHITECTURE.md │ ├── CODE_QUALITY.md │ ├── CONTRIBUTING.md │ ├── ESLINT_TYPE_SAFETY.md │ ├── MANUAL_TESTING.md │ ├── NODEJS_2025.md │ ├── PLUGIN_DEVELOPMENT.md │ ├── RELEASE_PROCESS.md │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md │ ├── RELOADEROO.md │ ├── session_management_plan.md │ ├── session-aware-migration-todo.md │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md │ ├── TESTING.md │ └── TOOLS.md ├── eslint.config.js ├── example_projects │ ├── .vscode │ │ └── launch.json │ ├── iOS │ │ ├── .cursor │ │ │ └── rules │ │ │ └── errors.mdc │ │ ├── .vscode │ │ │ └── settings.json │ │ ├── Makefile │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ ├── MCPTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── MCPTest.xcscheme │ │ └── MCPTestUITests │ │ └── MCPTestUITests.swift │ ├── iOS_Calculator │ │ ├── CalculatorApp │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── CalculatorApp.swift │ │ │ └── CalculatorApp.xctestplan │ │ ├── CalculatorApp.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── CalculatorApp.xcscheme │ │ ├── CalculatorApp.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── CalculatorAppPackage │ │ │ ├── .gitignore │ │ │ ├── Package.swift │ │ │ ├── Sources │ │ │ │ └── CalculatorAppFeature │ │ │ │ ├── BackgroundEffect.swift │ │ │ │ ├── CalculatorButton.swift │ │ │ │ ├── CalculatorDisplay.swift │ │ │ │ ├── CalculatorInputHandler.swift │ │ │ │ ├── CalculatorService.swift │ │ │ │ └── ContentView.swift │ │ │ └── Tests │ │ │ └── CalculatorAppFeatureTests │ │ │ └── CalculatorServiceTests.swift │ │ ├── CalculatorAppTests │ │ │ └── CalculatorAppTests.swift │ │ └── Config │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ ├── Shared.xcconfig │ │ └── Tests.xcconfig │ ├── macOS │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTest.entitlements │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── MCPTest.xcodeproj │ │ ├── project.pbxproj │ │ └── xcshareddata │ │ └── xcschemes │ │ └── MCPTest.xcscheme │ └── spm │ ├── .gitignore │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ │ ├── long-server │ │ │ └── main.swift │ │ ├── quick-task │ │ │ └── main.swift │ │ ├── spm │ │ │ └── main.swift │ │ └── TestLib │ │ └── TaskManager.swift │ └── Tests │ └── TestLibTests │ └── SimpleTests.swift ├── LICENSE ├── mcp-install-dark.png ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── analysis │ │ └── tools-analysis.ts │ ├── bundle-axe.sh │ ├── check-code-patterns.js │ ├── release.sh │ ├── tools-cli.ts │ └── update-tools-docs.ts ├── server.json ├── smithery.yaml ├── src │ ├── core │ │ ├── __tests__ │ │ │ └── resources.test.ts │ │ ├── dynamic-tools.ts │ │ ├── plugin-registry.ts │ │ ├── plugin-types.ts │ │ └── resources.ts │ ├── doctor-cli.ts │ ├── index.ts │ ├── mcp │ │ ├── resources │ │ │ ├── __tests__ │ │ │ │ ├── devices.test.ts │ │ │ │ ├── doctor.test.ts │ │ │ │ └── simulators.test.ts │ │ │ ├── devices.ts │ │ │ ├── doctor.ts │ │ │ └── simulators.ts │ │ └── tools │ │ ├── device │ │ │ ├── __tests__ │ │ │ │ ├── build_device.test.ts │ │ │ │ ├── get_device_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_device.test.ts │ │ │ │ ├── launch_app_device.test.ts │ │ │ │ ├── list_devices.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_app_device.test.ts │ │ │ │ └── test_device.test.ts │ │ │ ├── build_device.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_device_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_device.ts │ │ │ ├── launch_app_device.ts │ │ │ ├── list_devices.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── stop_app_device.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── test_device.ts │ │ ├── discovery │ │ │ ├── __tests__ │ │ │ │ └── discover_tools.test.ts │ │ │ ├── discover_tools.ts │ │ │ └── index.ts │ │ ├── doctor │ │ │ ├── __tests__ │ │ │ │ ├── doctor.test.ts │ │ │ │ └── index.test.ts │ │ │ ├── doctor.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ └── doctor.deps.ts │ │ ├── logging │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── start_device_log_cap.test.ts │ │ │ │ ├── start_sim_log_cap.test.ts │ │ │ │ ├── stop_device_log_cap.test.ts │ │ │ │ └── stop_sim_log_cap.test.ts │ │ │ ├── index.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── start_sim_log_cap.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── stop_sim_log_cap.ts │ │ ├── macos │ │ │ ├── __tests__ │ │ │ │ ├── build_macos.test.ts │ │ │ │ ├── build_run_macos.test.ts │ │ │ │ ├── get_mac_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── launch_mac_app.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_mac_app.test.ts │ │ │ │ └── test_macos.test.ts │ │ │ ├── build_macos.ts │ │ │ ├── build_run_macos.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_mac_app_path.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── launch_mac_app.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_mac_app.ts │ │ │ └── test_macos.ts │ │ ├── project-discovery │ │ │ ├── __tests__ │ │ │ │ ├── discover_projs.test.ts │ │ │ │ ├── get_app_bundle_id.test.ts │ │ │ │ ├── get_mac_bundle_id.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── list_schemes.test.ts │ │ │ │ └── show_build_settings.test.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── list_schemes.ts │ │ │ └── show_build_settings.ts │ │ ├── project-scaffolding │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── scaffold_ios_project.test.ts │ │ │ │ └── scaffold_macos_project.test.ts │ │ │ ├── index.ts │ │ │ ├── scaffold_ios_project.ts │ │ │ └── scaffold_macos_project.ts │ │ ├── session-management │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── session_clear_defaults.test.ts │ │ │ │ ├── session_set_defaults.test.ts │ │ │ │ └── session_show_defaults.test.ts │ │ │ ├── index.ts │ │ │ ├── session_clear_defaults.ts │ │ │ ├── session_set_defaults.ts │ │ │ └── session_show_defaults.ts │ │ ├── simulator │ │ │ ├── __tests__ │ │ │ │ ├── boot_sim.test.ts │ │ │ │ ├── build_run_sim.test.ts │ │ │ │ ├── build_sim.test.ts │ │ │ │ ├── get_sim_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_sim.test.ts │ │ │ │ ├── launch_app_logs_sim.test.ts │ │ │ │ ├── launch_app_sim.test.ts │ │ │ │ ├── list_sims.test.ts │ │ │ │ ├── open_sim.test.ts │ │ │ │ ├── record_sim_video.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── stop_app_sim.test.ts │ │ │ │ └── test_sim.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── build_run_sim.ts │ │ │ ├── build_sim.ts │ │ │ ├── clean.ts │ │ │ ├── describe_ui.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_sim_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_sim.ts │ │ │ ├── launch_app_logs_sim.ts │ │ │ ├── launch_app_sim.ts │ │ │ ├── list_schemes.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── record_sim_video.ts │ │ │ ├── screenshot.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_app_sim.ts │ │ │ └── test_sim.ts │ │ ├── simulator-management │ │ │ ├── __tests__ │ │ │ │ ├── erase_sims.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── reset_sim_location.test.ts │ │ │ │ ├── set_sim_appearance.test.ts │ │ │ │ ├── set_sim_location.test.ts │ │ │ │ └── sim_statusbar.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── erase_sims.ts │ │ │ ├── index.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── reset_sim_location.ts │ │ │ ├── set_sim_appearance.ts │ │ │ ├── set_sim_location.ts │ │ │ └── sim_statusbar.ts │ │ ├── swift-package │ │ │ ├── __tests__ │ │ │ │ ├── active-processes.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── swift_package_build.test.ts │ │ │ │ ├── swift_package_clean.test.ts │ │ │ │ ├── swift_package_list.test.ts │ │ │ │ ├── swift_package_run.test.ts │ │ │ │ ├── swift_package_stop.test.ts │ │ │ │ └── swift_package_test.test.ts │ │ │ ├── active-processes.ts │ │ │ ├── index.ts │ │ │ ├── swift_package_build.ts │ │ │ ├── swift_package_clean.ts │ │ │ ├── swift_package_list.ts │ │ │ ├── swift_package_run.ts │ │ │ ├── swift_package_stop.ts │ │ │ └── swift_package_test.ts │ │ ├── ui-testing │ │ │ ├── __tests__ │ │ │ │ ├── button.test.ts │ │ │ │ ├── describe_ui.test.ts │ │ │ │ ├── gesture.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── key_press.test.ts │ │ │ │ ├── key_sequence.test.ts │ │ │ │ ├── long_press.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── swipe.test.ts │ │ │ │ ├── tap.test.ts │ │ │ │ ├── touch.test.ts │ │ │ │ └── type_text.test.ts │ │ │ ├── button.ts │ │ │ ├── describe_ui.ts │ │ │ ├── gesture.ts │ │ │ ├── index.ts │ │ │ ├── key_press.ts │ │ │ ├── key_sequence.ts │ │ │ ├── long_press.ts │ │ │ ├── screenshot.ts │ │ │ ├── swipe.ts │ │ │ ├── tap.ts │ │ │ ├── touch.ts │ │ │ └── type_text.ts │ │ └── utilities │ │ ├── __tests__ │ │ │ ├── clean.test.ts │ │ │ └── index.test.ts │ │ ├── clean.ts │ │ └── index.ts │ ├── server │ │ └── server.ts │ ├── test-utils │ │ └── mock-executors.ts │ ├── types │ │ └── common.ts │ └── utils │ ├── __tests__ │ │ ├── build-utils.test.ts │ │ ├── environment.test.ts │ │ ├── session-aware-tool-factory.test.ts │ │ ├── session-store.test.ts │ │ ├── simulator-utils.test.ts │ │ ├── test-runner-env-integration.test.ts │ │ └── typed-tool-factory.test.ts │ ├── axe │ │ └── index.ts │ ├── axe-helpers.ts │ ├── build │ │ └── index.ts │ ├── build-utils.ts │ ├── capabilities.ts │ ├── command.ts │ ├── CommandExecutor.ts │ ├── environment.ts │ ├── errors.ts │ ├── execution │ │ └── index.ts │ ├── FileSystemExecutor.ts │ ├── log_capture.ts │ ├── log-capture │ │ └── index.ts │ ├── logger.ts │ ├── logging │ │ └── index.ts │ ├── plugin-registry │ │ └── index.ts │ ├── responses │ │ └── index.ts │ ├── schema-helpers.ts │ ├── sentry.ts │ ├── session-store.ts │ ├── simulator-utils.ts │ ├── template │ │ └── index.ts │ ├── template-manager.ts │ ├── test │ │ └── index.ts │ ├── test-common.ts │ ├── tool-registry.ts │ ├── typed-tool-factory.ts │ ├── validation │ │ └── index.ts │ ├── validation.ts │ ├── version │ │ └── index.ts │ ├── video_capture.ts │ ├── video-capture │ │ └── index.ts │ ├── xcode.ts │ ├── xcodemake │ │ └── index.ts │ └── xcodemake.ts ├── tsconfig.json ├── tsconfig.test.json ├── tsup.config.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for start_device_log_cap plugin * Following CLAUDE.md testing standards with pure dependency injection */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'events'; import type { ChildProcess } from 'child_process'; import { z } from 'zod'; import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import plugin, { start_device_log_capLogic, activeDeviceLogSessions, } from '../start_device_log_cap.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('start_device_log_cap plugin', () => { // Mock state tracking let commandCalls: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: Record<string, string>; }> = []; let mkdirCalls: string[] = []; let writeFileCalls: Array<{ path: string; content: string }> = []; // Reset state commandCalls = []; mkdirCalls = []; writeFileCalls = []; const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; beforeEach(() => { sessionStore.clear(); activeDeviceLogSessions.clear(); process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25'; }); afterEach(() => { if (originalJsonWaitEnv === undefined) { delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS; } else { process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv; } }); describe('Plugin Structure', () => { it('should export an object with required properties', () => { expect(plugin).toHaveProperty('name'); expect(plugin).toHaveProperty('description'); expect(plugin).toHaveProperty('schema'); expect(plugin).toHaveProperty('handler'); }); it('should have correct tool name', () => { expect(plugin.name).toBe('start_device_log_cap'); }); it('should have correct description', () => { expect(plugin.description).toBe('Starts log capture on a connected device.'); }); it('should have correct schema structure', () => { // Schema should be a plain object for MCP protocol compliance expect(typeof plugin.schema).toBe('object'); expect(Object.keys(plugin.schema)).toEqual(['bundleId']); // Validate that schema fields are Zod types that can be used for validation const schema = z.object(plugin.schema).strict(); expect(schema.safeParse({ bundleId: 'com.test.app' }).success).toBe(true); expect(schema.safeParse({}).success).toBe(false); }); it('should have handler as a function', () => { expect(typeof plugin.handler).toBe('function'); }); }); describe('Handler Requirements', () => { it('should require deviceId when not provided', async () => { const result = await plugin.handler({ bundleId: 'com.example.MyApp' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('deviceId is required'); }); }); describe('Handler Functionality', () => { it('should start log capture successfully', async () => { // Mock successful command execution const mockExecutor = createMockExecutor({ success: true, output: 'App launched successfully', }); const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async (path: string) => { mkdirCalls.push(path); }, writeFile: async (path: string, content: string) => { writeFileCalls.push({ path, content }); }, }); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content[0].text).toMatch(/✅ Device log capture started successfully/); expect(result.content[0].text).toMatch(/Session ID: [a-f0-9-]{36}/); expect(result.isError ?? false).toBe(false); }); it('should include next steps in success response', async () => { // Mock successful command execution const mockExecutor = createMockExecutor({ success: true, output: 'App launched successfully', }); const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async (path: string) => { mkdirCalls.push(path); }, writeFile: async (path: string, content: string) => { writeFileCalls.push({ path, content }); }, }); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content[0].text).toContain('Next Steps:'); expect(result.content[0].text).toContain('Use stop_device_log_cap'); }); it('should surface early launch failures when process exits immediately', async () => { const failingProcess = new EventEmitter() as unknown as ChildProcess & { exitCode: number | null; killed: boolean; kill(signal?: string): boolean; stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; }; const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void; }; stubOutput.setEncoding = () => {}; const stubError = new EventEmitter() as NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void; }; stubError.setEncoding = () => {}; failingProcess.stdout = stubOutput; failingProcess.stderr = stubError; failingProcess.exitCode = null; failingProcess.killed = false; failingProcess.kill = () => { failingProcess.killed = true; failingProcess.exitCode = 0; failingProcess.emit('close', 0, null); return true; }; const mockExecutor = createMockExecutor({ success: true, output: '', process: failingProcess, }); let createdLogPath = ''; const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async () => {}, writeFile: async (path: string, content: string) => { createdLogPath = path; writeFileCalls.push({ path, content }); }, }); const resultPromise = start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.invalid.App', }, mockExecutor, mockFileSystemExecutor, ); setTimeout(() => { stubError.emit( 'data', 'ERROR: The application failed to launch. (com.apple.dt.CoreDeviceError error 10002)\nNSLocalizedRecoverySuggestion = Provide a valid bundle identifier.\n', ); failingProcess.exitCode = 70; failingProcess.emit('close', 70, null); }, 10); const result = await resultPromise; expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a valid bundle identifier'); expect(activeDeviceLogSessions.size).toBe(0); expect(createdLogPath).not.toBe(''); }); it('should surface JSON-reported failures when launch cannot start', async () => { const jsonFailure = { error: { domain: 'com.apple.dt.CoreDeviceError', code: 10002, localizedDescription: 'The application failed to launch.', userInfo: { NSLocalizedRecoverySuggestion: 'Provide a valid bundle identifier.', NSLocalizedFailureReason: 'The requested application com.invalid.App is not installed.', BundleIdentifier: 'com.invalid.App', }, }, }; const failingProcess = new EventEmitter() as unknown as ChildProcess & { exitCode: number | null; killed: boolean; kill(signal?: string): boolean; stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; }; const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void; }; stubOutput.setEncoding = () => {}; const stubError = new EventEmitter() as NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void; }; stubError.setEncoding = () => {}; failingProcess.stdout = stubOutput; failingProcess.stderr = stubError; failingProcess.exitCode = null; failingProcess.killed = false; failingProcess.kill = () => { failingProcess.killed = true; return true; }; const mockExecutor = createMockExecutor({ success: true, output: '', process: failingProcess, }); let jsonPathSeen = ''; let removedJsonPath = ''; const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async () => {}, writeFile: async () => {}, existsSync: (filePath: string): boolean => { if (filePath.includes('devicectl-launch-')) { jsonPathSeen = filePath; return true; } return false; }, readFile: async (filePath: string): Promise<string> => { if (filePath.includes('devicectl-launch-')) { jsonPathSeen = filePath; return JSON.stringify(jsonFailure); } return ''; }, rm: async (filePath: string) => { if (filePath.includes('devicectl-launch-')) { removedJsonPath = filePath; } }, }); setTimeout(() => { failingProcess.exitCode = 0; failingProcess.emit('close', 0, null); }, 5); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.invalid.App', }, mockExecutor, mockFileSystemExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a valid bundle identifier'); expect(jsonPathSeen).not.toBe(''); expect(removedJsonPath).toBe(jsonPathSeen); expect(activeDeviceLogSessions.size).toBe(0); expect(failingProcess.killed).toBe(true); }); it('should treat JSON success payload as confirmation of launch', async () => { const jsonSuccess = { result: { process: { processIdentifier: 4321, }, }, }; const runningProcess = new EventEmitter() as unknown as ChildProcess & { exitCode: number | null; killed: boolean; kill(signal?: string): boolean; stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; }; const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void; }; stubOutput.setEncoding = () => {}; const stubError = new EventEmitter() as NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void; }; stubError.setEncoding = () => {}; runningProcess.stdout = stubOutput; runningProcess.stderr = stubError; runningProcess.exitCode = null; runningProcess.killed = false; runningProcess.kill = () => { runningProcess.killed = true; runningProcess.emit('close', 0, null); return true; }; const mockExecutor = createMockExecutor({ success: true, output: '', process: runningProcess, }); let jsonPathSeen = ''; let removedJsonPath = ''; let jsonRemoved = false; const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async () => {}, writeFile: async () => {}, existsSync: (filePath: string): boolean => { if (filePath.includes('devicectl-launch-')) { jsonPathSeen = filePath; return !jsonRemoved; } return false; }, readFile: async (filePath: string): Promise<string> => { if (filePath.includes('devicectl-launch-')) { jsonPathSeen = filePath; return JSON.stringify(jsonSuccess); } return ''; }, rm: async (filePath: string) => { if (filePath.includes('devicectl-launch-')) { jsonRemoved = true; removedJsonPath = filePath; } }, }); setTimeout(() => { runningProcess.emit('close', 0, null); }, 5); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content[0].text).toContain('Device log capture started successfully'); expect(result.isError ?? false).toBe(false); expect(jsonPathSeen).not.toBe(''); expect(removedJsonPath).toBe(jsonPathSeen); expect(activeDeviceLogSessions.size).toBe(1); }); it('should handle directory creation failure', async () => { // Mock mkdir to fail const mockExecutor = createMockExecutor({ success: false, output: '', error: 'Command failed', }); const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async (path: string) => { mkdirCalls.push(path); throw new Error('Permission denied'); }, }); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to start device log capture: Permission denied', }, ], isError: true, }); }); it('should handle file write failure', async () => { // Mock writeFile to fail const mockExecutor = createMockExecutor({ success: false, output: '', error: 'Command failed', }); const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async (path: string) => { mkdirCalls.push(path); }, writeFile: async (path: string, content: string) => { writeFileCalls.push({ path, content }); throw new Error('Disk full'); }, }); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to start device log capture: Disk full', }, ], isError: true, }); }); it('should handle spawn process error', async () => { // Mock spawn to throw error const mockExecutor = createMockExecutor(new Error('Command not found')); const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async (path: string) => { mkdirCalls.push(path); }, writeFile: async (path: string, content: string) => { writeFileCalls.push({ path, content }); }, }); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to start device log capture: Command not found', }, ], isError: true, }); }); it('should handle string error objects', async () => { // Mock mkdir to fail with string error const mockExecutor = createMockExecutor('String error message'); const mockFileSystemExecutor = createMockFileSystemExecutor({ mkdir: async (path: string) => { mkdirCalls.push(path); }, writeFile: async (path: string, content: string) => { writeFileCalls.push({ path, content }); }, }); const result = await start_device_log_capLogic( { deviceId: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to start device log capture: String error message', }, ], isError: true, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-scaffolding/scaffold_ios_project.ts: -------------------------------------------------------------------------------- ```typescript /** * Utilities Plugin: Scaffold iOS Project * * Scaffold a new iOS project from templates. */ import { z } from 'zod'; import { join, dirname, basename } from 'path'; import { log } from '../../../utils/logging/index.ts'; import { ValidationError } from '../../../utils/responses/index.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; import { ToolResponse } from '../../../types/common.ts'; // Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ projectName: z.string().min(1).describe('Name of the new project'), outputPath: z.string().describe('Path where the project should be created'), bundleIdentifier: z .string() .optional() .describe( 'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname', ), displayName: z .string() .optional() .describe( 'App display name (shown on home screen/dock). If not provided, will use projectName', ), marketingVersion: z .string() .optional() .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'), currentProjectVersion: z .string() .optional() .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'), customizeNames: z .boolean() .default(true) .describe('Whether to customize project names and identifiers. Default is true.'), }); // iOS-specific schema const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({ deploymentTarget: z .string() .optional() .describe('iOS deployment target (e.g., 18.4, 17.0). If not provided, will use 18.4'), targetedDeviceFamily: z .array(z.enum(['iphone', 'ipad', 'universal'])) .optional() .describe('Targeted device families'), supportedOrientations: z .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down'])) .optional() .describe('Supported orientations for iPhone'), supportedOrientationsIpad: z .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down'])) .optional() .describe('Supported orientations for iPad'), }); /** * Convert orientation enum to iOS constant */ function orientationToIOSConstant(orientation: string): string { switch (orientation) { case 'Portrait': return 'UIInterfaceOrientationPortrait'; case 'PortraitUpsideDown': return 'UIInterfaceOrientationPortraitUpsideDown'; case 'LandscapeLeft': return 'UIInterfaceOrientationLandscapeLeft'; case 'LandscapeRight': return 'UIInterfaceOrientationLandscapeRight'; default: return orientation; } } /** * Convert device family enum to numeric value */ function deviceFamilyToNumeric(family: string): string { switch (family) { case 'iPhone': return '1'; case 'iPad': return '2'; case 'iPhone+iPad': return '1,2'; default: return '1,2'; } } /** * Update Package.swift file with deployment target */ function updatePackageSwiftFile(content: string, params: Record<string, unknown>): string { let result = content; const projectName = params.projectName as string; const platform = params.platform as string; const deploymentTarget = params.deploymentTarget as string | undefined; // Update ALL target name references in Package.swift const featureName = `${projectName}Feature`; const testName = `${projectName}FeatureTests`; // Replace ALL occurrences of MyProjectFeatureTests first (more specific) result = result.replace(/MyProjectFeatureTests/g, testName); // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after) result = result.replace(/MyProjectFeature/g, featureName); // Update deployment targets based on platform if (platform === 'iOS') { if (deploymentTarget) { // Extract major version (e.g., "17.0" -> "17") const majorVersion = deploymentTarget.split('.')[0]; result = result.replace(/\.iOS\(\.v\d+\)/, `.iOS(.v${majorVersion})`); } } return result; } /** * Update XCConfig file with scaffold parameters */ function updateXCConfigFile(content: string, params: Record<string, unknown>): string { let result = content; const projectName = params.projectName as string; const displayName = params.displayName as string | undefined; const bundleIdentifier = params.bundleIdentifier as string | undefined; const marketingVersion = params.marketingVersion as string | undefined; const currentProjectVersion = params.currentProjectVersion as string | undefined; const platform = params.platform as string; const deploymentTarget = params.deploymentTarget as string | undefined; const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined; const supportedOrientations = params.supportedOrientations as string[] | undefined; const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined; // Update project identity settings result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`); result = result.replace( /PRODUCT_DISPLAY_NAME = .+/g, `PRODUCT_DISPLAY_NAME = ${displayName ?? projectName}`, ); result = result.replace( /PRODUCT_BUNDLE_IDENTIFIER = .+/g, `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`, ); result = result.replace( /MARKETING_VERSION = .+/g, `MARKETING_VERSION = ${marketingVersion ?? '1.0'}`, ); result = result.replace( /CURRENT_PROJECT_VERSION = .+/g, `CURRENT_PROJECT_VERSION = ${currentProjectVersion ?? '1'}`, ); // Platform-specific updates if (platform === 'iOS') { // iOS deployment target if (deploymentTarget) { result = result.replace( /IPHONEOS_DEPLOYMENT_TARGET = .+/g, `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}`, ); } // Device family if (targetedDeviceFamily) { const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily); result = result.replace( /TARGETED_DEVICE_FAMILY = .+/g, `TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`, ); } // iPhone orientations if (supportedOrientations && supportedOrientations.length > 0) { // Filter out any empty strings and validate const validOrientations = supportedOrientations.filter((o: string) => o && o.trim() !== ''); if (validOrientations.length > 0) { const orientations = validOrientations.map(orientationToIOSConstant).join(' '); result = result.replace( /INFOPLIST_KEY_UISupportedInterfaceOrientations = .+/g, `INFOPLIST_KEY_UISupportedInterfaceOrientations = ${orientations}`, ); } } // iPad orientations if (supportedOrientationsIpad && supportedOrientationsIpad.length > 0) { // Filter out any empty strings and validate const validOrientations = supportedOrientationsIpad.filter( (o: string) => o && o.trim() !== '', ); if (validOrientations.length > 0) { const orientations = validOrientations.map(orientationToIOSConstant).join(' '); result = result.replace( /INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = .+/g, `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = ${orientations}`, ); } } // Update entitlements path for iOS result = result.replace( /CODE_SIGN_ENTITLEMENTS = .+/g, `CODE_SIGN_ENTITLEMENTS = Config/${projectName}.entitlements`, ); } // Update test bundle identifier and target name result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${projectName}`); // Update comments that reference MyProject in entitlements paths result = result.replace(/Config\/MyProject\.entitlements/g, `Config/${projectName}.entitlements`); return result; } /** * Replace placeholders in a string (for non-XCConfig files) */ function replacePlaceholders( content: string, projectName: string, bundleIdentifier: string, ): string { let result = content; // Replace project name result = result.replace(/MyProject/g, projectName); // Replace bundle identifier - check for both patterns used in templates if (bundleIdentifier) { result = result.replace(/com\.example\.MyProject/g, bundleIdentifier); result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier); } return result; } /** * Process a single file, replacing placeholders if it's a text file */ async function processFile( sourcePath: string, destPath: string, params: Record<string, unknown>, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise<void> { const projectName = params.projectName as string; const bundleIdentifierParam = params.bundleIdentifier as string | undefined; const customizeNames = params.customizeNames as boolean | undefined; // Determine the destination file path let finalDestPath = destPath; if (customizeNames) { // Replace MyProject in file/directory names const fileName = basename(destPath); const dirName = dirname(destPath); const newFileName = fileName.replace(/MyProject/g, projectName); finalDestPath = join(dirName, newFileName); } // Text file extensions that should be processed const textExtensions = [ '.swift', '.h', '.m', '.mm', '.cpp', '.c', '.pbxproj', '.plist', '.xcscheme', '.xctestplan', '.xcworkspacedata', '.xcconfig', '.json', '.xml', '.entitlements', '.storyboard', '.xib', '.md', ]; const ext = sourcePath.toLowerCase(); const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt)); const isXCConfig = sourcePath.endsWith('.xcconfig'); const isPackageSwift = sourcePath.endsWith('Package.swift'); if (isTextFile && customizeNames) { // Read the file content const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8'); let processedContent; if (isXCConfig) { // Use special XCConfig processing processedContent = updateXCConfigFile(content, params); } else if (isPackageSwift) { // Use special Package.swift processing processedContent = updatePackageSwiftFile(content, params); } else { // Use standard placeholder replacement const bundleIdentifier = bundleIdentifierParam ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; processedContent = replacePlaceholders(content, projectName, bundleIdentifier); } await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8'); } else { // Copy binary files as-is await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); await fileSystemExecutor.cp(sourcePath, finalDestPath); } } /** * Recursively process a directory */ async function processDirectory( sourceDir: string, destDir: string, params: Record<string, unknown>, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise<void> { const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const entryTyped = entry as { name: string; isDirectory: () => boolean; isFile: () => boolean }; const sourcePath = join(sourceDir, entryTyped.name); let destName = entryTyped.name; if (params.customizeNames) { // Replace MyProject in directory names destName = destName.replace(/MyProject/g, params.projectName as string); } const destPath = join(destDir, destName); if (entryTyped.isDirectory()) { // Skip certain directories if (entryTyped.name === '.git' || entryTyped.name === 'xcuserdata') { continue; } await fileSystemExecutor.mkdir(destPath, { recursive: true }); await processDirectory(sourcePath, destPath, params, fileSystemExecutor); } else if (entryTyped.isFile()) { // Skip certain files if (entryTyped.name === '.DS_Store' || entryTyped.name.endsWith('.xcuserstate')) { continue; } await processFile(sourcePath, destPath, params, fileSystemExecutor); } } } // Use z.infer for type safety type ScaffoldIOSProjectParams = z.infer<typeof ScaffoldiOSProjectSchema>; /** * Logic function for scaffolding iOS projects */ export async function scaffold_ios_projectLogic( params: ScaffoldIOSProjectParams, commandExecutor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, ): Promise<ToolResponse> { try { const projectParams = { ...params, platform: 'iOS' }; const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); const response = { success: true, projectPath, platform: 'iOS', message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`, nextSteps: [ `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, `Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, `Build and run on simulator: build_run_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, ], }; return { content: [ { type: 'text', text: JSON.stringify(response, null, 2), }, ], }; } catch (error) { log( 'error', `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`, ); return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', }, null, 2, ), }, ], isError: true, }; } } /** * Scaffold a new iOS or macOS project */ async function scaffoldProject( params: Record<string, unknown>, commandExecutor?: CommandExecutor, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise<string> { const projectName = params.projectName as string; const outputPath = params.outputPath as string; const platform = params.platform as 'iOS' | 'macOS'; const customizeNames = (params.customizeNames as boolean | undefined) ?? true; log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`); // Validate project name if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) { throw new ValidationError( 'Project name must start with a letter and contain only letters, numbers, and underscores', ); } // Get template path from TemplateManager let templatePath; try { // Use the default command executor if not provided commandExecutor ??= getDefaultCommandExecutor(); templatePath = await TemplateManager.getTemplatePath( platform, commandExecutor, fileSystemExecutor, ); } catch (error) { throw new ValidationError( `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`, ); } // Use outputPath directly as the destination const projectPath = outputPath; // Check if the output directory already has Xcode project files const xcworkspaceExists = fileSystemExecutor.existsSync( join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`), ); const xcodeprojExists = fileSystemExecutor.existsSync( join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`), ); if (xcworkspaceExists || xcodeprojExists) { throw new ValidationError(`Xcode project files already exist in ${projectPath}`); } try { // Process the template directly into the output path await processDirectory(templatePath, projectPath, params, fileSystemExecutor); return projectPath; } finally { // Clean up downloaded template if needed await TemplateManager.cleanup(templatePath, fileSystemExecutor); } } export default { name: 'scaffold_ios_project', description: 'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.', schema: ScaffoldiOSProjectSchema.shape, async handler(args: Record<string, unknown>): Promise<ToolResponse> { const params = ScaffoldiOSProjectSchema.parse(args); return scaffold_ios_projectLogic( params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor(), ); }, }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/__tests__/test_macos.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for test_macos plugin (unified project/workspace) * Following CLAUDE.md testing standards with literal validation * Using dependency injection for deterministic testing */ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import testMacos, { testMacosLogic } from '../test_macos.ts'; describe('test_macos plugin (unified)', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(testMacos.name).toBe('test_macos'); }); it('should have correct description', () => { expect(testMacos.description).toBe('Runs tests for a macOS target.'); }); it('should have handler function', () => { expect(typeof testMacos.handler).toBe('function'); }); it('should validate schema correctly', () => { const schema = z.object(testMacos.schema); expect(schema.safeParse({}).success).toBe(true); expect( schema.safeParse({ derivedDataPath: '/path/to/derived-data', extraArgs: ['--arg1', '--arg2'], preferXcodebuild: true, testRunnerEnv: { FOO: 'BAR' }, }).success, ).toBe(true); expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); const schemaKeys = Object.keys(testMacos.schema).sort(); expect(schemaKeys).toEqual( ['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(), ); }); }); describe('Handler Requirements', () => { it('should require scheme before running', async () => { const result = await testMacos.handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); }); it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); const result = await testMacos.handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should reject when both projectPath and workspacePath provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); const result = await testMacos.handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); }); }); describe('XOR Parameter Validation', () => { it('should validate that either projectPath or workspacePath is provided', async () => { // Should return error response when neither is provided const result = await testMacos.handler({ scheme: 'MyScheme', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should validate that both projectPath and workspacePath cannot be provided', async () => { // Should return error response when both are provided const result = await testMacos.handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); }); it('should allow only projectPath', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); it('should allow only workspacePath', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); }); describe('Handler Behavior (Complete Literal Returns)', () => { it('should return successful test response with workspace when xcodebuild succeeds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', configuration: 'Debug', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); it('should return successful test response with project when xcodebuild succeeds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', configuration: 'Debug', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); it('should use default configuration when not provided', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); it('should handle optional parameters correctly', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', configuration: 'Release', derivedDataPath: '/custom/derived', extraArgs: ['--verbose'], preferXcodebuild: true, }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); it('should handle successful test execution with minimal parameters', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBeUndefined(); }); it('should return exact successful test response', async () => { // Track command execution calls const commandCalls: any[] = []; // Mock executor for successful test const mockExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { commandCalls.push({ command, logPrefix, useShell, env }); // Handle xcresulttool command if (command.includes('xcresulttool')) { return { success: true, output: JSON.stringify({ title: 'Test Results', result: 'SUCCEEDED', totalTestCount: 5, passedTests: 5, failedTests: 0, skippedTests: 0, expectedFailures: 0, }), error: undefined, }; } return { success: true, output: 'Test Succeeded', error: undefined, process: { pid: 12345 }, }; }; // Mock file system dependencies using approved utility const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/xcodebuild-test-abc123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, mockFileSystemExecutor, ); // Verify commands were called with correct parameters expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool expect(commandCalls[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=macOS', '-resultBundlePath', '/tmp/xcodebuild-test-abc123/TestResults.xcresult', 'test', ]); expect(commandCalls[0].logPrefix).toBe('Test Run'); expect(commandCalls[0].useShell).toBe(true); // Verify xcresulttool was called expect(commandCalls[1].command).toEqual([ 'xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', '/tmp/xcodebuild-test-abc123/TestResults.xcresult', ]); expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle'); expect(result.content).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'text', text: '✅ Test Run test succeeded for scheme MyScheme.', }), ]), ); }); it('should return exact test failure response', async () => { // Track command execution calls let callCount = 0; const mockExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callCount++; // First call is xcodebuild test - fails if (callCount === 1) { return { success: false, output: '', error: 'error: Test failed', process: { pid: 12345 }, }; } // Second call is xcresulttool if (command.includes('xcresulttool')) { return { success: true, output: JSON.stringify({ title: 'Test Results', result: 'FAILED', totalTestCount: 5, passedTests: 3, failedTests: 2, skippedTests: 0, expectedFailures: 0, }), error: undefined, }; } return { success: true, output: '', error: undefined }; }; // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/xcodebuild-test-abc123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'text', text: '❌ Test Run test failed for scheme MyScheme.', }), ]), ); expect(result.isError).toBe(true); }); it('should return exact successful test response with optional parameters', async () => { // Track command execution calls const commandCalls: any[] = []; // Mock executor for successful test with optional parameters const mockExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { commandCalls.push({ command, logPrefix, useShell, env }); // Handle xcresulttool command if (command.includes('xcresulttool')) { return { success: true, output: JSON.stringify({ title: 'Test Results', result: 'SUCCEEDED', totalTestCount: 5, passedTests: 5, failedTests: 0, skippedTests: 0, expectedFailures: 0, }), error: undefined, }; } return { success: true, output: 'Test Succeeded', error: undefined, process: { pid: 12345 }, }; }; // Mock file system dependencies const mockFileSystemExecutor = { mkdtemp: async () => '/tmp/xcodebuild-test-abc123', rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', configuration: 'Release', derivedDataPath: '/path/to/derived-data', extraArgs: ['--verbose'], preferXcodebuild: true, }, mockExecutor, mockFileSystemExecutor, ); expect(result.content).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'text', text: '✅ Test Run test succeeded for scheme MyScheme.', }), ]), ); }); it('should return exact exception handling response', async () => { // Mock executor (won't be called due to mkdtemp failure) const mockExecutor = createMockExecutor({ success: true, output: 'Test Succeeded', }); // Mock file system dependencies - mkdtemp fails const mockFileSystemExecutor = { mkdtemp: async () => { throw new Error('Network error'); }, rm: async () => {}, tmpdir: () => '/tmp', stat: async () => ({ isDirectory: () => true }), }; const result = await testMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, mockFileSystemExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Error during test run: Network error', }, ], isError: true, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/build_run_sim.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for build_run_sim plugin (unified) * Following CLAUDE.md testing standards with dependency injection and literal validation */ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts'; describe('build_run_sim tool', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(buildRunSim.name).toBe('build_run_sim'); }); it('should have correct description', () => { expect(buildRunSim.description).toBe('Builds and runs an app on an iOS simulator.'); }); it('should have handler function', () => { expect(typeof buildRunSim.handler).toBe('function'); }); it('should expose only non-session fields in public schema', () => { const schema = z.object(buildRunSim.schema); expect(schema.safeParse({}).success).toBe(true); expect( schema.safeParse({ derivedDataPath: '/path/to/derived', extraArgs: ['--verbose'], preferXcodebuild: false, }).success, ).toBe(true); expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); const schemaKeys = Object.keys(buildRunSim.schema).sort(); expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); expect(schemaKeys).not.toContain('scheme'); expect(schemaKeys).not.toContain('simulatorName'); expect(schemaKeys).not.toContain('projectPath'); }); }); describe('Handler Behavior (Complete Literal Returns)', () => { // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema // The logic function receives validated parameters, so these tests focus on business logic it('should handle simulator not found', async () => { let callCount = 0; const mockExecutor = async (command: string[]) => { callCount++; if (callCount === 1) { // First call: build succeeds return { success: true, output: 'BUILD SUCCEEDED', process: { pid: 12345 }, }; } else if (callCount === 2) { // Second call: showBuildSettings fails to get app path return { success: false, error: 'Could not get build settings', process: { pid: 12345 }, }; } return { success: false, error: 'Unexpected call', process: { pid: 12345 }, }; }; const result = await build_run_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Build succeeded, but failed to get app path: Could not get build settings', }, ], isError: true, }); }); it('should handle build failure', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Build failed with error', }); const result = await build_run_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.isError).toBe(true); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); }); it('should handle successful build and run', async () => { // Create a mock executor that simulates full successful flow let callCount = 0; const mockExecutor = async (command: string[], logPrefix?: string) => { callCount++; if (command.includes('xcodebuild') && command.includes('build')) { // First call: build succeeds return { success: true, output: 'BUILD SUCCEEDED', process: { pid: 12345 }, }; } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { // Second call: build settings to get app path return { success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', process: { pid: 12345 }, }; } else if (command.includes('simctl') && command.includes('list')) { // Find simulator calls return { success: true, output: JSON.stringify({ devices: { 'iOS 16.0': [ { udid: 'test-uuid-123', name: 'iPhone 16', state: 'Booted', isAvailable: true, }, ], }, }), process: { pid: 12345 }, }; } else if ( command.includes('plutil') || command.includes('PlistBuddy') || command.includes('defaults') ) { // Bundle ID extraction return { success: true, output: 'com.example.MyApp', process: { pid: 12345 }, }; } else { // All other commands (boot, open, install, launch) succeed return { success: true, output: 'Success', process: { pid: 12345 }, }; } }; const result = await build_run_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); expect(result.isError).toBe(false); }); it('should handle exception with Error object', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Command failed', }); const result = await build_run_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.isError).toBe(true); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); }); it('should handle exception with string error', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'String error', }); const result = await build_run_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.isError).toBe(true); expect(result.content).toBeDefined(); expect(Array.isArray(result.content)).toBe(true); }); }); describe('Command Generation', () => { it('should generate correct simctl list command with minimal parameters', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_run_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, trackingExecutor, ); // Should generate the initial build command expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command after finding simulator', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; let callCount = 0; // Create tracking executor that succeeds on first call (list) and fails on second const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); callCount++; if (callCount === 1) { // First call: simulator list succeeds return { success: true, output: JSON.stringify({ devices: { 'iOS 16.0': [ { udid: 'test-uuid-123', name: 'iPhone 16', state: 'Booted', }, ], }, }), error: undefined, process: { pid: 12345 }, }; } else { // Second call: build command fails to stop execution return { success: false, output: '', error: 'Test error to stop execution', process: { pid: 12345 }, }; } }; const result = await build_run_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, trackingExecutor, ); // Should generate build command and then build settings command expect(callHistory).toHaveLength(2); // First call: build command expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); // Second call: build settings command to get app path expect(callHistory[1].command).toEqual([ 'xcodebuild', '-showBuildSettings', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', ]); expect(callHistory[1].logPrefix).toBe('Get App Path'); }); it('should generate correct build settings command after successful build', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; let callCount = 0; // Create tracking executor that succeeds on first two calls and fails on third const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); callCount++; if (callCount === 1) { // First call: simulator list succeeds return { success: true, output: JSON.stringify({ devices: { 'iOS 16.0': [ { udid: 'test-uuid-123', name: 'iPhone 16', state: 'Booted', }, ], }, }), error: undefined, process: { pid: 12345 }, }; } else if (callCount === 2) { // Second call: build command succeeds return { success: true, output: 'BUILD SUCCEEDED', error: undefined, process: { pid: 12345 }, }; } else { // Third call: build settings command fails to stop execution return { success: false, output: '', error: 'Test error to stop execution', process: { pid: 12345 }, }; } }; const result = await build_run_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', configuration: 'Release', useLatestOS: false, }, trackingExecutor, ); // Should generate build command and build settings command expect(callHistory).toHaveLength(2); // First call: build command expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Release', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); // Second call: build settings command expect(callHistory[1].command).toEqual([ 'xcodebuild', '-showBuildSettings', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Release', '-destination', 'platform=iOS Simulator,name=iPhone 16', ]); expect(callHistory[1].logPrefix).toBe('Get App Path'); }); it('should handle paths with spaces in command generation', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_run_simLogic( { workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', scheme: 'My Scheme', simulatorName: 'iPhone 16 Pro', }, trackingExecutor, ); // Should generate build command first expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/Users/dev/My Project/MyProject.xcworkspace', '-scheme', 'My Scheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); }); describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { const result = await buildRunSim.handler({ scheme: 'MyScheme', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should error when both projectPath and workspacePath provided', async () => { const result = await buildRunSim.handler({ projectPath: '/path/project.xcodeproj', workspacePath: '/path/workspace.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); expect(result.content[0].text).toContain('projectPath'); expect(result.content[0].text).toContain('workspacePath'); }); it('should succeed with only projectPath', async () => { // This test fails early due to build failure, which is expected behavior const mockExecutor = createMockExecutor({ success: false, error: 'Build failed', }); const result = await build_run_simLogic( { projectPath: '/path/project.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); // The test succeeds if the logic function accepts the parameters and attempts to build expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Build failed'); }); it('should succeed with only workspacePath', async () => { // This test fails early due to build failure, which is expected behavior const mockExecutor = createMockExecutor({ success: false, error: 'Build failed', }); const result = await build_run_simLogic( { workspacePath: '/path/workspace.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); // The test succeeds if the logic function accepts the parameters and attempts to build expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Build failed'); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/build_run_sim.ts: -------------------------------------------------------------------------------- ```typescript /** * Simulator Build & Run Plugin: Build Run Simulator (Unified) * * Builds and runs an app from a project or workspace on a specific simulator by UUID or name. * Accepts mutually exclusive `projectPath` or `workspacePath`. * Accepts mutually exclusive `simulatorId` or `simulatorName`. */ import { z } from 'zod'; import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z .string() .optional() .describe( 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z .string() .optional() .describe('Path where build products and other derived data will go'), extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), useLatestOS: z .boolean() .optional() .describe('Whether to use the latest OS version for the named simulator'), preferXcodebuild: z .boolean() .optional() .describe( 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', ), }; const baseSchemaObject = z.object({ projectPath: z .string() .optional() .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), workspacePath: z .string() .optional() .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), ...baseOptions, }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const buildRunSimulatorSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }) .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { message: 'Either simulatorId or simulatorName is required.', }) .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', }); export type BuildRunSimulatorParams = z.infer<typeof buildRunSimulatorSchema>; // Internal logic for building Simulator apps. async function _handleSimulatorBuildLogic( params: BuildRunSimulatorParams, executor: CommandExecutor, executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, ): Promise<ToolResponse> { const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warning', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } log( 'info', `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, ); // Create SharedBuildParams object with required configuration property const sharedBuildParams: SharedBuildParams = { workspacePath: params.workspacePath, projectPath: params.projectPath, scheme: params.scheme, configuration: params.configuration ?? 'Debug', derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, }; return executeXcodeBuildCommandFn( sharedBuildParams, { platform: XcodePlatform.iOSSimulator, simulatorId: params.simulatorId, simulatorName: params.simulatorName, useLatestOS: params.simulatorId ? false : params.useLatestOS, logPrefix: 'iOS Simulator Build', }, params.preferXcodebuild as boolean, 'build', executor, ); } // Exported business logic function for building and running iOS Simulator apps. export async function build_run_simLogic( params: BuildRunSimulatorParams, executor: CommandExecutor, executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, ): Promise<ToolResponse> { const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; log( 'info', `Starting iOS Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, ); try { // --- Build Step --- const buildResult = await _handleSimulatorBuildLogic( params, executor, executeXcodeBuildCommandFn, ); if (buildResult.isError) { return buildResult; // Return the build error } // --- Get App Path Step --- // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; // Add the workspace or project if (params.workspacePath) { command.push('-workspace', params.workspacePath); } else if (params.projectPath) { command.push('-project', params.projectPath); } // Add the scheme and configuration command.push('-scheme', params.scheme); command.push('-configuration', params.configuration ?? 'Debug'); // Handle destination for simulator let destinationString: string; if (params.simulatorId) { destinationString = `platform=iOS Simulator,id=${params.simulatorId}`; } else if (params.simulatorName) { destinationString = `platform=iOS Simulator,name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; } else { // This shouldn't happen due to validation, but handle it destinationString = 'platform=iOS Simulator'; } command.push('-destination', destinationString); // Add derived data path if provided if (params.derivedDataPath) { command.push('-derivedDataPath', params.derivedDataPath); } // Add extra args if provided if (params.extraArgs && params.extraArgs.length > 0) { command.push(...params.extraArgs); } // Execute the command directly const result = await executor(command, 'Get App Path', true, undefined); // If there was an error with the command execution, return it if (!result.success) { return createTextResponse( `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`, true, ); } // Parse the output to extract the app path const buildSettingsOutput = result.output; // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH) let appBundlePath: string | null = null; // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/); if (appPathMatch?.[1]) { appBundlePath = appPathMatch[1].trim(); } else { // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME const builtProductsDirMatch = buildSettingsOutput.match( /^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m, ); const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (builtProductsDirMatch && fullProductNameMatch) { const builtProductsDir = builtProductsDirMatch[1].trim(); const fullProductName = fullProductNameMatch[1].trim(); appBundlePath = `${builtProductsDir}/${fullProductName}`; } } if (!appBundlePath) { return createTextResponse( `Build succeeded, but could not find app path in build settings.`, true, ); } log('info', `App bundle path for run: ${appBundlePath}`); // --- Find/Boot Simulator Step --- // Use our helper to determine the simulator UUID const uuidResult = await determineSimulatorUuid( { simulatorUuid: params.simulatorId, simulatorName: params.simulatorName }, executor, ); if (uuidResult.error) { return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true); } if (uuidResult.warning) { log('warning', uuidResult.warning); } const simulatorUuid = uuidResult.uuid; if (!simulatorUuid) { return createTextResponse( 'Build succeeded, but no simulator specified and failed to find a suitable one.', true, ); } // Check simulator state and boot if needed try { log('info', `Checking simulator state for UUID: ${simulatorUuid}`); const simulatorListResult = await executor( ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 'List Simulators', ); if (!simulatorListResult.success) { throw new Error(simulatorListResult.error ?? 'Failed to list simulators'); } const simulatorsData = JSON.parse(simulatorListResult.output) as { devices: Record<string, unknown[]>; }; let targetSimulator: { udid: string; name: string; state: string } | null = null; // Find the target simulator for (const runtime in simulatorsData.devices) { const devices = simulatorsData.devices[runtime]; if (Array.isArray(devices)) { for (const device of devices) { if ( typeof device === 'object' && device !== null && 'udid' in device && 'name' in device && 'state' in device && typeof device.udid === 'string' && typeof device.name === 'string' && typeof device.state === 'string' && device.udid === simulatorUuid ) { targetSimulator = { udid: device.udid, name: device.name, state: device.state, }; break; } } if (targetSimulator) break; } } if (!targetSimulator) { return createTextResponse( `Build succeeded, but could not find simulator with UUID: ${simulatorUuid}`, true, ); } // Boot if needed if (targetSimulator.state !== 'Booted') { log('info', `Booting simulator ${targetSimulator.name}...`); const bootResult = await executor( ['xcrun', 'simctl', 'boot', simulatorUuid], 'Boot Simulator', ); if (!bootResult.success) { throw new Error(bootResult.error ?? 'Failed to boot simulator'); } } else { log('info', `Simulator ${simulatorUuid} is already booted`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error checking/booting simulator: ${errorMessage}`); return createTextResponse( `Build succeeded, but error checking/booting simulator: ${errorMessage}`, true, ); } // --- Open Simulator UI Step --- try { log('info', 'Opening Simulator app'); const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App'); if (!openResult.success) { throw new Error(openResult.error ?? 'Failed to open Simulator app'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('warning', `Warning: Could not open Simulator app: ${errorMessage}`); // Don't fail the whole operation for this } // --- Install App Step --- try { log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorUuid}`); const installResult = await executor( ['xcrun', 'simctl', 'install', simulatorUuid, appBundlePath], 'Install App', ); if (!installResult.success) { throw new Error(installResult.error ?? 'Failed to install app'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error installing app: ${errorMessage}`); return createTextResponse( `Build succeeded, but error installing app on simulator: ${errorMessage}`, true, ); } // --- Get Bundle ID Step --- let bundleId; try { log('info', `Extracting bundle ID from app: ${appBundlePath}`); // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults let bundleIdResult = null; // Method 1: PlistBuddy (most reliable) try { bundleIdResult = await executor( [ '/usr/libexec/PlistBuddy', '-c', 'Print :CFBundleIdentifier', `${appBundlePath}/Info.plist`, ], 'Get Bundle ID with PlistBuddy', ); if (bundleIdResult.success) { bundleId = bundleIdResult.output.trim(); } } catch { // Continue to next method } // Method 2: plutil (workspace approach) if (!bundleId) { try { bundleIdResult = await executor( ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`], 'Get Bundle ID with plutil', ); if (bundleIdResult?.success) { bundleId = bundleIdResult.output?.trim(); } } catch { // Continue to next method } } // Method 3: defaults (fallback) if (!bundleId) { try { bundleIdResult = await executor( ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'], 'Get Bundle ID with defaults', ); if (bundleIdResult?.success) { bundleId = bundleIdResult.output?.trim(); } } catch { // All methods failed } } if (!bundleId) { throw new Error('Could not extract bundle ID from Info.plist using any method'); } log('info', `Bundle ID for run: ${bundleId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error getting bundle ID: ${errorMessage}`); return createTextResponse( `Build and install succeeded, but error getting bundle ID: ${errorMessage}`, true, ); } // --- Launch App Step --- try { log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorUuid}`); const launchResult = await executor( ['xcrun', 'simctl', 'launch', simulatorUuid, bundleId], 'Launch App', ); if (!launchResult.success) { throw new Error(launchResult.error ?? 'Failed to launch app'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error launching app: ${errorMessage}`); return createTextResponse( `Build and install succeeded, but error launching app on simulator: ${errorMessage}`, true, ); } // --- Success --- log('info', '✅ iOS simulator build & run succeeded.'); const target = params.simulatorId ? `simulator UUID '${params.simulatorId}'` : `simulator name '${params.simulatorName}'`; const sourceType = params.projectPath ? 'project' : 'workspace'; const sourcePath = params.projectPath ?? params.workspacePath; return { content: [ { type: 'text', text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}. The app (${bundleId}) is now running in the iOS Simulator. If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open. Next Steps: - Option 1: Capture structured logs only (app continues running): start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' }) - Option 2: Capture both console and structured logs (app will restart): start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}', captureConsole: true }) - Option 3: Launch app with logs in one step (for a fresh start): launch_app_with_logs_in_simulator({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' }) When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, }, ], isError: false, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error in iOS Simulator build and run: ${errorMessage}`); return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true); } } const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, configuration: true, simulatorId: true, simulatorName: true, useLatestOS: true, } as const); export default { name: 'build_run_sim', description: 'Builds and runs an app on an iOS simulator.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<BuildRunSimulatorParams>({ internalSchema: buildRunSimulatorSchema as unknown as z.ZodType<BuildRunSimulatorParams>, logicFunction: build_run_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], exclusivePairs: [ ['projectPath', 'workspacePath'], ['simulatorId', 'simulatorName'], ], }), }; ``` -------------------------------------------------------------------------------- /docs/session_management_plan.md: -------------------------------------------------------------------------------- ```markdown # Stateful Session Defaults for MCP Tools — Design, Middleware, and Plan Below is a concise architecture and implementation plan to introduce a session-aware defaults layer that removes repeated tool parameters from public schemas, while keeping all tool logic and tests unchanged. ## Architecture Overview - **Core idea**: keep logic functions and tests untouched; move argument consolidation into a session-aware interop layer and expose minimal public schemas. - **Data flow**: - Client calls a tool with zero or few args → session middleware merges session defaults → validates with the internal schema → calls the existing logic function. - **Components**: - `SessionStore` (singleton, in-memory): set/get/clear/show defaults. - Session-aware tool factory: merges defaults, performs preflight requirement checks (allOf/oneOf), then validates with the tool's internal zod schema. - Public vs internal schema: plugins register a minimal "public" input schema; handlers validate with the unchanged "internal" schema. ## Core Types ```typescript // src/utils/session-store.ts export type SessionDefaults = { projectPath?: string; workspacePath?: string; scheme?: string; configuration?: string; simulatorName?: string; simulatorId?: string; deviceId?: string; useLatestOS?: boolean; arch?: 'arm64' | 'x86_64'; }; ``` ## Session Store (singleton) ```typescript // src/utils/session-store.ts import { log } from './logger.ts'; class SessionStore { private defaults: SessionDefaults = {}; setDefaults(partial: Partial<SessionDefaults>): void { this.defaults = { ...this.defaults, ...partial }; log('info', '[Session] Defaults set', { keys: Object.keys(partial) }); } clear(keys?: (keyof SessionDefaults)[]): void { if (!keys || keys.length === 0) { this.defaults = {}; log('info', '[Session] All defaults cleared'); return; } for (const k of keys) delete this.defaults[k]; log('info', '[Session] Defaults cleared', { keys }); } get<K extends keyof SessionDefaults>(key: K): SessionDefaults[K] { return this.defaults[key]; } getAll(): SessionDefaults { return { ...this.defaults }; } } export const sessionStore = new SessionStore(); ``` ## Session-Aware Tool Factory ```typescript // src/utils/typed-tool-factory.ts (add new helper, keep createTypedTool as-is) import { z } from 'zod'; import { sessionStore, type SessionDefaults } from './session-store.ts'; import type { CommandExecutor } from './execution/index.ts'; import { createErrorResponse } from './responses/index.ts'; import type { ToolResponse } from '../types/common.ts'; export type SessionRequirement = | { allOf: (keyof SessionDefaults)[]; message?: string } | { oneOf: (keyof SessionDefaults)[]; message?: string }; function missingFromArgsAndSession( keys: (keyof SessionDefaults)[], args: Record<string, unknown>, ): string[] { return keys.filter((k) => args[k] == null && sessionStore.get(k) == null); } export function createSessionAwareTool<TParams>(opts: { internalSchema: z.ZodType<TParams>; logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>; getExecutor: () => CommandExecutor; // Optional extras to improve UX and ergonomics sessionKeys?: (keyof SessionDefaults)[]; requirements?: SessionRequirement[]; // preflight, friendlier than raw zod errors }) { const { internalSchema, logicFunction, getExecutor, sessionKeys = [], requirements = [] } = opts; return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => { try { // Merge: explicit args take precedence over session defaults const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...rawArgs }; // Preflight requirement checks (clear message how to fix) for (const req of requirements) { if ('allOf' in req) { const missing = missingFromArgsAndSession(req.allOf, rawArgs); if (missing.length > 0) { return createErrorResponse( 'Missing required session defaults', `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` + `Set with: session-set-defaults { ${missing.map((k) => `"${k}": "..."`).join(', ')} }`, ); } } else if ('oneOf' in req) { const missing = missingFromArgsAndSession(req.oneOf, rawArgs); // oneOf satisfied if at least one is present in merged const satisfied = req.oneOf.some((k) => merged[k] != null); if (!satisfied) { return createErrorResponse( 'Missing required session defaults', `${req.message ?? `Provide one of: ${req.oneOf.join(', ')}`}\n` + `Set with: session-set-defaults { "${req.oneOf[0]}": "..." }`, ); } } } // Validate against unchanged internal schema (logic/api untouched) const validated = internalSchema.parse(merged); return await logicFunction(validated, getExecutor()); } catch (error) { if (error instanceof z.ZodError) { const msgs = error.errors.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`); return createErrorResponse( 'Parameter validation failed', `Invalid parameters:\n${msgs.join('\n')}\n` + `Tip: set session defaults via session-set-defaults`, ); } throw error; } }; } ``` ## Plugin Migration Pattern (Example: build_sim) Public schema hides session fields; handler uses session-aware factory with internal schema and requirements; logic function unchanged. ```typescript // src/mcp/tools/simulator/build_sim.ts (key parts only) import { z } from 'zod'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; // Existing internal schema (unchanged)… const baseOptions = { /* as-is (scheme, simulatorId, simulatorName, configuration, …) */ }; const baseSchemaObject = z.object({ projectPath: z.string().optional(), workspacePath: z.string().optional(), ...baseOptions, }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const buildSimulatorSchema = baseSchema .refine(/* as-is: projectPath XOR workspacePath */) .refine(/* as-is: simulatorId XOR simulatorName */); export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>; // Public schema = internal minus session-managed fields const sessionManaged = [ 'projectPath', 'workspacePath', 'scheme', 'configuration', 'simulatorId', 'simulatorName', 'useLatestOS', ] as const; const publicSchemaObject = baseSchemaObject.omit( Object.fromEntries(sessionManaged.map((k) => [k, true])) as Record<string, true>, ); export default { name: 'build_sim', description: 'Builds an app for an iOS simulator.', schema: publicSchemaObject.shape, // what the MCP client sees handler: createSessionAwareTool<BuildSimulatorParams>({ internalSchema: buildSimulatorSchema, logicFunction: build_simLogic, getExecutor: getDefaultCommandExecutor, sessionKeys: sessionManaged, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], }), }; ``` This same pattern applies to `build_run_sim`, `test_sim`, device/macos tools, etc. Public schemas become minimal, while internal schemas and logic remain unchanged. ## New Tool Group: session-management ### session_set_defaults.ts ```typescript // src/mcp/tools/session-management/session_set_defaults.ts import { z } from 'zod'; import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; const schemaObj = z.object({ projectPath: z.string().optional(), workspacePath: z.string().optional(), scheme: z.string().optional(), configuration: z.string().optional(), simulatorName: z.string().optional(), simulatorId: z.string().optional(), deviceId: z.string().optional(), useLatestOS: z.boolean().optional(), arch: z.enum(['arm64', 'x86_64']).optional(), }); type Params = z.infer<typeof schemaObj>; async function logic(params: Params): Promise<import('../../../types/common.ts').ToolResponse> { sessionStore.setDefaults(params as Partial<SessionDefaults>); const current = sessionStore.getAll(); return { content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }] }; } export default { name: 'session-set-defaults', description: 'Set session defaults used by other tools.', schema: schemaObj.shape, handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor), }; ``` ### session_clear_defaults.ts ```typescript // src/mcp/tools/session-management/session_clear_defaults.ts import { z } from 'zod'; import { sessionStore } from '../../../utils/session-store.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; const keys = [ 'projectPath','workspacePath','scheme','configuration', 'simulatorName','simulatorId','deviceId','useLatestOS','arch', ] as const; const schemaObj = z.object({ keys: z.array(z.enum(keys)).optional(), all: z.boolean().optional(), }); async function logic(params: z.infer<typeof schemaObj>) { if (params.all || !params.keys) sessionStore.clear(); else sessionStore.clear(params.keys); return { content: [{ type: 'text', text: 'Session defaults cleared' }] }; } export default { name: 'session-clear-defaults', description: 'Clear selected or all session defaults.', schema: schemaObj.shape, handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor), }; ``` ### session_show_defaults.ts ```typescript // src/mcp/tools/session-management/session_show_defaults.ts import { sessionStore } from '../../../utils/session-store.ts'; export default { name: 'session-show-defaults', description: 'Show current session defaults.', schema: {}, // no args handler: async () => { const current = sessionStore.getAll(); return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }] }; }, }; ``` ## Step-by-Step Implementation Plan (Incremental, buildable at each step) 1. **Add SessionStore** ✅ **DONE** - New file: `src/utils/session-store.ts`. - No existing code changes; run: `npm run build`, `lint`, `test`. - Commit checkpoint (after review): see Commit & Review Protocol below. 2. **Add session-management tools** ✅ **DONE** - New folder: `src/mcp/tools/session-management` with the three tools above. - Register via existing plugin discovery (same pattern as others). - Build and test. - Commit checkpoint (after review). 3. **Add session-aware tool factory** ✅ **DONE** - Add `createSessionAwareTool` to `src/utils/typed-tool-factory.ts` (keep `createTypedTool` intact). - Unit tests for requirement preflight and merge precedence. - Commit checkpoint (after review). 4. **Migrate 2-3 representative tools** - Example: `simulator/build_sim`, `macos/build_macos`, `device/build_device`. - Create `publicSchemaObject` (omit session fields), switch handler to `createSessionAwareTool` with requirements. - Keep internal schema and logic unchanged. Build and test. - Commit checkpoint (after review). 5. **Migrate remaining tools in small batches** - Apply the same pattern across simulator/device/macos/test utilities. - After each batch: `npm run typecheck`, `lint`, `test`. - Commit checkpoint (after review). 6. **Final polish** - Add tests for session tools and session-aware preflight error messages. - Ensure public schemas no longer expose session parameters globally. - Commit checkpoint (after review). ## Standard Testing & DI Checklist (Mandatory) - Handlers must use dependency injection; tests must never call real executors. - For validation-only tests, calling the handler is acceptable because Zod validation occurs before executor acquisition. - For logic tests that would otherwise trigger `getDefaultCommandExecutor`, export the logic function and test it directly (no executor needed if logic doesn’t use one): ```ts // Example: src/mcp/tools/session-management/session_clear_defaults.ts export async function sessionClearDefaultsLogic(params: Params): Promise<ToolResponse> { /* ... */ } export default { name: 'session-clear-defaults', handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor), }; // Test: import logic and call directly to avoid real executor import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; ``` - Add tests for the new group and tools: - Group metadata test: `src/mcp/tools/session-management/__tests__/index.test.ts` - Tool tests: `session_set_defaults.test.ts`, `session_clear_defaults.test.ts`, `session_show_defaults.test.ts` - Utils tests: `src/utils/__tests__/session-store.test.ts` - Factory tests: `src/utils/__tests__/session-aware-tool-factory.test.ts` covering: - Preflight requirements (allOf/oneOf) - Merge precedence (explicit args override session defaults) - Zod error reporting with helpful tips - Always run locally before requesting review: - `npm run typecheck` - `npm run lint` - `npm run format:check` - `npm run build` - `npm run test` - Perform a quick manual CLI check (mcpli or reloaderoo) per the Manual Testing section ### Minimal Changes Policy for Tests (Enforced) - Only make material, essential edits to tests required by the code change (e.g., new preflight error messages or added/removed fields). - Do not change sample input values or defaults in tests (e.g., flipping a boolean like `preferXcodebuild`) unless strictly necessary to validate behavior. - Preserve the original intent and coverage of logic-function tests; keep handler vs logic boundaries intact. - When session-awareness is added, prefer setting/clearing session defaults around tests rather than altering existing assertions or sample inputs. ### Tool Description Policy (Enforced) - Keep tool descriptions concise (maximum one short sentence). - Do not mention session defaults, setup steps, examples, or parameter relationships in descriptions. - Use clear, imperative phrasing (e.g., "Builds an app for an iOS simulator."). - Apply consistently across all migrated tools; update any tests that assert `description` to match the concise string only. ## Commit & Review Protocol (Enforced) At the end of each numbered step above: 1. Ensure all checks pass: `typecheck`, `lint`, `format:check`, `build`, `test`; then perform a quick manual CLI test (mcpli or reloaderoo) per the Manual Testing section. - Verify tool descriptions comply with the Tool Description Policy (concise, no session-defaults mention). 2. Stage only the files for that step. 3. Prepare a concise commit message focused on the “why”. 4. Request manual review and approval before committing. Do not push. Example messages per step: - Step 1 (SessionStore) - `chore(utils): add in-memory SessionStore for session defaults` - Body: “Introduces singleton SessionStore with set/get/clear/show for session defaults; no behavior changes.” - Step 2 (session-management tools) - `feat(session-management): add set/clear/show session defaults tools and workflow metadata` - Body: “Adds tools to manage session defaults and exposes workflow metadata; minimal schemas via typed factory.” - Step 3 (middleware) - `feat(utils): add createSessionAwareTool with preflight requirements and args>session merge` - Body: “Session-aware interop layer performing requirements checks and Zod validation against internal schema.” - Step 6 (tests/final polish) - `test(session-management): add tool, store, and middleware tests; export logic for DI` - Body: “Covers group metadata, tools, SessionStore, and factory (requirements/merge/errors). No production behavior changes.” Approval flow: - After preparing messages and confirming checks, request maintainer approval. - On approval: commit locally (no push). - On rejection: revise and re-run checks. Note on commit hooks and selective commits: - The pre-commit hook runs format/lint/build and can auto-add or modify files, causing additional files to be included in the commit. If you must commit a minimal subset, skip hooks with: `git commit --no-verify` (use sparingly and run `npm run typecheck && npm run lint && npm run test` manually first). ## Safety, Buildability, Testability - Logic functions and their types remain unchanged; existing unit tests that import logic directly continue to pass. - Public schemas shrink; MCP clients see smaller input schemas without session fields. - Handlers validate with internal schemas after session-defaults merge, preserving runtime guarantees. - Preflight requirement checks return clear guidance, e.g., "Provide one of: projectPath or workspacePath" + "Set with: session-set-defaults { "projectPath": "..." }". ## Developer Usage - **Set defaults once**: - `session-set-defaults { "workspacePath": "...", "scheme": "App", "simulatorName": "iPhone 16" }` - **Run tools without args**: - `build_sim {}` - **Inspect/reset**: - `session-show-defaults {}` - `session-clear-defaults { "all": true }` ## Manual Testing with mcpli (CLI) The following commands exercise the session workflow end‑to‑end using the built server. 1) Build the server (required after code changes): ```bash npm run build ``` 2) Discover a scheme (optional helper): ```bash mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js ``` 3) Set the session defaults (project/workspace, scheme, and simulator): ```bash mcpli --raw session-set-defaults \ --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \ --scheme MCPTest \ --simulatorName "iPhone 16" \ -- node build/index.js ``` 4) Verify defaults are stored: ```bash mcpli --raw session-show-defaults -- node build/index.js ``` 5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically): ```bash # Optionally provide a scratch derived data path and a short timeout mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js ``` Troubleshooting: - If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys. - If you see connect ECONNREFUSED or the daemon appears flaky: - Check logs: `mcpli daemon log --since=10m -- node build/index.js` - Restart daemon: `mcpli daemon restart -- node build/index.js` - Clean daemon state: `mcpli daemon clean -- node build/index.js` then `mcpli daemon start -- node build/index.js` - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js` Notes: - Public schemas for session‑aware tools intentionally omit session fields (e.g., `scheme`, `projectPath`, `simulatorName`). Provide them once via `session-set-defaults` and then call the tool with zero/minimal flags. - Use `--tool-timeout=<seconds>` to cap long‑running builds during manual testing. - mcpli CLI normalizes tool names: tools exported with underscores (e.g., `build_sim`) can be invoked with hyphens (e.g., `build-sim`). Copy/paste samples using hyphens are valid because mcpli converts underscores to dashes. ## Next Steps Would you like me to proceed with Phase 1–3 implementation (store + session tools + middleware), then migrate a first tool (build_sim) and run the test suite? ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/build_sim.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; // Import the plugin and logic function import buildSim, { build_simLogic } from '../build_sim.ts'; describe('build_sim tool', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(buildSim.name).toBe('build_sim'); }); it('should have correct description', () => { expect(buildSim.description).toBe('Builds an app for an iOS simulator.'); }); it('should have handler function', () => { expect(typeof buildSim.handler).toBe('function'); }); it('should have correct public schema (only non-session fields)', () => { const schema = z.object(buildSim.schema); // Public schema should allow empty input expect(schema.safeParse({}).success).toBe(true); // Valid public inputs expect( schema.safeParse({ derivedDataPath: '/path/to/derived', extraArgs: ['--verbose'], preferXcodebuild: false, }).success, ).toBe(true); // Invalid types on public inputs expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); }); }); describe('Parameter Validation', () => { it('should handle missing both projectPath and workspacePath', async () => { const result = await buildSim.handler({ scheme: 'MyScheme', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should handle both projectPath and workspacePath provided', async () => { const result = await buildSim.handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); expect(result.content[0].text).toContain('projectPath'); expect(result.content[0].text).toContain('workspacePath'); }); it('should handle empty workspacePath parameter', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); const result = await build_simLogic( { workspacePath: '', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); // Empty string passes validation but may cause build issues expect(result.content).toEqual([ { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]); }); it('should handle missing scheme parameter', async () => { const result = await buildSim.handler({ workspacePath: '/path/to/workspace', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('scheme is required'); }); it('should handle empty scheme parameter', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: '', simulatorName: 'iPhone 16', }, mockExecutor, ); // Empty string passes validation but may cause build issues expect(result.content).toEqual([ { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme .', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]); }); it('should handle missing both simulatorId and simulatorName', async () => { const result = await buildSim.handler({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); }); it('should handle both simulatorId and simulatorName provided', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' }); // Should fail with XOR validation const result = await buildSim.handler({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorId: 'ABC-123', simulatorName: 'iPhone 16', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); expect(result.content[0].text).toContain('simulatorId'); expect(result.content[0].text).toContain('simulatorName'); }); it('should handle empty simulatorName parameter', async () => { const mockExecutor = createMockExecutor({ success: false, output: '', error: 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: '', }, mockExecutor, ); // Empty simulatorName passes validation but causes early failure in destination construction expect(result.isError).toBe(true); expect(result.content[0].text).toBe( 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', ); }); }); describe('Command Generation', () => { it('should generate correct build command with minimal parameters (workspace)', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, trackingExecutor, ); // Should generate one build command expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command with minimal parameters (project)', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_simLogic( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, trackingExecutor, ); // Should generate one build command expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-project', '/path/to/MyProject.xcodeproj', '-scheme', 'MyScheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command with all optional parameters', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', configuration: 'Release', derivedDataPath: '/custom/derived/path', extraArgs: ['--verbose'], useLatestOS: false, }, trackingExecutor, ); // Should generate one build command with all parameters expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Release', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16', '-derivedDataPath', '/custom/derived/path', '--verbose', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); it('should handle paths with spaces in command generation', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_simLogic( { workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', scheme: 'My Scheme', simulatorName: 'iPhone 16 Pro', }, trackingExecutor, ); // Should generate one build command with paths containing spaces expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/Users/dev/My Project/MyProject.xcworkspace', '-scheme', 'My Scheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command with useLatestOS set to true', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: any; }> = []; // Create tracking executor const trackingExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return { success: false, output: '', error: 'Test error to stop execution early', process: { pid: 12345 }, }; }; const result = await build_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', useLatestOS: true, }, trackingExecutor, ); // Should generate one build command with OS=latest expect(callHistory).toHaveLength(1); expect(callHistory[0].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); }); describe('Response Processing', () => { it('should handle successful build', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.content).toEqual([ { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]); }); it('should handle successful build with all optional parameters', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', configuration: 'Release', derivedDataPath: '/path/to/derived', extraArgs: ['--verbose'], useLatestOS: false, preferXcodebuild: true, }, mockExecutor, ); expect(result.content).toEqual([ { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]); }); it('should handle build failure', async () => { const mockExecutor = createMockExecutor({ success: false, output: '', error: 'Build failed: Compilation error', }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '❌ [stderr] Build failed: Compilation error', }, { type: 'text', text: '❌ iOS Simulator Build build failed for scheme MyScheme.', }, ], isError: true, }); }); it('should handle build warnings', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'warning: deprecated method used\nBUILD SUCCEEDED', }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.content).toEqual( expect.arrayContaining([ { type: 'text', text: expect.stringContaining('⚠️'), }, { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]), ); }); it('should handle command executor errors', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'spawn xcodebuild ENOENT', }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toBe('❌ [stderr] spawn xcodebuild ENOENT'); }); it('should handle mixed warning and error output', async () => { const mockExecutor = createMockExecutor({ success: false, output: 'warning: deprecated method\nerror: undefined symbol', error: 'Build failed', }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); expect(result.isError).toBe(true); expect(result.content).toEqual([ { type: 'text', text: '⚠️ Warning: warning: deprecated method', }, { type: 'text', text: '❌ Error: error: undefined symbol', }, { type: 'text', text: '❌ [stderr] Build failed', }, { type: 'text', text: '❌ iOS Simulator Build build failed for scheme MyScheme.', }, ]); }); it('should use default configuration when not provided', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', // configuration intentionally omitted - should default to Debug }, mockExecutor, ); expect(result.content).toEqual([ { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]); }); }); describe('Error Handling', () => { it('should handle catch block exceptions', async () => { // Create a mock that throws an error when called const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); // Mock the handler to throw an error by passing invalid parameters to internal functions const result = await build_simLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, mockExecutor, ); // Should handle the build successfully expect(result.content).toEqual([ { type: 'text', text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', }, { type: 'text', text: expect.stringContaining('Next Steps:'), }, ]); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/logging/start_device_log_cap.ts: -------------------------------------------------------------------------------- ```typescript /** * Logging Plugin: Start Device Log Capture * * Starts capturing logs from a specified Apple device by launching the app with console output. */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { ChildProcess } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; /** * Log file retention policy for device logs: * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory * - Cleanup runs on every new log capture start */ const LOG_RETENTION_DAYS = 3; const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; // Note: Device and simulator logging use different approaches due to platform constraints: // - Simulators use 'xcrun simctl' with console-pty and OSLog stream capabilities // - Devices use 'xcrun devicectl' with console output only (no OSLog streaming) // The different command structures and output formats make sharing infrastructure complex. // However, both follow similar patterns for session management and log retention. export interface DeviceLogSession { process: ChildProcess; logFilePath: string; deviceUuid: string; bundleId: string; logStream?: fs.WriteStream; hasEnded: boolean; } export const activeDeviceLogSessions = new Map<string, DeviceLogSession>(); const EARLY_FAILURE_WINDOW_MS = 5000; const INITIAL_OUTPUT_LIMIT = 8_192; const DEFAULT_JSON_RESULT_WAIT_MS = 8000; const FAILURE_PATTERNS = [ /The application failed to launch/i, /Provide a valid bundle identifier/i, /The requested application .* is not installed/i, /NSOSStatusErrorDomain/i, /NSLocalizedFailureReason/i, /ERROR:/i, ]; type JsonOutcome = { errorMessage?: string; pid?: number; }; type DevicectlLaunchJson = { result?: { process?: { processIdentifier?: unknown; }; }; error?: { code?: unknown; domain?: unknown; localizedDescription?: unknown; userInfo?: Record<string, unknown> | undefined; }; }; function getJsonResultWaitMs(): number { const raw = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; if (raw === undefined) { return DEFAULT_JSON_RESULT_WAIT_MS; } const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 0) { return DEFAULT_JSON_RESULT_WAIT_MS; } return parsed; } function safeParseJson(text: string): DevicectlLaunchJson | null { try { const parsed = JSON.parse(text) as unknown; if (!parsed || typeof parsed !== 'object') { return null; } return parsed as DevicectlLaunchJson; } catch { return null; } } function extractJsonOutcome(json: DevicectlLaunchJson | null): JsonOutcome | null { if (!json) { return null; } const resultProcess = json.result?.process; const pidValue = resultProcess?.processIdentifier; if (typeof pidValue === 'number' && Number.isFinite(pidValue)) { return { pid: pidValue }; } const error = json.error; if (!error) { return null; } const parts: string[] = []; if (typeof error.localizedDescription === 'string' && error.localizedDescription.length > 0) { parts.push(error.localizedDescription); } const userInfo = error.userInfo ?? {}; const recovery = userInfo?.NSLocalizedRecoverySuggestion; const failureReason = userInfo?.NSLocalizedFailureReason; const bundleIdentifier = userInfo?.BundleIdentifier; if (typeof failureReason === 'string' && failureReason.length > 0) { parts.push(failureReason); } if (typeof recovery === 'string' && recovery.length > 0) { parts.push(recovery); } if (typeof bundleIdentifier === 'string' && bundleIdentifier.length > 0) { parts.push(`BundleIdentifier = ${bundleIdentifier}`); } const domain = error.domain; const code = error.code; const domainPart = typeof domain === 'string' && domain.length > 0 ? domain : undefined; const codePart = typeof code === 'number' && Number.isFinite(code) ? code : undefined; if (domainPart || codePart !== undefined) { parts.push(`(${domainPart ?? 'UnknownDomain'} code ${codePart ?? 'unknown'})`); } if (parts.length === 0) { return { errorMessage: 'Launch failed' }; } return { errorMessage: parts.join('\n') }; } async function removeFileIfExists( targetPath: string, fileExecutor?: FileSystemExecutor, ): Promise<void> { try { if (fileExecutor) { if (fileExecutor.existsSync(targetPath)) { await fileExecutor.rm(targetPath, { force: true }); } return; } if (fs.existsSync(targetPath)) { await fs.promises.rm(targetPath, { force: true }); } } catch { // Best-effort cleanup only } } async function pollJsonOutcome( jsonPath: string, fileExecutor: FileSystemExecutor | undefined, timeoutMs: number, ): Promise<JsonOutcome | null> { const start = Date.now(); const readOnce = async (): Promise<JsonOutcome | null> => { try { const exists = fileExecutor?.existsSync(jsonPath) ?? fs.existsSync(jsonPath); if (!exists) { return null; } const content = fileExecutor ? await fileExecutor.readFile(jsonPath, 'utf8') : await fs.promises.readFile(jsonPath, 'utf8'); const outcome = extractJsonOutcome(safeParseJson(content)); if (outcome) { await removeFileIfExists(jsonPath, fileExecutor); return outcome; } } catch { // File may still be written; try again later } return null; }; const immediate = await readOnce(); if (immediate) { return immediate; } if (timeoutMs <= 0) { return null; } let delay = Math.min(100, Math.max(10, Math.floor(timeoutMs / 4) || 10)); while (Date.now() - start < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, delay)); const result = await readOnce(); if (result) { return result; } delay = Math.min(400, delay + 50); } return null; } type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean }; /** * Start a log capture session for an iOS device by launching the app with console output. * Uses the devicectl command to launch the app and capture console logs. * Returns { sessionId, error? } */ export async function startDeviceLogCapture( params: { deviceUuid: string; bundleId: string; }, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystemExecutor?: FileSystemExecutor, ): Promise<{ sessionId: string; error?: string }> { // Clean up old logs before starting a new session await cleanOldDeviceLogs(); const { deviceUuid, bundleId } = params; const logSessionId = uuidv4(); const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`; const tempDir = fileSystemExecutor ? fileSystemExecutor.tmpdir() : os.tmpdir(); const logFilePath = path.join(tempDir, logFileName); const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`); let logStream: fs.WriteStream | undefined; try { // Use injected file system executor or default if (fileSystemExecutor) { await fileSystemExecutor.mkdir(tempDir, { recursive: true }); await fileSystemExecutor.writeFile(logFilePath, ''); } else { await fs.promises.mkdir(tempDir, { recursive: true }); await fs.promises.writeFile(logFilePath, ''); } logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); logStream.write( `\n--- Device log capture for bundle ID: ${bundleId} on device: ${deviceUuid} ---\n`, ); // Use executor with dependency injection instead of spawn directly const result = await executor( [ 'xcrun', 'devicectl', 'device', 'process', 'launch', '--console', '--terminate-existing', '--device', deviceUuid, '--json-output', launchJsonPath, bundleId, ], 'Device Log Capture', true, undefined, true, ); if (!result.success) { log( 'error', `Device log capture process reported failure: ${result.error ?? 'unknown error'}`, ); if (logStream && !logStream.destroyed) { logStream.write( `\n--- Device log capture failed to start ---\n${result.error ?? 'Unknown error'}\n`, ); logStream.end(); } return { sessionId: '', error: result.error ?? 'Failed to start device log capture', }; } const childProcess = result.process; if (!childProcess) { throw new Error('Device log capture process handle was not returned'); } const session: DeviceLogSession = { process: childProcess, logFilePath, deviceUuid, bundleId, logStream, hasEnded: false, }; let bufferedOutput = ''; const appendBufferedOutput = (text: string): void => { bufferedOutput += text; if (bufferedOutput.length > INITIAL_OUTPUT_LIMIT) { bufferedOutput = bufferedOutput.slice(bufferedOutput.length - INITIAL_OUTPUT_LIMIT); } }; let triggerImmediateFailure: ((message: string) => void) | undefined; const handleOutput = (chunk: unknown): void => { if (!logStream || logStream.destroyed) return; const text = typeof chunk === 'string' ? chunk : chunk instanceof Buffer ? chunk.toString('utf8') : String(chunk ?? ''); if (text.length > 0) { appendBufferedOutput(text); const extracted = extractFailureMessage(bufferedOutput); if (extracted) { triggerImmediateFailure?.(extracted); } logStream.write(text); } }; childProcess.stdout?.setEncoding?.('utf8'); childProcess.stdout?.on?.('data', handleOutput); childProcess.stderr?.setEncoding?.('utf8'); childProcess.stderr?.on?.('data', handleOutput); const cleanupStreams = (): void => { childProcess.stdout?.off?.('data', handleOutput); childProcess.stderr?.off?.('data', handleOutput); }; const earlyFailure = await detectEarlyLaunchFailure( childProcess, EARLY_FAILURE_WINDOW_MS, () => bufferedOutput, (handler) => { triggerImmediateFailure = handler; }, ); if (earlyFailure) { cleanupStreams(); session.hasEnded = true; const failureMessage = earlyFailure.errorMessage && earlyFailure.errorMessage.length > 0 ? earlyFailure.errorMessage : `Device log capture process exited immediately (exit code: ${ earlyFailure.exitCode ?? 'unknown' })`; log('error', `Device log capture failed to start: ${failureMessage}`); if (logStream && !logStream.destroyed) { try { logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); } catch { // best-effort logging } logStream.end(); } await removeFileIfExists(launchJsonPath, fileSystemExecutor); childProcess.kill?.('SIGTERM'); return { sessionId: '', error: failureMessage }; } const jsonOutcome = await pollJsonOutcome( launchJsonPath, fileSystemExecutor, getJsonResultWaitMs(), ); if (jsonOutcome?.errorMessage) { cleanupStreams(); session.hasEnded = true; const failureMessage = jsonOutcome.errorMessage; log('error', `Device log capture failed to start (JSON): ${failureMessage}`); if (logStream && !logStream.destroyed) { try { logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); } catch { // ignore secondary logging failures } logStream.end(); } childProcess.kill?.('SIGTERM'); return { sessionId: '', error: failureMessage }; } if (jsonOutcome?.pid && logStream && !logStream.destroyed) { try { logStream.write(`Process ID: ${jsonOutcome.pid}\n`); } catch { // best-effort logging only } } childProcess.once?.('error', (err) => { log( 'error', `Device log capture process error (session ${logSessionId}): ${ err instanceof Error ? err.message : String(err) }`, ); }); childProcess.once?.('close', (code) => { cleanupStreams(); session.hasEnded = true; if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { logStream.write(`\n--- Device log capture ended (exit code: ${code ?? 'unknown'}) ---\n`); logStream.end(); } void removeFileIfExists(launchJsonPath, fileSystemExecutor); }); // For testing purposes, we'll simulate process management // In actual usage, the process would be managed by the executor result activeDeviceLogSessions.set(logSessionId, session); log('info', `Device log capture started with session ID: ${logSessionId}`); return { sessionId: logSessionId }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log('error', `Failed to start device log capture: ${message}`); if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { try { logStream.write(`\n--- Device log capture failed: ${message} ---\n`); } catch { // ignore secondary stream write failures } logStream.end(); } await removeFileIfExists(launchJsonPath, fileSystemExecutor); return { sessionId: '', error: message }; } } type EarlyFailureResult = { exitCode: number | null; errorMessage?: string; }; function detectEarlyLaunchFailure( process: ChildProcess, timeoutMs: number, getBufferedOutput?: () => string, registerImmediateFailure?: (handler: (message: string) => void) => void, ): Promise<EarlyFailureResult | null> { if (process.exitCode != null) { if (process.exitCode === 0) { const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); return Promise.resolve( failureFromOutput ? { exitCode: process.exitCode, errorMessage: failureFromOutput } : null, ); } const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); return Promise.resolve({ exitCode: process.exitCode, errorMessage: failureFromOutput }); } return new Promise<EarlyFailureResult | null>((resolve) => { let settled = false; const finalize = (result: EarlyFailureResult | null): void => { if (settled) return; settled = true; process.removeListener('close', onClose); process.removeListener('error', onError); clearTimeout(timer); resolve(result); }; registerImmediateFailure?.((message) => { finalize({ exitCode: process.exitCode ?? null, errorMessage: message }); }); const onClose = (code: number | null): void => { const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); if (code === 0 && failureFromOutput) { finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); return; } if (code === 0) { finalize(null); } else { finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); } }; const onError = (error: Error): void => { finalize({ exitCode: null, errorMessage: error.message }); }; const timer = setTimeout(() => { const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); if (failureFromOutput) { process.kill?.('SIGTERM'); finalize({ exitCode: process.exitCode ?? null, errorMessage: failureFromOutput }); return; } finalize(null); }, timeoutMs); process.once('close', onClose); process.once('error', onError); }); } function extractFailureMessage(output?: string): string | undefined { if (!output) { return undefined; } const normalized = output.replace(/\r/g, ''); const lines = normalized .split('\n') .map((line) => line.trim()) .filter(Boolean); const shouldInclude = (line?: string): boolean => { if (!line) return false; return ( line.startsWith('NS') || line.startsWith('BundleIdentifier') || line.startsWith('Provide ') || line.startsWith('The application') || line.startsWith('ERROR:') ); }; for (const pattern of FAILURE_PATTERNS) { const matchIndex = lines.findIndex((line) => pattern.test(line)); if (matchIndex === -1) { continue; } const snippet: string[] = [lines[matchIndex]]; const nextLine = lines[matchIndex + 1]; const thirdLine = lines[matchIndex + 2]; if (shouldInclude(nextLine)) snippet.push(nextLine); if (shouldInclude(thirdLine)) snippet.push(thirdLine); const message = snippet.join('\n').trim(); if (message.length > 0) { return message; } return lines[matchIndex]; } return undefined; } /** * Deletes device log files older than LOG_RETENTION_DAYS from the temp directory. * Runs quietly; errors are logged but do not throw. */ // Device logs follow the same retention policy as simulator logs but use a different prefix // to avoid conflicts. Both clean up logs older than LOG_RETENTION_DAYS automatically. async function cleanOldDeviceLogs(): Promise<void> { const tempDir = os.tmpdir(); let files; try { files = await fs.promises.readdir(tempDir); } catch (err) { log( 'warn', `Could not read temp dir for device log cleanup: ${err instanceof Error ? err.message : String(err)}`, ); return; } const now = Date.now(); const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; await Promise.all( files .filter((f) => f.startsWith(DEVICE_LOG_FILE_PREFIX) && f.endsWith('.log')) .map(async (f) => { const filePath = path.join(tempDir, f); try { const stat = await fs.promises.stat(filePath); if (now - stat.mtimeMs > retentionMs) { await fs.promises.unlink(filePath); log('info', `Deleted old device log file: ${filePath}`); } } catch (err) { log( 'warn', `Error during device log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`, ); } }), ); } // Define schema as ZodObject const startDeviceLogCapSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), bundleId: z.string().describe('Bundle identifier of the app to launch and capture logs for.'), }); // Use z.infer for type safety type StartDeviceLogCapParams = z.infer<typeof startDeviceLogCapSchema>; /** * Core business logic for starting device log capture. */ export async function start_device_log_capLogic( params: StartDeviceLogCapParams, executor: CommandExecutor, fileSystemExecutor?: FileSystemExecutor, ): Promise<ToolResponse> { const { deviceId, bundleId } = params; const { sessionId, error } = await startDeviceLogCapture( { deviceUuid: deviceId, bundleId: bundleId, }, executor, fileSystemExecutor, ); if (error) { return { content: [ { type: 'text', text: `Failed to start device log capture: ${error}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nNext Steps:\n1. Interact with your app on the device\n2. Use stop_device_log_cap({ logSessionId: '${sessionId}' }) to stop capture and retrieve logs`, }, ], }; } export default { name: 'start_device_log_cap', description: 'Starts log capture on a connected device.', schema: startDeviceLogCapSchema.omit({ deviceId: true } as const).shape, handler: createSessionAwareTool<StartDeviceLogCapParams>({ internalSchema: startDeviceLogCapSchema as unknown as z.ZodType<StartDeviceLogCapParams>, logicFunction: start_device_log_capLogic, getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], }), }; ``` -------------------------------------------------------------------------------- /scripts/tools-cli.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * XcodeBuildMCP Tools CLI * * A unified command-line tool that provides comprehensive information about * XcodeBuildMCP tools and resources. Supports both runtime inspection * (actual server state) and static analysis (source file analysis). * * Usage: * npm run tools [command] [options] * npx tsx src/cli/tools-cli.ts [command] [options] * * Commands: * count, c Show tool and workflow counts * list, l List all tools and resources * static, s Show static source file analysis * help, h Show this help message * * Options: * --runtime, -r Use runtime inspection (respects env config) * --static, -s Use static file analysis (development mode) * --tools, -t Include tools in output * --resources Include resources in output * --workflows, -w Include workflow information * --verbose, -v Show detailed information * --json Output JSON format * --help Show help for specific command * * Examples: * npm run tools # Runtime summary with workflows * npm run tools:count # Runtime tool count * npm run tools:static # Static file analysis * npm run tools:list # List runtime tools * npx tsx src/cli/tools-cli.ts --json # JSON output */ import { spawn } from 'child_process'; import * as path from 'path'; import { fileURLToPath } from 'url'; import * as fs from 'fs'; import { getStaticToolAnalysis, type StaticAnalysisResult } from './analysis/tools-analysis.js'; // Get project paths const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ANSI color codes const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', magenta: '\x1b[35m', } as const; // Types interface CLIOptions { runtime: boolean; static: boolean; tools: boolean; resources: boolean; workflows: boolean; verbose: boolean; json: boolean; help: boolean; } interface RuntimeTool { name: string; description: string; } interface RuntimeResource { uri: string; name: string; description: string; } interface RuntimeData { tools: RuntimeTool[]; resources: RuntimeResource[]; toolCount: number; resourceCount: number; dynamicMode: boolean; mode: 'runtime'; } // CLI argument parsing const args = process.argv.slice(2); // Find the command (first non-flag argument) let command = 'count'; // default for (const arg of args) { if (!arg.startsWith('-')) { command = arg; break; } } const options: CLIOptions = { runtime: args.includes('--runtime') || args.includes('-r'), static: args.includes('--static') || args.includes('-s'), tools: args.includes('--tools') || args.includes('-t'), resources: args.includes('--resources'), workflows: args.includes('--workflows') || args.includes('-w'), verbose: args.includes('--verbose') || args.includes('-v'), json: args.includes('--json'), help: args.includes('--help') || args.includes('-h'), }; // Set sensible defaults for each command if (!options.runtime && !options.static) { if (command === 'static' || command === 's') { options.static = true; } else { // Default to static analysis for development-friendly usage options.static = true; } } // Set sensible content defaults if (command === 'list' || command === 'l') { if (!options.tools && !options.resources && !options.workflows) { options.tools = true; // Default to showing tools for list command } } else if (!command || command === 'count' || command === 'c') { // For no command or count, show comprehensive summary if (!options.tools && !options.resources && !options.workflows) { options.workflows = true; // Show workflows by default for summary } } // Help text const helpText = { main: ` ${colors.bright}${colors.blue}XcodeBuildMCP Tools CLI${colors.reset} A unified command-line tool for XcodeBuildMCP tool and resource information. ${colors.bright}COMMANDS:${colors.reset} count, c Show tool and workflow counts list, l List all tools and resources static, s Show static source file analysis help, h Show this help message ${colors.bright}OPTIONS:${colors.reset} --runtime, -r Use runtime inspection (respects env config) --static, -s Use static file analysis (default, development mode) --tools, -t Include tools in output --resources Include resources in output --workflows, -w Include workflow information --verbose, -v Show detailed information --json Output JSON format ${colors.bright}EXAMPLES:${colors.reset} ${colors.cyan}npm run tools${colors.reset} # Static summary with workflows (default) ${colors.cyan}npm run tools list${colors.reset} # List tools ${colors.cyan}npm run tools --runtime${colors.reset} # Runtime analysis (requires build) ${colors.cyan}npm run tools static${colors.reset} # Static analysis summary ${colors.cyan}npm run tools count --json${colors.reset} # JSON output ${colors.bright}ANALYSIS MODES:${colors.reset} ${colors.green}Runtime${colors.reset} Uses actual server inspection via Reloaderoo - Respects XCODEBUILDMCP_DYNAMIC_TOOLS environment variable - Shows tools actually enabled at runtime - Requires built server (npm run build) ${colors.yellow}Static${colors.reset} Scans source files directly using AST parsing - Shows all tools in codebase regardless of config - Development-time analysis with reliable description extraction - No server build required `, count: ` ${colors.bright}COUNT COMMAND${colors.reset} Shows tool and workflow counts using runtime or static analysis. ${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts count [options] ${colors.bright}Options:${colors.reset} --runtime, -r Count tools from running server --static, -s Count tools from source files --workflows, -w Include workflow directory counts --json Output JSON format ${colors.bright}Examples:${colors.reset} ${colors.cyan}npx tsx scripts/tools-cli.ts count${colors.reset} # Runtime count ${colors.cyan}npx tsx scripts/tools-cli.ts count --static${colors.reset} # Static count ${colors.cyan}npx tsx scripts/tools-cli.ts count --workflows${colors.reset} # Include workflows `, list: ` ${colors.bright}LIST COMMAND${colors.reset} Lists tools and resources with optional details. ${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts list [options] ${colors.bright}Options:${colors.reset} --runtime, -r List from running server --static, -s List from source files --tools, -t Show tool names --resources Show resource URIs --verbose, -v Show detailed information --json Output JSON format ${colors.bright}Examples:${colors.reset} ${colors.cyan}npx tsx scripts/tools-cli.ts list --tools${colors.reset} # Runtime tool list ${colors.cyan}npx tsx scripts/tools-cli.ts list --resources${colors.reset} # Runtime resource list ${colors.cyan}npx tsx scripts/tools-cli.ts list --static --verbose${colors.reset} # Static detailed list `, static: ` ${colors.bright}STATIC COMMAND${colors.reset} Performs detailed static analysis of source files using AST parsing. ${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts static [options] ${colors.bright}Options:${colors.reset} --tools, -t Show canonical tool details --workflows, -w Show workflow directory analysis --verbose, -v Show detailed file information --json Output JSON format ${colors.bright}Examples:${colors.reset} ${colors.cyan}npx tsx scripts/tools-cli.ts static${colors.reset} # Basic static analysis ${colors.cyan}npx tsx scripts/tools-cli.ts static --verbose${colors.reset} # Detailed analysis ${colors.cyan}npx tsx scripts/tools-cli.ts static --workflows${colors.reset} # Include workflow info `, }; if (options.help) { console.log(helpText[command as keyof typeof helpText] || helpText.main); process.exit(0); } if (command === 'help' || command === 'h') { const helpCommand = args[1]; console.log(helpText[helpCommand as keyof typeof helpText] || helpText.main); process.exit(0); } /** * Execute reloaderoo command and parse JSON response */ async function executeReloaderoo(reloaderooArgs: string[]): Promise<unknown> { const buildPath = path.resolve(__dirname, '..', 'build', 'index.js'); if (!fs.existsSync(buildPath)) { throw new Error('Build not found. Please run "npm run build" first.'); } const tempFile = `/tmp/reloaderoo-output-${Date.now()}.json`; const command = `npx -y reloaderoo@latest inspect ${reloaderooArgs.join(' ')} -- node "${buildPath}"`; return new Promise((resolve, reject) => { const child = spawn('bash', ['-c', `${command} > "${tempFile}"`], { stdio: 'inherit', }); child.on('close', (code) => { try { if (code !== 0) { reject(new Error(`Command failed with code ${code}`)); return; } const content = fs.readFileSync(tempFile, 'utf8'); // Remove stderr log lines and find JSON const lines = content.split('\n'); const cleanLines: string[] = []; for (const line of lines) { if ( line.match(/^\[\d{4}-\d{2}-\d{2}T/) || line.includes('[INFO]') || line.includes('[DEBUG]') || line.includes('[ERROR]') ) { continue; } const trimmed = line.trim(); if (trimmed) { cleanLines.push(line); } } // Find JSON start let jsonStartIndex = -1; for (let i = 0; i < cleanLines.length; i++) { if (cleanLines[i].trim().startsWith('{')) { jsonStartIndex = i; break; } } if (jsonStartIndex === -1) { reject( new Error(`No JSON response found in output.\nOutput: ${content.substring(0, 500)}...`), ); return; } const jsonText = cleanLines.slice(jsonStartIndex).join('\n'); const response = JSON.parse(jsonText); resolve(response); } catch (error) { reject(new Error(`Failed to parse JSON response: ${(error as Error).message}`)); } finally { try { fs.unlinkSync(tempFile); } catch { // Ignore cleanup errors } } }); child.on('error', (error) => { reject(new Error(`Failed to spawn process: ${error.message}`)); }); }); } /** * Get runtime server information */ async function getRuntimeInfo(): Promise<RuntimeData> { try { const toolsResponse = (await executeReloaderoo(['list-tools'])) as { tools?: { name: string; description: string }[]; }; const resourcesResponse = (await executeReloaderoo(['list-resources'])) as { resources?: { uri: string; name: string; description?: string; title?: string }[]; }; let tools: RuntimeTool[] = []; let toolCount = 0; if (toolsResponse.tools && Array.isArray(toolsResponse.tools)) { toolCount = toolsResponse.tools.length; tools = toolsResponse.tools.map((tool) => ({ name: tool.name, description: tool.description, })); } let resources: RuntimeResource[] = []; let resourceCount = 0; if (resourcesResponse.resources && Array.isArray(resourcesResponse.resources)) { resourceCount = resourcesResponse.resources.length; resources = resourcesResponse.resources.map((resource) => ({ uri: resource.uri, name: resource.name, description: resource.title ?? resource.description ?? 'No description available', })); } return { tools, resources, toolCount, resourceCount, dynamicMode: process.env.XCODEBUILDMCP_DYNAMIC_TOOLS === 'true', mode: 'runtime', }; } catch (error) { throw new Error(`Runtime analysis failed: ${(error as Error).message}`); } } /** * Display summary information */ function displaySummary( runtimeData: RuntimeData | null, staticData: StaticAnalysisResult | null, ): void { if (options.json) { return; // JSON output handled separately } console.log(`${colors.bright}${colors.blue}📊 XcodeBuildMCP Tools Summary${colors.reset}`); console.log('═'.repeat(60)); if (runtimeData) { console.log(`${colors.green}🚀 Runtime Analysis:${colors.reset}`); console.log(` Mode: ${runtimeData.dynamicMode ? 'Dynamic' : 'Static'}`); console.log(` Tools: ${runtimeData.toolCount}`); console.log(` Resources: ${runtimeData.resourceCount}`); console.log(` Total: ${runtimeData.toolCount + runtimeData.resourceCount}`); if (runtimeData.dynamicMode) { console.log( ` ${colors.yellow}ℹ️ Dynamic mode: Only enabled workflow tools shown${colors.reset}`, ); } console.log(); } if (staticData) { console.log(`${colors.cyan}📁 Static Analysis:${colors.reset}`); console.log(` Workflow directories: ${staticData.stats.workflowCount}`); console.log(` Canonical tools: ${staticData.stats.canonicalTools}`); console.log(` Re-export files: ${staticData.stats.reExportTools}`); console.log(` Total tool files: ${staticData.stats.totalTools}`); console.log(); } } /** * Display workflow information */ function displayWorkflows(staticData: StaticAnalysisResult | null): void { if (!options.workflows || !staticData || options.json) return; console.log(`${colors.bright}📂 Workflow Directories:${colors.reset}`); console.log('─'.repeat(40)); for (const workflow of staticData.workflows) { const totalTools = workflow.toolCount; console.log(`${colors.green}• ${workflow.displayName}${colors.reset} (${totalTools} tools)`); if (options.verbose) { const canonicalTools = workflow.tools.filter((t) => t.isCanonical).map((t) => t.name); const reExportTools = workflow.tools.filter((t) => !t.isCanonical).map((t) => t.name); if (canonicalTools.length > 0) { console.log(` ${colors.cyan}Canonical:${colors.reset} ${canonicalTools.join(', ')}`); } if (reExportTools.length > 0) { console.log(` ${colors.yellow}Re-exports:${colors.reset} ${reExportTools.join(', ')}`); } } } console.log(); } /** * Display tool lists */ function displayTools( runtimeData: RuntimeData | null, staticData: StaticAnalysisResult | null, ): void { if (!options.tools || options.json) return; if (runtimeData) { console.log(`${colors.bright}🛠️ Runtime Tools (${runtimeData.toolCount}):${colors.reset}`); console.log('─'.repeat(40)); if (runtimeData.tools.length === 0) { console.log(' No tools available'); } else { runtimeData.tools.forEach((tool) => { if (options.verbose && tool.description) { console.log( ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset}`, ); console.log(` ${tool.description}`); } else { console.log(` ${colors.green}•${colors.reset} ${tool.name}`); } }); } console.log(); } if (staticData && options.static) { const canonicalTools = staticData.tools.filter((tool) => tool.isCanonical); console.log(`${colors.bright}📁 Static Tools (${canonicalTools.length}):${colors.reset}`); console.log('─'.repeat(40)); if (canonicalTools.length === 0) { console.log(' No tools found'); } else { canonicalTools .sort((a, b) => a.name.localeCompare(b.name)) .forEach((tool) => { if (options.verbose) { console.log( ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset} (${tool.workflow})`, ); console.log(` ${tool.description}`); console.log(` ${colors.cyan}${tool.relativePath}${colors.reset}`); } else { console.log(` ${colors.green}•${colors.reset} ${tool.name}`); } }); } console.log(); } } /** * Display resource lists */ function displayResources(runtimeData: RuntimeData | null): void { if (!options.resources || !runtimeData || options.json) return; console.log(`${colors.bright}📚 Resources (${runtimeData.resourceCount}):${colors.reset}`); console.log('─'.repeat(40)); if (runtimeData.resources.length === 0) { console.log(' No resources available'); } else { runtimeData.resources.forEach((resource) => { if (options.verbose) { console.log( ` ${colors.magenta}•${colors.reset} ${colors.bright}${resource.uri}${colors.reset}`, ); console.log(` ${resource.description}`); } else { console.log(` ${colors.magenta}•${colors.reset} ${resource.uri}`); } }); } console.log(); } /** * Output JSON format - matches the structure of human-readable output */ function outputJSON( runtimeData: RuntimeData | null, staticData: StaticAnalysisResult | null, ): void { const output: Record<string, unknown> = {}; // Add summary stats (equivalent to the summary table) if (runtimeData) { output.runtime = { toolCount: runtimeData.toolCount, resourceCount: runtimeData.resourceCount, totalCount: runtimeData.toolCount + runtimeData.resourceCount, dynamicMode: runtimeData.dynamicMode, }; } if (staticData) { output.static = { workflowCount: staticData.stats.workflowCount, canonicalTools: staticData.stats.canonicalTools, reExportTools: staticData.stats.reExportTools, totalTools: staticData.stats.totalTools, }; } // Add detailed data only if requested if (options.workflows && staticData) { output.workflows = staticData.workflows.map((w) => ({ name: w.displayName, toolCount: w.toolCount, canonicalCount: w.canonicalCount, reExportCount: w.reExportCount, })); } if (options.tools) { if (runtimeData) { output.runtimeTools = runtimeData.tools.map((t) => t.name); } if (staticData) { output.staticTools = staticData.tools .filter((t) => t.isCanonical) .map((t) => t.name) .sort(); } } if (options.resources && runtimeData) { output.resources = runtimeData.resources.map((r) => r.uri); } console.log(JSON.stringify(output, null, 2)); } /** * Main execution function */ async function main(): Promise<void> { try { let runtimeData: RuntimeData | null = null; let staticData: StaticAnalysisResult | null = null; // Gather data based on options if (options.runtime) { if (!options.json) { console.log(`${colors.cyan}🔍 Gathering runtime information...${colors.reset}`); } runtimeData = await getRuntimeInfo(); } if (options.static) { if (!options.json) { console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}`); } staticData = await getStaticToolAnalysis(); } // For default command or workflows option, always gather static data for workflow info if (options.workflows && !staticData) { if (!options.json) { console.log(`${colors.cyan}📁 Gathering workflow information...${colors.reset}`); } staticData = await getStaticToolAnalysis(); } if (!options.json) { console.log(); // Blank line after gathering } // Handle JSON output if (options.json) { outputJSON(runtimeData, staticData); return; } // Display based on command switch (command) { case 'count': case 'c': displaySummary(runtimeData, staticData); displayWorkflows(staticData); break; case 'list': case 'l': displaySummary(runtimeData, staticData); displayTools(runtimeData, staticData); displayResources(runtimeData); break; case 'static': case 's': if (!staticData) { console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}\n`); staticData = await getStaticToolAnalysis(); } displaySummary(null, staticData); displayWorkflows(staticData); if (options.verbose) { displayTools(null, staticData); const reExportTools = staticData.tools.filter((t) => !t.isCanonical); console.log( `${colors.bright}🔄 Re-export Files (${reExportTools.length}):${colors.reset}`, ); console.log('─'.repeat(40)); reExportTools.forEach((file) => { console.log(` ${colors.yellow}•${colors.reset} ${file.name} (${file.workflow})`); console.log(` ${file.relativePath}`); }); } break; default: // Default case (no command) - show runtime summary with workflows displaySummary(runtimeData, staticData); displayWorkflows(staticData); break; } if (!options.json) { console.log(`${colors.green}✅ Analysis complete!${colors.reset}`); } } catch (error) { if (options.json) { console.error( JSON.stringify( { success: false, error: (error as Error).message, timestamp: new Date().toISOString(), }, null, 2, ), ); } else { console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`); } process.exit(1); } } // Run the CLI main(); ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/__tests__/tap.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for tap plugin */ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import tapPlugin, { AxeHelpers, tapLogic } from '../tap.ts'; // Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { return { getAxePath: () => '/mocked/axe/path', getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), createAxeNotAvailableResponse: () => ({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }), }; } // Helper function to create mock axe helpers with null path (for dependency error tests) function createMockAxeHelpersWithNullPath(): AxeHelpers { return { getAxePath: () => null, getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), createAxeNotAvailableResponse: () => ({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }), }; } describe('Tap Plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(tapPlugin.name).toBe('tap'); }); it('should have correct description', () => { expect(tapPlugin.description).toBe( "Tap at specific coordinates. Use describe_ui to get precise element coordinates (don't guess from screenshots). Supports optional timing delays.", ); }); it('should have handler function', () => { expect(typeof tapPlugin.handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { const schema = z.object(tapPlugin.schema); // Valid case expect( schema.safeParse({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }).success, ).toBe(true); // Invalid simulatorUuid expect( schema.safeParse({ simulatorUuid: 'invalid-uuid', x: 100, y: 200, }).success, ).toBe(false); // Invalid x coordinate - non-integer expect( schema.safeParse({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 3.14, y: 200, }).success, ).toBe(false); // Invalid y coordinate - non-integer expect( schema.safeParse({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 3.14, }).success, ).toBe(false); // Invalid preDelay - negative expect( schema.safeParse({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, preDelay: -1, }).success, ).toBe(false); // Invalid postDelay - negative expect( schema.safeParse({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, postDelay: -1, }).success, ).toBe(false); // Valid with optional delays expect( schema.safeParse({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, preDelay: 0.5, postDelay: 1.0, }).success, ).toBe(true); // Missing required fields expect(schema.safeParse({}).success).toBe(false); }); }); describe('Command Generation', () => { let callHistory: Array<{ command: string[]; logPrefix?: string; useShell?: boolean; env?: Record<string, string>; }>; beforeEach(() => { callHistory = []; }); it('should generate correct axe command with minimal parameters', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const wrappedExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return mockExecutor(command, logPrefix, useShell, env); }; const mockAxeHelpers = createMockAxeHelpers(); await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, wrappedExecutor, mockAxeHelpers, ); expect(callHistory).toHaveLength(1); expect(callHistory[0]).toEqual({ command: [ '/mocked/axe/path', 'tap', '-x', '100', '-y', '200', '--udid', '12345678-1234-1234-1234-123456789012', ], logPrefix: '[AXe]: tap', useShell: false, env: { SOME_ENV: 'value' }, }); }); it('should generate correct axe command with pre-delay', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const wrappedExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return mockExecutor(command, logPrefix, useShell, env); }; const mockAxeHelpers = createMockAxeHelpers(); await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 150, y: 300, preDelay: 0.5, }, wrappedExecutor, mockAxeHelpers, ); expect(callHistory).toHaveLength(1); expect(callHistory[0]).toEqual({ command: [ '/mocked/axe/path', 'tap', '-x', '150', '-y', '300', '--pre-delay', '0.5', '--udid', '12345678-1234-1234-1234-123456789012', ], logPrefix: '[AXe]: tap', useShell: false, env: { SOME_ENV: 'value' }, }); }); it('should generate correct axe command with post-delay', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const wrappedExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return mockExecutor(command, logPrefix, useShell, env); }; const mockAxeHelpers = createMockAxeHelpers(); await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 250, y: 400, postDelay: 1.0, }, wrappedExecutor, mockAxeHelpers, ); expect(callHistory).toHaveLength(1); expect(callHistory[0]).toEqual({ command: [ '/mocked/axe/path', 'tap', '-x', '250', '-y', '400', '--post-delay', '1', '--udid', '12345678-1234-1234-1234-123456789012', ], logPrefix: '[AXe]: tap', useShell: false, env: { SOME_ENV: 'value' }, }); }); it('should generate correct axe command with both delays', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const wrappedExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record<string, string>, ) => { callHistory.push({ command, logPrefix, useShell, env }); return mockExecutor(command, logPrefix, useShell, env); }; const mockAxeHelpers = createMockAxeHelpers(); await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 350, y: 500, preDelay: 0.3, postDelay: 0.7, }, wrappedExecutor, mockAxeHelpers, ); expect(callHistory).toHaveLength(1); expect(callHistory[0]).toEqual({ command: [ '/mocked/axe/path', 'tap', '-x', '350', '-y', '500', '--pre-delay', '0.3', '--post-delay', '0.7', '--udid', '12345678-1234-1234-1234-123456789012', ], logPrefix: '[AXe]: tap', useShell: false, env: { SOME_ENV: 'value' }, }); }); }); describe('Success Response Processing', () => { it('should return successful response for basic tap', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const mockAxeHelpers = createMockAxeHelpers(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Tap at (100, 200) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', }, ], isError: false, }); }); it('should return successful response with coordinate warning when describe_ui not called', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const mockAxeHelpers = createMockAxeHelpers(); const result = await tapLogic( { simulatorUuid: '87654321-4321-4321-4321-210987654321', x: 150, y: 300, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Tap at (150, 300) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', }, ], isError: false, }); }); it('should return successful response with delays', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const mockAxeHelpers = createMockAxeHelpers(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 250, y: 400, preDelay: 0.5, postDelay: 1.0, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Tap at (250, 400) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', }, ], isError: false, }); }); it('should return successful response with integer coordinates', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const mockAxeHelpers = createMockAxeHelpers(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 0, y: 0, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Tap at (0, 0) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', }, ], isError: false, }); }); it('should return successful response with large coordinates', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', }); const mockAxeHelpers = createMockAxeHelpers(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 1920, y: 1080, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Tap at (1920, 1080) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', }, ], isError: false, }); }); }); describe('Plugin Handler Validation', () => { it('should return Zod validation error for missing simulatorUuid', async () => { const result = await tapPlugin.handler({ x: 100, y: 200, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', }, ], isError: true, }); }); it('should return Zod validation error for missing x coordinate', async () => { const result = await tapPlugin.handler({ simulatorUuid: '12345678-1234-1234-1234-123456789012', y: 200, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nx: Required', }, ], isError: true, }); }); it('should return Zod validation error for missing y coordinate', async () => { const result = await tapPlugin.handler({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\ny: Required', }, ], isError: true, }); }); it('should return Zod validation error for invalid UUID format', async () => { const result = await tapPlugin.handler({ simulatorUuid: 'invalid-uuid', x: 100, y: 200, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Invalid Simulator UUID format', }, ], isError: true, }); }); it('should return Zod validation error for non-integer x coordinate', async () => { const result = await tapPlugin.handler({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 3.14, y: 200, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nx: X coordinate must be an integer', }, ], isError: true, }); }); it('should return Zod validation error for non-integer y coordinate', async () => { const result = await tapPlugin.handler({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 3.14, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\ny: Y coordinate must be an integer', }, ], isError: true, }); }); it('should return Zod validation error for negative preDelay', async () => { const result = await tapPlugin.handler({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, preDelay: -1, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npreDelay: Pre-delay must be non-negative', }, ], isError: true, }); }); it('should return Zod validation error for negative postDelay', async () => { const result = await tapPlugin.handler({ simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, postDelay: -1, }); expect(result).toEqual({ content: [ { type: 'text', text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npostDelay: Post-delay must be non-negative', }, ], isError: true, }); }); }); describe('Handler Behavior (Complete Literal Returns)', () => { it('should return DependencyError when axe binary is not found', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Tap completed', error: undefined, }); const mockAxeHelpers = createMockAxeHelpersWithNullPath(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, preDelay: 0.5, postDelay: 1.0, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }); }); it('should handle DependencyError when axe binary not found (second test)', async () => { const mockExecutor = createMockExecutor({ success: false, output: '', error: 'Coordinates out of bounds', }); const mockAxeHelpers = createMockAxeHelpersWithNullPath(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }); }); it('should handle DependencyError when axe binary not found (third test)', async () => { const mockExecutor = createMockExecutor({ success: false, output: '', error: 'System error occurred', }); const mockAxeHelpers = createMockAxeHelpersWithNullPath(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }); }); it('should handle DependencyError when axe binary not found (fourth test)', async () => { const mockExecutor = async () => { throw new Error('ENOENT: no such file or directory'); }; const mockAxeHelpers = createMockAxeHelpersWithNullPath(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }); }); it('should handle DependencyError when axe binary not found (fifth test)', async () => { const mockExecutor = async () => { throw new Error('Unexpected error'); }; const mockAxeHelpers = createMockAxeHelpersWithNullPath(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }); }); it('should handle DependencyError when axe binary not found (sixth test)', async () => { const mockExecutor = async () => { throw 'String error'; }; const mockAxeHelpers = createMockAxeHelpersWithNullPath(); const result = await tapLogic( { simulatorUuid: '12345678-1234-1234-1234-123456789012', x: 100, y: 200, }, mockExecutor, mockAxeHelpers, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', }, ], isError: true, }); }); }); }); ```