This is page 2 of 4. Use http://codebase.md/stefan-nitu/mcp-xcode-server?page={x} to view the full context. # Directory Structure ``` ├── .claude │ └── settings.local.json ├── .github │ └── workflows │ └── ci.yml ├── .gitignore ├── .vscode │ └── settings.json ├── CLAUDE.md ├── CONTRIBUTING.md ├── docs │ ├── ARCHITECTURE.md │ ├── ERROR-HANDLING.md │ └── TESTING-PHILOSOPHY.md ├── examples │ └── screenshot-demo.js ├── jest.config.cjs ├── jest.e2e.config.cjs ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── scripts │ └── xcode-sync.swift ├── src │ ├── application │ │ └── ports │ │ ├── ArtifactPorts.ts │ │ ├── BuildPorts.ts │ │ ├── CommandPorts.ts │ │ ├── ConfigPorts.ts │ │ ├── LoggingPorts.ts │ │ ├── MappingPorts.ts │ │ ├── OutputFormatterPorts.ts │ │ ├── OutputParserPorts.ts │ │ └── SimulatorPorts.ts │ ├── cli.ts │ ├── config.ts │ ├── domain │ │ ├── errors │ │ │ └── DomainError.ts │ │ ├── services │ │ │ └── PlatformDetector.ts │ │ ├── shared │ │ │ └── Result.ts │ │ └── tests │ │ └── unit │ │ └── PlatformDetector.unit.test.ts │ ├── features │ │ ├── app-management │ │ │ ├── controllers │ │ │ │ └── InstallAppController.ts │ │ │ ├── domain │ │ │ │ ├── InstallRequest.ts │ │ │ │ └── InstallResult.ts │ │ │ ├── factories │ │ │ │ └── InstallAppControllerFactory.ts │ │ │ ├── index.ts │ │ │ ├── infrastructure │ │ │ │ └── AppInstallerAdapter.ts │ │ │ ├── tests │ │ │ │ ├── e2e │ │ │ │ │ ├── InstallAppController.e2e.test.ts │ │ │ │ │ └── InstallAppMCP.e2e.test.ts │ │ │ │ ├── integration │ │ │ │ │ └── InstallAppController.integration.test.ts │ │ │ │ └── unit │ │ │ │ ├── AppInstallerAdapter.unit.test.ts │ │ │ │ ├── InstallAppController.unit.test.ts │ │ │ │ ├── InstallAppUseCase.unit.test.ts │ │ │ │ ├── InstallRequest.unit.test.ts │ │ │ │ └── InstallResult.unit.test.ts │ │ │ └── use-cases │ │ │ └── InstallAppUseCase.ts │ │ ├── build │ │ │ ├── controllers │ │ │ │ └── BuildXcodeController.ts │ │ │ ├── domain │ │ │ │ ├── BuildDestination.ts │ │ │ │ ├── BuildIssue.ts │ │ │ │ ├── BuildRequest.ts │ │ │ │ ├── BuildResult.ts │ │ │ │ └── PlatformInfo.ts │ │ │ ├── factories │ │ │ │ └── BuildXcodeControllerFactory.ts │ │ │ ├── index.ts │ │ │ ├── infrastructure │ │ │ │ ├── BuildArtifactLocatorAdapter.ts │ │ │ │ ├── BuildDestinationMapperAdapter.ts │ │ │ │ ├── XcbeautifyFormatterAdapter.ts │ │ │ │ ├── XcbeautifyOutputParserAdapter.ts │ │ │ │ └── XcodeBuildCommandAdapter.ts │ │ │ ├── tests │ │ │ │ ├── e2e │ │ │ │ │ ├── BuildXcodeController.e2e.test.ts │ │ │ │ │ └── BuildXcodeMCP.e2e.test.ts │ │ │ │ ├── integration │ │ │ │ │ └── BuildXcodeController.integration.test.ts │ │ │ │ └── unit │ │ │ │ ├── BuildArtifactLocatorAdapter.unit.test.ts │ │ │ │ ├── BuildDestinationMapperAdapter.unit.test.ts │ │ │ │ ├── BuildIssue.unit.test.ts │ │ │ │ ├── BuildProjectUseCase.unit.test.ts │ │ │ │ ├── BuildRequest.unit.test.ts │ │ │ │ ├── BuildResult.unit.test.ts │ │ │ │ ├── BuildXcodeController.unit.test.ts │ │ │ │ ├── BuildXcodePresenter.unit.test.ts │ │ │ │ ├── PlatformInfo.unit.test.ts │ │ │ │ ├── XcbeautifyFormatterAdapter.unit.test.ts │ │ │ │ ├── XcbeautifyOutputParserAdapter.unit.test.ts │ │ │ │ └── XcodeBuildCommandAdapter.unit.test.ts │ │ │ └── use-cases │ │ │ └── BuildProjectUseCase.ts │ │ └── simulator │ │ ├── controllers │ │ │ ├── BootSimulatorController.ts │ │ │ ├── ListSimulatorsController.ts │ │ │ └── ShutdownSimulatorController.ts │ │ ├── domain │ │ │ ├── BootRequest.ts │ │ │ ├── BootResult.ts │ │ │ ├── ListSimulatorsRequest.ts │ │ │ ├── ListSimulatorsResult.ts │ │ │ ├── ShutdownRequest.ts │ │ │ ├── ShutdownResult.ts │ │ │ └── SimulatorState.ts │ │ ├── factories │ │ │ ├── BootSimulatorControllerFactory.ts │ │ │ ├── ListSimulatorsControllerFactory.ts │ │ │ └── ShutdownSimulatorControllerFactory.ts │ │ ├── index.ts │ │ ├── infrastructure │ │ │ ├── SimulatorControlAdapter.ts │ │ │ └── SimulatorLocatorAdapter.ts │ │ ├── tests │ │ │ ├── e2e │ │ │ │ ├── BootSimulatorController.e2e.test.ts │ │ │ │ ├── BootSimulatorMCP.e2e.test.ts │ │ │ │ ├── ListSimulatorsController.e2e.test.ts │ │ │ │ ├── ListSimulatorsMCP.e2e.test.ts │ │ │ │ ├── ShutdownSimulatorController.e2e.test.ts │ │ │ │ └── ShutdownSimulatorMCP.e2e.test.ts │ │ │ ├── integration │ │ │ │ ├── BootSimulatorController.integration.test.ts │ │ │ │ ├── ListSimulatorsController.integration.test.ts │ │ │ │ └── ShutdownSimulatorController.integration.test.ts │ │ │ └── unit │ │ │ ├── BootRequest.unit.test.ts │ │ │ ├── BootResult.unit.test.ts │ │ │ ├── BootSimulatorController.unit.test.ts │ │ │ ├── BootSimulatorUseCase.unit.test.ts │ │ │ ├── ListSimulatorsController.unit.test.ts │ │ │ ├── ListSimulatorsUseCase.unit.test.ts │ │ │ ├── ShutdownRequest.unit.test.ts │ │ │ ├── ShutdownResult.unit.test.ts │ │ │ ├── ShutdownSimulatorUseCase.unit.test.ts │ │ │ ├── SimulatorControlAdapter.unit.test.ts │ │ │ └── SimulatorLocatorAdapter.unit.test.ts │ │ └── use-cases │ │ ├── BootSimulatorUseCase.ts │ │ ├── ListSimulatorsUseCase.ts │ │ └── ShutdownSimulatorUseCase.ts │ ├── index.ts │ ├── infrastructure │ │ ├── repositories │ │ │ └── DeviceRepository.ts │ │ ├── services │ │ │ └── DependencyChecker.ts │ │ └── tests │ │ └── unit │ │ ├── DependencyChecker.unit.test.ts │ │ └── DeviceRepository.unit.test.ts │ ├── logger.ts │ ├── presentation │ │ ├── decorators │ │ │ └── DependencyCheckingDecorator.ts │ │ ├── formatters │ │ │ ├── ErrorFormatter.ts │ │ │ └── strategies │ │ │ ├── BuildIssuesStrategy.ts │ │ │ ├── DefaultErrorStrategy.ts │ │ │ ├── ErrorFormattingStrategy.ts │ │ │ └── OutputFormatterErrorStrategy.ts │ │ ├── interfaces │ │ │ ├── IDependencyChecker.ts │ │ │ ├── MCPController.ts │ │ │ └── MCPResponse.ts │ │ ├── presenters │ │ │ └── BuildXcodePresenter.ts │ │ └── tests │ │ └── unit │ │ ├── BuildIssuesStrategy.unit.test.ts │ │ ├── DefaultErrorStrategy.unit.test.ts │ │ ├── DependencyCheckingDecorator.unit.test.ts │ │ └── ErrorFormatter.unit.test.ts │ ├── shared │ │ ├── domain │ │ │ ├── AppPath.ts │ │ │ ├── DeviceId.ts │ │ │ ├── Platform.ts │ │ │ └── ProjectPath.ts │ │ ├── index.ts │ │ ├── infrastructure │ │ │ ├── ConfigProviderAdapter.ts │ │ │ └── ShellCommandExecutorAdapter.ts │ │ └── tests │ │ ├── mocks │ │ │ ├── promisifyExec.ts │ │ │ ├── selectiveExecMock.ts │ │ │ └── xcodebuildHelpers.ts │ │ ├── skipped │ │ │ ├── cli.e2e.test.skip │ │ │ ├── hook-e2e.test.skip │ │ │ ├── hook-path.e2e.test.skip │ │ │ └── hook.test.skip │ │ ├── types │ │ │ └── execTypes.ts │ │ ├── unit │ │ │ ├── AppPath.unit.test.ts │ │ │ ├── ConfigProviderAdapter.unit.test.ts │ │ │ ├── ProjectPath.unit.test.ts │ │ │ └── ShellCommandExecutorAdapter.unit.test.ts │ │ └── utils │ │ ├── gitResetTestArtifacts.ts │ │ ├── mockHelpers.ts │ │ ├── TestEnvironmentCleaner.ts │ │ ├── TestErrorInjector.ts │ │ ├── testHelpers.ts │ │ ├── TestProjectManager.ts │ │ └── TestSimulatorManager.ts │ ├── types.ts │ ├── utils │ │ ├── devices │ │ │ ├── Devices.ts │ │ │ ├── SimulatorApps.ts │ │ │ ├── SimulatorBoot.ts │ │ │ ├── SimulatorDevice.ts │ │ │ ├── SimulatorInfo.ts │ │ │ ├── SimulatorReset.ts │ │ │ └── SimulatorUI.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ └── xcbeautify-parser.ts │ │ ├── index.ts │ │ ├── LogManager.ts │ │ ├── LogManagerInstance.ts │ │ └── projects │ │ ├── SwiftBuild.ts │ │ ├── SwiftPackage.ts │ │ ├── SwiftPackageInfo.ts │ │ ├── Xcode.ts │ │ ├── XcodeArchive.ts │ │ ├── XcodeBuild.ts │ │ ├── XcodeErrors.ts │ │ ├── XcodeInfo.ts │ │ └── XcodeProject.ts │ └── utils.ts ├── test_artifacts │ ├── Test.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcuserdata │ │ └── stefan.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ ├── TestProjectSwiftTesting │ │ ├── TestProjectSwiftTesting │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── Item.swift │ │ │ ├── TestProjectSwiftTesting.entitlements │ │ │ └── TestProjectSwiftTestingApp.swift │ │ ├── TestProjectSwiftTesting.xcodeproj │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcuserdata │ │ │ │ └── stefan.xcuserdatad │ │ │ │ └── UserInterfaceState.xcuserstate │ │ │ └── xcuserdata │ │ │ └── stefan.xcuserdatad │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ │ ├── TestProjectSwiftTestingTests │ │ │ └── TestProjectSwiftTestingTests.swift │ │ └── TestProjectSwiftTestingUITests │ │ ├── TestProjectSwiftTestingUITests.swift │ │ └── TestProjectSwiftTestingUITestsLaunchTests.swift │ ├── TestProjectWatchOS │ │ ├── TestProjectWatchOS │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ └── TestProjectWatchOSApp.swift │ │ ├── TestProjectWatchOS Watch App │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ └── TestProjectWatchOSApp.swift │ │ ├── TestProjectWatchOS Watch AppTests │ │ │ └── TestProjectWatchOS_Watch_AppTests.swift │ │ ├── TestProjectWatchOS Watch AppUITests │ │ │ ├── TestProjectWatchOS_Watch_AppUITests.swift │ │ │ └── TestProjectWatchOS_Watch_AppUITestsLaunchTests.swift │ │ ├── TestProjectWatchOS.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── TestProjectWatchOSTests │ │ │ └── TestProjectWatchOSTests.swift │ │ └── TestProjectWatchOSUITests │ │ ├── TestProjectWatchOSUITests.swift │ │ └── TestProjectWatchOSUITestsLaunchTests.swift │ ├── TestProjectXCTest │ │ ├── TestProjectXCTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── Item.swift │ │ │ ├── TestProjectXCTest.entitlements │ │ │ └── TestProjectXCTestApp.swift │ │ ├── TestProjectXCTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcuserdata │ │ │ │ └── stefan.xcuserdatad │ │ │ │ └── UserInterfaceState.xcuserstate │ │ │ └── xcuserdata │ │ │ └── stefan.xcuserdatad │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ │ ├── TestProjectXCTestTests │ │ │ └── TestProjectXCTestTests.swift │ │ └── TestProjectXCTestUITests │ │ ├── TestProjectXCTestUITests.swift │ │ └── TestProjectXCTestUITestsLaunchTests.swift │ ├── TestSwiftPackageSwiftTesting │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── Sources │ │ │ ├── TestSwiftPackageSwiftTesting │ │ │ │ └── TestSwiftPackageSwiftTesting.swift │ │ │ └── TestSwiftPackageSwiftTestingExecutable │ │ │ └── main.swift │ │ └── Tests │ │ └── TestSwiftPackageSwiftTestingTests │ │ └── TestSwiftPackageSwiftTestingTests.swift │ └── TestSwiftPackageXCTest │ ├── .gitignore │ ├── Package.swift │ ├── Sources │ │ ├── TestSwiftPackageXCTest │ │ │ └── TestSwiftPackageXCTest.swift │ │ └── TestSwiftPackageXCTestExecutable │ │ └── main.swift │ └── Tests │ └── TestSwiftPackageXCTestTests │ └── TestSwiftPackageXCTestTests.swift ├── tsconfig.json └── XcodeProjectModifier ├── Package.resolved ├── Package.swift └── Sources └── XcodeProjectModifier └── main.swift ``` # Files -------------------------------------------------------------------------------- /src/infrastructure/tests/unit/DependencyChecker.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest } from '@jest/globals'; import { DependencyChecker } from '../../services/DependencyChecker.js'; import { ICommandExecutor } from '../../../application/ports/CommandPorts.js'; describe('DependencyChecker', () => { function createSUT() { const mockExecute = jest.fn<ICommandExecutor['execute']>(); const mockExecutor: ICommandExecutor = { execute: mockExecute }; const sut = new DependencyChecker(mockExecutor); return { sut, mockExecute }; } describe('check', () => { it('should return empty array when all dependencies are installed', async () => { // Arrange const { sut, mockExecute } = createSUT(); // All which commands succeed mockExecute.mockResolvedValue({ stdout: '/usr/bin/xcodebuild', stderr: '', exitCode: 0 }); // Act const result = await sut.check(['xcodebuild', 'xcbeautify']); // Assert - behavior: no missing dependencies expect(result).toEqual([]); }); it('should return missing dependencies with install commands', async () => { // Arrange const { sut, mockExecute } = createSUT(); // xcbeautify not found mockExecute.mockResolvedValue({ stdout: '', stderr: 'xcbeautify not found', exitCode: 1 }); // Act const result = await sut.check(['xcbeautify']); // Assert - behavior: returns missing dependency with install command expect(result).toEqual([ { name: 'xcbeautify', installCommand: 'brew install xcbeautify' } ]); }); it('should handle mix of installed and missing dependencies', async () => { // Arrange const { sut, mockExecute } = createSUT(); // xcodebuild found, xcbeautify not found mockExecute .mockResolvedValueOnce({ stdout: '/usr/bin/xcodebuild', stderr: '', exitCode: 0 }) .mockResolvedValueOnce({ stdout: '', stderr: 'xcbeautify not found', exitCode: 1 }); // Act const result = await sut.check(['xcodebuild', 'xcbeautify']); // Assert - behavior: only missing dependencies returned expect(result).toEqual([ { name: 'xcbeautify', installCommand: 'brew install xcbeautify' } ]); }); it('should handle unknown dependencies', async () => { // Arrange const { sut, mockExecute } = createSUT(); // Unknown tool not found mockExecute.mockResolvedValue({ stdout: '', stderr: 'unknowntool not found', exitCode: 1 }); // Act const result = await sut.check(['unknowntool']); // Assert - behavior: returns missing dependency without install command expect(result).toEqual([ { name: 'unknowntool' } ]); }); it('should provide appropriate install commands for known tools', async () => { // Arrange const { sut, mockExecute } = createSUT(); // All tools missing mockExecute.mockResolvedValue({ stdout: '', stderr: 'not found', exitCode: 1 }); // Act const result = await sut.check(['xcodebuild', 'xcrun', 'xcbeautify']); // Assert - behavior: each tool has appropriate install command expect(result).toContainEqual({ name: 'xcodebuild', installCommand: 'Install Xcode from the App Store' }); expect(result).toContainEqual({ name: 'xcrun', installCommand: 'Install Xcode Command Line Tools: xcode-select --install' }); expect(result).toContainEqual({ name: 'xcbeautify', installCommand: 'brew install xcbeautify' }); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/e2e/InstallAppMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for Install App through MCP Protocol * * Tests critical user journey: Installing an app on simulator through MCP * Following testing philosophy: E2E tests for critical paths only (10%) * * Focus: MCP protocol interaction, not app installation logic * The controller tests already verify installation works with real simulators * This test verifies the MCP transport/serialization/protocol works * * NO MOCKS - Uses real MCP server, real simulators, real apps */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from '@jest/globals'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { createAndConnectClient, cleanupClientAndTransport, bootAndWaitForSimulator } from '../../../../shared/tests/utils/testHelpers.js'; import { TestProjectManager } from '../../../../shared/tests/utils/TestProjectManager.js'; import { exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs'; const execAsync = promisify(exec); describe('Install App MCP E2E', () => { let client: Client; let transport: StdioClientTransport; let testManager: TestProjectManager; let testDeviceId: string; let testAppPath: string; beforeAll(async () => { // Prepare test projects testManager = new TestProjectManager(); await testManager.setup(); // Build the server const { execSync } = await import('child_process'); execSync('npm run build', { stdio: 'inherit' }); // Build the test app using TestProjectManager testAppPath = await testManager.buildApp('xcodeProject'); // Get the latest iOS runtime const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); const runtimes = JSON.parse(runtimesResult.stdout); const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); if (!iosRuntime) { throw new Error('No iOS runtime found. Please install an iOS simulator runtime.'); } // Create and boot a test simulator const createResult = await execAsync( `xcrun simctl create "TestSimulator-InstallAppMCP" "iPhone 15" "${iosRuntime.identifier}"` ); testDeviceId = createResult.stdout.trim(); // Boot the simulator and wait for it to be ready await bootAndWaitForSimulator(testDeviceId, 30); }); afterAll(async () => { // Clean up simulator if (testDeviceId) { try { await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); await execAsync(`xcrun simctl delete "${testDeviceId}"`); } catch (error) { // Ignore cleanup errors } } // Clean up test project await testManager.cleanup(); }); beforeEach(async () => { ({ client, transport } = await createAndConnectClient()); }); afterEach(async () => { await cleanupClientAndTransport(client, transport); }); it('should complete install workflow through MCP', async () => { // This tests the critical user journey: // User connects via MCP → calls install_app → receives result const result = await client.request( { method: 'tools/call', params: { name: 'install_app', arguments: { appPath: testAppPath, simulatorId: testDeviceId } } }, CallToolResultSchema, { timeout: 120000 } ); expect(result).toBeDefined(); expect(result.content).toBeInstanceOf(Array); const textContent = result.content.find((c: any) => c.type === 'text'); expect(textContent?.text).toContain('Successfully installed'); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/BootSimulatorMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for Boot Simulator through MCP Protocol * * Tests critical user journey: Booting a simulator through MCP * Following testing philosophy: E2E tests for critical paths only (10%) * * Focus: MCP protocol interaction, not simulator boot logic * The controller tests already verify boot works with real simulators * This test verifies the MCP transport/serialization/protocol works * * NO MOCKS - Uses real MCP server, real simulators */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { createAndConnectClient, cleanupClientAndTransport } from '../../../../shared/tests/utils/testHelpers.js'; import { TestSimulatorManager } from '../../../../shared/tests/utils/TestSimulatorManager.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); describe('Boot Simulator MCP E2E', () => { let client: Client; let transport: StdioClientTransport; let testSimManager: TestSimulatorManager; beforeAll(async () => { // Build the server const { execSync } = await import('child_process'); execSync('npm run build', { stdio: 'inherit' }); // Set up test simulator testSimManager = new TestSimulatorManager(); await testSimManager.getOrCreateSimulator('TestSimulator-BootMCP'); // Connect to MCP server ({ client, transport } = await createAndConnectClient()); }); beforeEach(async () => { // Ensure simulator is shutdown before each test await testSimManager.shutdownAndWait(5); }); afterAll(async () => { // Cleanup test simulator await testSimManager.cleanup(); // Cleanup MCP connection await cleanupClientAndTransport(client, transport); }); describe('boot simulator through MCP', () => { it('should boot simulator via MCP protocol', async () => { // Act - Call tool through MCP const result = await client.callTool({ name: 'boot_simulator', arguments: { deviceId: testSimManager.getSimulatorName() } }); // Assert - Verify MCP response const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].type).toBe('text'); expect(parsed.content[0].text).toBe(`✅ Successfully booted simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); // Verify simulator is actually booted const isBooted = await testSimManager.isBooted(); expect(isBooted).toBe(true); }); it('should handle already booted simulator via MCP', async () => { // Arrange - boot the simulator first await testSimManager.bootAndWait(5); await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for boot // Act - Call tool through MCP const result = await client.callTool({ name: 'boot_simulator', arguments: { deviceId: testSimManager.getSimulatorId() } }); // Assert const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].text).toBe(`✅ Simulator already booted: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); }); }); describe('error handling through MCP', () => { it('should return error for non-existent simulator', async () => { // Act const result = await client.callTool({ name: 'boot_simulator', arguments: { deviceId: 'NonExistentSimulator-MCP' } }); // Assert const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].text).toBe('❌ Simulator not found: NonExistentSimulator-MCP'); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/AppPath.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from '@jest/globals'; import { AppPath } from '../../domain/AppPath.js'; describe('AppPath', () => { describe('create', () => { it('should create valid AppPath with .app extension', () => { // Arrange & Act const appPath = AppPath.create('/path/to/MyApp.app'); // Assert expect(appPath.toString()).toBe('/path/to/MyApp.app'); expect(appPath.name).toBe('MyApp.app'); }); it('should accept paths with spaces', () => { // Arrange & Act const appPath = AppPath.create('/path/to/My Cool App.app'); // Assert expect(appPath.toString()).toBe('/path/to/My Cool App.app'); expect(appPath.name).toBe('My Cool App.app'); }); it('should accept relative paths', () => { // Arrange & Act const appPath = AppPath.create('./build/Debug/TestApp.app'); // Assert expect(appPath.toString()).toBe('./build/Debug/TestApp.app'); expect(appPath.name).toBe('TestApp.app'); }); it('should throw error for empty path', () => { // Arrange, Act & Assert expect(() => AppPath.create('')).toThrow('App path cannot be empty'); }); it('should throw error for path without .app extension', () => { // Arrange, Act & Assert expect(() => AppPath.create('/path/to/MyApp')).toThrow('App path must end with .app'); expect(() => AppPath.create('/path/to/MyApp.ipa')).toThrow('App path must end with .app'); expect(() => AppPath.create('/path/to/binary')).toThrow('App path must end with .app'); }); it('should throw error for path with directory traversal', () => { // Arrange, Act & Assert expect(() => AppPath.create('../../../etc/passwd.app')).toThrow('App path cannot contain directory traversal'); expect(() => AppPath.create('/path/../../../etc/evil.app')).toThrow('App path cannot contain directory traversal'); expect(() => AppPath.create('/valid/path/../../sneaky.app')).toThrow('App path cannot contain directory traversal'); }); it('should throw error for path with null characters', () => { // Arrange, Act & Assert expect(() => AppPath.create('/path/to/MyApp.app\0')).toThrow('App path cannot contain null characters'); expect(() => AppPath.create('/path\0/to/MyApp.app')).toThrow('App path cannot contain null characters'); }); it('should handle paths ending with slash after .app', () => { // Arrange & Act const appPath = AppPath.create('/path/to/MyApp.app/'); // Assert expect(appPath.toString()).toBe('/path/to/MyApp.app/'); expect(appPath.name).toBe('MyApp.app'); }); }); describe('name property', () => { it('should extract app name from simple path', () => { // Arrange & Act const appPath = AppPath.create('/Users/dev/MyApp.app'); // Assert expect(appPath.name).toBe('MyApp.app'); }); it('should extract app name from Windows-style path', () => { // Arrange & Act const appPath = AppPath.create('C:\\Users\\dev\\MyApp.app'); // Assert expect(appPath.name).toBe('MyApp.app'); }); it('should handle app name with special characters', () => { // Arrange & Act const appPath = AppPath.create('/path/to/My-App_v1.2.3.app'); // Assert expect(appPath.name).toBe('My-App_v1.2.3.app'); }); it('should handle just the app name without path', () => { // Arrange & Act const appPath = AppPath.create('MyApp.app'); // Assert expect(appPath.name).toBe('MyApp.app'); }); }); describe('toString', () => { it('should return the original path', () => { // Arrange const originalPath = '/path/to/MyApp.app'; // Act const appPath = AppPath.create(originalPath); // Assert expect(appPath.toString()).toBe(originalPath); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/AppInstallerAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { AppInstallerAdapter } from '../../infrastructure/AppInstallerAdapter.js'; import { ICommandExecutor } from '../../../../application/ports/CommandPorts.js'; describe('AppInstallerAdapter', () => { beforeEach(() => { jest.clearAllMocks(); }); function createSUT() { const mockExecute = jest.fn<ICommandExecutor['execute']>(); const mockExecutor: ICommandExecutor = { execute: mockExecute }; const sut = new AppInstallerAdapter(mockExecutor); return { sut, mockExecute }; } describe('installApp', () => { it('should install app successfully', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); // Act await sut.installApp('/path/to/MyApp.app', 'ABC-123'); // Assert expect(mockExecute).toHaveBeenCalledWith( 'xcrun simctl install "ABC-123" "/path/to/MyApp.app"' ); }); it('should handle paths with spaces', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); // Act await sut.installApp('/path/to/My Cool App.app', 'ABC-123'); // Assert expect(mockExecute).toHaveBeenCalledWith( 'xcrun simctl install "ABC-123" "/path/to/My Cool App.app"' ); }); it('should throw error for invalid app bundle', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: 'An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=2):\nFailed to install "/path/to/NotAnApp.app"', exitCode: 1 }); // Act & Assert await expect(sut.installApp('/path/to/NotAnApp.app', 'ABC-123')) .rejects.toThrow('An error was encountered processing the command'); }); it('should throw error when device not found', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: 'Invalid device: NON-EXISTENT', exitCode: 164 }); // Act & Assert await expect(sut.installApp('/path/to/MyApp.app', 'NON-EXISTENT')) .rejects.toThrow('Invalid device: NON-EXISTENT'); }); it('should throw error when simulator not booted', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: 'Unable to install "/path/to/MyApp.app"\nAn error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to install applications when the device is not booted.', exitCode: 149 }); // Act & Assert await expect(sut.installApp('/path/to/MyApp.app', 'ABC-123')) .rejects.toThrow('Unable to install applications when the device is not booted'); }); it('should throw generic error when stderr is empty', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: '', exitCode: 1 }); // Act & Assert await expect(sut.installApp('/path/to/MyApp.app', 'ABC-123')) .rejects.toThrow('Failed to install app'); }); it('should throw error for app with invalid signature', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockResolvedValue({ stdout: '', stderr: 'The code signature version is no longer supported', exitCode: 1 }); // Act & Assert await expect(sut.installApp('/path/to/MyApp.app', 'ABC-123')) .rejects.toThrow('The code signature version is no longer supported'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ShutdownResult.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from '@jest/globals'; import { ShutdownResult, ShutdownOutcome, SimulatorNotFoundError, ShutdownCommandFailedError } from '../../domain/ShutdownResult.js'; describe('ShutdownResult', () => { describe('shutdown', () => { it('should create a successful shutdown result', () => { // Arrange const simulatorId = 'ABC123'; const simulatorName = 'iPhone 15'; // Act const result = ShutdownResult.shutdown(simulatorId, simulatorName); // Assert expect(result.outcome).toBe(ShutdownOutcome.Shutdown); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); expect(result.diagnostics.error).toBeUndefined(); }); }); describe('alreadyShutdown', () => { it('should create an already shutdown result', () => { // Arrange const simulatorId = 'ABC123'; const simulatorName = 'iPhone 15'; // Act const result = ShutdownResult.alreadyShutdown(simulatorId, simulatorName); // Assert expect(result.outcome).toBe(ShutdownOutcome.AlreadyShutdown); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); expect(result.diagnostics.error).toBeUndefined(); }); }); describe('failed', () => { it('should create a failed result with SimulatorNotFoundError', () => { // Arrange const error = new SimulatorNotFoundError('non-existent'); // Act const result = ShutdownResult.failed(undefined, undefined, error); // Assert expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.simulatorId).toBeUndefined(); expect(result.diagnostics.simulatorName).toBeUndefined(); expect(result.diagnostics.error).toBe(error); expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); }); it('should create a failed result with ShutdownCommandFailedError', () => { // Arrange const error = new ShutdownCommandFailedError('Device is busy'); const simulatorId = 'ABC123'; const simulatorName = 'iPhone 15'; // Act const result = ShutdownResult.failed(simulatorId, simulatorName, error); // Assert expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); expect(result.diagnostics.error).toBe(error); expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); }); it('should handle generic errors', () => { // Arrange const error = new Error('Unknown error'); // Act const result = ShutdownResult.failed('123', 'Test Device', error); // Assert expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.error).toBe(error); }); }); }); describe('SimulatorNotFoundError', () => { it('should store device ID', () => { // Arrange & Act const error = new SimulatorNotFoundError('iPhone-16'); // Assert expect(error.deviceId).toBe('iPhone-16'); expect(error.name).toBe('SimulatorNotFoundError'); expect(error.message).toBe('iPhone-16'); }); it('should be an instance of Error', () => { // Arrange & Act const error = new SimulatorNotFoundError('test'); // Assert expect(error).toBeInstanceOf(Error); }); }); describe('ShutdownCommandFailedError', () => { it('should store stderr output', () => { // Arrange & Act const error = new ShutdownCommandFailedError('Device is locked'); // Assert expect(error.stderr).toBe('Device is locked'); expect(error.name).toBe('ShutdownCommandFailedError'); expect(error.message).toBe('Device is locked'); }); it('should handle empty stderr', () => { // Arrange & Act const error = new ShutdownCommandFailedError(''); // Assert expect(error.stderr).toBe(''); expect(error.message).toBe(''); }); it('should be an instance of Error', () => { // Arrange & Act const error = new ShutdownCommandFailedError('test'); // Assert expect(error).toBeInstanceOf(Error); }); }); ``` -------------------------------------------------------------------------------- /src/utils/projects/XcodeArchive.ts: -------------------------------------------------------------------------------- ```typescript import { execAsync } from '../../utils.js'; import { createModuleLogger } from '../../logger.js'; import { Platform } from '../../types.js'; import { PlatformInfo } from '../../features/build/domain/PlatformInfo.js'; import path from 'path'; const logger = createModuleLogger('XcodeArchive'); export interface ArchiveOptions { scheme: string; configuration?: string; platform?: Platform; archivePath?: string; } export interface ExportOptions { exportMethod?: 'app-store' | 'ad-hoc' | 'enterprise' | 'development'; exportPath?: string; } /** * Handles archiving and exporting for Xcode projects */ export class XcodeArchive { /** * Archive an Xcode project */ async archive( projectPath: string, isWorkspace: boolean, options: ArchiveOptions ): Promise<{ success: boolean; archivePath: string }> { const { scheme, configuration = 'Release', platform = Platform.iOS, archivePath } = options; // Generate archive path if not provided const finalArchivePath = archivePath || `./build/${scheme}-${new Date().toISOString().split('T')[0]}.xcarchive`; const projectFlag = isWorkspace ? '-workspace' : '-project'; let command = `xcodebuild archive ${projectFlag} "${projectPath}"`; command += ` -scheme "${scheme}"`; command += ` -configuration "${configuration}"`; command += ` -archivePath "${finalArchivePath}"`; // Add platform-specific destination const platformInfo = PlatformInfo.fromPlatform(platform); const destination = platformInfo.generateGenericDestination(); command += ` -destination "${destination}"`; logger.debug({ command }, 'Archive command'); try { const { stdout } = await execAsync(command, { maxBuffer: 50 * 1024 * 1024 }); logger.info({ projectPath, scheme, archivePath: finalArchivePath }, 'Archive succeeded'); return { success: true, archivePath: finalArchivePath }; } catch (error: any) { logger.error({ error: error.message, projectPath }, 'Archive failed'); throw new Error(`Archive failed: ${error.message}`); } } /** * Export an IPA from an archive */ async exportIPA( archivePath: string, options: ExportOptions = {} ): Promise<{ success: boolean; ipaPath: string }> { const { exportMethod = 'development', exportPath = './build' } = options; // Create export options plist const exportPlist = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>method</key> <string>${exportMethod}</string> <key>compileBitcode</key> <false/> </dict> </plist>`; // Write plist to temp file const tempPlistPath = path.join(exportPath, 'ExportOptions.plist'); const { writeFile, mkdir } = await import('fs/promises'); await mkdir(exportPath, { recursive: true }); await writeFile(tempPlistPath, exportPlist); const command = `xcodebuild -exportArchive -archivePath "${archivePath}" -exportPath "${exportPath}" -exportOptionsPlist "${tempPlistPath}"`; logger.debug({ command }, 'Export command'); try { const { stdout } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 }); // Find the IPA file in the export directory const { readdir } = await import('fs/promises'); const files = await readdir(exportPath); const ipaFile = files.find(f => f.endsWith('.ipa')); if (!ipaFile) { throw new Error('IPA file not found in export directory'); } const ipaPath = path.join(exportPath, ipaFile); // Clean up temp plist const { unlink } = await import('fs/promises'); await unlink(tempPlistPath).catch(() => {}); logger.info({ archivePath, ipaPath, exportMethod }, 'IPA export succeeded'); return { success: true, ipaPath }; } catch (error: any) { logger.error({ error: error.message, archivePath }, 'Export failed'); // Clean up temp plist const { unlink } = await import('fs/promises'); await unlink(tempPlistPath).catch(() => {}); throw new Error(`Export failed: ${error.message}`); } } } ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/InstallAppController.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { InstallAppController } from '../../controllers/InstallAppController.js'; import { InstallAppUseCase } from '../../use-cases/InstallAppUseCase.js'; import { InstallRequest } from '../../domain/InstallRequest.js'; import { InstallResult } from '../../domain/InstallResult.js'; import { AppPath } from '../../../../shared/domain/AppPath.js'; import { DeviceId } from '../../../../shared/domain/DeviceId.js'; describe('InstallAppController', () => { function createSUT() { const mockExecute = jest.fn<(request: InstallRequest) => Promise<InstallResult>>(); const mockUseCase: Partial<InstallAppUseCase> = { execute: mockExecute }; const sut = new InstallAppController(mockUseCase as InstallAppUseCase); return { sut, mockExecute }; } describe('MCP tool interface', () => { it('should define correct tool metadata', () => { const { sut } = createSUT(); const definition = sut.getToolDefinition(); expect(definition.name).toBe('install_app'); expect(definition.description).toBe('Install an app on the simulator'); expect(definition.inputSchema).toBeDefined(); }); it('should define correct input schema', () => { const { sut } = createSUT(); const schema = sut.inputSchema; expect(schema.type).toBe('object'); expect(schema.properties.appPath).toBeDefined(); expect(schema.properties.simulatorId).toBeDefined(); expect(schema.required).toEqual(['appPath']); }); }); describe('execute', () => { it('should install app on specified simulator', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = InstallResult.succeeded( 'com.example.app', DeviceId.create('iPhone-15-Simulator'), 'iPhone 15', AppPath.create('/path/to/app.app') ); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ appPath: '/path/to/app.app', simulatorId: 'iPhone-15-Simulator' }); // Assert expect(result.content[0].text).toBe('✅ Successfully installed com.example.app on iPhone 15 (iPhone-15-Simulator)'); }); it('should install app on booted simulator when no ID specified', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = InstallResult.succeeded( 'com.example.app', DeviceId.create('Booted-iPhone-15'), 'iPhone 15', AppPath.create('/path/to/app.app') ); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ appPath: '/path/to/app.app' }); // Assert expect(result.content[0].text).toBe('✅ Successfully installed com.example.app on iPhone 15 (Booted-iPhone-15)'); }); it('should handle validation errors', async () => { // Arrange const { sut } = createSUT(); // Act const result = await sut.execute({ // Missing required appPath simulatorId: 'test-sim' }); // Assert expect(result.content[0].text).toBe('❌ App path is required'); }); it('should handle use case errors', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockRejectedValue(new Error('Simulator not found')); // Act const result = await sut.execute({ appPath: '/path/to/app.app', simulatorId: 'non-existent' }); // Assert expect(result.content[0].text).toBe('❌ Simulator not found'); }); it('should validate app path format', async () => { // Arrange const { sut } = createSUT(); // Act const result = await sut.execute({ appPath: '../../../etc/passwd' // Path traversal attempt }); // Assert expect(result.content[0].text).toBe('❌ App path cannot contain directory traversal'); }); it('should handle app not found errors', async () => { // Arrange const { sut, mockExecute } = createSUT(); mockExecute.mockRejectedValue(new Error('App bundle not found at path')); // Act const result = await sut.execute({ appPath: '/non/existent/app.app' }); // Assert expect(result.content[0].text).toBe('❌ App bundle not found at path'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/BootSimulatorController.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest } from '@jest/globals'; import { BootSimulatorController } from '../../controllers/BootSimulatorController.js'; import { BootSimulatorUseCase } from '../../use-cases/BootSimulatorUseCase.js'; import { BootRequest } from '../../domain/BootRequest.js'; import { BootResult, BootOutcome, BootCommandFailedError } from '../../domain/BootResult.js'; describe('BootSimulatorController', () => { function createSUT() { const mockExecute = jest.fn<(request: BootRequest) => Promise<BootResult>>(); const mockUseCase: Partial<BootSimulatorUseCase> = { execute: mockExecute }; const sut = new BootSimulatorController(mockUseCase as BootSimulatorUseCase); return { sut, mockExecute }; } describe('MCP tool interface', () => { it('should define correct tool metadata', () => { // Arrange const { sut } = createSUT(); // Act const definition = sut.getToolDefinition(); // Assert expect(definition.name).toBe('boot_simulator'); expect(definition.description).toBe('Boot a simulator'); expect(definition.inputSchema).toBeDefined(); }); it('should define correct input schema', () => { // Arrange const { sut } = createSUT(); // Act const schema = sut.inputSchema; // Assert expect(schema.type).toBe('object'); expect(schema.properties.deviceId).toBeDefined(); expect(schema.properties.deviceId.type).toBe('string'); expect(schema.required).toEqual(['deviceId']); }); }); describe('execute', () => { it('should boot simulator successfully', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = BootResult.booted('ABC123', 'iPhone 15', { platform: 'iOS', runtime: 'iOS-17.0' }); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ deviceId: 'iPhone-15' }); // Assert expect(result.content[0].text).toBe('✅ Successfully booted simulator: iPhone 15 (ABC123)'); }); it('should handle already booted simulator', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = BootResult.alreadyBooted('ABC123', 'iPhone 15'); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ deviceId: 'iPhone-15' }); // Assert expect(result.content[0].text).toBe('✅ Simulator already booted: iPhone 15 (ABC123)'); }); it('should handle boot failure', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = BootResult.failed( 'ABC123', 'iPhone 15', new BootCommandFailedError('Device is locked') ); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ deviceId: 'iPhone-15' }); // Assert - Error with ❌ emoji prefix and simulator context expect(result.content[0].text).toBe('❌ iPhone 15 (ABC123) - Device is locked'); }); it('should validate required deviceId', async () => { // Arrange const { sut } = createSUT(); // Act const result = await sut.execute({} as any); // Assert expect(result.content[0].text).toBe('❌ Device ID is required'); }); it('should validate empty deviceId', async () => { // Arrange const { sut } = createSUT(); // Act const result = await sut.execute({ deviceId: '' }); // Assert expect(result.content[0].text).toBe('❌ Device ID cannot be empty'); }); it('should validate whitespace-only deviceId', async () => { // Arrange const { sut } = createSUT(); // Act const result = await sut.execute({ deviceId: ' ' }); // Assert expect(result.content[0].text).toBe('❌ Device ID cannot be whitespace only'); }); it('should pass UUID directly to use case', async () => { // Arrange const { sut, mockExecute } = createSUT(); const uuid = '838C707D-5703-4AEE-AF43-4798E0BA1B05'; const mockResult = BootResult.booted(uuid, 'iPhone 15'); mockExecute.mockResolvedValue(mockResult); // Act await sut.execute({ deviceId: uuid }); // Assert const calledWith = mockExecute.mock.calls[0][0]; expect(calledWith.deviceId).toBe(uuid); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestEnvironmentCleaner.ts: -------------------------------------------------------------------------------- ```typescript import { execSync } from 'child_process'; import { createModuleLogger } from '../../../logger'; const logger = createModuleLogger('TestEnvironmentCleaner'); /** * Utility class for cleaning up test environment (simulators, processes, etc.) * Separate from TestProjectManager to maintain single responsibility */ export class TestEnvironmentCleaner { /** * Shutdown all running simulators * Faster than erasing simulators, just powers them off */ static shutdownAllSimulators(): void { try { execSync('xcrun simctl shutdown all', { stdio: 'ignore' }); } catch (error) { logger.debug('No simulators to shutdown or shutdown failed (normal)'); } } /** * Kill a running macOS app by name * @param appName Name of the app process to kill */ static killMacOSApp(appName: string): void { try { execSync(`pkill -f ${appName}`, { stdio: 'ignore' }); } catch (error) { // Ignore errors - app might not be running } } /** * Kill the test project app if it's running on macOS */ static killTestProjectApp(): void { this.killMacOSApp('TestProjectXCTest'); } /** * Clean DerivedData and SPM build artifacts for test projects */ static cleanDerivedData(): void { try { // Clean MCP-Xcode DerivedData location (where our tests actually write) // This includes Logs/Test/*.xcresult files execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/MCP-Xcode/TestProjectSwiftTesting', { shell: '/bin/bash', stdio: 'ignore' }); execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/MCP-Xcode/TestProjectXCTest', { shell: '/bin/bash', stdio: 'ignore' }); execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/MCP-Xcode/TestSwiftPackage*', { shell: '/bin/bash', stdio: 'ignore' }); // Also clean standard Xcode DerivedData locations (in case xcodebuild uses them directly) execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/TestProjectSwiftTesting-*', { shell: '/bin/bash', stdio: 'ignore' }); execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/TestProjectXCTest-*', { shell: '/bin/bash', stdio: 'ignore' }); execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/TestSwiftPackage-*', { shell: '/bin/bash', stdio: 'ignore' }); // Clean SPM .build directories in test artifacts const testArtifactsDir = process.cwd() + '/test_artifacts'; execSync(`find ${testArtifactsDir} -name .build -type d -exec rm -rf {} + 2>/dev/null || true`, { shell: '/bin/bash', stdio: 'ignore' }); // Clean .swiftpm directories execSync(`find ${testArtifactsDir} -name .swiftpm -type d -exec rm -rf {} + 2>/dev/null || true`, { shell: '/bin/bash', stdio: 'ignore' }); } catch (error) { logger.debug('DerivedData cleanup failed or nothing to clean (normal)'); } } /** * Full cleanup of test environment * Shuts down simulators, kills test apps, and cleans DerivedData */ static cleanupTestEnvironment(): void { // Shutdown all simulators this.shutdownAllSimulators(); // Kill any running test apps this.killTestProjectApp(); // Clean DerivedData for test projects this.cleanDerivedData(); } /** * Reset a specific simulator by erasing its contents * @param deviceId The simulator device ID to reset */ static resetSimulator(deviceId: string): void { try { execSync(`xcrun simctl erase "${deviceId}"`); } catch (error: any) { // Log the actual error message for debugging logger.warn({ deviceId, error: error.message, stderr: error.stderr?.toString() }, 'Failed to erase simulator'); } } /** * Boot a specific simulator * @param deviceId The simulator device ID to boot */ static bootSimulator(deviceId: string): void { try { execSync(`xcrun simctl boot "${deviceId}"`, { stdio: 'ignore' }); } catch (error) { logger.warn({ deviceId }, 'Simulator already booted or boot failed'); } } /** * Shutdown a specific simulator * @param deviceId The simulator device ID to boot */ static shutdownSimulator(deviceId: string): void { try { execSync(`xcrun simctl shutdown "${deviceId}"`, { stdio: 'ignore' }); } catch (error) { logger.warn({ deviceId }, 'Simulator already booted or boot failed'); } } } ``` -------------------------------------------------------------------------------- /src/utils/projects/XcodeProject.ts: -------------------------------------------------------------------------------- ```typescript import { XcodeBuild, BuildOptions, TestOptions } from './XcodeBuild.js'; import { XcodeArchive, ArchiveOptions, ExportOptions } from './XcodeArchive.js'; import { XcodeInfo } from './XcodeInfo.js'; import { Issue } from '../errors/index.js'; import { createModuleLogger } from '../../logger.js'; import * as pathModule from 'path'; import { Platform } from '../../types.js'; const logger = createModuleLogger('XcodeProject'); /** * Represents an Xcode project (.xcodeproj) or workspace (.xcworkspace) */ export class XcodeProject { public readonly name: string; private build: XcodeBuild; private archive: XcodeArchive; private info: XcodeInfo; constructor( public readonly path: string, public readonly type: 'project' | 'workspace', components?: { build?: XcodeBuild; archive?: XcodeArchive; info?: XcodeInfo; } ) { // Extract name from path const ext = type === 'workspace' ? '.xcworkspace' : '.xcodeproj'; this.name = pathModule.basename(this.path, ext); // Initialize components this.build = components?.build || new XcodeBuild(); this.archive = components?.archive || new XcodeArchive(); this.info = components?.info || new XcodeInfo(); logger.debug({ path: this.path, type, name: this.name }, 'XcodeProject created'); } /** * Build the project */ async buildProject(options: BuildOptions = {}): Promise<{ success: boolean; output: string; appPath?: string; logPath?: string; errors?: Issue[]; }> { logger.info({ path: this.path, options }, 'Building Xcode project'); const isWorkspace = this.type === 'workspace'; return await this.build.build(this.path, isWorkspace, options); } /** * Run tests for the project */ async test(options: TestOptions = {}): Promise<{ success: boolean; output: string; passed: number; failed: number; failingTests?: Array<{ identifier: string; reason: string }>; compileErrors?: Issue[]; compileWarnings?: Issue[]; buildErrors?: Issue[]; logPath: string; }> { logger.info({ path: this.path, options }, 'Testing Xcode project'); const isWorkspace = this.type === 'workspace'; return await this.build.test(this.path, isWorkspace, options); } /** * Archive the project for distribution */ async archiveProject(options: ArchiveOptions): Promise<{ success: boolean; archivePath: string; }> { logger.info({ path: this.path, options }, 'Archiving Xcode project'); const isWorkspace = this.type === 'workspace'; return await this.archive.archive(this.path, isWorkspace, options); } /** * Export an IPA from an archive */ async exportIPA( archivePath: string, options: ExportOptions = {} ): Promise<{ success: boolean; ipaPath: string; }> { logger.info({ archivePath, options }, 'Exporting IPA'); return await this.archive.exportIPA(archivePath, options); } /** * Clean build artifacts */ async clean(options: { scheme?: string; configuration?: string; } = {}): Promise<void> { logger.info({ path: this.path, options }, 'Cleaning Xcode project'); const isWorkspace = this.type === 'workspace'; await this.build.clean(this.path, isWorkspace, options); } /** * Get list of schemes */ async getSchemes(): Promise<string[]> { const isWorkspace = this.type === 'workspace'; return await this.info.getSchemes(this.path, isWorkspace); } /** * Get list of targets */ async getTargets(): Promise<string[]> { const isWorkspace = this.type === 'workspace'; return await this.info.getTargets(this.path, isWorkspace); } /** * Get build settings for a scheme */ async getBuildSettings( scheme: string, configuration?: string, platform?: Platform ): Promise<any> { const isWorkspace = this.type === 'workspace'; return await this.info.getBuildSettings( this.path, isWorkspace, scheme, configuration, platform ); } /** * Get comprehensive project information */ async getProjectInfo(): Promise<{ name: string; schemes: string[]; targets: string[]; configurations: string[]; }> { const isWorkspace = this.type === 'workspace'; return await this.info.getProjectInfo(this.path, isWorkspace); } /** * Check if this is a workspace */ isWorkspace(): boolean { return this.type === 'workspace'; } /** * Get the project directory */ getDirectory(): string { return pathModule.dirname(this.path); } } ``` -------------------------------------------------------------------------------- /src/utils/projects/SwiftPackage.ts: -------------------------------------------------------------------------------- ```typescript import { SwiftBuild, SwiftBuildOptions, SwiftRunOptions, SwiftTestOptions } from './SwiftBuild.js'; import { SwiftPackageInfo, Dependency, Product } from './SwiftPackageInfo.js'; import { Issue } from '../errors/index.js'; import { createModuleLogger } from '../../logger.js'; import * as pathModule from 'path'; import { existsSync } from 'fs'; const logger = createModuleLogger('SwiftPackage'); /** * Represents a Swift package (Package.swift) */ export class SwiftPackage { public readonly name: string; private build: SwiftBuild; private info: SwiftPackageInfo; constructor( public readonly path: string, components?: { build?: SwiftBuild; info?: SwiftPackageInfo; } ) { // Validate that Package.swift exists const packageSwiftPath = pathModule.join(this.path, 'Package.swift'); if (!existsSync(packageSwiftPath)) { throw new Error(`No Package.swift found at: ${this.path}`); } // Extract name from directory this.name = pathModule.basename(this.path); // Initialize components this.build = components?.build || new SwiftBuild(); this.info = components?.info || new SwiftPackageInfo(); logger.debug({ path: this.path, name: this.name }, 'SwiftPackage created'); } /** * Build the package */ async buildPackage(options: SwiftBuildOptions = {}): Promise<{ success: boolean; output: string; logPath?: string; compileErrors?: Issue[]; buildErrors?: Issue[]; }> { logger.info({ path: this.path, options }, 'Building Swift package'); return await this.build.build(this.path, options); } /** * Run an executable from the package */ async run(options: SwiftRunOptions = {}): Promise<{ success: boolean; output: string; logPath?: string; compileErrors?: Issue[]; buildErrors?: Issue[]; }> { logger.info({ path: this.path, options }, 'Running Swift package'); return await this.build.run(this.path, options); } /** * Test the package */ async test(options: SwiftTestOptions = {}): Promise<{ success: boolean; output: string; passed: number; failed: number; failingTests?: Array<{ identifier: string; reason: string }>; compileErrors?: Issue[]; buildErrors?: Issue[]; logPath: string; }> { logger.info({ path: this.path, options }, 'Testing Swift package'); return await this.build.test(this.path, options); } /** * Clean build artifacts */ async clean(): Promise<void> { logger.info({ path: this.path }, 'Cleaning Swift package'); await this.build.clean(this.path); } /** * Get list of products (executables and libraries) */ async getProducts(): Promise<Product[]> { return await this.info.getProducts(this.path); } /** * Get list of targets */ async getTargets(): Promise<string[]> { return await this.info.getTargets(this.path); } /** * Get list of dependencies */ async getDependencies(): Promise<Dependency[]> { return await this.info.getDependencies(this.path); } /** * Add a dependency */ async addDependency( url: string, options: { version?: string; branch?: string; exact?: boolean; from?: string; upToNextMajor?: string; } = {} ): Promise<void> { logger.info({ path: this.path, url, options }, 'Adding dependency'); await this.info.addDependency(this.path, url, options); } /** * Remove a dependency */ async removeDependency(name: string): Promise<void> { logger.info({ path: this.path, name }, 'Removing dependency'); await this.info.removeDependency(this.path, name); } /** * Update all dependencies */ async updateDependencies(): Promise<void> { logger.info({ path: this.path }, 'Updating dependencies'); await this.info.updateDependencies(this.path); } /** * Resolve dependencies */ async resolveDependencies(): Promise<void> { logger.info({ path: this.path }, 'Resolving dependencies'); await this.info.resolveDependencies(this.path); } /** * Get the package directory */ getDirectory(): string { return this.path; } /** * Check if this is an executable package */ async isExecutable(): Promise<boolean> { const products = await this.getProducts(); return products.some(p => p.type === 'executable'); } /** * Get executable products */ async getExecutables(): Promise<string[]> { const products = await this.getProducts(); return products .filter(p => p.type === 'executable') .map(p => p.name); } } ``` -------------------------------------------------------------------------------- /src/utils/LogManager.ts: -------------------------------------------------------------------------------- ```typescript import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; /** * Manages persistent logs for MCP server debugging * Stores logs in ~/.mcp-xcode-server/logs/ with daily rotation */ export class LogManager { private static readonly LOG_DIR = path.join(os.homedir(), '.mcp-xcode-server', 'logs'); private static readonly MAX_AGE_DAYS = 7; /** * Initialize log directory structure */ private init(): void { if (!fs.existsSync(LogManager.LOG_DIR)) { fs.mkdirSync(LogManager.LOG_DIR, { recursive: true }); } // Clean up old logs on startup this.cleanupOldLogs(); } /** * Get the log directory for today */ private getTodayLogDir(): string { const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const dir = path.join(LogManager.LOG_DIR, today); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } return dir; } /** * Generate a log filename with timestamp */ private getLogFilename(operation: string, projectName?: string): string { const timestamp = new Date().toISOString() .replace(/:/g, '-') .replace(/\./g, '-') .split('T')[1] .slice(0, 8); // HH-MM-SS const name = projectName ? `${operation}-${projectName}` : operation; return `${timestamp}-${name}.log`; } /** * Save log content to a file * Returns the full path to the log file */ saveLog( operation: 'build' | 'test' | 'run' | 'archive' | 'clean', content: string, projectName?: string, metadata?: Record<string, any> ): string { const dir = this.getTodayLogDir(); const filename = this.getLogFilename(operation, projectName); const filepath = path.join(dir, filename); // Add metadata header if provided let fullContent = ''; if (metadata) { fullContent += '=== Log Metadata ===\n'; fullContent += JSON.stringify(metadata, null, 2) + '\n'; fullContent += '=== End Metadata ===\n\n'; } fullContent += content; fs.writeFileSync(filepath, fullContent, 'utf8'); // Also create/update a symlink to latest log const latestLink = path.join(LogManager.LOG_DIR, `latest-${operation}.log`); if (fs.existsSync(latestLink)) { fs.unlinkSync(latestLink); } // Create relative symlink for portability const relativePath = `./${new Date().toISOString().split('T')[0]}/${filename}`; try { // Use execSync to create symlink as fs.symlinkSync has issues on some systems const { execSync } = require('child_process'); execSync(`ln -sf "${relativePath}" "${latestLink}"`, { cwd: LogManager.LOG_DIR }); } catch { // Symlink creation failed, not critical } return filepath; } /** * Save debug data (like parsed xcresult) for analysis */ saveDebugData( operation: string, data: any, projectName?: string ): string { const dir = this.getTodayLogDir(); const timestamp = new Date().toISOString() .replace(/:/g, '-') .replace(/\./g, '-') .split('T')[1] .slice(0, 8); const name = projectName ? `${operation}-${projectName}` : operation; const filename = `${timestamp}-${name}-debug.json`; const filepath = path.join(dir, filename); fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); return filepath; } /** * Clean up logs older than MAX_AGE_DAYS */ cleanupOldLogs(): void { if (!fs.existsSync(LogManager.LOG_DIR)) { return; } const now = Date.now(); const maxAge = LogManager.MAX_AGE_DAYS * 24 * 60 * 60 * 1000; try { const entries = fs.readdirSync(LogManager.LOG_DIR); for (const entry of entries) { const fullPath = path.join(LogManager.LOG_DIR, entry); // Skip symlinks const stat = fs.statSync(fullPath); if (stat.isSymbolicLink()) { continue; } // Check if it's a date directory (YYYY-MM-DD format) if (stat.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry)) { const dirDate = new Date(entry).getTime(); if (now - dirDate > maxAge) { fs.rmSync(fullPath, { recursive: true, force: true }); } } } } catch (error) { // Cleanup failed, not critical } } /** * Get the user-friendly log path for display */ getDisplayPath(fullPath: string): string { // Replace home directory with ~ const home = os.homedir(); return fullPath.replace(home, '~'); } /** * Get the log directory path */ getLogDirectory(): string { return LogManager.LOG_DIR; } } ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/InstallResult.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from '@jest/globals'; import { InstallResult, InstallOutcome, InstallCommandFailedError, SimulatorNotFoundError } from '../../domain/InstallResult.js'; import { DeviceId } from '../../../../shared/domain/DeviceId.js'; import { AppPath } from '../../../../shared/domain/AppPath.js'; describe('InstallResult', () => { describe('succeeded', () => { it('should create successful install result', () => { // Arrange & Act const simulatorId = DeviceId.create('iPhone-15-Simulator'); const appPath = AppPath.create('/path/to/app.app'); const result = InstallResult.succeeded( 'com.example.app', simulatorId, 'iPhone 15', appPath ); // Assert expect(result.outcome).toBe(InstallOutcome.Succeeded); expect(result.diagnostics.bundleId).toBe('com.example.app'); expect(result.diagnostics.simulatorId?.toString()).toBe('iPhone-15-Simulator'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); expect(result.diagnostics.appPath.toString()).toBe('/path/to/app.app'); expect(result.diagnostics.error).toBeUndefined(); }); it('should include install timestamp', () => { // Arrange & Act const before = Date.now(); const simulatorId = DeviceId.create('test-sim'); const appPath = AppPath.create('/path/to/app.app'); const result = InstallResult.succeeded( 'com.example.app', simulatorId, 'Test Simulator', appPath ); const after = Date.now(); // Assert expect(result.diagnostics.installedAt.getTime()).toBeGreaterThanOrEqual(before); expect(result.diagnostics.installedAt.getTime()).toBeLessThanOrEqual(after); }); }); describe('failed', () => { it('should create failed install result with SimulatorNotFoundError', () => { // Arrange const simulatorId = DeviceId.create('non-existent-sim'); const error = new SimulatorNotFoundError(simulatorId); // Act const appPath = AppPath.create('/path/to/app.app'); const result = InstallResult.failed( error, appPath, simulatorId, 'Unknown Simulator' ); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBe(error); expect(result.diagnostics.appPath.toString()).toBe('/path/to/app.app'); expect(result.diagnostics.simulatorId?.toString()).toBe('non-existent-sim'); expect(result.diagnostics.bundleId).toBeUndefined(); }); it('should handle failure without simulator ID', () => { // Arrange const simulatorId = DeviceId.create('booted'); const error = new SimulatorNotFoundError(simulatorId); // Act const appPath = AppPath.create('/path/to/app.app'); const result = InstallResult.failed( error, appPath ); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBe(error); expect(result.diagnostics.appPath.toString()).toBe('/path/to/app.app'); expect(result.diagnostics.simulatorId).toBeUndefined(); }); it('should create failed install result with InstallCommandFailedError', () => { // Arrange const error = new InstallCommandFailedError('App bundle not found'); // Act const appPath = AppPath.create('/path/to/app.app'); const simulatorId = DeviceId.create('test-sim'); const result = InstallResult.failed( error, appPath, simulatorId, 'Test Simulator' ); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBe(error); expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('App bundle not found'); }); }); describe('outcome checking', () => { it('should identify successful installation', () => { // Arrange & Act const simulatorId = DeviceId.create('sim-id'); const appPath = AppPath.create('/app.app'); const result = InstallResult.succeeded( 'com.example.app', simulatorId, 'Simulator', appPath ); // Assert expect(result.outcome).toBe(InstallOutcome.Succeeded); }); it('should identify failed installation', () => { // Arrange const error = new InstallCommandFailedError('Installation failed'); // Act const appPath = AppPath.create('/app.app'); const result = InstallResult.failed( error, appPath ); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/BootSimulatorController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for BootSimulatorController * * Tests the controller with REAL simulators and REAL system commands * Following testing philosophy: E2E tests for critical paths only (10%) * * NO MOCKS - Uses real xcrun simctl commands with actual simulators */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { BootSimulatorControllerFactory } from '../../factories/BootSimulatorControllerFactory.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); describe('BootSimulatorController E2E', () => { let controller: MCPController; let testDeviceId: string; let testSimulatorName: string; beforeAll(async () => { // Create controller with REAL components controller = BootSimulatorControllerFactory.create(); // Find or create a test simulator const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); // Look for an existing test simulator for (const runtime of Object.values(devices.devices) as any[]) { const testSim = runtime.find((d: any) => d.name.includes('TestSimulator-Boot')); if (testSim) { testDeviceId = testSim.udid; testSimulatorName = testSim.name; break; } } // Create one if not found if (!testDeviceId) { // Get available runtime const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); const runtimes = JSON.parse(runtimesResult.stdout); const iosRuntime = runtimes.runtimes.find((r: any) => r.platform === 'iOS'); if (!iosRuntime) { throw new Error('No iOS runtime available. Please install Xcode with iOS simulator support.'); } const createResult = await execAsync( `xcrun simctl create "TestSimulator-Boot" "com.apple.CoreSimulator.SimDeviceType.iPhone-15" "${iosRuntime.identifier}"` ); testDeviceId = createResult.stdout.trim(); testSimulatorName = 'TestSimulator-Boot'; } }); beforeEach(async () => { // Ensure simulator is shutdown before each test try { await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); } catch { // Ignore if already shutdown } // Wait for shutdown to complete await new Promise(resolve => setTimeout(resolve, 1000)); }); afterAll(async () => { // Shutdown the test simulator try { await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); } catch { // Ignore if already shutdown } }); describe('boot real simulators', () => { it('should boot a shutdown simulator', async () => { // Act const result = await controller.execute({ deviceId: testSimulatorName }); // Assert expect(result.content[0].text).toBe(`✅ Successfully booted simulator: ${testSimulatorName} (${testDeviceId})`); // Verify simulator is actually booted const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); let found = false; for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === testDeviceId); if (device) { expect(device.state).toBe('Booted'); found = true; break; } } expect(found).toBe(true); }); it('should handle already booted simulator', async () => { // Arrange - boot the simulator first await execAsync(`xcrun simctl boot "${testDeviceId}"`); await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for boot // Act const result = await controller.execute({ deviceId: testDeviceId }); // Assert expect(result.content[0].text).toBe(`✅ Simulator already booted: ${testSimulatorName} (${testDeviceId})`); }); it('should boot simulator by UUID', async () => { // Act - use UUID directly const result = await controller.execute({ deviceId: testDeviceId }); // Assert expect(result.content[0].text).toBe(`✅ Successfully booted simulator: ${testSimulatorName} (${testDeviceId})`); }); }); describe('error handling with real simulators', () => { it('should fail when simulator does not exist', async () => { // Act const result = await controller.execute({ deviceId: 'NonExistentSimulator-12345' }); // Assert expect(result.content[0].text).toBe('❌ Simulator not found: NonExistentSimulator-12345'); }); }); }); ``` -------------------------------------------------------------------------------- /src/presentation/tests/unit/DependencyCheckingDecorator.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest } from '@jest/globals'; import { DependencyCheckingDecorator } from '../../decorators/DependencyCheckingDecorator.js'; import { MCPController } from '../../interfaces/MCPController.js'; import { MCPResponse } from '../../interfaces/MCPResponse.js'; import { IDependencyChecker, MissingDependency } from '../../interfaces/IDependencyChecker.js'; describe('DependencyCheckingDecorator', () => { function createSUT(missingDeps: MissingDependency[] = []) { // Create mock controller const mockExecute = jest.fn<(args: unknown) => Promise<MCPResponse>>(); const mockController: MCPController = { name: 'test_tool', description: 'Test tool', inputSchema: {}, execute: mockExecute, getToolDefinition: () => ({ name: 'test_tool', description: 'Test tool', inputSchema: {} }) }; // Create mock dependency checker const mockCheck = jest.fn<IDependencyChecker['check']>(); mockCheck.mockResolvedValue(missingDeps); const mockChecker: IDependencyChecker = { check: mockCheck }; // Create decorator const sut = new DependencyCheckingDecorator( mockController, ['xcodebuild', 'xcbeautify'], mockChecker ); return { sut, mockExecute, mockCheck }; } describe('execute', () => { it('should execute controller when all dependencies are available', async () => { // Arrange const { sut, mockExecute } = createSUT([]); // No missing dependencies const args = { someArg: 'value' }; const expectedResponse = { content: [{ type: 'text', text: 'Success' }] }; mockExecute.mockResolvedValue(expectedResponse); // Act const result = await sut.execute(args); // Assert - behavior: delegates to controller expect(result).toBe(expectedResponse); expect(mockExecute).toHaveBeenCalledWith(args); }); it('should return error when dependencies are missing', async () => { // Arrange const missingDeps: MissingDependency[] = [ { name: 'xcbeautify', installCommand: 'brew install xcbeautify' } ]; const { sut, mockExecute } = createSUT(missingDeps); // Act const result = await sut.execute({}); // Assert - behavior: returns error, doesn't execute controller expect(result.content[0].text).toContain('Missing required dependencies'); expect(result.content[0].text).toContain('xcbeautify'); expect(result.content[0].text).toContain('brew install xcbeautify'); expect(mockExecute).not.toHaveBeenCalled(); }); it('should format multiple missing dependencies clearly', async () => { // Arrange const missingDeps: MissingDependency[] = [ { name: 'xcodebuild', installCommand: 'Install Xcode from the App Store' }, { name: 'xcbeautify', installCommand: 'brew install xcbeautify' } ]; const { sut, mockExecute } = createSUT(missingDeps); // Act const result = await sut.execute({}); // Assert - behavior: shows all missing dependencies expect(result.content[0].text).toContain('xcodebuild'); expect(result.content[0].text).toContain('Install Xcode from the App Store'); expect(result.content[0].text).toContain('xcbeautify'); expect(result.content[0].text).toContain('brew install xcbeautify'); expect(mockExecute).not.toHaveBeenCalled(); }); it('should handle dependencies without install commands', async () => { // Arrange const missingDeps: MissingDependency[] = [ { name: 'customtool' } // No install command ]; const { sut, mockExecute } = createSUT(missingDeps); // Act const result = await sut.execute({}); // Assert - behavior: shows tool name without install command expect(result.content[0].text).toContain('customtool'); expect(result.content[0].text).not.toContain('undefined'); expect(mockExecute).not.toHaveBeenCalled(); }); }); describe('getToolDefinition', () => { it('should delegate to decoratee', () => { // Arrange const { sut } = createSUT(); // Act const definition = sut.getToolDefinition(); // Assert - behavior: returns controller's definition expect(definition).toEqual({ name: 'test_tool', description: 'Test tool', inputSchema: {} }); }); }); describe('properties', () => { it('should delegate properties to decoratee', () => { // Arrange const { sut } = createSUT(); // Act & Assert - behavior: properties match controller expect(sut.name).toBe('test_tool'); expect(sut.description).toBe('Test tool'); expect(sut.inputSchema).toEqual({}); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/LogManagerInstance.ts: -------------------------------------------------------------------------------- ```typescript import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ILogManager } from '../application/ports/LoggingPorts.js'; /** * Instance-based log manager for dependency injection * Manages persistent logs for MCP server debugging */ export class LogManagerInstance implements ILogManager { private readonly LOG_DIR: string; private readonly MAX_AGE_DAYS = 7; constructor(logDir?: string) { this.LOG_DIR = logDir || path.join(os.homedir(), '.mcp-xcode-server', 'logs'); this.init(); } /** * Initialize log directory structure */ private init(): void { if (!fs.existsSync(this.LOG_DIR)) { fs.mkdirSync(this.LOG_DIR, { recursive: true }); } // Clean up old logs on startup this.cleanupOldLogs(); } /** * Get the log directory for today */ private getTodayLogDir(): string { const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const dir = path.join(this.LOG_DIR, today); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } return dir; } /** * Generate a log filename with timestamp */ private getLogFilename(operation: string, projectName?: string): string { const timestamp = new Date().toISOString() .replace(/:/g, '-') .replace(/\./g, '-') .split('T')[1] .slice(0, 8); // HH-MM-SS const name = projectName ? `${operation}-${projectName}` : operation; return `${timestamp}-${name}.log`; } /** * Save log content to a file * Returns the full path to the log file */ saveLog( operation: 'build' | 'test' | 'run' | 'archive' | 'clean', content: string, projectName?: string, metadata?: Record<string, any> ): string { const dir = this.getTodayLogDir(); const filename = this.getLogFilename(operation, projectName); const filepath = path.join(dir, filename); // Add metadata header if provided let fullContent = ''; if (metadata) { fullContent += '=== Log Metadata ===\n'; fullContent += JSON.stringify(metadata, null, 2) + '\n'; fullContent += '=== End Metadata ===\n\n'; } fullContent += content; fs.writeFileSync(filepath, fullContent, 'utf8'); // Also create/update a symlink to latest log const latestLink = path.join(this.LOG_DIR, `latest-${operation}.log`); if (fs.existsSync(latestLink)) { fs.unlinkSync(latestLink); } // Create relative symlink for portability const relativePath = `./${new Date().toISOString().split('T')[0]}/${filename}`; try { // Use execSync to create symlink as fs.symlinkSync has issues on some systems const { execSync } = require('child_process'); execSync(`ln -sf "${relativePath}" "${latestLink}"`, { cwd: this.LOG_DIR }); } catch { // Symlink creation failed, not critical } return filepath; } /** * Save debug data (like parsed xcresult) for analysis */ saveDebugData( operation: string, data: any, projectName?: string ): string { const dir = this.getTodayLogDir(); const timestamp = new Date().toISOString() .replace(/:/g, '-') .replace(/\./g, '-') .split('T')[1] .slice(0, 8); const name = projectName ? `${operation}-${projectName}` : operation; const filename = `${timestamp}-${name}-debug.json`; const filepath = path.join(dir, filename); fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); return filepath; } /** * Clean up logs older than MAX_AGE_DAYS */ cleanupOldLogs(): void { if (!fs.existsSync(this.LOG_DIR)) { return; } const now = Date.now(); const maxAge = this.MAX_AGE_DAYS * 24 * 60 * 60 * 1000; try { const entries = fs.readdirSync(this.LOG_DIR); for (const entry of entries) { const fullPath = path.join(this.LOG_DIR, entry); // Skip symlinks const stat = fs.statSync(fullPath); if (stat.isSymbolicLink()) { continue; } // Check if it's a date directory (YYYY-MM-DD format) if (stat.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry)) { const dirDate = new Date(entry).getTime(); if (now - dirDate > maxAge) { fs.rmSync(fullPath, { recursive: true, force: true }); } } } } catch (error) { // Cleanup failed, not critical } } /** * Get the user-friendly log path for display */ getDisplayPath(fullPath: string): string { // Replace home directory with ~ const home = os.homedir(); return fullPath.replace(home, '~'); } /** * Get the log directory path */ getLogDirectory(): string { return this.LOG_DIR; } } ``` -------------------------------------------------------------------------------- /src/presentation/presenters/BuildXcodePresenter.ts: -------------------------------------------------------------------------------- ```typescript import { BuildResult, BuildOutcome, OutputFormatterError } from '../../features/build/domain/BuildResult.js'; import { Platform } from '../../shared/domain/Platform.js'; import { ErrorFormatter } from '../formatters/ErrorFormatter.js'; import { MCPResponse } from '../interfaces/MCPResponse.js'; /** * Presenter for build results * * Single Responsibility: Format BuildResult for MCP display * - Success formatting * - Failure formatting with errors/warnings * - Log path information */ export class BuildXcodePresenter { private readonly maxErrorsToShow = 50; private readonly maxWarningsToShow = 20; present(result: BuildResult, metadata: { scheme: string; platform: Platform; configuration: string; showWarningDetails?: boolean; }): MCPResponse { if (result.outcome === BuildOutcome.Succeeded) { return this.presentSuccess(result, metadata); } return this.presentFailure(result, metadata); } private presentSuccess( result: BuildResult, metadata: { scheme: string; platform: Platform; configuration: string; showWarningDetails?: boolean } ): MCPResponse { const warnings = BuildResult.getWarnings(result); let text = `✅ Build succeeded: ${metadata.scheme} Platform: ${metadata.platform} Configuration: ${metadata.configuration}`; // Show warning count if there are any if (warnings.length > 0) { text += `\nWarnings: ${warnings.length}`; // Show warning details if requested if (metadata.showWarningDetails) { text += '\n\n⚠️ Warnings:'; const warningsToShow = Math.min(warnings.length, this.maxWarningsToShow); warnings.slice(0, warningsToShow).forEach(warning => { text += `\n • ${this.formatIssue(warning)}`; }); if (warnings.length > this.maxWarningsToShow) { text += `\n ... and ${warnings.length - this.maxWarningsToShow} more warnings`; } } } text += `\nApp path: ${result.diagnostics.appPath || 'N/A'}${result.diagnostics.logPath ? ` 📁 Full logs saved to: ${result.diagnostics.logPath}` : ''}`; return { content: [{ type: 'text', text }] }; } private presentFailure( result: BuildResult, metadata: { scheme: string; platform: Platform; configuration: string; showWarningDetails?: boolean } ): MCPResponse { // Check if this is a dependency/tool error (not an actual build failure) if (result.diagnostics.error && result.diagnostics.error instanceof OutputFormatterError) { // Tool dependency missing - show only that error const text = `❌ ${ErrorFormatter.format(result.diagnostics.error)}`; return { content: [{ type: 'text', text }] }; } const errors = BuildResult.getErrors(result); const warnings = BuildResult.getWarnings(result); let text = `❌ Build failed: ${metadata.scheme}\n`; text += `Platform: ${metadata.platform}\n`; text += `Configuration: ${metadata.configuration}\n`; // Check for other errors in diagnostics if (result.diagnostics.error) { text += `\n❌ ${ErrorFormatter.format(result.diagnostics.error)}\n`; } if (errors.length > 0) { text += `\n❌ Errors (${errors.length}):\n`; // Show up to maxErrorsToShow errors const errorsToShow = Math.min(errors.length, this.maxErrorsToShow); errors.slice(0, errorsToShow).forEach(error => { text += ` • ${this.formatIssue(error)}\n`; }); if (errors.length > this.maxErrorsToShow) { text += ` ... and ${errors.length - this.maxErrorsToShow} more errors\n`; } } // Always show warning count if there are warnings if (warnings.length > 0) { if (metadata.showWarningDetails) { // Show detailed warnings text += `\n⚠️ Warnings (${warnings.length}):\n`; const warningsToShow = Math.min(warnings.length, this.maxWarningsToShow); warnings.slice(0, warningsToShow).forEach(warning => { text += ` • ${this.formatIssue(warning)}\n`; }); if (warnings.length > this.maxWarningsToShow) { text += ` ... and ${warnings.length - this.maxWarningsToShow} more warnings\n`; } } else { // Just show count text += `\n⚠️ Warnings: ${warnings.length}\n`; } } if (result.diagnostics.logPath) { text += `\n📁 Full logs saved to: ${result.diagnostics.logPath}\n`; } return { content: [{ type: 'text', text }] }; } private formatIssue(issue: any): string { if (issue.file && issue.line) { if (issue.column) { return `${issue.file}:${issue.line}:${issue.column}: ${issue.message}`; } return `${issue.file}:${issue.line}: ${issue.message}`; } return issue.message; } presentError(error: Error): MCPResponse { const message = ErrorFormatter.format(error); return { content: [{ type: 'text', text: `❌ ${message}` }] }; } } ``` -------------------------------------------------------------------------------- /src/utils/projects/XcodeInfo.ts: -------------------------------------------------------------------------------- ```typescript import { execAsync } from '../../utils.js'; import { createModuleLogger } from '../../logger.js'; import { Platform } from '../../types.js'; import path from 'path'; const logger = createModuleLogger('XcodeInfo'); /** * Queries information about Xcode projects */ export class XcodeInfo { /** * Get list of schemes in a project */ async getSchemes( projectPath: string, isWorkspace: boolean ): Promise<string[]> { const projectFlag = isWorkspace ? '-workspace' : '-project'; const command = `xcodebuild -list -json ${projectFlag} "${projectPath}"`; logger.debug({ command }, 'List schemes command'); try { const { stdout } = await execAsync(command); const data = JSON.parse(stdout); // Get schemes from the appropriate property let schemes: string[] = []; if (isWorkspace && data.workspace?.schemes) { schemes = data.workspace.schemes; } else if (!isWorkspace && data.project?.schemes) { schemes = data.project.schemes; } logger.debug({ projectPath, schemes }, 'Found schemes'); return schemes; } catch (error: any) { logger.error({ error: error.message, projectPath }, 'Failed to get schemes'); throw new Error(`Failed to get schemes: ${error.message}`); } } /** * Get list of targets in a project */ async getTargets( projectPath: string, isWorkspace: boolean ): Promise<string[]> { const projectFlag = isWorkspace ? '-workspace' : '-project'; const command = `xcodebuild -list -json ${projectFlag} "${projectPath}"`; logger.debug({ command }, 'List targets command'); try { const { stdout } = await execAsync(command); const data = JSON.parse(stdout); // Get targets from the project (even for workspaces, targets come from projects) const targets = data.project?.targets || []; logger.debug({ projectPath, targets }, 'Found targets'); return targets; } catch (error: any) { logger.error({ error: error.message, projectPath }, 'Failed to get targets'); throw new Error(`Failed to get targets: ${error.message}`); } } /** * Get build settings for a scheme */ async getBuildSettings( projectPath: string, isWorkspace: boolean, scheme: string, configuration?: string, platform?: Platform ): Promise<any> { const projectFlag = isWorkspace ? '-workspace' : '-project'; let command = `xcodebuild -showBuildSettings ${projectFlag} "${projectPath}"`; command += ` -scheme "${scheme}"`; if (configuration) { command += ` -configuration "${configuration}"`; } if (platform) { // Add a generic destination for the platform to get appropriate settings const { PlatformInfo } = await import('../../features/build/domain/PlatformInfo.js'); const platformInfo = PlatformInfo.fromPlatform(platform); const destination = platformInfo.generateGenericDestination(); command += ` -destination '${destination}'`; } command += ' -json'; logger.debug({ command }, 'Get build settings command'); try { const { stdout } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 }); const settings = JSON.parse(stdout); logger.debug({ projectPath, scheme }, 'Got build settings'); return settings; } catch (error: any) { logger.error({ error: error.message, projectPath, scheme }, 'Failed to get build settings'); throw new Error(`Failed to get build settings: ${error.message}`); } } /** * Get comprehensive project information */ async getProjectInfo( projectPath: string, isWorkspace: boolean ): Promise<{ name: string; schemes: string[]; targets: string[]; configurations: string[]; }> { const projectFlag = isWorkspace ? '-workspace' : '-project'; const command = `xcodebuild -list -json ${projectFlag} "${projectPath}"`; logger.debug({ command }, 'Get project info command'); try { const { stdout } = await execAsync(command); const data = JSON.parse(stdout); // Extract info based on project type let info; if (isWorkspace) { info = { name: data.workspace?.name || path.basename(projectPath, '.xcworkspace'), schemes: data.workspace?.schemes || [], targets: data.project?.targets || [], configurations: data.project?.configurations || [] }; } else { info = { name: data.project?.name || path.basename(projectPath, '.xcodeproj'), schemes: data.project?.schemes || [], targets: data.project?.targets || [], configurations: data.project?.configurations || [] }; } logger.debug({ projectPath, info }, 'Got project info'); return info; } catch (error: any) { logger.error({ error: error.message, projectPath }, 'Failed to get project info'); throw new Error(`Failed to get project info: ${error.message}`); } } } ``` -------------------------------------------------------------------------------- /src/utils/devices/SimulatorDevice.ts: -------------------------------------------------------------------------------- ```typescript import { SimulatorBoot } from './SimulatorBoot.js'; import { SimulatorApps } from './SimulatorApps.js'; import { SimulatorUI } from './SimulatorUI.js'; import { SimulatorInfo } from './SimulatorInfo.js'; import { SimulatorReset } from './SimulatorReset.js'; import { createModuleLogger } from '../../logger.js'; const logger = createModuleLogger('SimulatorDevice'); /** * Represents a specific simulator device instance. * Provides a complete interface for simulator operations while * delegating to specialized components internally. */ export class SimulatorDevice { private boot: SimulatorBoot; private apps: SimulatorApps; private ui: SimulatorUI; private info: SimulatorInfo; private reset: SimulatorReset; constructor( public readonly id: string, public readonly name: string, public readonly platform: string, public readonly runtime: string, components?: { boot?: SimulatorBoot; apps?: SimulatorApps; ui?: SimulatorUI; info?: SimulatorInfo; reset?: SimulatorReset; } ) { this.boot = components?.boot || new SimulatorBoot(); this.apps = components?.apps || new SimulatorApps(); this.ui = components?.ui || new SimulatorUI(); this.info = components?.info || new SimulatorInfo(); this.reset = components?.reset || new SimulatorReset(); } /** * Boot this simulator device */ async bootDevice(): Promise<void> { logger.debug({ deviceId: this.id, name: this.name }, 'Booting device'); await this.boot.boot(this.id); } /** * Shutdown this simulator device */ async shutdown(): Promise<void> { logger.debug({ deviceId: this.id, name: this.name }, 'Shutting down device'); await this.boot.shutdown(this.id); } /** * Install an app on this device */ async install(appPath: string): Promise<void> { logger.debug({ deviceId: this.id, appPath }, 'Installing app on device'); await this.apps.install(appPath, this.id); } /** * Uninstall an app from this device */ async uninstall(bundleId: string): Promise<void> { logger.debug({ deviceId: this.id, bundleId }, 'Uninstalling app from device'); await this.apps.uninstall(bundleId, this.id); } /** * Launch an app on this device */ async launch(bundleId: string): Promise<string> { logger.debug({ deviceId: this.id, bundleId }, 'Launching app on device'); return await this.apps.launch(bundleId, this.id); } /** * Get bundle ID from an app path */ async getBundleId(appPath: string): Promise<string> { return await this.apps.getBundleId(appPath); } /** * Take a screenshot of this device */ async screenshot(outputPath: string): Promise<void> { logger.debug({ deviceId: this.id, outputPath }, 'Taking screenshot'); await this.ui.screenshot(outputPath, this.id); } /** * Get screenshot data as base64 */ async screenshotData(): Promise<{ base64: string; mimeType: string }> { logger.debug({ deviceId: this.id }, 'Getting screenshot data'); return await this.ui.screenshotData(this.id); } /** * Set appearance mode (light/dark) */ async setAppearance(appearance: 'light' | 'dark'): Promise<void> { logger.debug({ deviceId: this.id, appearance }, 'Setting appearance'); await this.ui.setAppearance(appearance, this.id); } /** * Open the Simulator app UI */ async open(): Promise<void> { await this.ui.open(); } /** * Get device logs */ async logs(predicate?: string, last?: string): Promise<string> { logger.debug({ deviceId: this.id, predicate, last }, 'Getting device logs'); return await this.info.logs(this.id, predicate, last); } /** * Get current device state */ async getState(): Promise<string> { return await this.info.getDeviceState(this.id); } /** * Check if device is available */ async checkAvailability(): Promise<boolean> { return await this.info.isAvailable(this.id); } /** * Reset this device to clean state */ async resetDevice(): Promise<void> { logger.debug({ deviceId: this.id, name: this.name }, 'Resetting device'); await this.reset.reset(this.id); } /** * Check if device is currently booted * Checks actual current state, not cached value */ async isBooted(): Promise<boolean> { const currentState = await this.getState(); return currentState === 'Booted'; } /** * Ensure this device is booted, boot if necessary */ async ensureBooted(): Promise<void> { // Check if device is available before trying to boot const available = await this.checkAvailability(); if (!available) { throw new Error( `Device "${this.name}" (${this.id}) is not available. ` + `The runtime "${this.runtime}" may be missing or corrupted. ` + `Try downloading the runtime in Xcode or use a different simulator.` ); } // Use the async isBooted() method to check actual state if (!(await this.isBooted())) { await this.bootDevice(); } else { logger.debug({ deviceId: this.id, name: this.name }, 'Device already booted'); } } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * MCP Xcode Server * Provides tools for building, running, and testing Apple platform projects */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { logger, logToolExecution, logError } from './logger.js'; // Import all tool classes // import { // ListSimulatorsTool, // BootSimulatorTool, // ShutdownSimulatorTool, // ViewSimulatorScreenTool, // BuildSwiftPackageTool, // RunSwiftPackageTool, // RunXcodeTool, // TestXcodeTool, // TestSwiftPackageTool, // CleanBuildTool, // ArchiveProjectTool, // ExportIPATool, // ListSchemesTool, // GetBuildSettingsTool, // GetProjectInfoTool, // ListTargetsTool, // InstallAppTool, // UninstallAppTool, // GetDeviceLogsTool, // ManageDependenciesTool // } from './tools/index.js'; // Import factories for Clean Architecture controllers import { BootSimulatorControllerFactory, ShutdownSimulatorControllerFactory, ListSimulatorsControllerFactory } from './features/simulator/index.js'; import { BuildXcodeControllerFactory } from './features/build/index.js'; import { InstallAppControllerFactory } from './features/app-management/index.js'; type Tool = { execute(args: any): Promise<any>; getToolDefinition(): any; }; class XcodeServer { private server: Server; private tools: Map<string, Tool>; constructor() { this.server = new Server( { name: 'mcp-xcode-server', version: '2.4.0', }, { capabilities: { tools: {}, }, } ); // Initialize all tools this.tools = new Map<string, Tool>(); this.registerTools(); this.setupHandlers(); } private registerTools() { // Create instances of all tools const toolInstances = [ // Simulator management ListSimulatorsControllerFactory.create(), BootSimulatorControllerFactory.create(), ShutdownSimulatorControllerFactory.create(), // new ViewSimulatorScreenTool(), // Build and test // new BuildSwiftPackageTool(), // new RunSwiftPackageTool(), BuildXcodeControllerFactory.create(), InstallAppControllerFactory.create(), // new RunXcodeTool(), // new TestXcodeTool(), // new TestSwiftPackageTool(), // new CleanBuildTool(), // Archive and export // new ArchiveProjectTool(), // new ExportIPATool(), // Project info and schemes // new ListSchemesTool(), // new GetBuildSettingsTool(), // new GetProjectInfoTool(), // new ListTargetsTool(), // App management // new InstallAppTool(), // new UninstallAppTool(), // Device logs // new GetDeviceLogsTool(), // Advanced project management // new ManageDependenciesTool() ]; // Register each tool by its name for (const tool of toolInstances) { const definition = tool.getToolDefinition(); this.tools.set(definition.name, tool); } logger.info({ toolCount: this.tools.size }, 'Tools registered'); } private setupHandlers() { // Handle listing all available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = Array.from(this.tools.values()).map(tool => tool.getToolDefinition()); return { tools }; }); // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const startTime = Date.now(); logger.debug({ tool: name, args }, 'Tool request received'); try { const tool = this.tools.get(name); if (!tool) { throw new Error(`Unknown tool: ${name}`); } const result = await tool.execute(args); // Log successful execution logToolExecution(name, args, Date.now() - startTime); return result; } catch (error: any) { logError(error as Error, { tool: name, args }); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); logger.info({ transport: 'stdio' }, 'MCP Xcode server started'); } } const server = new XcodeServer(); // Handle graceful shutdown process.on('SIGTERM', async () => { logger.info('Received SIGTERM, shutting down gracefully'); // Give logger time to flush await new Promise(resolve => setTimeout(resolve, 100)); process.exit(0); }); process.on('SIGINT', async () => { logger.info('Received SIGINT, shutting down gracefully'); // Give logger time to flush await new Promise(resolve => setTimeout(resolve, 100)); process.exit(0); }); server.run().catch((error) => { logger.fatal({ error }, 'Failed to start MCP server'); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ListSimulatorsMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for List Simulators through MCP Protocol * * Tests critical user journey: Listing simulators through MCP * Following testing philosophy: E2E tests for critical paths only (10%) * * NO MOCKS - Uses real MCP server, real simulators */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { createAndConnectClient, cleanupClientAndTransport } from '../../../../shared/tests/utils/testHelpers.js'; describe('List Simulators MCP E2E', () => { let client: Client; let transport: StdioClientTransport; beforeAll(async () => { // Build the server const { execSync } = await import('child_process'); execSync('npm run build', { stdio: 'inherit' }); }); beforeEach(async () => { ({ client, transport } = await createAndConnectClient()); }); afterEach(async () => { await cleanupClientAndTransport(client, transport); }); it('should list simulators through MCP', async () => { // This tests the critical user journey: // User connects via MCP → calls list_simulators → receives result const result = await client.request( { method: 'tools/call', params: { name: 'list_simulators', arguments: {} } }, CallToolResultSchema, { timeout: 30000 } ); expect(result).toBeDefined(); expect(result.content).toBeInstanceOf(Array); const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; expect(textContent).toBeDefined(); const text = textContent?.text || ''; if (text.includes('No simulators found')) { expect(text).toBe('🔍 No simulators found'); } else { expect(text).toMatch(/Found \d+ simulator/); } }); it('should filter simulators by platform through MCP', async () => { const result = await client.request( { method: 'tools/call', params: { name: 'list_simulators', arguments: { platform: 'iOS' } } }, CallToolResultSchema, { timeout: 30000 } ); expect(result).toBeDefined(); expect(result.content).toBeInstanceOf(Array); const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; const text = textContent?.text || ''; // Should find iOS simulators expect(text).toMatch(/Found \d+ simulator/); const lines = text.split('\n'); const deviceLines = lines.filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); for (const line of deviceLines) { // All devices should show iOS runtime since we filtered by iOS platform expect(line).toContain(' - iOS '); // Should not contain other platform devices expect(line).not.toMatch(/Apple TV|Apple Watch/); } }); it('should filter simulators by state through MCP', async () => { const result = await client.request( { method: 'tools/call', params: { name: 'list_simulators', arguments: { state: 'Shutdown' } } }, CallToolResultSchema, { timeout: 30000 } ); expect(result).toBeDefined(); expect(result.content).toBeInstanceOf(Array); const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; const text = textContent?.text || ''; // Should find simulators in shutdown state expect(text).toMatch(/Found \d+ simulator/); const lines = text.split('\n'); const deviceLines = lines.filter(line => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); for (const line of deviceLines) { expect(line).toContain('Shutdown'); } }); it('should handle combined filters through MCP', async () => { const result = await client.request( { method: 'tools/call', params: { name: 'list_simulators', arguments: { platform: 'iOS', state: 'Booted' } } }, CallToolResultSchema, { timeout: 30000 } ); expect(result).toBeDefined(); expect(result.content).toBeInstanceOf(Array); const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; const text = textContent?.text || ''; // The combined filter might not find any booted iOS simulators // but the test should still assert the behavior if (text.includes('No simulators found')) { expect(text).toBe('🔍 No simulators found'); } else { expect(text).toMatch(/Found \d+ simulator/); const lines = text.split('\n'); const deviceLines = lines.filter(line => line.includes('(') && line.includes(')') && line.includes('-') ); for (const line of deviceLines) { expect(line).toContain(' - iOS '); expect(line).toContain('Booted'); } } }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ShutdownSimulatorMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for Shutdown Simulator through MCP Protocol * * Tests critical user journey: Shutting down a simulator through MCP * Following testing philosophy: E2E tests for critical paths only (10%) * * Focus: MCP protocol interaction, not simulator shutdown logic * The controller tests already verify shutdown works with real simulators * This test verifies the MCP transport/serialization/protocol works * * NO MOCKS - Uses real MCP server, real simulators */ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { createAndConnectClient, cleanupClientAndTransport } from '../../../../shared/tests/utils/testHelpers.js'; import { TestSimulatorManager } from '../../../../shared/tests/utils/TestSimulatorManager.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); describe('Shutdown Simulator MCP E2E', () => { let client: Client; let transport: StdioClientTransport; let testSimManager: TestSimulatorManager; beforeAll(async () => { // Build the server const { execSync } = await import('child_process'); execSync('npm run build', { stdio: 'inherit' }); // Set up test simulator testSimManager = new TestSimulatorManager(); await testSimManager.getOrCreateSimulator('TestSimulator-ShutdownMCP'); // Connect to MCP server ({ client, transport } = await createAndConnectClient()); }); afterAll(async () => { // Cleanup test simulator await testSimManager.cleanup(); // Cleanup MCP connection await cleanupClientAndTransport(client, transport); }); describe('shutdown simulator through MCP', () => { it('should shutdown simulator via MCP protocol', async () => { // Arrange - Boot the simulator first await testSimManager.bootAndWait(30); // Act - Call tool through MCP const result = await client.callTool({ name: 'shutdown_simulator', arguments: { deviceId: testSimManager.getSimulatorName() } }); // Assert - Verify MCP response const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].type).toBe('text'); expect(parsed.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); // Verify simulator is actually shutdown const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); let found = false; for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); if (device) { expect(device.state).toBe('Shutdown'); found = true; break; } } expect(found).toBe(true); }); it('should handle already shutdown simulator via MCP', async () => { // Arrange - ensure simulator is shutdown await testSimManager.shutdownAndWait(); // Act - Call tool through MCP const result = await client.callTool({ name: 'shutdown_simulator', arguments: { deviceId: testSimManager.getSimulatorId() } }); // Assert const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].text).toBe(`✅ Simulator already shutdown: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); }); it('should shutdown simulator by UUID via MCP', async () => { // Arrange - Boot the simulator first await testSimManager.bootAndWait(30); // Act - Call tool with UUID const result = await client.callTool({ name: 'shutdown_simulator', arguments: { deviceId: testSimManager.getSimulatorId() } }); // Assert const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); // Verify simulator is actually shutdown const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); if (device) { expect(device.state).toBe('Shutdown'); break; } } }); }); describe('error handling through MCP', () => { it('should return error for non-existent simulator', async () => { // Act const result = await client.callTool({ name: 'shutdown_simulator', arguments: { deviceId: 'NonExistentSimulator-MCP' } }); // Assert const parsed = CallToolResultSchema.parse(result); expect(parsed.content[0].text).toBe('❌ Simulator not found: NonExistentSimulator-MCP'); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/testHelpers.ts: -------------------------------------------------------------------------------- ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); /** * Cleanup MCP client and transport connections */ export async function cleanupClientAndTransport( client: Client | null | undefined, transport: StdioClientTransport | null | undefined ): Promise<void> { if (client) { await client.close(); } if (transport) { const transportProcess = (transport as any)._process; await transport.close(); if (transportProcess) { if (transportProcess.stdin && !transportProcess.stdin.destroyed) { transportProcess.stdin.end(); transportProcess.stdin.destroy(); } if (transportProcess.stdout && !transportProcess.stdout.destroyed) { transportProcess.stdout.destroy(); } if (transportProcess.stderr && !transportProcess.stderr.destroyed) { transportProcess.stderr.destroy(); } transportProcess.unref(); if (!transportProcess.killed) { transportProcess.kill('SIGTERM'); await new Promise(resolve => { const timeout = setTimeout(resolve, 100); transportProcess.once('exit', () => { clearTimeout(timeout); resolve(undefined); }); }); } } } } /** * Create and connect a new MCP client and transport */ export async function createAndConnectClient(): Promise<{ client: Client; transport: StdioClientTransport; }> { const transport = new StdioClientTransport({ command: 'node', args: ['dist/index.js'], cwd: process.cwd(), }); const client = new Client({ name: 'test-client', version: '1.0.0', }, { capabilities: {} }); await client.connect(transport); return { client, transport }; } /** * Wait for a simulator to reach the Booted state * @param simulatorId The simulator UUID to wait for * @param maxSeconds Maximum seconds to wait (default 30) * @returns Promise that resolves when booted or rejects on timeout */ export async function waitForSimulatorBoot( simulatorId: string, maxSeconds: number = 30 ): Promise<void> { for (let i = 0; i < maxSeconds; i++) { const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === simulatorId); if (device && device.state === 'Booted') { return; // Successfully booted } } // Wait 1 second before trying again await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`Failed to boot simulator ${simulatorId} after ${maxSeconds} seconds`); } /** * Boot a simulator and wait for it to be ready * @param simulatorId The simulator UUID to boot * @param maxSeconds Maximum seconds to wait (default 30) */ export async function bootAndWaitForSimulator( simulatorId: string, maxSeconds: number = 30 ): Promise<void> { try { await execAsync(`xcrun simctl boot "${simulatorId}"`); } catch { // Ignore if already booted } await waitForSimulatorBoot(simulatorId, maxSeconds); } /** * Wait for a simulator to reach the Shutdown state * @param simulatorId The simulator UUID to wait for * @param maxSeconds Maximum seconds to wait (default 30) * @returns Promise that resolves when shutdown or rejects on timeout */ export async function waitForSimulatorShutdown( simulatorId: string, maxSeconds: number = 30 ): Promise<void> { for (let i = 0; i < maxSeconds; i++) { const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === simulatorId); if (device && device.state === 'Shutdown') { return; // Successfully shutdown } } // Wait 1 second before trying again await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`Failed to shutdown simulator ${simulatorId} after ${maxSeconds} seconds`); } /** * Shutdown a simulator and wait for it to be shutdown * @param simulatorId The simulator UUID to shutdown * @param maxSeconds Maximum seconds to wait (default 30) */ export async function shutdownAndWaitForSimulator( simulatorId: string, maxSeconds: number = 30 ): Promise<void> { try { await execAsync(`xcrun simctl shutdown "${simulatorId}"`); } catch { // Ignore if already shutdown } await waitForSimulatorShutdown(simulatorId, maxSeconds); } /** * Cleanup a test simulator by shutting it down and deleting it * @param simulatorId The simulator UUID to cleanup */ export async function cleanupTestSimulator(simulatorId: string | undefined): Promise<void> { if (!simulatorId) return; try { await execAsync(`xcrun simctl shutdown "${simulatorId}"`); } catch { // Ignore shutdown errors - simulator might already be shutdown } try { await execAsync(`xcrun simctl delete "${simulatorId}"`); } catch { // Ignore delete errors - simulator might already be deleted } } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ShutdownSimulatorController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for ShutdownSimulatorController * * Tests the controller with REAL simulators and REAL system commands * Following testing philosophy: E2E tests for critical paths only (10%) * * NO MOCKS - Uses real xcrun simctl commands with actual simulators */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { ShutdownSimulatorControllerFactory } from '../../factories/ShutdownSimulatorControllerFactory.js'; import { exec } from 'child_process'; import { promisify } from 'util'; import { TestSimulatorManager } from '../../../../shared/tests/utils/TestSimulatorManager.js'; const execAsync = promisify(exec); describe('ShutdownSimulatorController E2E', () => { let controller: MCPController; let testSimManager: TestSimulatorManager; beforeAll(async () => { // Create controller with REAL components controller = ShutdownSimulatorControllerFactory.create(); // Set up test simulator testSimManager = new TestSimulatorManager(); await testSimManager.getOrCreateSimulator('TestSimulator-Shutdown'); }); beforeEach(async () => { // Boot simulator before each test (to ensure we can shut it down) await testSimManager.bootAndWait(30); }); afterAll(async () => { // Cleanup test simulator await testSimManager.cleanup(); }); describe('shutdown real simulators', () => { it('should shutdown a booted simulator', async () => { // Act const result = await controller.execute({ deviceId: testSimManager.getSimulatorName() }); // Assert expect(result.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); // Verify simulator is actually shutdown const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); let found = false; for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); if (device) { expect(device.state).toBe('Shutdown'); found = true; break; } } expect(found).toBe(true); }); it('should handle already shutdown simulator', async () => { // Arrange - shutdown simulator first await testSimManager.shutdownAndWait(5); await new Promise(resolve => setTimeout(resolve, 1000)); // Act const result = await controller.execute({ deviceId: testSimManager.getSimulatorName() }); // Assert expect(result.content[0].text).toBe(`✅ Simulator already shutdown: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); }); it('should shutdown simulator by UUID', async () => { // Act const result = await controller.execute({ deviceId: testSimManager.getSimulatorId() }); // Assert expect(result.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); // Verify simulator is actually shutdown const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); let found = false; for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); if (device) { expect(device.state).toBe('Shutdown'); found = true; break; } } expect(found).toBe(true); }); }); describe('error handling', () => { it('should handle non-existent simulator', async () => { // Act const result = await controller.execute({ deviceId: 'NonExistent-Simulator-That-Does-Not-Exist' }); // Assert expect(result.content[0].text).toBe('❌ Simulator not found: NonExistent-Simulator-That-Does-Not-Exist'); }); }); describe('complex scenarios', () => { it('should shutdown simulator that was booting', async () => { // Arrange - boot and immediately try to shutdown const bootPromise = testSimManager.bootAndWait(30); // Act - shutdown while booting const result = await controller.execute({ deviceId: testSimManager.getSimulatorName() }); // Assert expect(result.content[0].text).toContain('✅'); expect(result.content[0].text).toContain(testSimManager.getSimulatorName()); // Wait for operations to complete try { await bootPromise; } catch { // Boot might fail if shutdown interrupted it, that's OK } // Verify final state is shutdown const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); if (device) { expect(device.state).toBe('Shutdown'); break; } } }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/projects/SwiftPackageInfo.ts: -------------------------------------------------------------------------------- ```typescript import { execAsync } from '../../utils.js'; import { createModuleLogger } from '../../logger.js'; const logger = createModuleLogger('SwiftPackageInfo'); export interface Dependency { name: string; url: string; version?: string; branch?: string; revision?: string; } export interface Product { name: string; type: 'executable' | 'library'; targets: string[]; } /** * Queries information about Swift packages */ export class SwiftPackageInfo { /** * Get list of products in a package */ async getProducts(packagePath: string): Promise<Product[]> { const command = `swift package --package-path "${packagePath}" describe --type json`; logger.debug({ command }, 'Describe package command'); try { const { stdout } = await execAsync(command); const packageInfo = JSON.parse(stdout); const products: Product[] = packageInfo.products?.map((p: any) => ({ name: p.name, type: p.type?.executable ? 'executable' : 'library', targets: p.targets || [] })) || []; logger.debug({ packagePath, products }, 'Found products'); return products; } catch (error: any) { logger.error({ error: error.message, packagePath }, 'Failed to get products'); throw new Error(`Failed to get products: ${error.message}`); } } /** * Get list of targets in a package */ async getTargets(packagePath: string): Promise<string[]> { const command = `swift package --package-path "${packagePath}" describe --type json`; logger.debug({ command }, 'Describe package command'); try { const { stdout } = await execAsync(command); const packageInfo = JSON.parse(stdout); const targets = packageInfo.targets?.map((t: any) => t.name) || []; logger.debug({ packagePath, targets }, 'Found targets'); return targets; } catch (error: any) { logger.error({ error: error.message, packagePath }, 'Failed to get targets'); throw new Error(`Failed to get targets: ${error.message}`); } } /** * Get list of dependencies */ async getDependencies(packagePath: string): Promise<Dependency[]> { const command = `swift package --package-path "${packagePath}" show-dependencies --format json`; logger.debug({ command }, 'Show dependencies command'); try { const { stdout } = await execAsync(command); const depTree = JSON.parse(stdout); // Extract direct dependencies from the tree const dependencies: Dependency[] = depTree.dependencies?.map((d: any) => ({ name: d.name, url: d.url, version: d.version, branch: d.branch, revision: d.revision })) || []; logger.debug({ packagePath, dependencies }, 'Found dependencies'); return dependencies; } catch (error: any) { logger.error({ error: error.message, packagePath }, 'Failed to get dependencies'); throw new Error(`Failed to get dependencies: ${error.message}`); } } /** * Add a dependency to the package */ async addDependency( packagePath: string, url: string, options: { version?: string; branch?: string; exact?: boolean; from?: string; upToNextMajor?: string; } = {} ): Promise<void> { let command = `swift package --package-path "${packagePath}" add-dependency "${url}"`; if (options.branch) { command += ` --branch "${options.branch}"`; } else if (options.exact) { command += ` --exact "${options.version}"`; } else if (options.from) { command += ` --from "${options.from}"`; } else if (options.upToNextMajor) { command += ` --up-to-next-major-from "${options.upToNextMajor}"`; } logger.debug({ command }, 'Add dependency command'); try { await execAsync(command); logger.info({ packagePath, url }, 'Dependency added'); } catch (error: any) { logger.error({ error: error.message, packagePath, url }, 'Failed to add dependency'); throw new Error(`Failed to add dependency: ${error.message}`); } } /** * Remove a dependency from the package */ async removeDependency(packagePath: string, name: string): Promise<void> { const command = `swift package --package-path "${packagePath}" remove-dependency "${name}"`; logger.debug({ command }, 'Remove dependency command'); try { await execAsync(command); logger.info({ packagePath, name }, 'Dependency removed'); } catch (error: any) { logger.error({ error: error.message, packagePath, name }, 'Failed to remove dependency'); throw new Error(`Failed to remove dependency: ${error.message}`); } } /** * Update package dependencies */ async updateDependencies(packagePath: string): Promise<void> { const command = `swift package --package-path "${packagePath}" update`; logger.debug({ command }, 'Update dependencies command'); try { await execAsync(command); logger.info({ packagePath }, 'Dependencies updated'); } catch (error: any) { logger.error({ error: error.message, packagePath }, 'Failed to update dependencies'); throw new Error(`Failed to update dependencies: ${error.message}`); } } /** * Resolve package dependencies */ async resolveDependencies(packagePath: string): Promise<void> { const command = `swift package --package-path "${packagePath}" resolve`; logger.debug({ command }, 'Resolve dependencies command'); try { await execAsync(command); logger.info({ packagePath }, 'Dependencies resolved'); } catch (error: any) { logger.error({ error: error.message, packagePath }, 'Failed to resolve dependencies'); throw new Error(`Failed to resolve dependencies: ${error.message}`); } } } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ListSimulatorsController.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest } from '@jest/globals'; import { ListSimulatorsController } from '../../controllers/ListSimulatorsController.js'; import { ListSimulatorsUseCase } from '../../use-cases/ListSimulatorsUseCase.js'; import { ListSimulatorsRequest } from '../../domain/ListSimulatorsRequest.js'; import { ListSimulatorsResult, SimulatorInfo } from '../../domain/ListSimulatorsResult.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; describe('ListSimulatorsController', () => { function createSUT() { const mockExecute = jest.fn<(request: ListSimulatorsRequest) => Promise<ListSimulatorsResult>>(); const mockUseCase: Partial<ListSimulatorsUseCase> = { execute: mockExecute }; const sut = new ListSimulatorsController(mockUseCase as ListSimulatorsUseCase); return { sut, mockExecute }; } describe('MCP tool interface', () => { it('should define correct tool metadata', () => { // Arrange const { sut } = createSUT(); // Act const definition = sut.getToolDefinition(); // Assert expect(definition.name).toBe('list_simulators'); expect(definition.description).toBe('List available iOS simulators'); expect(definition.inputSchema).toBeDefined(); }); it('should define correct input schema with optional filters', () => { // Arrange const { sut } = createSUT(); // Act const schema = sut.inputSchema; // Assert expect(schema.type).toBe('object'); expect(schema.properties.platform).toBeDefined(); expect(schema.properties.platform.type).toBe('string'); expect(schema.properties.platform.enum).toEqual(['iOS', 'tvOS', 'watchOS', 'visionOS']); expect(schema.properties.state).toBeDefined(); expect(schema.properties.state.type).toBe('string'); expect(schema.properties.state.enum).toEqual(['Booted', 'Shutdown']); expect(schema.properties.name).toBeDefined(); expect(schema.properties.name.type).toBe('string'); expect(schema.properties.name.description).toBe('Filter by device name (partial match, case-insensitive)'); expect(schema.required).toEqual([]); }); }); describe('execute', () => { it('should list all simulators without filters', async () => { // Arrange const { sut, mockExecute } = createSUT(); const simulators: SimulatorInfo[] = [ { udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS 17.0' }, { udid: 'DEF456', name: 'iPad Pro', state: SimulatorState.Shutdown, platform: 'iOS', runtime: 'iOS 17.0' } ]; const mockResult = ListSimulatorsResult.success(simulators); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({}); // Assert expect(result.content[0].text).toContain('Found 2 simulators'); expect(result.content[0].text).toContain('iPhone 15 (ABC123) - Booted'); expect(result.content[0].text).toContain('iPad Pro (DEF456) - Shutdown'); }); it('should filter by platform', async () => { // Arrange const { sut, mockExecute } = createSUT(); const simulators: SimulatorInfo[] = [ { udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS 17.0' } ]; const mockResult = ListSimulatorsResult.success(simulators); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ platform: 'iOS' }); // Assert expect(result.content[0].text).toContain('Found 1 simulator'); }); it('should filter by state', async () => { // Arrange const { sut, mockExecute } = createSUT(); const simulators: SimulatorInfo[] = [ { udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS 17.0' } ]; const mockResult = ListSimulatorsResult.success(simulators); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({ state: 'Booted' }); // Assert expect(result.content[0].text).toContain('✅'); }); it('should handle no simulators found', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = ListSimulatorsResult.success([]); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({}); // Assert expect(result.content[0].text).toBe('🔍 No simulators found'); }); it('should handle errors gracefully', async () => { // Arrange const { sut, mockExecute } = createSUT(); const mockResult = ListSimulatorsResult.failed(new Error('Failed to list devices')); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({}); // Assert expect(result.content[0].text).toContain('❌'); expect(result.content[0].text).toContain('Failed to list devices'); }); it('should format simulators with runtime info', async () => { // Arrange const { sut, mockExecute } = createSUT(); const simulators: SimulatorInfo[] = [ { udid: 'ABC123', name: 'iPhone 15 Pro Max', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS 17.2' } ]; const mockResult = ListSimulatorsResult.success(simulators); mockExecute.mockResolvedValue(mockResult); // Act const result = await sut.execute({}); // Assert expect(result.content[0].text).toContain('iOS 17.2'); }); it('should return validation error for invalid input', async () => { // Arrange const { sut } = createSUT(); // Act const result = await sut.execute({ platform: 'invalid' }); // Assert expect(result.content[0].text).toBe('❌ Invalid platform: invalid. Valid values are: iOS, macOS, tvOS, watchOS, visionOS'); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/ConfigProviderAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { ConfigProviderAdapter } from '../../infrastructure/ConfigProviderAdapter.js'; import { IConfigProvider } from '../../../application/ports/ConfigPorts.js'; import { homedir } from 'os'; import path from 'path'; describe('ConfigProvider', () => { // Save original env const originalEnv = process.env; beforeEach(() => { // Reset env before each test process.env = { ...originalEnv }; }); afterAll(() => { // Restore original env process.env = originalEnv; }); // Factory method for creating the SUT function createSUT(): IConfigProvider { return new ConfigProviderAdapter(); } describe('getDerivedDataPath', () => { it('should use default path when no env var is set', () => { // Arrange delete process.env.MCP_XCODE_DERIVED_DATA_PATH; const sut = createSUT(); const expectedPath = path.join(homedir(), 'Library', 'Developer', 'Xcode', 'DerivedData', 'MCP-Xcode'); // Act const result = sut.getDerivedDataPath(); // Assert expect(result).toBe(expectedPath); }); it('should use env var when set', () => { // Arrange process.env.MCP_XCODE_DERIVED_DATA_PATH = '/custom/path'; const sut = createSUT(); // Act const result = sut.getDerivedDataPath(); // Assert expect(result).toBe('/custom/path'); }); it('should return project-specific path when project path is provided', () => { // Arrange process.env.MCP_XCODE_DERIVED_DATA_PATH = '/base/path'; const sut = createSUT(); // Act const result = sut.getDerivedDataPath('/Users/dev/MyApp.xcodeproj'); // Assert expect(result).toBe('/base/path/MyApp'); }); it('should handle workspace paths correctly', () => { // Arrange process.env.MCP_XCODE_DERIVED_DATA_PATH = '/base/path'; const sut = createSUT(); // Act const result = sut.getDerivedDataPath('/Users/dev/MyWorkspace.xcworkspace'); // Assert expect(result).toBe('/base/path/MyWorkspace'); }); it('should handle paths with spaces', () => { // Arrange process.env.MCP_XCODE_DERIVED_DATA_PATH = '/base/path'; const sut = createSUT(); // Act const result = sut.getDerivedDataPath('/Users/dev/My App.xcodeproj'); // Assert expect(result).toBe('/base/path/My App'); }); }); describe('getBuildTimeout', () => { it('should return default timeout when no env var is set', () => { // Arrange delete process.env.MCP_XCODE_BUILD_TIMEOUT; const sut = createSUT(); // Act const result = sut.getBuildTimeout(); // Assert expect(result).toBe(600000); // 10 minutes }); it('should use env var when set', () => { // Arrange process.env.MCP_XCODE_BUILD_TIMEOUT = '300000'; const sut = createSUT(); // Act const result = sut.getBuildTimeout(); // Assert expect(result).toBe(300000); }); it('should handle invalid timeout value', () => { // Arrange process.env.MCP_XCODE_BUILD_TIMEOUT = 'invalid'; const sut = createSUT(); // Act const result = sut.getBuildTimeout(); // Assert expect(result).toBeNaN(); // parseInt returns NaN for invalid strings }); }); describe('isXcbeautifyEnabled', () => { it('should return true by default', () => { // Arrange delete process.env.MCP_XCODE_XCBEAUTIFY_ENABLED; const sut = createSUT(); // Act const result = sut.isXcbeautifyEnabled(); // Assert expect(result).toBe(true); }); it('should return true when env var is "true"', () => { // Arrange process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'true'; const sut = createSUT(); // Act const result = sut.isXcbeautifyEnabled(); // Assert expect(result).toBe(true); }); it('should return false when env var is "false"', () => { // Arrange process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'false'; const sut = createSUT(); // Act const result = sut.isXcbeautifyEnabled(); // Assert expect(result).toBe(false); }); it('should handle case insensitive true value', () => { // Arrange process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'TRUE'; const sut = createSUT(); // Act const result = sut.isXcbeautifyEnabled(); // Assert expect(result).toBe(true); }); it('should return false for any non-true value', () => { // Arrange process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'yes'; const sut = createSUT(); // Act const result = sut.isXcbeautifyEnabled(); // Assert expect(result).toBe(false); }); }); describe('getCustomBuildSettings', () => { it('should return empty object by default', () => { // Arrange delete process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS; const sut = createSUT(); // Act const result = sut.getCustomBuildSettings(); // Assert expect(result).toEqual({}); }); it('should parse valid JSON from env var', () => { // Arrange const settings = { 'SWIFT_VERSION': '5.9', 'DEBUG': 'true' }; process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS = JSON.stringify(settings); const sut = createSUT(); // Act const result = sut.getCustomBuildSettings(); // Assert expect(result).toEqual(settings); }); it('should return empty object for invalid JSON', () => { // Arrange process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS = 'not valid json'; const sut = createSUT(); // Act const result = sut.getCustomBuildSettings(); // Assert expect(result).toEqual({}); }); it('should handle empty JSON object', () => { // Arrange process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS = '{}'; const sut = createSUT(); // Act const result = sut.getCustomBuildSettings(); // Assert expect(result).toEqual({}); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestSimulatorManager.ts: -------------------------------------------------------------------------------- ```typescript import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); /** * Manages test simulator lifecycle for E2E tests * Handles creation, booting, shutdown, and cleanup of test simulators */ export class TestSimulatorManager { private simulatorId?: string; private simulatorName?: string; /** * Create or reuse a test simulator * @param namePrefix Prefix for the simulator name (e.g., "TestSimulator-Boot") * @param deviceType Device type (defaults to iPhone 15) * @returns The simulator ID */ async getOrCreateSimulator( namePrefix: string, deviceType: string = 'iPhone 15' ): Promise<string> { // Check if simulator already exists const devicesResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(devicesResult.stdout); // Look for existing test simulator for (const runtime of Object.values(devices.devices) as any[]) { const existingSim = runtime.find((d: any) => d.name.includes(namePrefix)); if (existingSim) { this.simulatorId = existingSim.udid; this.simulatorName = existingSim.name; return existingSim.udid; } } // Create new simulator if none exists const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); const runtimes = JSON.parse(runtimesResult.stdout); const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); if (!iosRuntime) { throw new Error('No iOS runtime found. Please install an iOS simulator runtime.'); } const createResult = await execAsync( `xcrun simctl create "${namePrefix}" "${deviceType}" "${iosRuntime.identifier}"` ); this.simulatorId = createResult.stdout.trim(); this.simulatorName = namePrefix; return this.simulatorId; } /** * Boot the simulator and wait for it to be ready * @param maxSeconds Maximum seconds to wait (default 30) */ async bootAndWait(maxSeconds: number = 30): Promise<void> { if (!this.simulatorId) { throw new Error('No simulator to boot. Call getOrCreateSimulator first.'); } try { await execAsync(`xcrun simctl boot "${this.simulatorId}"`); } catch { // Ignore if already booted } await this.waitForBoot(maxSeconds); } /** * Shutdown the simulator and wait for completion * @param maxSeconds Maximum seconds to wait (default 30) */ async shutdownAndWait(maxSeconds: number = 30): Promise<void> { if (!this.simulatorId) return; try { await execAsync(`xcrun simctl shutdown "${this.simulatorId}"`); } catch { // Ignore if already shutdown } await this.waitForShutdown(maxSeconds); } /** * Cleanup the test simulator (shutdown and delete) */ async cleanup(): Promise<void> { if (!this.simulatorId) return; try { await execAsync(`xcrun simctl shutdown "${this.simulatorId}"`); } catch { // Ignore shutdown errors } try { await execAsync(`xcrun simctl delete "${this.simulatorId}"`); } catch { // Ignore delete errors } this.simulatorId = undefined; this.simulatorName = undefined; } /** * Get the current simulator ID */ getSimulatorId(): string | undefined { return this.simulatorId; } /** * Get the current simulator name */ getSimulatorName(): string | undefined { return this.simulatorName; } /** * Check if the simulator is booted */ async isBooted(): Promise<boolean> { if (!this.simulatorId) return false; const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === this.simulatorId); if (device) { return device.state === 'Booted'; } } return false; } /** * Check if the simulator is shutdown */ async isShutdown(): Promise<boolean> { if (!this.simulatorId) return true; const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === this.simulatorId); if (device) { return device.state === 'Shutdown'; } } return true; } private async waitForBoot(maxSeconds: number): Promise<void> { for (let i = 0; i < maxSeconds; i++) { const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === this.simulatorId); if (device && device.state === 'Booted') { // Wait a bit more for services to be ready await new Promise(resolve => setTimeout(resolve, 2000)); return; } } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`Failed to boot simulator ${this.simulatorId} after ${maxSeconds} seconds`); } private async waitForShutdown(maxSeconds: number): Promise<void> { for (let i = 0; i < maxSeconds; i++) { const listResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(listResult.stdout); for (const runtime of Object.values(devices.devices) as any[]) { const device = runtime.find((d: any) => d.udid === this.simulatorId); if (device && device.state === 'Shutdown') { return; } } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`Failed to shutdown simulator ${this.simulatorId} after ${maxSeconds} seconds`); } /** * Shutdown all other booted simulators except this one */ async shutdownOtherSimulators(): Promise<void> { const devicesResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(devicesResult.stdout); for (const runtime of Object.values(devices.devices) as any[][]) { for (const device of runtime) { if (device.state === 'Booted' && device.udid !== this.simulatorId) { try { await execAsync(`xcrun simctl shutdown "${device.udid}"`); } catch { // Ignore errors } } } } } } ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/ProjectPath.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; import { ProjectPath } from '../../domain/ProjectPath.js'; import { existsSync } from 'fs'; // Mock fs module jest.mock('fs', () => ({ existsSync: jest.fn<(path: string) => boolean>() })); const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>; describe('ProjectPath', () => { // Reset mocks between tests for isolation beforeEach(() => { jest.clearAllMocks(); }); afterEach(() => { jest.restoreAllMocks(); }); describe('when creating a project path', () => { it('should accept valid .xcodeproj path that exists', () => { // Setup - all visible in test const projectPath = '/Users/dev/MyApp.xcodeproj'; mockExistsSync.mockReturnValue(true); // Act const result = ProjectPath.create(projectPath); // Assert expect(result.toString()).toBe(projectPath); expect(mockExistsSync).toHaveBeenCalledWith(projectPath); }); it('should accept valid .xcworkspace path that exists', () => { const workspacePath = '/Users/dev/MyApp.xcworkspace'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(workspacePath); expect(result.toString()).toBe(workspacePath); expect(mockExistsSync).toHaveBeenCalledWith(workspacePath); }); it('should accept paths with spaces', () => { const pathWithSpaces = '/Users/dev/My Cool App.xcodeproj'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(pathWithSpaces); expect(result.toString()).toBe(pathWithSpaces); }); it('should accept paths with special characters', () => { const specialPath = '/Users/dev/App-2024_v1.0.xcworkspace'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(specialPath); expect(result.toString()).toBe(specialPath); }); }); describe('when validating input', () => { it('should reject empty path', () => { expect(() => ProjectPath.create('')).toThrow('Project path cannot be empty'); expect(mockExistsSync).not.toHaveBeenCalled(); }); it('should reject null path', () => { expect(() => ProjectPath.create(null as any)) .toThrow('Project path is required'); expect(mockExistsSync).not.toHaveBeenCalled(); }); it('should reject undefined path', () => { expect(() => ProjectPath.create(undefined as any)) .toThrow('Project path is required'); expect(mockExistsSync).not.toHaveBeenCalled(); }); it('should reject non-existent path', () => { const nonExistentPath = '/Users/dev/DoesNotExist.xcodeproj'; mockExistsSync.mockReturnValue(false); expect(() => ProjectPath.create(nonExistentPath)) .toThrow(`Project path does not exist: ${nonExistentPath}`); expect(mockExistsSync).toHaveBeenCalledWith(nonExistentPath); }); it('should reject non-Xcode project files', () => { mockExistsSync.mockReturnValue(true); const invalidFiles = [ '/Users/dev/MyApp.swift', '/Users/dev/MyApp.txt', '/Users/dev/MyApp.app', '/Users/dev/MyApp.framework', '/Users/dev/MyApp', // No extension '/Users/dev/MyApp.xcode', // Wrong extension ]; invalidFiles.forEach(file => { expect(() => ProjectPath.create(file)) .toThrow('Project path must be an .xcodeproj or .xcworkspace file'); }); }); it('should reject directories without proper extension', () => { const directory = '/Users/dev/MyProject'; mockExistsSync.mockReturnValue(true); expect(() => ProjectPath.create(directory)) .toThrow('Project path must be an .xcodeproj or .xcworkspace file'); }); }); describe('when getting project name', () => { it('should extract name from .xcodeproj path', () => { const projectPath = '/Users/dev/MyAwesomeApp.xcodeproj'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(projectPath); expect(result.name).toBe('MyAwesomeApp'); }); it('should extract name from .xcworkspace path', () => { const workspacePath = '/Users/dev/CoolWorkspace.xcworkspace'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(workspacePath); expect(result.name).toBe('CoolWorkspace'); }); it('should handle names with dots', () => { const pathWithDots = '/Users/dev/App.v2.0.xcodeproj'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(pathWithDots); expect(result.name).toBe('App.v2.0'); }); it('should handle names with spaces', () => { const pathWithSpaces = '/Users/dev/My Cool App.xcworkspace'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(pathWithSpaces); expect(result.name).toBe('My Cool App'); }); }); describe('when checking project type', () => { it('should identify workspace files', () => { const workspacePath = '/Users/dev/MyApp.xcworkspace'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(workspacePath); expect(result.isWorkspace).toBe(true); }); it('should identify non-workspace files as projects', () => { const projectPath = '/Users/dev/MyApp.xcodeproj'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(projectPath); expect(result.isWorkspace).toBe(false); }); }); describe('when converting to string', () => { it('should return the original path', () => { const originalPath = '/Users/dev/path/to/MyApp.xcodeproj'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(originalPath); expect(result.toString()).toBe(originalPath); expect(`${result}`).toBe(originalPath); // Implicit string conversion }); }); describe('when path validation is called multiple times', () => { it('should check existence only once during creation', () => { const projectPath = '/Users/dev/MyApp.xcodeproj'; mockExistsSync.mockReturnValue(true); const result = ProjectPath.create(projectPath); // Access properties multiple times result.name; result.isWorkspace; result.toString(); // Should only check existence once during creation expect(mockExistsSync).toHaveBeenCalledTimes(1); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/ShellCommandExecutorAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { ExecOptions } from 'child_process'; import { ShellCommandExecutorAdapter } from '../../infrastructure/ShellCommandExecutorAdapter.js'; /** * Unit tests for ShellCommandExecutor * * Following testing philosophy: * - Test behavior, not implementation * - Use dependency injection for clean testing */ describe('ShellCommandExecutor', () => { beforeEach(() => { jest.clearAllMocks(); }); // Factory method for creating the SUT with mocked exec function function createSUT() { const mockExecAsync = jest.fn<(command: string, options: ExecOptions) => Promise<{ stdout: string; stderr: string }>>(); const sut = new ShellCommandExecutorAdapter(mockExecAsync); return { sut, mockExecAsync }; } describe('execute', () => { describe('when executing successful commands', () => { it('should return stdout and stderr with exitCode 0', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'echo "hello world"'; mockExecAsync.mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); // Act const result = await sut.execute(command); // Assert expect(result).toEqual({ stdout: 'hello world\n', stderr: '', exitCode: 0 }); expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ maxBuffer: 50 * 1024 * 1024, timeout: 300000, shell: '/bin/bash' })); }); it('should handle large output', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'cat large_file.txt'; const largeOutput = 'x'.repeat(10 * 1024 * 1024); // 10MB mockExecAsync.mockResolvedValue({ stdout: largeOutput, stderr: '' }); // Act const result = await sut.execute(command); // Assert expect(result.stdout).toHaveLength(10 * 1024 * 1024); expect(result.exitCode).toBe(0); }); it('should handle both stdout and stderr', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'some-command'; mockExecAsync.mockResolvedValue({ stdout: 'standard output', stderr: 'warning: something happened' }); // Act const result = await sut.execute(command); // Assert expect(result.stdout).toBe('standard output'); expect(result.stderr).toBe('warning: something happened'); expect(result.exitCode).toBe(0); }); }); describe('when executing failing commands', () => { it('should return output with non-zero exit code', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'false'; const error: any = new Error('Command failed'); error.code = 1; error.stdout = ''; error.stderr = 'command failed'; mockExecAsync.mockRejectedValue(error); // Act const result = await sut.execute(command); // Assert expect(result).toEqual({ stdout: '', stderr: 'command failed', exitCode: 1 }); }); it('should capture output even on failure', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'build-command'; const error: any = new Error('Build failed'); error.code = 65; error.stdout = 'Compiling...\nError at line 42'; error.stderr = 'error: undefined symbol'; mockExecAsync.mockRejectedValue(error); // Act const result = await sut.execute(command); // Assert expect(result.stdout).toContain('Compiling'); expect(result.stderr).toContain('undefined symbol'); expect(result.exitCode).toBe(65); }); it('should handle missing error code', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'unknown-command'; const error: any = new Error('Command not found'); // No error.code set error.stdout = ''; error.stderr = 'command not found'; mockExecAsync.mockRejectedValue(error); // Act const result = await sut.execute(command); // Assert expect(result.exitCode).toBe(1); // Default to 1 when no code }); }); describe('with custom options', () => { it('should pass maxBuffer option', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'echo test'; const options = { maxBuffer: 100 * 1024 * 1024 }; // 100MB mockExecAsync.mockResolvedValue({ stdout: 'test', stderr: '' }); // Act await sut.execute(command, options); // Assert expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ maxBuffer: 100 * 1024 * 1024 })); }); it('should pass timeout option', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'long-running-command'; const options = { timeout: 600000 }; // 10 minutes mockExecAsync.mockResolvedValue({ stdout: 'done', stderr: '' }); // Act await sut.execute(command, options); // Assert expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ timeout: 600000 })); }); it('should pass shell option', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'echo $SHELL'; const options = { shell: '/bin/zsh' }; mockExecAsync.mockResolvedValue({ stdout: '/bin/zsh', stderr: '' }); // Act await sut.execute(command, options); // Assert expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ shell: '/bin/zsh' })); }); it('should use default options when not provided', async () => { // Arrange const { sut, mockExecAsync } = createSUT(); const command = 'echo test'; mockExecAsync.mockResolvedValue({ stdout: 'test', stderr: '' }); // Act await sut.execute(command); // Assert expect(mockExecAsync).toHaveBeenCalledWith(command, { maxBuffer: 50 * 1024 * 1024, timeout: 300000, shell: '/bin/bash' }); }); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/devices/SimulatorBoot.ts: -------------------------------------------------------------------------------- ```typescript import { execAsync } from '../../utils.js'; import { createModuleLogger } from '../../logger.js'; const logger = createModuleLogger('SimulatorBoot'); /** * Simple utility to boot simulators for Xcode builds * Extracted shared logic from BuildXcodeTool and RunXcodeTool */ export class SimulatorBoot { /** * Boot a simulator */ async boot(deviceId: string): Promise<void> { try { await execAsync(`xcrun simctl boot "${deviceId}"`); logger.debug({ deviceId }, 'Simulator booted successfully'); } catch (error: any) { // Check if already booted if (!error.message?.includes('Unable to boot device in current state: Booted')) { logger.error({ error: error.message, deviceId }, 'Failed to boot simulator'); throw new Error(`Failed to boot simulator: ${error.message}`); } logger.debug({ deviceId }, 'Simulator already booted'); } } /** * Shutdown a simulator */ async shutdown(deviceId: string): Promise<void> { try { await execAsync(`xcrun simctl shutdown "${deviceId}"`); logger.debug({ deviceId }, 'Simulator shutdown successfully'); } catch (error: any) { logger.error({ error: error.message, deviceId }, 'Failed to shutdown simulator'); throw new Error(`Failed to shutdown simulator: ${error.message}`); } } /** * Opens the Simulator app GUI (skipped during tests) */ async openSimulatorApp(): Promise<void> { // Skip opening GUI during tests if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID) { logger.debug('Skipping Simulator GUI in test environment'); return; } try { await execAsync('open -g -a Simulator'); logger.debug('Opened Simulator app'); } catch (error: any) { logger.warn({ error: error.message }, 'Failed to open Simulator app'); } } /** * Ensures a simulator is booted for the given platform and device * Returns the booted device ID (UDID) */ async ensureBooted(platform: string, deviceId?: string): Promise<string> { if (!deviceId) { // No specific device requested, use first available return this.bootFirstAvailable(platform); } // Check if the device exists and get its state try { const { stdout } = await execAsync('xcrun simctl list devices --json'); const data = JSON.parse(stdout); // Collect all matching devices first, then pick the best one const matchingDevices: any[] = []; for (const deviceList of Object.values(data.devices)) { for (const device of deviceList as any[]) { if (device.udid === deviceId || device.name === deviceId) { matchingDevices.push(device); } } } if (matchingDevices.length === 0) { throw new Error(`Device '${deviceId}' not found`); } // Sort devices: prefer available ones, then already booted ones matchingDevices.sort((a, b) => { // First priority: available devices if (a.isAvailable && !b.isAvailable) return -1; if (!a.isAvailable && b.isAvailable) return 1; // Second priority: booted devices if (a.state === 'Booted' && b.state !== 'Booted') return -1; if (a.state !== 'Booted' && b.state === 'Booted') return 1; return 0; }); // Use the best matching device const device = matchingDevices[0]; if (!device.isAvailable) { // All matching devices are unavailable const availableErrors = matchingDevices .map(d => `${d.name} (${d.udid}): ${d.availabilityError || 'unavailable'}`) .join(', '); throw new Error(`All devices named '${deviceId}' are unavailable: ${availableErrors}`); } if (device.state === 'Booted') { logger.debug({ deviceId: device.udid, name: device.name }, 'Device already booted'); // Still open the Simulator app to make it visible await this.openSimulatorApp(); return device.udid; } // Boot the device logger.info({ deviceId: device.udid, name: device.name }, 'Booting simulator'); try { await execAsync(`xcrun simctl boot "${device.udid}"`); } catch (error: any) { // Device might already be booted if (!error.message?.includes('Unable to boot device in current state: Booted')) { throw error; } } // Open the Simulator app to show the GUI await this.openSimulatorApp(); return device.udid; } catch (error: any) { logger.error({ error: error.message, deviceId }, 'Failed to boot simulator'); throw new Error(`Failed to boot simulator: ${error.message}`); } } /** * Boots the first available simulator for a platform */ private async bootFirstAvailable(platform: string): Promise<string> { try { const { stdout } = await execAsync('xcrun simctl list devices --json'); const data = JSON.parse(stdout); // Find first available device for the platform for (const [runtime, deviceList] of Object.entries(data.devices)) { const runtimeLower = runtime.toLowerCase(); const platformLower = platform.toLowerCase(); // Handle visionOS which is internally called xrOS const isVisionOS = platformLower === 'visionos' && runtimeLower.includes('xros'); const isOtherPlatform = platformLower !== 'visionos' && runtimeLower.includes(platformLower); if (!isVisionOS && !isOtherPlatform) { continue; } for (const device of deviceList as any[]) { if (!device.isAvailable) continue; // Check if already booted if (device.state === 'Booted') { logger.debug({ deviceId: device.udid, name: device.name }, 'Using already booted device'); // Still open the Simulator app to make it visible await this.openSimulatorApp(); return device.udid; } // Boot this device logger.info({ deviceId: device.udid, name: device.name }, 'Booting first available simulator'); try { await execAsync(`xcrun simctl boot ${device.udid}`); } catch (error: any) { // Device might already be booted if (!error.message?.includes('Unable to boot device in current state: Booted')) { throw error; } } // Open the Simulator app to show the GUI await this.openSimulatorApp(); return device.udid; } } throw new Error(`No available simulators found for platform ${platform}`); } catch (error: any) { logger.error({ error: error.message, platform }, 'Failed to boot simulator'); throw new Error(`Failed to boot simulator: ${error.message}`); } } } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/BootSimulatorUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { BootSimulatorUseCase } from '../../use-cases/BootSimulatorUseCase.js'; import { BootRequest } from '../../domain/BootRequest.js'; import { DeviceId } from '../../../../shared/domain/DeviceId.js'; import { BootResult, BootOutcome, SimulatorNotFoundError, BootCommandFailedError, SimulatorBusyError } from '../../domain/BootResult.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; import { ISimulatorLocator, ISimulatorControl, SimulatorInfo } from '../../../../application/ports/SimulatorPorts.js'; describe('BootSimulatorUseCase', () => { let useCase: BootSimulatorUseCase; let mockLocator: jest.Mocked<ISimulatorLocator>; let mockControl: jest.Mocked<ISimulatorControl>; beforeEach(() => { jest.clearAllMocks(); mockLocator = { findSimulator: jest.fn<ISimulatorLocator['findSimulator']>(), findBootedSimulator: jest.fn<ISimulatorLocator['findBootedSimulator']>() }; mockControl = { boot: jest.fn<ISimulatorControl['boot']>(), shutdown: jest.fn<ISimulatorControl['shutdown']>() }; useCase = new BootSimulatorUseCase(mockLocator, mockControl); }); describe('execute', () => { it('should boot a shutdown simulator', async () => { // Arrange const request = BootRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Shutdown, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.boot.mockResolvedValue(undefined); // Act const result = await useCase.execute(request); // Assert expect(mockLocator.findSimulator).toHaveBeenCalledWith('iPhone-15'); expect(mockControl.boot).toHaveBeenCalledWith('ABC123'); expect(result.outcome).toBe(BootOutcome.Booted); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); expect(result.diagnostics.platform).toBe('iOS'); expect(result.diagnostics.runtime).toBe('iOS-17.0'); }); it('should handle already booted simulator', async () => { // Arrange const request = BootRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); // Act const result = await useCase.execute(request); // Assert expect(mockControl.boot).not.toHaveBeenCalled(); expect(result.outcome).toBe(BootOutcome.AlreadyBooted); expect(result.diagnostics.simulatorId).toBe('ABC123'); }); it('should return failure when simulator not found', async () => { // Arrange const request = BootRequest.create(DeviceId.create('non-existent')); mockLocator.findSimulator.mockResolvedValue(null); // Act const result = await useCase.execute(request); // Assert - Test behavior: simulator not found error expect(mockControl.boot).not.toHaveBeenCalled(); expect(result.outcome).toBe(BootOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); expect((result.diagnostics.error as SimulatorNotFoundError).deviceId).toBe('non-existent'); }); it('should return failure on boot error', async () => { // Arrange const request = BootRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Shutdown, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.boot.mockRejectedValue(new Error('Boot failed')); // Act const result = await useCase.execute(request); // Assert - Test behavior: boot command failed error expect(result.outcome).toBe(BootOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(BootCommandFailedError); expect((result.diagnostics.error as BootCommandFailedError).stderr).toBe('Boot failed'); }); it('should boot simulator found by UUID', async () => { // Arrange const uuid = '838C707D-5703-4AEE-AF43-4798E0BA1B05'; const request = BootRequest.create(DeviceId.create(uuid)); const simulatorInfo: SimulatorInfo = { id: uuid, name: 'iPhone 15', state: SimulatorState.Shutdown, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.boot.mockResolvedValue(undefined); // Act const result = await useCase.execute(request); // Assert expect(mockLocator.findSimulator).toHaveBeenCalledWith(uuid); expect(mockControl.boot).toHaveBeenCalledWith(uuid); expect(result.outcome).toBe(BootOutcome.Booted); expect(result.diagnostics.simulatorId).toBe(uuid); }); it('should handle simulator in Booting state as already booted', async () => { // Arrange const request = BootRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booting, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); // Act const result = await useCase.execute(request); // Assert expect(mockControl.boot).not.toHaveBeenCalled(); expect(result.outcome).toBe(BootOutcome.AlreadyBooted); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); }); it('should return failure when simulator is ShuttingDown', async () => { // Arrange const request = BootRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.ShuttingDown, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); // Act const result = await useCase.execute(request); // Assert expect(mockControl.boot).not.toHaveBeenCalled(); expect(result.outcome).toBe(BootOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(SimulatorBusyError); expect((result.diagnostics.error as SimulatorBusyError).currentState).toBe(SimulatorState.ShuttingDown); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ListSimulatorsController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for ListSimulatorsController * * Tests CRITICAL USER PATH with REAL simulators: * - Can the controller actually list real simulators? * - Does filtering work with real simulator data? * - Does error handling work with real failures? * * NO MOCKS - uses real simulators * This is an E2E test (10% of test suite) for critical user journeys * * NOTE: This test requires Xcode and iOS simulators to be installed */ import { describe, it, expect, beforeAll, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { ListSimulatorsControllerFactory } from '../../factories/ListSimulatorsControllerFactory.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); describe('ListSimulatorsController E2E', () => { let controller: MCPController; beforeAll(() => { controller = ListSimulatorsControllerFactory.create(); }); describe('list real simulators', () => { it('should list all available simulators', async () => { // Arrange, Act, Assert const result = await controller.execute({}); expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.any(String) }) ]) }); const text = result.content[0].text; expect(text).toMatch(/Found \d+ simulator/); // Verify actual device lines exist const deviceLines = text.split('\n').filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); }); it('should filter by iOS platform', async () => { // Arrange, Act, Assert const result = await controller.execute({ platform: 'iOS' }); const text = result.content[0].text; expect(text).toMatch(/Found \d+ simulator/); const deviceLines = text.split('\n').filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); for (const line of deviceLines) { // All devices should show iOS runtime since we filtered by iOS platform expect(line).toContain(' - iOS '); // Should not contain other platform devices expect(line).not.toMatch(/Apple TV|Apple Watch/); } }); it('should filter by booted state', async () => { // First, check if there are any booted simulators const checkResult = await execAsync('xcrun simctl list devices booted --json'); const bootedDevices = JSON.parse(checkResult.stdout); const hasBootedDevices = Object.values(bootedDevices.devices).some( (devices: any) => devices.length > 0 ); // Act const result = await controller.execute({ state: 'Booted' }); // Assert const text = result.content[0].text; if (hasBootedDevices) { expect(text).toMatch(/Found \d+ simulator/); const lines = text.split('\n'); const deviceLines = lines.filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); for (const line of deviceLines) { expect(line).toContain('Booted'); } } else { expect(text).toBe('🔍 No simulators found'); } }); it('should show runtime information', async () => { // Arrange, Act, Assert const result = await controller.execute({}); const text = result.content[0].text; expect(text).toMatch(/Found \d+ simulator/); const deviceLines = text.split('\n').filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); for (const line of deviceLines) { expect(line).toMatch(/iOS \d+\.\d+|tvOS \d+\.\d+|watchOS \d+\.\d+|visionOS \d+\.\d+/); } }); it('should filter by tvOS platform', async () => { // Arrange, Act, Assert const result = await controller.execute({ platform: 'tvOS' }); const text = result.content[0].text; // tvOS simulators might not exist in all environments if (text.includes('No simulators found')) { expect(text).toBe('🔍 No simulators found'); } else { expect(text).toMatch(/Found \d+ simulator/); const deviceLines = text.split('\n').filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); for (const line of deviceLines) { expect(line).toContain('Apple TV'); expect(line).toContain(' - tvOS '); } } }); it('should handle combined filters', async () => { // Arrange, Act, Assert const result = await controller.execute({ platform: 'iOS', state: 'Shutdown' }); const text = result.content[0].text; expect(text).toMatch(/Found \d+ simulator/); const deviceLines = text.split('\n').filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); for (const line of deviceLines) { expect(line).toContain(' - iOS '); expect(line).toContain('Shutdown'); } }); }); describe('error handling', () => { it('should return error for invalid platform', async () => { // Arrange, Act, Assert const result = await controller.execute({ platform: 'Android' }); expect(result.content[0].text).toBe('❌ Invalid platform: Android. Valid values are: iOS, macOS, tvOS, watchOS, visionOS'); }); it('should return error for invalid state', async () => { // Arrange, Act, Assert const result = await controller.execute({ state: 'Running' }); expect(result.content[0].text).toBe('❌ Invalid simulator state: Running. Valid values are: Booted, Booting, Shutdown, Shutting Down'); }); it('should return error for invalid input types', async () => { // Arrange, Act, Assert const result1 = await controller.execute({ platform: 123 }); expect(result1.content[0].text).toBe('❌ Platform must be a string (one of: iOS, macOS, tvOS, watchOS, visionOS), got number'); const result2 = await controller.execute({ state: true }); expect(result2.content[0].text).toBe('❌ Simulator state must be a string (one of: Booted, Booting, Shutdown, Shutting Down), got boolean'); }); }); describe('output formatting', () => { it('should format simulator list properly', async () => { // Arrange, Act, Assert const result = await controller.execute({}); const text = result.content[0].text; expect(text).toMatch(/^✅ Found \d+ simulator/); const lines = text.split('\n'); expect(lines[0]).toMatch(/^✅ Found \d+ simulator/); expect(lines[1]).toBe(''); const deviceLines = lines.filter((line: string) => line.includes('(') && line.includes(')') && line.includes('-') ); expect(deviceLines.length).toBeGreaterThan(0); for (const line of deviceLines) { expect(line).toMatch(/^• .+ \([A-F0-9-]+\) - (Booted|Booting|Shutdown|Shutting Down|Unknown) - (iOS|tvOS|watchOS|visionOS|macOS|Unknown) \d+\.\d+$/); } }); it('should use warning emoji for no results', async () => { // Act - filter that likely returns no results const result = await controller.execute({ platform: 'visionOS', state: 'Booted' }); // Assert const text = result.content[0].text; if (text.includes('No simulators found')) { expect(text).toBe('🔍 No simulators found'); } }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/errors/xcbeautify-parser.ts: -------------------------------------------------------------------------------- ```typescript /** * Unified parser for xcbeautify output * * This replaces all the complex parsers and handlers since all our output * goes through xcbeautify which already formats it nicely. * * xcbeautify output format: * - ❌ for errors * - ⚠️ for warnings * - ✔ for test passes * - ✖ for test failures */ import { createModuleLogger } from '../../logger.js'; const logger = createModuleLogger('XcbeautifyParser'); export interface Issue { type: 'error' | 'warning'; file?: string; line?: number; column?: number; message: string; rawLine: string; } export interface Test { name: string; passed: boolean; duration?: number; failureReason?: string; } export interface XcbeautifyOutput { errors: Issue[]; warnings: Issue[]; tests: Test[]; buildSucceeded: boolean; testsPassed: boolean; totalTests: number; failedTests: number; } /** * Parse a line with error or warning from xcbeautify */ function parseErrorLine(line: string, isError: boolean): Issue { // Remove the emoji prefix (❌ or ⚠️) and any color codes const cleanLine = line .replace(/^[❌⚠]\s*/, '') // Character class with individual emojis .replace(/^️\s*/, '') // Remove any lingering emoji variation selectors .replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI color codes // Try to extract file:line:column information // Format: /path/to/file.swift:10:15: error message const fileMatch = cleanLine.match(/^([^:]+):(\d+):(\d+):\s*(.*)$/); if (fileMatch) { const [, file, lineStr, columnStr, message] = fileMatch; return { type: isError ? 'error' : 'warning', file, line: parseInt(lineStr, 10), column: parseInt(columnStr, 10), message, rawLine: line }; } // No file information return { type: isError ? 'error' : 'warning', message: cleanLine, rawLine: line }; } /** * Parse test results from xcbeautify output */ function parseTestLine(line: string): Test | null { // Remove color codes const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, ''); // Test passed: ✔ testName (0.123 seconds) const passMatch = cleanLine.match(/✔\s+(\w+)\s*\(([0-9.]+)\s+seconds?\)/); if (passMatch) { return { name: passMatch[1], passed: true, duration: parseFloat(passMatch[2]) }; } // Test failed: ✖ testName, failure reason const failMatch = cleanLine.match(/✖\s+(\w+)(?:,\s*(.*))?/); if (failMatch) { return { name: failMatch[1], passed: false, failureReason: failMatch[2] || 'Test failed' }; } // XCTest format: Test Case '-[ClassName testName]' passed/failed (X.XXX seconds) const xcTestMatch = cleanLine.match(/Test Case\s+'-\[(\w+)\s+(\w+)\]'\s+(passed|failed)\s*\(([0-9.]+)\s+seconds\)/); if (xcTestMatch) { return { name: `${xcTestMatch[1]}.${xcTestMatch[2]}`, passed: xcTestMatch[3] === 'passed', duration: parseFloat(xcTestMatch[4]) }; } return null; } /** * Main parser for xcbeautify output */ export function parseXcbeautifyOutput(output: string): XcbeautifyOutput { const lines = output.split('\n'); // Use Maps to deduplicate errors/warnings (for multi-architecture builds) const errorMap = new Map<string, Issue>(); const warningMap = new Map<string, Issue>(); let buildSucceeded = true; let testsPassed = true; let totalTests = 0; let failedTests = 0; const tests: Test[] = []; for (const line of lines) { if (!line.trim()) continue; // Skip xcbeautify header if (line.includes('xcbeautify') || line.startsWith('---') || line.startsWith('Version:')) { continue; } // Parse errors (❌) if (line.includes('❌')) { const error = parseErrorLine(line, true); const key = `${error.file}:${error.line}:${error.column}:${error.message}`; errorMap.set(key, error); buildSucceeded = false; } // Parse warnings (⚠️) else if (line.includes('⚠️')) { const warning = parseErrorLine(line, false); const key = `${warning.file}:${warning.line}:${warning.column}:${warning.message}`; warningMap.set(key, warning); } // Parse test results (✔ or ✖) else if (line.includes('✔') || line.includes('✖')) { const test = parseTestLine(line); if (test) { tests.push(test); totalTests++; if (!test.passed) { failedTests++; testsPassed = false; } } } // Check for build/test failure indicators else if (line.includes('** BUILD FAILED **') || line.includes('BUILD FAILED')) { buildSucceeded = false; } else if (line.includes('** TEST FAILED **') || line.includes('TEST FAILED')) { testsPassed = false; } // Parse test summary: "Executed X tests, with Y failures" else if (line.includes('Executed') && line.includes('test')) { const summaryMatch = line.match(/Executed\s+(\d+)\s+tests?,\s+with\s+(\d+)\s+failures?/); if (summaryMatch) { totalTests = parseInt(summaryMatch[1], 10); failedTests = parseInt(summaryMatch[2], 10); testsPassed = failedTests === 0; } } } // Convert Maps to arrays const result: XcbeautifyOutput = { errors: Array.from(errorMap.values()), warnings: Array.from(warningMap.values()), tests, buildSucceeded, testsPassed, totalTests, failedTests }; // Log summary logger.debug({ errors: result.errors.length, warnings: result.warnings.length, tests: result.tests.length, buildSucceeded: result.buildSucceeded, testsPassed: result.testsPassed }, 'Parsed xcbeautify output'); return result; } /** * Format parsed output for display */ export function formatParsedOutput(parsed: XcbeautifyOutput): string { const lines: string[] = []; // Build status if (!parsed.buildSucceeded) { lines.push(`❌ Build failed with ${parsed.errors.length} error${parsed.errors.length !== 1 ? 's' : ''}`); } else if (parsed.errors.length === 0 && parsed.warnings.length === 0) { lines.push('✅ Build succeeded'); } else { lines.push(`⚠️ Build succeeded with ${parsed.warnings.length} warning${parsed.warnings.length !== 1 ? 's' : ''}`); } // Errors if (parsed.errors.length > 0) { lines.push('\nErrors:'); for (const error of parsed.errors.slice(0, 10)) { // Show first 10 if (error.file) { lines.push(` ❌ ${error.file}:${error.line}:${error.column} - ${error.message}`); } else { lines.push(` ❌ ${error.message}`); } } if (parsed.errors.length > 10) { lines.push(` ... and ${parsed.errors.length - 10} more errors`); } } // Warnings (only show if no errors) if (parsed.errors.length === 0 && parsed.warnings.length > 0) { lines.push('\nWarnings:'); for (const warning of parsed.warnings.slice(0, 5)) { // Show first 5 if (warning.file) { lines.push(` ⚠️ ${warning.file}:${warning.line}:${warning.column} - ${warning.message}`); } else { lines.push(` ⚠️ ${warning.message}`); } } if (parsed.warnings.length > 5) { lines.push(` ... and ${parsed.warnings.length - 5} more warnings`); } } // Test results if (parsed.tests.length > 0) { lines.push('\nTest Results:'); if (parsed.testsPassed) { lines.push(` ✅ All ${parsed.totalTests} tests passed`); } else { lines.push(` ❌ ${parsed.failedTests} of ${parsed.totalTests} tests failed`); // Show failed tests const failedTests = parsed.tests.filter(t => !t.passed); for (const test of failedTests.slice(0, 5)) { lines.push(` ✖ ${test.name}: ${test.failureReason || 'Failed'}`); } if (failedTests.length > 5) { lines.push(` ... and ${failedTests.length - 5} more failures`); } } } return lines.join('\n'); } ```