This is page 3 of 5. Use http://codebase.md/stefan-nitu/mcp-xcode-server?lines=true&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/utils/devices/SimulatorDevice.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { SimulatorBoot } from './SimulatorBoot.js'; 2 | import { SimulatorApps } from './SimulatorApps.js'; 3 | import { SimulatorUI } from './SimulatorUI.js'; 4 | import { SimulatorInfo } from './SimulatorInfo.js'; 5 | import { SimulatorReset } from './SimulatorReset.js'; 6 | import { createModuleLogger } from '../../logger.js'; 7 | 8 | const logger = createModuleLogger('SimulatorDevice'); 9 | 10 | /** 11 | * Represents a specific simulator device instance. 12 | * Provides a complete interface for simulator operations while 13 | * delegating to specialized components internally. 14 | */ 15 | export class SimulatorDevice { 16 | private boot: SimulatorBoot; 17 | private apps: SimulatorApps; 18 | private ui: SimulatorUI; 19 | private info: SimulatorInfo; 20 | private reset: SimulatorReset; 21 | 22 | constructor( 23 | public readonly id: string, 24 | public readonly name: string, 25 | public readonly platform: string, 26 | public readonly runtime: string, 27 | components?: { 28 | boot?: SimulatorBoot; 29 | apps?: SimulatorApps; 30 | ui?: SimulatorUI; 31 | info?: SimulatorInfo; 32 | reset?: SimulatorReset; 33 | } 34 | ) { 35 | this.boot = components?.boot || new SimulatorBoot(); 36 | this.apps = components?.apps || new SimulatorApps(); 37 | this.ui = components?.ui || new SimulatorUI(); 38 | this.info = components?.info || new SimulatorInfo(); 39 | this.reset = components?.reset || new SimulatorReset(); 40 | } 41 | 42 | /** 43 | * Boot this simulator device 44 | */ 45 | async bootDevice(): Promise<void> { 46 | logger.debug({ deviceId: this.id, name: this.name }, 'Booting device'); 47 | await this.boot.boot(this.id); 48 | } 49 | 50 | /** 51 | * Shutdown this simulator device 52 | */ 53 | async shutdown(): Promise<void> { 54 | logger.debug({ deviceId: this.id, name: this.name }, 'Shutting down device'); 55 | await this.boot.shutdown(this.id); 56 | } 57 | 58 | /** 59 | * Install an app on this device 60 | */ 61 | async install(appPath: string): Promise<void> { 62 | logger.debug({ deviceId: this.id, appPath }, 'Installing app on device'); 63 | await this.apps.install(appPath, this.id); 64 | } 65 | 66 | /** 67 | * Uninstall an app from this device 68 | */ 69 | async uninstall(bundleId: string): Promise<void> { 70 | logger.debug({ deviceId: this.id, bundleId }, 'Uninstalling app from device'); 71 | await this.apps.uninstall(bundleId, this.id); 72 | } 73 | 74 | /** 75 | * Launch an app on this device 76 | */ 77 | async launch(bundleId: string): Promise<string> { 78 | logger.debug({ deviceId: this.id, bundleId }, 'Launching app on device'); 79 | return await this.apps.launch(bundleId, this.id); 80 | } 81 | 82 | /** 83 | * Get bundle ID from an app path 84 | */ 85 | async getBundleId(appPath: string): Promise<string> { 86 | return await this.apps.getBundleId(appPath); 87 | } 88 | 89 | /** 90 | * Take a screenshot of this device 91 | */ 92 | async screenshot(outputPath: string): Promise<void> { 93 | logger.debug({ deviceId: this.id, outputPath }, 'Taking screenshot'); 94 | await this.ui.screenshot(outputPath, this.id); 95 | } 96 | 97 | /** 98 | * Get screenshot data as base64 99 | */ 100 | async screenshotData(): Promise<{ base64: string; mimeType: string }> { 101 | logger.debug({ deviceId: this.id }, 'Getting screenshot data'); 102 | return await this.ui.screenshotData(this.id); 103 | } 104 | 105 | /** 106 | * Set appearance mode (light/dark) 107 | */ 108 | async setAppearance(appearance: 'light' | 'dark'): Promise<void> { 109 | logger.debug({ deviceId: this.id, appearance }, 'Setting appearance'); 110 | await this.ui.setAppearance(appearance, this.id); 111 | } 112 | 113 | /** 114 | * Open the Simulator app UI 115 | */ 116 | async open(): Promise<void> { 117 | await this.ui.open(); 118 | } 119 | 120 | /** 121 | * Get device logs 122 | */ 123 | async logs(predicate?: string, last?: string): Promise<string> { 124 | logger.debug({ deviceId: this.id, predicate, last }, 'Getting device logs'); 125 | return await this.info.logs(this.id, predicate, last); 126 | } 127 | 128 | /** 129 | * Get current device state 130 | */ 131 | async getState(): Promise<string> { 132 | return await this.info.getDeviceState(this.id); 133 | } 134 | 135 | /** 136 | * Check if device is available 137 | */ 138 | async checkAvailability(): Promise<boolean> { 139 | return await this.info.isAvailable(this.id); 140 | } 141 | 142 | /** 143 | * Reset this device to clean state 144 | */ 145 | async resetDevice(): Promise<void> { 146 | logger.debug({ deviceId: this.id, name: this.name }, 'Resetting device'); 147 | await this.reset.reset(this.id); 148 | } 149 | 150 | /** 151 | * Check if device is currently booted 152 | * Checks actual current state, not cached value 153 | */ 154 | async isBooted(): Promise<boolean> { 155 | const currentState = await this.getState(); 156 | return currentState === 'Booted'; 157 | } 158 | 159 | /** 160 | * Ensure this device is booted, boot if necessary 161 | */ 162 | async ensureBooted(): Promise<void> { 163 | // Check if device is available before trying to boot 164 | const available = await this.checkAvailability(); 165 | if (!available) { 166 | throw new Error( 167 | `Device "${this.name}" (${this.id}) is not available. ` + 168 | `The runtime "${this.runtime}" may be missing or corrupted. ` + 169 | `Try downloading the runtime in Xcode or use a different simulator.` 170 | ); 171 | } 172 | 173 | // Use the async isBooted() method to check actual state 174 | if (!(await this.isBooted())) { 175 | await this.bootDevice(); 176 | } else { 177 | logger.debug({ deviceId: this.id, name: this.name }, 'Device already booted'); 178 | } 179 | } 180 | } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * MCP Xcode Server 5 | * Provides tools for building, running, and testing Apple platform projects 6 | */ 7 | 8 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 9 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 10 | import { 11 | CallToolRequestSchema, 12 | ListToolsRequestSchema, 13 | } from '@modelcontextprotocol/sdk/types.js'; 14 | import { logger, logToolExecution, logError } from './logger.js'; 15 | 16 | // Import all tool classes 17 | // import { 18 | // ListSimulatorsTool, 19 | // BootSimulatorTool, 20 | // ShutdownSimulatorTool, 21 | // ViewSimulatorScreenTool, 22 | // BuildSwiftPackageTool, 23 | // RunSwiftPackageTool, 24 | // RunXcodeTool, 25 | // TestXcodeTool, 26 | // TestSwiftPackageTool, 27 | // CleanBuildTool, 28 | // ArchiveProjectTool, 29 | // ExportIPATool, 30 | // ListSchemesTool, 31 | // GetBuildSettingsTool, 32 | // GetProjectInfoTool, 33 | // ListTargetsTool, 34 | // InstallAppTool, 35 | // UninstallAppTool, 36 | // GetDeviceLogsTool, 37 | // ManageDependenciesTool 38 | // } from './tools/index.js'; 39 | 40 | // Import factories for Clean Architecture controllers 41 | import { 42 | BootSimulatorControllerFactory, 43 | ShutdownSimulatorControllerFactory, 44 | ListSimulatorsControllerFactory 45 | } from './features/simulator/index.js'; 46 | import { BuildXcodeControllerFactory } from './features/build/index.js'; 47 | import { InstallAppControllerFactory } from './features/app-management/index.js'; 48 | 49 | type Tool = { 50 | execute(args: any): Promise<any>; 51 | getToolDefinition(): any; 52 | }; 53 | 54 | class XcodeServer { 55 | private server: Server; 56 | private tools: Map<string, Tool>; 57 | 58 | constructor() { 59 | this.server = new Server( 60 | { 61 | name: 'mcp-xcode-server', 62 | version: '2.4.0', 63 | }, 64 | { 65 | capabilities: { 66 | tools: {}, 67 | }, 68 | } 69 | ); 70 | 71 | // Initialize all tools 72 | this.tools = new Map<string, Tool>(); 73 | this.registerTools(); 74 | this.setupHandlers(); 75 | } 76 | 77 | private registerTools() { 78 | // Create instances of all tools 79 | const toolInstances = [ 80 | // Simulator management 81 | ListSimulatorsControllerFactory.create(), 82 | BootSimulatorControllerFactory.create(), 83 | ShutdownSimulatorControllerFactory.create(), 84 | // new ViewSimulatorScreenTool(), 85 | // Build and test 86 | // new BuildSwiftPackageTool(), 87 | // new RunSwiftPackageTool(), 88 | BuildXcodeControllerFactory.create(), 89 | InstallAppControllerFactory.create(), 90 | // new RunXcodeTool(), 91 | // new TestXcodeTool(), 92 | // new TestSwiftPackageTool(), 93 | // new CleanBuildTool(), 94 | // Archive and export 95 | // new ArchiveProjectTool(), 96 | // new ExportIPATool(), 97 | // Project info and schemes 98 | // new ListSchemesTool(), 99 | // new GetBuildSettingsTool(), 100 | // new GetProjectInfoTool(), 101 | // new ListTargetsTool(), 102 | // App management 103 | // new InstallAppTool(), 104 | // new UninstallAppTool(), 105 | // Device logs 106 | // new GetDeviceLogsTool(), 107 | // Advanced project management 108 | // new ManageDependenciesTool() 109 | ]; 110 | 111 | // Register each tool by its name 112 | for (const tool of toolInstances) { 113 | const definition = tool.getToolDefinition(); 114 | this.tools.set(definition.name, tool); 115 | } 116 | 117 | logger.info({ toolCount: this.tools.size }, 'Tools registered'); 118 | } 119 | 120 | private setupHandlers() { 121 | // Handle listing all available tools 122 | this.server.setRequestHandler(ListToolsRequestSchema, async () => { 123 | const tools = Array.from(this.tools.values()).map(tool => tool.getToolDefinition()); 124 | return { tools }; 125 | }); 126 | 127 | // Handle tool execution 128 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 129 | const { name, arguments: args } = request.params; 130 | const startTime = Date.now(); 131 | 132 | logger.debug({ tool: name, args }, 'Tool request received'); 133 | 134 | try { 135 | const tool = this.tools.get(name); 136 | if (!tool) { 137 | throw new Error(`Unknown tool: ${name}`); 138 | } 139 | 140 | const result = await tool.execute(args); 141 | 142 | // Log successful execution 143 | logToolExecution(name, args, Date.now() - startTime); 144 | return result; 145 | } catch (error: any) { 146 | 147 | logError(error as Error, { tool: name, args }); 148 | return { 149 | content: [ 150 | { 151 | type: 'text', 152 | text: `Error: ${error instanceof Error ? error.message : String(error)}` 153 | } 154 | ] 155 | }; 156 | } 157 | }); 158 | } 159 | 160 | async run() { 161 | const transport = new StdioServerTransport(); 162 | await this.server.connect(transport); 163 | logger.info({ transport: 'stdio' }, 'MCP Xcode server started'); 164 | } 165 | } 166 | 167 | const server = new XcodeServer(); 168 | 169 | // Handle graceful shutdown 170 | process.on('SIGTERM', async () => { 171 | logger.info('Received SIGTERM, shutting down gracefully'); 172 | // Give logger time to flush 173 | await new Promise(resolve => setTimeout(resolve, 100)); 174 | process.exit(0); 175 | }); 176 | 177 | process.on('SIGINT', async () => { 178 | logger.info('Received SIGINT, shutting down gracefully'); 179 | // Give logger time to flush 180 | await new Promise(resolve => setTimeout(resolve, 100)); 181 | process.exit(0); 182 | }); 183 | 184 | server.run().catch((error) => { 185 | logger.fatal({ error }, 'Failed to start MCP server'); 186 | process.exit(1); 187 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ListSimulatorsMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for List Simulators through MCP Protocol 3 | * 4 | * Tests critical user journey: Listing simulators through MCP 5 | * Following testing philosophy: E2E tests for critical paths only (10%) 6 | * 7 | * NO MOCKS - Uses real MCP server, real simulators 8 | */ 9 | 10 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 11 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 12 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 13 | import { createAndConnectClient, cleanupClientAndTransport } from '../../../../shared/tests/utils/testHelpers.js'; 14 | 15 | describe('List Simulators MCP E2E', () => { 16 | let client: Client; 17 | let transport: StdioClientTransport; 18 | 19 | beforeAll(async () => { 20 | // Build the server 21 | const { execSync } = await import('child_process'); 22 | execSync('npm run build', { stdio: 'inherit' }); 23 | }); 24 | 25 | beforeEach(async () => { 26 | ({ client, transport } = await createAndConnectClient()); 27 | }); 28 | 29 | afterEach(async () => { 30 | await cleanupClientAndTransport(client, transport); 31 | }); 32 | 33 | it('should list simulators through MCP', async () => { 34 | // This tests the critical user journey: 35 | // User connects via MCP → calls list_simulators → receives result 36 | 37 | const result = await client.request( 38 | { 39 | method: 'tools/call', 40 | params: { 41 | name: 'list_simulators', 42 | arguments: {} 43 | } 44 | }, 45 | CallToolResultSchema, 46 | { timeout: 30000 } 47 | ); 48 | 49 | expect(result).toBeDefined(); 50 | expect(result.content).toBeInstanceOf(Array); 51 | 52 | const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; 53 | expect(textContent).toBeDefined(); 54 | 55 | const text = textContent?.text || ''; 56 | if (text.includes('No simulators found')) { 57 | expect(text).toBe('🔍 No simulators found'); 58 | } else { 59 | expect(text).toMatch(/Found \d+ simulator/); 60 | } 61 | }); 62 | 63 | it('should filter simulators by platform through MCP', async () => { 64 | const result = await client.request( 65 | { 66 | method: 'tools/call', 67 | params: { 68 | name: 'list_simulators', 69 | arguments: { 70 | platform: 'iOS' 71 | } 72 | } 73 | }, 74 | CallToolResultSchema, 75 | { timeout: 30000 } 76 | ); 77 | 78 | expect(result).toBeDefined(); 79 | expect(result.content).toBeInstanceOf(Array); 80 | 81 | const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; 82 | const text = textContent?.text || ''; 83 | 84 | // Should find iOS simulators 85 | expect(text).toMatch(/Found \d+ simulator/); 86 | 87 | const lines = text.split('\n'); 88 | const deviceLines = lines.filter((line: string) => 89 | line.includes('(') && line.includes(')') && line.includes('-') 90 | ); 91 | 92 | expect(deviceLines.length).toBeGreaterThan(0); 93 | for (const line of deviceLines) { 94 | // All devices should show iOS runtime since we filtered by iOS platform 95 | expect(line).toContain(' - iOS '); 96 | // Should not contain other platform devices 97 | expect(line).not.toMatch(/Apple TV|Apple Watch/); 98 | } 99 | }); 100 | 101 | it('should filter simulators by state through MCP', async () => { 102 | const result = await client.request( 103 | { 104 | method: 'tools/call', 105 | params: { 106 | name: 'list_simulators', 107 | arguments: { 108 | state: 'Shutdown' 109 | } 110 | } 111 | }, 112 | CallToolResultSchema, 113 | { timeout: 30000 } 114 | ); 115 | 116 | expect(result).toBeDefined(); 117 | expect(result.content).toBeInstanceOf(Array); 118 | 119 | const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; 120 | const text = textContent?.text || ''; 121 | 122 | // Should find simulators in shutdown state 123 | expect(text).toMatch(/Found \d+ simulator/); 124 | 125 | const lines = text.split('\n'); 126 | const deviceLines = lines.filter(line => 127 | line.includes('(') && line.includes(')') && line.includes('-') 128 | ); 129 | 130 | expect(deviceLines.length).toBeGreaterThan(0); 131 | for (const line of deviceLines) { 132 | expect(line).toContain('Shutdown'); 133 | } 134 | }); 135 | 136 | it('should handle combined filters through MCP', async () => { 137 | const result = await client.request( 138 | { 139 | method: 'tools/call', 140 | params: { 141 | name: 'list_simulators', 142 | arguments: { 143 | platform: 'iOS', 144 | state: 'Booted' 145 | } 146 | } 147 | }, 148 | CallToolResultSchema, 149 | { timeout: 30000 } 150 | ); 151 | 152 | expect(result).toBeDefined(); 153 | expect(result.content).toBeInstanceOf(Array); 154 | 155 | const textContent = result.content.find((c: any) => c.type === 'text') as { type: string; text: string } | undefined; 156 | const text = textContent?.text || ''; 157 | 158 | // The combined filter might not find any booted iOS simulators 159 | // but the test should still assert the behavior 160 | if (text.includes('No simulators found')) { 161 | expect(text).toBe('🔍 No simulators found'); 162 | } else { 163 | expect(text).toMatch(/Found \d+ simulator/); 164 | 165 | const lines = text.split('\n'); 166 | const deviceLines = lines.filter(line => 167 | line.includes('(') && line.includes(')') && line.includes('-') 168 | ); 169 | 170 | for (const line of deviceLines) { 171 | expect(line).toContain(' - iOS '); 172 | expect(line).toContain('Booted'); 173 | } 174 | } 175 | }); 176 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ShutdownSimulatorMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for Shutdown Simulator through MCP Protocol 3 | * 4 | * Tests critical user journey: Shutting down a simulator through MCP 5 | * Following testing philosophy: E2E tests for critical paths only (10%) 6 | * 7 | * Focus: MCP protocol interaction, not simulator shutdown logic 8 | * The controller tests already verify shutdown works with real simulators 9 | * This test verifies the MCP transport/serialization/protocol works 10 | * 11 | * NO MOCKS - Uses real MCP server, real simulators 12 | */ 13 | 14 | import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; 15 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 16 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 17 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 18 | import { createAndConnectClient, cleanupClientAndTransport } from '../../../../shared/tests/utils/testHelpers.js'; 19 | import { TestSimulatorManager } from '../../../../shared/tests/utils/TestSimulatorManager.js'; 20 | import { exec } from 'child_process'; 21 | import { promisify } from 'util'; 22 | 23 | const execAsync = promisify(exec); 24 | 25 | describe('Shutdown Simulator MCP E2E', () => { 26 | let client: Client; 27 | let transport: StdioClientTransport; 28 | let testSimManager: TestSimulatorManager; 29 | 30 | beforeAll(async () => { 31 | // Build the server 32 | const { execSync } = await import('child_process'); 33 | execSync('npm run build', { stdio: 'inherit' }); 34 | 35 | // Set up test simulator 36 | testSimManager = new TestSimulatorManager(); 37 | await testSimManager.getOrCreateSimulator('TestSimulator-ShutdownMCP'); 38 | 39 | // Connect to MCP server 40 | ({ client, transport } = await createAndConnectClient()); 41 | }); 42 | 43 | afterAll(async () => { 44 | // Cleanup test simulator 45 | await testSimManager.cleanup(); 46 | 47 | // Cleanup MCP connection 48 | await cleanupClientAndTransport(client, transport); 49 | }); 50 | 51 | describe('shutdown simulator through MCP', () => { 52 | it('should shutdown simulator via MCP protocol', async () => { 53 | // Arrange - Boot the simulator first 54 | await testSimManager.bootAndWait(30); 55 | 56 | // Act - Call tool through MCP 57 | const result = await client.callTool({ 58 | name: 'shutdown_simulator', 59 | arguments: { 60 | deviceId: testSimManager.getSimulatorName() 61 | } 62 | }); 63 | 64 | // Assert - Verify MCP response 65 | const parsed = CallToolResultSchema.parse(result); 66 | expect(parsed.content[0].type).toBe('text'); 67 | expect(parsed.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 68 | 69 | // Verify simulator is actually shutdown 70 | const listResult = await execAsync('xcrun simctl list devices --json'); 71 | const devices = JSON.parse(listResult.stdout); 72 | let found = false; 73 | for (const runtime of Object.values(devices.devices) as any[]) { 74 | const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); 75 | if (device) { 76 | expect(device.state).toBe('Shutdown'); 77 | found = true; 78 | break; 79 | } 80 | } 81 | expect(found).toBe(true); 82 | }); 83 | 84 | it('should handle already shutdown simulator via MCP', async () => { 85 | // Arrange - ensure simulator is shutdown 86 | await testSimManager.shutdownAndWait(); 87 | 88 | // Act - Call tool through MCP 89 | const result = await client.callTool({ 90 | name: 'shutdown_simulator', 91 | arguments: { 92 | deviceId: testSimManager.getSimulatorId() 93 | } 94 | }); 95 | 96 | // Assert 97 | const parsed = CallToolResultSchema.parse(result); 98 | expect(parsed.content[0].text).toBe(`✅ Simulator already shutdown: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 99 | }); 100 | 101 | it('should shutdown simulator by UUID via MCP', async () => { 102 | // Arrange - Boot the simulator first 103 | await testSimManager.bootAndWait(30); 104 | 105 | // Act - Call tool with UUID 106 | const result = await client.callTool({ 107 | name: 'shutdown_simulator', 108 | arguments: { 109 | deviceId: testSimManager.getSimulatorId() 110 | } 111 | }); 112 | 113 | // Assert 114 | const parsed = CallToolResultSchema.parse(result); 115 | expect(parsed.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 116 | 117 | // Verify simulator is actually shutdown 118 | const listResult = await execAsync('xcrun simctl list devices --json'); 119 | const devices = JSON.parse(listResult.stdout); 120 | for (const runtime of Object.values(devices.devices) as any[]) { 121 | const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); 122 | if (device) { 123 | expect(device.state).toBe('Shutdown'); 124 | break; 125 | } 126 | } 127 | }); 128 | }); 129 | 130 | describe('error handling through MCP', () => { 131 | it('should return error for non-existent simulator', async () => { 132 | // Act 133 | const result = await client.callTool({ 134 | name: 'shutdown_simulator', 135 | arguments: { 136 | deviceId: 'NonExistentSimulator-MCP' 137 | } 138 | }); 139 | 140 | // Assert 141 | const parsed = CallToolResultSchema.parse(result); 142 | expect(parsed.content[0].text).toBe('❌ Simulator not found: NonExistentSimulator-MCP'); 143 | }); 144 | }); 145 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/testHelpers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Client } from '@modelcontextprotocol/sdk/client/index'; 2 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; 3 | import { exec } from 'child_process'; 4 | import { promisify } from 'util'; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | /** 9 | * Cleanup MCP client and transport connections 10 | */ 11 | export async function cleanupClientAndTransport( 12 | client: Client | null | undefined, 13 | transport: StdioClientTransport | null | undefined 14 | ): Promise<void> { 15 | if (client) { 16 | await client.close(); 17 | } 18 | 19 | if (transport) { 20 | const transportProcess = (transport as any)._process; 21 | await transport.close(); 22 | 23 | if (transportProcess) { 24 | if (transportProcess.stdin && !transportProcess.stdin.destroyed) { 25 | transportProcess.stdin.end(); 26 | transportProcess.stdin.destroy(); 27 | } 28 | if (transportProcess.stdout && !transportProcess.stdout.destroyed) { 29 | transportProcess.stdout.destroy(); 30 | } 31 | if (transportProcess.stderr && !transportProcess.stderr.destroyed) { 32 | transportProcess.stderr.destroy(); 33 | } 34 | transportProcess.unref(); 35 | if (!transportProcess.killed) { 36 | transportProcess.kill('SIGTERM'); 37 | await new Promise(resolve => { 38 | const timeout = setTimeout(resolve, 100); 39 | transportProcess.once('exit', () => { 40 | clearTimeout(timeout); 41 | resolve(undefined); 42 | }); 43 | }); 44 | } 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Create and connect a new MCP client and transport 51 | */ 52 | export async function createAndConnectClient(): Promise<{ 53 | client: Client; 54 | transport: StdioClientTransport; 55 | }> { 56 | const transport = new StdioClientTransport({ 57 | command: 'node', 58 | args: ['dist/index.js'], 59 | cwd: process.cwd(), 60 | }); 61 | 62 | const client = new Client({ 63 | name: 'test-client', 64 | version: '1.0.0', 65 | }, { 66 | capabilities: {} 67 | }); 68 | 69 | await client.connect(transport); 70 | 71 | return { client, transport }; 72 | } 73 | 74 | /** 75 | * Wait for a simulator to reach the Booted state 76 | * @param simulatorId The simulator UUID to wait for 77 | * @param maxSeconds Maximum seconds to wait (default 30) 78 | * @returns Promise that resolves when booted or rejects on timeout 79 | */ 80 | export async function waitForSimulatorBoot( 81 | simulatorId: string, 82 | maxSeconds: number = 30 83 | ): Promise<void> { 84 | for (let i = 0; i < maxSeconds; i++) { 85 | const listResult = await execAsync('xcrun simctl list devices --json'); 86 | const devices = JSON.parse(listResult.stdout); 87 | 88 | for (const runtime of Object.values(devices.devices) as any[]) { 89 | const device = runtime.find((d: any) => d.udid === simulatorId); 90 | if (device && device.state === 'Booted') { 91 | return; // Successfully booted 92 | } 93 | } 94 | 95 | // Wait 1 second before trying again 96 | await new Promise(resolve => setTimeout(resolve, 1000)); 97 | } 98 | 99 | throw new Error(`Failed to boot simulator ${simulatorId} after ${maxSeconds} seconds`); 100 | } 101 | 102 | /** 103 | * Boot a simulator and wait for it to be ready 104 | * @param simulatorId The simulator UUID to boot 105 | * @param maxSeconds Maximum seconds to wait (default 30) 106 | */ 107 | export async function bootAndWaitForSimulator( 108 | simulatorId: string, 109 | maxSeconds: number = 30 110 | ): Promise<void> { 111 | try { 112 | await execAsync(`xcrun simctl boot "${simulatorId}"`); 113 | } catch { 114 | // Ignore if already booted 115 | } 116 | 117 | await waitForSimulatorBoot(simulatorId, maxSeconds); 118 | } 119 | 120 | /** 121 | * Wait for a simulator to reach the Shutdown state 122 | * @param simulatorId The simulator UUID to wait for 123 | * @param maxSeconds Maximum seconds to wait (default 30) 124 | * @returns Promise that resolves when shutdown or rejects on timeout 125 | */ 126 | export async function waitForSimulatorShutdown( 127 | simulatorId: string, 128 | maxSeconds: number = 30 129 | ): Promise<void> { 130 | for (let i = 0; i < maxSeconds; i++) { 131 | const listResult = await execAsync('xcrun simctl list devices --json'); 132 | const devices = JSON.parse(listResult.stdout); 133 | 134 | for (const runtime of Object.values(devices.devices) as any[]) { 135 | const device = runtime.find((d: any) => d.udid === simulatorId); 136 | if (device && device.state === 'Shutdown') { 137 | return; // Successfully shutdown 138 | } 139 | } 140 | 141 | // Wait 1 second before trying again 142 | await new Promise(resolve => setTimeout(resolve, 1000)); 143 | } 144 | 145 | throw new Error(`Failed to shutdown simulator ${simulatorId} after ${maxSeconds} seconds`); 146 | } 147 | 148 | /** 149 | * Shutdown a simulator and wait for it to be shutdown 150 | * @param simulatorId The simulator UUID to shutdown 151 | * @param maxSeconds Maximum seconds to wait (default 30) 152 | */ 153 | export async function shutdownAndWaitForSimulator( 154 | simulatorId: string, 155 | maxSeconds: number = 30 156 | ): Promise<void> { 157 | try { 158 | await execAsync(`xcrun simctl shutdown "${simulatorId}"`); 159 | } catch { 160 | // Ignore if already shutdown 161 | } 162 | 163 | await waitForSimulatorShutdown(simulatorId, maxSeconds); 164 | } 165 | 166 | /** 167 | * Cleanup a test simulator by shutting it down and deleting it 168 | * @param simulatorId The simulator UUID to cleanup 169 | */ 170 | export async function cleanupTestSimulator(simulatorId: string | undefined): Promise<void> { 171 | if (!simulatorId) return; 172 | 173 | try { 174 | await execAsync(`xcrun simctl shutdown "${simulatorId}"`); 175 | } catch { 176 | // Ignore shutdown errors - simulator might already be shutdown 177 | } 178 | 179 | try { 180 | await execAsync(`xcrun simctl delete "${simulatorId}"`); 181 | } catch { 182 | // Ignore delete errors - simulator might already be deleted 183 | } 184 | } 185 | 186 | ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ShutdownSimulatorController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for ShutdownSimulatorController 3 | * 4 | * Tests the controller with REAL simulators and REAL system commands 5 | * Following testing philosophy: E2E tests for critical paths only (10%) 6 | * 7 | * NO MOCKS - Uses real xcrun simctl commands with actual simulators 8 | */ 9 | 10 | import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; 11 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 12 | import { ShutdownSimulatorControllerFactory } from '../../factories/ShutdownSimulatorControllerFactory.js'; 13 | import { exec } from 'child_process'; 14 | import { promisify } from 'util'; 15 | import { TestSimulatorManager } from '../../../../shared/tests/utils/TestSimulatorManager.js'; 16 | 17 | const execAsync = promisify(exec); 18 | 19 | describe('ShutdownSimulatorController E2E', () => { 20 | let controller: MCPController; 21 | let testSimManager: TestSimulatorManager; 22 | 23 | beforeAll(async () => { 24 | // Create controller with REAL components 25 | controller = ShutdownSimulatorControllerFactory.create(); 26 | 27 | // Set up test simulator 28 | testSimManager = new TestSimulatorManager(); 29 | await testSimManager.getOrCreateSimulator('TestSimulator-Shutdown'); 30 | }); 31 | 32 | beforeEach(async () => { 33 | // Boot simulator before each test (to ensure we can shut it down) 34 | await testSimManager.bootAndWait(30); 35 | }); 36 | 37 | afterAll(async () => { 38 | // Cleanup test simulator 39 | await testSimManager.cleanup(); 40 | }); 41 | 42 | describe('shutdown real simulators', () => { 43 | it('should shutdown a booted simulator', async () => { 44 | // Act 45 | const result = await controller.execute({ 46 | deviceId: testSimManager.getSimulatorName() 47 | }); 48 | 49 | // Assert 50 | expect(result.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 51 | 52 | // Verify simulator is actually shutdown 53 | const listResult = await execAsync('xcrun simctl list devices --json'); 54 | const devices = JSON.parse(listResult.stdout); 55 | let found = false; 56 | for (const runtime of Object.values(devices.devices) as any[]) { 57 | const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); 58 | if (device) { 59 | expect(device.state).toBe('Shutdown'); 60 | found = true; 61 | break; 62 | } 63 | } 64 | expect(found).toBe(true); 65 | }); 66 | 67 | it('should handle already shutdown simulator', async () => { 68 | // Arrange - shutdown simulator first 69 | await testSimManager.shutdownAndWait(5); 70 | await new Promise(resolve => setTimeout(resolve, 1000)); 71 | 72 | // Act 73 | const result = await controller.execute({ 74 | deviceId: testSimManager.getSimulatorName() 75 | }); 76 | 77 | // Assert 78 | expect(result.content[0].text).toBe(`✅ Simulator already shutdown: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 79 | }); 80 | 81 | it('should shutdown simulator by UUID', async () => { 82 | // Act 83 | const result = await controller.execute({ 84 | deviceId: testSimManager.getSimulatorId() 85 | }); 86 | 87 | // Assert 88 | expect(result.content[0].text).toBe(`✅ Successfully shutdown simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 89 | 90 | // Verify simulator is actually shutdown 91 | const listResult = await execAsync('xcrun simctl list devices --json'); 92 | const devices = JSON.parse(listResult.stdout); 93 | let found = false; 94 | for (const runtime of Object.values(devices.devices) as any[]) { 95 | const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); 96 | if (device) { 97 | expect(device.state).toBe('Shutdown'); 98 | found = true; 99 | break; 100 | } 101 | } 102 | expect(found).toBe(true); 103 | }); 104 | }); 105 | 106 | describe('error handling', () => { 107 | it('should handle non-existent simulator', async () => { 108 | // Act 109 | const result = await controller.execute({ 110 | deviceId: 'NonExistent-Simulator-That-Does-Not-Exist' 111 | }); 112 | 113 | // Assert 114 | expect(result.content[0].text).toBe('❌ Simulator not found: NonExistent-Simulator-That-Does-Not-Exist'); 115 | }); 116 | }); 117 | 118 | describe('complex scenarios', () => { 119 | it('should shutdown simulator that was booting', async () => { 120 | // Arrange - boot and immediately try to shutdown 121 | const bootPromise = testSimManager.bootAndWait(30); 122 | 123 | // Act - shutdown while booting 124 | const result = await controller.execute({ 125 | deviceId: testSimManager.getSimulatorName() 126 | }); 127 | 128 | // Assert 129 | expect(result.content[0].text).toContain('✅'); 130 | expect(result.content[0].text).toContain(testSimManager.getSimulatorName()); 131 | 132 | // Wait for operations to complete 133 | try { 134 | await bootPromise; 135 | } catch { 136 | // Boot might fail if shutdown interrupted it, that's OK 137 | } 138 | 139 | // Verify final state is shutdown 140 | const listResult = await execAsync('xcrun simctl list devices --json'); 141 | const devices = JSON.parse(listResult.stdout); 142 | for (const runtime of Object.values(devices.devices) as any[]) { 143 | const device = runtime.find((d: any) => d.udid === testSimManager.getSimulatorId()); 144 | if (device) { 145 | expect(device.state).toBe('Shutdown'); 146 | break; 147 | } 148 | } 149 | }); 150 | }); 151 | }); ``` -------------------------------------------------------------------------------- /src/utils/projects/SwiftPackageInfo.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | 4 | const logger = createModuleLogger('SwiftPackageInfo'); 5 | 6 | export interface Dependency { 7 | name: string; 8 | url: string; 9 | version?: string; 10 | branch?: string; 11 | revision?: string; 12 | } 13 | 14 | export interface Product { 15 | name: string; 16 | type: 'executable' | 'library'; 17 | targets: string[]; 18 | } 19 | 20 | /** 21 | * Queries information about Swift packages 22 | */ 23 | export class SwiftPackageInfo { 24 | /** 25 | * Get list of products in a package 26 | */ 27 | async getProducts(packagePath: string): Promise<Product[]> { 28 | const command = `swift package --package-path "${packagePath}" describe --type json`; 29 | 30 | logger.debug({ command }, 'Describe package command'); 31 | 32 | try { 33 | const { stdout } = await execAsync(command); 34 | const packageInfo = JSON.parse(stdout); 35 | 36 | const products: Product[] = packageInfo.products?.map((p: any) => ({ 37 | name: p.name, 38 | type: p.type?.executable ? 'executable' : 'library', 39 | targets: p.targets || [] 40 | })) || []; 41 | 42 | logger.debug({ packagePath, products }, 'Found products'); 43 | return products; 44 | } catch (error: any) { 45 | logger.error({ error: error.message, packagePath }, 'Failed to get products'); 46 | throw new Error(`Failed to get products: ${error.message}`); 47 | } 48 | } 49 | 50 | /** 51 | * Get list of targets in a package 52 | */ 53 | async getTargets(packagePath: string): Promise<string[]> { 54 | const command = `swift package --package-path "${packagePath}" describe --type json`; 55 | 56 | logger.debug({ command }, 'Describe package command'); 57 | 58 | try { 59 | const { stdout } = await execAsync(command); 60 | const packageInfo = JSON.parse(stdout); 61 | 62 | const targets = packageInfo.targets?.map((t: any) => t.name) || []; 63 | 64 | logger.debug({ packagePath, targets }, 'Found targets'); 65 | return targets; 66 | } catch (error: any) { 67 | logger.error({ error: error.message, packagePath }, 'Failed to get targets'); 68 | throw new Error(`Failed to get targets: ${error.message}`); 69 | } 70 | } 71 | 72 | /** 73 | * Get list of dependencies 74 | */ 75 | async getDependencies(packagePath: string): Promise<Dependency[]> { 76 | const command = `swift package --package-path "${packagePath}" show-dependencies --format json`; 77 | 78 | logger.debug({ command }, 'Show dependencies command'); 79 | 80 | try { 81 | const { stdout } = await execAsync(command); 82 | const depTree = JSON.parse(stdout); 83 | 84 | // Extract direct dependencies from the tree 85 | const dependencies: Dependency[] = depTree.dependencies?.map((d: any) => ({ 86 | name: d.name, 87 | url: d.url, 88 | version: d.version, 89 | branch: d.branch, 90 | revision: d.revision 91 | })) || []; 92 | 93 | logger.debug({ packagePath, dependencies }, 'Found dependencies'); 94 | return dependencies; 95 | } catch (error: any) { 96 | logger.error({ error: error.message, packagePath }, 'Failed to get dependencies'); 97 | throw new Error(`Failed to get dependencies: ${error.message}`); 98 | } 99 | } 100 | 101 | /** 102 | * Add a dependency to the package 103 | */ 104 | async addDependency( 105 | packagePath: string, 106 | url: string, 107 | options: { 108 | version?: string; 109 | branch?: string; 110 | exact?: boolean; 111 | from?: string; 112 | upToNextMajor?: string; 113 | } = {} 114 | ): Promise<void> { 115 | let command = `swift package --package-path "${packagePath}" add-dependency "${url}"`; 116 | 117 | if (options.branch) { 118 | command += ` --branch "${options.branch}"`; 119 | } else if (options.exact) { 120 | command += ` --exact "${options.version}"`; 121 | } else if (options.from) { 122 | command += ` --from "${options.from}"`; 123 | } else if (options.upToNextMajor) { 124 | command += ` --up-to-next-major-from "${options.upToNextMajor}"`; 125 | } 126 | 127 | logger.debug({ command }, 'Add dependency command'); 128 | 129 | try { 130 | await execAsync(command); 131 | logger.info({ packagePath, url }, 'Dependency added'); 132 | } catch (error: any) { 133 | logger.error({ error: error.message, packagePath, url }, 'Failed to add dependency'); 134 | throw new Error(`Failed to add dependency: ${error.message}`); 135 | } 136 | } 137 | 138 | /** 139 | * Remove a dependency from the package 140 | */ 141 | async removeDependency(packagePath: string, name: string): Promise<void> { 142 | const command = `swift package --package-path "${packagePath}" remove-dependency "${name}"`; 143 | 144 | logger.debug({ command }, 'Remove dependency command'); 145 | 146 | try { 147 | await execAsync(command); 148 | logger.info({ packagePath, name }, 'Dependency removed'); 149 | } catch (error: any) { 150 | logger.error({ error: error.message, packagePath, name }, 'Failed to remove dependency'); 151 | throw new Error(`Failed to remove dependency: ${error.message}`); 152 | } 153 | } 154 | 155 | /** 156 | * Update package dependencies 157 | */ 158 | async updateDependencies(packagePath: string): Promise<void> { 159 | const command = `swift package --package-path "${packagePath}" update`; 160 | 161 | logger.debug({ command }, 'Update dependencies command'); 162 | 163 | try { 164 | await execAsync(command); 165 | logger.info({ packagePath }, 'Dependencies updated'); 166 | } catch (error: any) { 167 | logger.error({ error: error.message, packagePath }, 'Failed to update dependencies'); 168 | throw new Error(`Failed to update dependencies: ${error.message}`); 169 | } 170 | } 171 | 172 | /** 173 | * Resolve package dependencies 174 | */ 175 | async resolveDependencies(packagePath: string): Promise<void> { 176 | const command = `swift package --package-path "${packagePath}" resolve`; 177 | 178 | logger.debug({ command }, 'Resolve dependencies command'); 179 | 180 | try { 181 | await execAsync(command); 182 | logger.info({ packagePath }, 'Dependencies resolved'); 183 | } catch (error: any) { 184 | logger.error({ error: error.message, packagePath }, 'Failed to resolve dependencies'); 185 | throw new Error(`Failed to resolve dependencies: ${error.message}`); 186 | } 187 | } 188 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ListSimulatorsController.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest } from '@jest/globals'; 2 | import { ListSimulatorsController } from '../../controllers/ListSimulatorsController.js'; 3 | import { ListSimulatorsUseCase } from '../../use-cases/ListSimulatorsUseCase.js'; 4 | import { ListSimulatorsRequest } from '../../domain/ListSimulatorsRequest.js'; 5 | import { ListSimulatorsResult, SimulatorInfo } from '../../domain/ListSimulatorsResult.js'; 6 | import { SimulatorState } from '../../domain/SimulatorState.js'; 7 | 8 | describe('ListSimulatorsController', () => { 9 | function createSUT() { 10 | const mockExecute = jest.fn<(request: ListSimulatorsRequest) => Promise<ListSimulatorsResult>>(); 11 | const mockUseCase: Partial<ListSimulatorsUseCase> = { 12 | execute: mockExecute 13 | }; 14 | const sut = new ListSimulatorsController(mockUseCase as ListSimulatorsUseCase); 15 | return { sut, mockExecute }; 16 | } 17 | 18 | describe('MCP tool interface', () => { 19 | it('should define correct tool metadata', () => { 20 | // Arrange 21 | const { sut } = createSUT(); 22 | 23 | // Act 24 | const definition = sut.getToolDefinition(); 25 | 26 | // Assert 27 | expect(definition.name).toBe('list_simulators'); 28 | expect(definition.description).toBe('List available iOS simulators'); 29 | expect(definition.inputSchema).toBeDefined(); 30 | }); 31 | 32 | it('should define correct input schema with optional filters', () => { 33 | // Arrange 34 | const { sut } = createSUT(); 35 | 36 | // Act 37 | const schema = sut.inputSchema; 38 | 39 | // Assert 40 | expect(schema.type).toBe('object'); 41 | expect(schema.properties.platform).toBeDefined(); 42 | expect(schema.properties.platform.type).toBe('string'); 43 | expect(schema.properties.platform.enum).toEqual(['iOS', 'tvOS', 'watchOS', 'visionOS']); 44 | expect(schema.properties.state).toBeDefined(); 45 | expect(schema.properties.state.type).toBe('string'); 46 | expect(schema.properties.state.enum).toEqual(['Booted', 'Shutdown']); 47 | expect(schema.properties.name).toBeDefined(); 48 | expect(schema.properties.name.type).toBe('string'); 49 | expect(schema.properties.name.description).toBe('Filter by device name (partial match, case-insensitive)'); 50 | expect(schema.required).toEqual([]); 51 | }); 52 | }); 53 | 54 | describe('execute', () => { 55 | it('should list all simulators without filters', async () => { 56 | // Arrange 57 | const { sut, mockExecute } = createSUT(); 58 | const simulators: SimulatorInfo[] = [ 59 | { 60 | udid: 'ABC123', 61 | name: 'iPhone 15', 62 | state: SimulatorState.Booted, 63 | platform: 'iOS', 64 | runtime: 'iOS 17.0' 65 | }, 66 | { 67 | udid: 'DEF456', 68 | name: 'iPad Pro', 69 | state: SimulatorState.Shutdown, 70 | platform: 'iOS', 71 | runtime: 'iOS 17.0' 72 | } 73 | ]; 74 | const mockResult = ListSimulatorsResult.success(simulators); 75 | mockExecute.mockResolvedValue(mockResult); 76 | 77 | // Act 78 | const result = await sut.execute({}); 79 | 80 | // Assert 81 | expect(result.content[0].text).toContain('Found 2 simulators'); 82 | expect(result.content[0].text).toContain('iPhone 15 (ABC123) - Booted'); 83 | expect(result.content[0].text).toContain('iPad Pro (DEF456) - Shutdown'); 84 | }); 85 | 86 | it('should filter by platform', async () => { 87 | // Arrange 88 | const { sut, mockExecute } = createSUT(); 89 | const simulators: SimulatorInfo[] = [ 90 | { 91 | udid: 'ABC123', 92 | name: 'iPhone 15', 93 | state: SimulatorState.Booted, 94 | platform: 'iOS', 95 | runtime: 'iOS 17.0' 96 | } 97 | ]; 98 | const mockResult = ListSimulatorsResult.success(simulators); 99 | mockExecute.mockResolvedValue(mockResult); 100 | 101 | // Act 102 | const result = await sut.execute({ platform: 'iOS' }); 103 | 104 | // Assert 105 | expect(result.content[0].text).toContain('Found 1 simulator'); 106 | }); 107 | 108 | it('should filter by state', async () => { 109 | // Arrange 110 | const { sut, mockExecute } = createSUT(); 111 | const simulators: SimulatorInfo[] = [ 112 | { 113 | udid: 'ABC123', 114 | name: 'iPhone 15', 115 | state: SimulatorState.Booted, 116 | platform: 'iOS', 117 | runtime: 'iOS 17.0' 118 | } 119 | ]; 120 | const mockResult = ListSimulatorsResult.success(simulators); 121 | mockExecute.mockResolvedValue(mockResult); 122 | 123 | // Act 124 | const result = await sut.execute({ state: 'Booted' }); 125 | 126 | // Assert 127 | expect(result.content[0].text).toContain('✅'); 128 | }); 129 | 130 | it('should handle no simulators found', async () => { 131 | // Arrange 132 | const { sut, mockExecute } = createSUT(); 133 | const mockResult = ListSimulatorsResult.success([]); 134 | mockExecute.mockResolvedValue(mockResult); 135 | 136 | // Act 137 | const result = await sut.execute({}); 138 | 139 | // Assert 140 | expect(result.content[0].text).toBe('🔍 No simulators found'); 141 | }); 142 | 143 | it('should handle errors gracefully', async () => { 144 | // Arrange 145 | const { sut, mockExecute } = createSUT(); 146 | const mockResult = ListSimulatorsResult.failed(new Error('Failed to list devices')); 147 | mockExecute.mockResolvedValue(mockResult); 148 | 149 | // Act 150 | const result = await sut.execute({}); 151 | 152 | // Assert 153 | expect(result.content[0].text).toContain('❌'); 154 | expect(result.content[0].text).toContain('Failed to list devices'); 155 | }); 156 | 157 | it('should format simulators with runtime info', async () => { 158 | // Arrange 159 | const { sut, mockExecute } = createSUT(); 160 | const simulators: SimulatorInfo[] = [ 161 | { 162 | udid: 'ABC123', 163 | name: 'iPhone 15 Pro Max', 164 | state: SimulatorState.Booted, 165 | platform: 'iOS', 166 | runtime: 'iOS 17.2' 167 | } 168 | ]; 169 | const mockResult = ListSimulatorsResult.success(simulators); 170 | mockExecute.mockResolvedValue(mockResult); 171 | 172 | // Act 173 | const result = await sut.execute({}); 174 | 175 | // Assert 176 | expect(result.content[0].text).toContain('iOS 17.2'); 177 | }); 178 | 179 | it('should return validation error for invalid input', async () => { 180 | // Arrange 181 | const { sut } = createSUT(); 182 | 183 | // Act 184 | const result = await sut.execute({ 185 | platform: 'invalid' 186 | }); 187 | 188 | // Assert 189 | expect(result.content[0].text).toBe('❌ Invalid platform: invalid. Valid values are: iOS, macOS, tvOS, watchOS, visionOS'); 190 | }); 191 | 192 | }); 193 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/ConfigProviderAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ConfigProviderAdapter } from '../../infrastructure/ConfigProviderAdapter.js'; 2 | import { IConfigProvider } from '../../../application/ports/ConfigPorts.js'; 3 | import { homedir } from 'os'; 4 | import path from 'path'; 5 | 6 | describe('ConfigProvider', () => { 7 | // Save original env 8 | const originalEnv = process.env; 9 | 10 | beforeEach(() => { 11 | // Reset env before each test 12 | process.env = { ...originalEnv }; 13 | }); 14 | 15 | afterAll(() => { 16 | // Restore original env 17 | process.env = originalEnv; 18 | }); 19 | 20 | // Factory method for creating the SUT 21 | function createSUT(): IConfigProvider { 22 | return new ConfigProviderAdapter(); 23 | } 24 | 25 | describe('getDerivedDataPath', () => { 26 | it('should use default path when no env var is set', () => { 27 | // Arrange 28 | delete process.env.MCP_XCODE_DERIVED_DATA_PATH; 29 | const sut = createSUT(); 30 | const expectedPath = path.join(homedir(), 'Library', 'Developer', 'Xcode', 'DerivedData', 'MCP-Xcode'); 31 | 32 | // Act 33 | const result = sut.getDerivedDataPath(); 34 | 35 | // Assert 36 | expect(result).toBe(expectedPath); 37 | }); 38 | 39 | it('should use env var when set', () => { 40 | // Arrange 41 | process.env.MCP_XCODE_DERIVED_DATA_PATH = '/custom/path'; 42 | const sut = createSUT(); 43 | 44 | // Act 45 | const result = sut.getDerivedDataPath(); 46 | 47 | // Assert 48 | expect(result).toBe('/custom/path'); 49 | }); 50 | 51 | it('should return project-specific path when project path is provided', () => { 52 | // Arrange 53 | process.env.MCP_XCODE_DERIVED_DATA_PATH = '/base/path'; 54 | const sut = createSUT(); 55 | 56 | // Act 57 | const result = sut.getDerivedDataPath('/Users/dev/MyApp.xcodeproj'); 58 | 59 | // Assert 60 | expect(result).toBe('/base/path/MyApp'); 61 | }); 62 | 63 | it('should handle workspace paths correctly', () => { 64 | // Arrange 65 | process.env.MCP_XCODE_DERIVED_DATA_PATH = '/base/path'; 66 | const sut = createSUT(); 67 | 68 | // Act 69 | const result = sut.getDerivedDataPath('/Users/dev/MyWorkspace.xcworkspace'); 70 | 71 | // Assert 72 | expect(result).toBe('/base/path/MyWorkspace'); 73 | }); 74 | 75 | it('should handle paths with spaces', () => { 76 | // Arrange 77 | process.env.MCP_XCODE_DERIVED_DATA_PATH = '/base/path'; 78 | const sut = createSUT(); 79 | 80 | // Act 81 | const result = sut.getDerivedDataPath('/Users/dev/My App.xcodeproj'); 82 | 83 | // Assert 84 | expect(result).toBe('/base/path/My App'); 85 | }); 86 | }); 87 | 88 | describe('getBuildTimeout', () => { 89 | it('should return default timeout when no env var is set', () => { 90 | // Arrange 91 | delete process.env.MCP_XCODE_BUILD_TIMEOUT; 92 | const sut = createSUT(); 93 | 94 | // Act 95 | const result = sut.getBuildTimeout(); 96 | 97 | // Assert 98 | expect(result).toBe(600000); // 10 minutes 99 | }); 100 | 101 | it('should use env var when set', () => { 102 | // Arrange 103 | process.env.MCP_XCODE_BUILD_TIMEOUT = '300000'; 104 | const sut = createSUT(); 105 | 106 | // Act 107 | const result = sut.getBuildTimeout(); 108 | 109 | // Assert 110 | expect(result).toBe(300000); 111 | }); 112 | 113 | it('should handle invalid timeout value', () => { 114 | // Arrange 115 | process.env.MCP_XCODE_BUILD_TIMEOUT = 'invalid'; 116 | const sut = createSUT(); 117 | 118 | // Act 119 | const result = sut.getBuildTimeout(); 120 | 121 | // Assert 122 | expect(result).toBeNaN(); // parseInt returns NaN for invalid strings 123 | }); 124 | }); 125 | 126 | describe('isXcbeautifyEnabled', () => { 127 | it('should return true by default', () => { 128 | // Arrange 129 | delete process.env.MCP_XCODE_XCBEAUTIFY_ENABLED; 130 | const sut = createSUT(); 131 | 132 | // Act 133 | const result = sut.isXcbeautifyEnabled(); 134 | 135 | // Assert 136 | expect(result).toBe(true); 137 | }); 138 | 139 | it('should return true when env var is "true"', () => { 140 | // Arrange 141 | process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'true'; 142 | const sut = createSUT(); 143 | 144 | // Act 145 | const result = sut.isXcbeautifyEnabled(); 146 | 147 | // Assert 148 | expect(result).toBe(true); 149 | }); 150 | 151 | it('should return false when env var is "false"', () => { 152 | // Arrange 153 | process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'false'; 154 | const sut = createSUT(); 155 | 156 | // Act 157 | const result = sut.isXcbeautifyEnabled(); 158 | 159 | // Assert 160 | expect(result).toBe(false); 161 | }); 162 | 163 | it('should handle case insensitive true value', () => { 164 | // Arrange 165 | process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'TRUE'; 166 | const sut = createSUT(); 167 | 168 | // Act 169 | const result = sut.isXcbeautifyEnabled(); 170 | 171 | // Assert 172 | expect(result).toBe(true); 173 | }); 174 | 175 | it('should return false for any non-true value', () => { 176 | // Arrange 177 | process.env.MCP_XCODE_XCBEAUTIFY_ENABLED = 'yes'; 178 | const sut = createSUT(); 179 | 180 | // Act 181 | const result = sut.isXcbeautifyEnabled(); 182 | 183 | // Assert 184 | expect(result).toBe(false); 185 | }); 186 | }); 187 | 188 | describe('getCustomBuildSettings', () => { 189 | it('should return empty object by default', () => { 190 | // Arrange 191 | delete process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS; 192 | const sut = createSUT(); 193 | 194 | // Act 195 | const result = sut.getCustomBuildSettings(); 196 | 197 | // Assert 198 | expect(result).toEqual({}); 199 | }); 200 | 201 | it('should parse valid JSON from env var', () => { 202 | // Arrange 203 | const settings = { 'SWIFT_VERSION': '5.9', 'DEBUG': 'true' }; 204 | process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS = JSON.stringify(settings); 205 | const sut = createSUT(); 206 | 207 | // Act 208 | const result = sut.getCustomBuildSettings(); 209 | 210 | // Assert 211 | expect(result).toEqual(settings); 212 | }); 213 | 214 | it('should return empty object for invalid JSON', () => { 215 | // Arrange 216 | process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS = 'not valid json'; 217 | const sut = createSUT(); 218 | 219 | // Act 220 | const result = sut.getCustomBuildSettings(); 221 | 222 | // Assert 223 | expect(result).toEqual({}); 224 | }); 225 | 226 | it('should handle empty JSON object', () => { 227 | // Arrange 228 | process.env.MCP_XCODE_CUSTOM_BUILD_SETTINGS = '{}'; 229 | const sut = createSUT(); 230 | 231 | // Act 232 | const result = sut.getCustomBuildSettings(); 233 | 234 | // Assert 235 | expect(result).toEqual({}); 236 | }); 237 | }); 238 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestSimulatorManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | /** 7 | * Manages test simulator lifecycle for E2E tests 8 | * Handles creation, booting, shutdown, and cleanup of test simulators 9 | */ 10 | export class TestSimulatorManager { 11 | private simulatorId?: string; 12 | private simulatorName?: string; 13 | 14 | /** 15 | * Create or reuse a test simulator 16 | * @param namePrefix Prefix for the simulator name (e.g., "TestSimulator-Boot") 17 | * @param deviceType Device type (defaults to iPhone 15) 18 | * @returns The simulator ID 19 | */ 20 | async getOrCreateSimulator( 21 | namePrefix: string, 22 | deviceType: string = 'iPhone 15' 23 | ): Promise<string> { 24 | // Check if simulator already exists 25 | const devicesResult = await execAsync('xcrun simctl list devices --json'); 26 | const devices = JSON.parse(devicesResult.stdout); 27 | 28 | // Look for existing test simulator 29 | for (const runtime of Object.values(devices.devices) as any[]) { 30 | const existingSim = runtime.find((d: any) => d.name.includes(namePrefix)); 31 | if (existingSim) { 32 | this.simulatorId = existingSim.udid; 33 | this.simulatorName = existingSim.name; 34 | return existingSim.udid; 35 | } 36 | } 37 | 38 | // Create new simulator if none exists 39 | const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); 40 | const runtimes = JSON.parse(runtimesResult.stdout); 41 | const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); 42 | 43 | if (!iosRuntime) { 44 | throw new Error('No iOS runtime found. Please install an iOS simulator runtime.'); 45 | } 46 | 47 | const createResult = await execAsync( 48 | `xcrun simctl create "${namePrefix}" "${deviceType}" "${iosRuntime.identifier}"` 49 | ); 50 | this.simulatorId = createResult.stdout.trim(); 51 | this.simulatorName = namePrefix; 52 | 53 | return this.simulatorId; 54 | } 55 | 56 | /** 57 | * Boot the simulator and wait for it to be ready 58 | * @param maxSeconds Maximum seconds to wait (default 30) 59 | */ 60 | async bootAndWait(maxSeconds: number = 30): Promise<void> { 61 | if (!this.simulatorId) { 62 | throw new Error('No simulator to boot. Call getOrCreateSimulator first.'); 63 | } 64 | 65 | try { 66 | await execAsync(`xcrun simctl boot "${this.simulatorId}"`); 67 | } catch { 68 | // Ignore if already booted 69 | } 70 | 71 | await this.waitForBoot(maxSeconds); 72 | } 73 | 74 | /** 75 | * Shutdown the simulator and wait for completion 76 | * @param maxSeconds Maximum seconds to wait (default 30) 77 | */ 78 | async shutdownAndWait(maxSeconds: number = 30): Promise<void> { 79 | if (!this.simulatorId) return; 80 | 81 | try { 82 | await execAsync(`xcrun simctl shutdown "${this.simulatorId}"`); 83 | } catch { 84 | // Ignore if already shutdown 85 | } 86 | 87 | await this.waitForShutdown(maxSeconds); 88 | } 89 | 90 | /** 91 | * Cleanup the test simulator (shutdown and delete) 92 | */ 93 | async cleanup(): Promise<void> { 94 | if (!this.simulatorId) return; 95 | 96 | try { 97 | await execAsync(`xcrun simctl shutdown "${this.simulatorId}"`); 98 | } catch { 99 | // Ignore shutdown errors 100 | } 101 | 102 | try { 103 | await execAsync(`xcrun simctl delete "${this.simulatorId}"`); 104 | } catch { 105 | // Ignore delete errors 106 | } 107 | 108 | this.simulatorId = undefined; 109 | this.simulatorName = undefined; 110 | } 111 | 112 | /** 113 | * Get the current simulator ID 114 | */ 115 | getSimulatorId(): string | undefined { 116 | return this.simulatorId; 117 | } 118 | 119 | /** 120 | * Get the current simulator name 121 | */ 122 | getSimulatorName(): string | undefined { 123 | return this.simulatorName; 124 | } 125 | 126 | /** 127 | * Check if the simulator is booted 128 | */ 129 | async isBooted(): Promise<boolean> { 130 | if (!this.simulatorId) return false; 131 | 132 | const listResult = await execAsync('xcrun simctl list devices --json'); 133 | const devices = JSON.parse(listResult.stdout); 134 | 135 | for (const runtime of Object.values(devices.devices) as any[]) { 136 | const device = runtime.find((d: any) => d.udid === this.simulatorId); 137 | if (device) { 138 | return device.state === 'Booted'; 139 | } 140 | } 141 | return false; 142 | } 143 | 144 | /** 145 | * Check if the simulator is shutdown 146 | */ 147 | async isShutdown(): Promise<boolean> { 148 | if (!this.simulatorId) return true; 149 | 150 | const listResult = await execAsync('xcrun simctl list devices --json'); 151 | const devices = JSON.parse(listResult.stdout); 152 | 153 | for (const runtime of Object.values(devices.devices) as any[]) { 154 | const device = runtime.find((d: any) => d.udid === this.simulatorId); 155 | if (device) { 156 | return device.state === 'Shutdown'; 157 | } 158 | } 159 | return true; 160 | } 161 | 162 | private async waitForBoot(maxSeconds: number): Promise<void> { 163 | for (let i = 0; i < maxSeconds; i++) { 164 | const listResult = await execAsync('xcrun simctl list devices --json'); 165 | const devices = JSON.parse(listResult.stdout); 166 | 167 | for (const runtime of Object.values(devices.devices) as any[]) { 168 | const device = runtime.find((d: any) => d.udid === this.simulatorId); 169 | if (device && device.state === 'Booted') { 170 | // Wait a bit more for services to be ready 171 | await new Promise(resolve => setTimeout(resolve, 2000)); 172 | return; 173 | } 174 | } 175 | 176 | await new Promise(resolve => setTimeout(resolve, 1000)); 177 | } 178 | 179 | throw new Error(`Failed to boot simulator ${this.simulatorId} after ${maxSeconds} seconds`); 180 | } 181 | 182 | private async waitForShutdown(maxSeconds: number): Promise<void> { 183 | for (let i = 0; i < maxSeconds; i++) { 184 | const listResult = await execAsync('xcrun simctl list devices --json'); 185 | const devices = JSON.parse(listResult.stdout); 186 | 187 | for (const runtime of Object.values(devices.devices) as any[]) { 188 | const device = runtime.find((d: any) => d.udid === this.simulatorId); 189 | if (device && device.state === 'Shutdown') { 190 | return; 191 | } 192 | } 193 | 194 | await new Promise(resolve => setTimeout(resolve, 1000)); 195 | } 196 | 197 | throw new Error(`Failed to shutdown simulator ${this.simulatorId} after ${maxSeconds} seconds`); 198 | } 199 | 200 | /** 201 | * Shutdown all other booted simulators except this one 202 | */ 203 | async shutdownOtherSimulators(): Promise<void> { 204 | const devicesResult = await execAsync('xcrun simctl list devices --json'); 205 | const devices = JSON.parse(devicesResult.stdout); 206 | 207 | for (const runtime of Object.values(devices.devices) as any[][]) { 208 | for (const device of runtime) { 209 | if (device.state === 'Booted' && device.udid !== this.simulatorId) { 210 | try { 211 | await execAsync(`xcrun simctl shutdown "${device.udid}"`); 212 | } catch { 213 | // Ignore errors 214 | } 215 | } 216 | } 217 | } 218 | } 219 | } ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/ProjectPath.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; 2 | import { ProjectPath } from '../../domain/ProjectPath.js'; 3 | import { existsSync } from 'fs'; 4 | 5 | // Mock fs module 6 | jest.mock('fs', () => ({ 7 | existsSync: jest.fn<(path: string) => boolean>() 8 | })); 9 | const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>; 10 | 11 | describe('ProjectPath', () => { 12 | // Reset mocks between tests for isolation 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.restoreAllMocks(); 19 | }); 20 | 21 | describe('when creating a project path', () => { 22 | it('should accept valid .xcodeproj path that exists', () => { 23 | // Setup - all visible in test 24 | const projectPath = '/Users/dev/MyApp.xcodeproj'; 25 | mockExistsSync.mockReturnValue(true); 26 | 27 | // Act 28 | const result = ProjectPath.create(projectPath); 29 | 30 | // Assert 31 | expect(result.toString()).toBe(projectPath); 32 | expect(mockExistsSync).toHaveBeenCalledWith(projectPath); 33 | }); 34 | 35 | it('should accept valid .xcworkspace path that exists', () => { 36 | const workspacePath = '/Users/dev/MyApp.xcworkspace'; 37 | mockExistsSync.mockReturnValue(true); 38 | 39 | const result = ProjectPath.create(workspacePath); 40 | 41 | expect(result.toString()).toBe(workspacePath); 42 | expect(mockExistsSync).toHaveBeenCalledWith(workspacePath); 43 | }); 44 | 45 | it('should accept paths with spaces', () => { 46 | const pathWithSpaces = '/Users/dev/My Cool App.xcodeproj'; 47 | mockExistsSync.mockReturnValue(true); 48 | 49 | const result = ProjectPath.create(pathWithSpaces); 50 | 51 | expect(result.toString()).toBe(pathWithSpaces); 52 | }); 53 | 54 | it('should accept paths with special characters', () => { 55 | const specialPath = '/Users/dev/App-2024_v1.0.xcworkspace'; 56 | mockExistsSync.mockReturnValue(true); 57 | 58 | const result = ProjectPath.create(specialPath); 59 | 60 | expect(result.toString()).toBe(specialPath); 61 | }); 62 | }); 63 | 64 | describe('when validating input', () => { 65 | it('should reject empty path', () => { 66 | expect(() => ProjectPath.create('')).toThrow('Project path cannot be empty'); 67 | expect(mockExistsSync).not.toHaveBeenCalled(); 68 | }); 69 | 70 | it('should reject null path', () => { 71 | expect(() => ProjectPath.create(null as any)) 72 | .toThrow('Project path is required'); 73 | expect(mockExistsSync).not.toHaveBeenCalled(); 74 | }); 75 | 76 | it('should reject undefined path', () => { 77 | expect(() => ProjectPath.create(undefined as any)) 78 | .toThrow('Project path is required'); 79 | expect(mockExistsSync).not.toHaveBeenCalled(); 80 | }); 81 | 82 | it('should reject non-existent path', () => { 83 | const nonExistentPath = '/Users/dev/DoesNotExist.xcodeproj'; 84 | mockExistsSync.mockReturnValue(false); 85 | 86 | expect(() => ProjectPath.create(nonExistentPath)) 87 | .toThrow(`Project path does not exist: ${nonExistentPath}`); 88 | expect(mockExistsSync).toHaveBeenCalledWith(nonExistentPath); 89 | }); 90 | 91 | it('should reject non-Xcode project files', () => { 92 | mockExistsSync.mockReturnValue(true); 93 | 94 | const invalidFiles = [ 95 | '/Users/dev/MyApp.swift', 96 | '/Users/dev/MyApp.txt', 97 | '/Users/dev/MyApp.app', 98 | '/Users/dev/MyApp.framework', 99 | '/Users/dev/MyApp', // No extension 100 | '/Users/dev/MyApp.xcode', // Wrong extension 101 | ]; 102 | 103 | invalidFiles.forEach(file => { 104 | expect(() => ProjectPath.create(file)) 105 | .toThrow('Project path must be an .xcodeproj or .xcworkspace file'); 106 | }); 107 | }); 108 | 109 | it('should reject directories without proper extension', () => { 110 | const directory = '/Users/dev/MyProject'; 111 | mockExistsSync.mockReturnValue(true); 112 | 113 | expect(() => ProjectPath.create(directory)) 114 | .toThrow('Project path must be an .xcodeproj or .xcworkspace file'); 115 | }); 116 | }); 117 | 118 | describe('when getting project name', () => { 119 | it('should extract name from .xcodeproj path', () => { 120 | const projectPath = '/Users/dev/MyAwesomeApp.xcodeproj'; 121 | mockExistsSync.mockReturnValue(true); 122 | 123 | const result = ProjectPath.create(projectPath); 124 | 125 | expect(result.name).toBe('MyAwesomeApp'); 126 | }); 127 | 128 | it('should extract name from .xcworkspace path', () => { 129 | const workspacePath = '/Users/dev/CoolWorkspace.xcworkspace'; 130 | mockExistsSync.mockReturnValue(true); 131 | 132 | const result = ProjectPath.create(workspacePath); 133 | 134 | expect(result.name).toBe('CoolWorkspace'); 135 | }); 136 | 137 | it('should handle names with dots', () => { 138 | const pathWithDots = '/Users/dev/App.v2.0.xcodeproj'; 139 | mockExistsSync.mockReturnValue(true); 140 | 141 | const result = ProjectPath.create(pathWithDots); 142 | 143 | expect(result.name).toBe('App.v2.0'); 144 | }); 145 | 146 | it('should handle names with spaces', () => { 147 | const pathWithSpaces = '/Users/dev/My Cool App.xcworkspace'; 148 | mockExistsSync.mockReturnValue(true); 149 | 150 | const result = ProjectPath.create(pathWithSpaces); 151 | 152 | expect(result.name).toBe('My Cool App'); 153 | }); 154 | }); 155 | 156 | describe('when checking project type', () => { 157 | it('should identify workspace files', () => { 158 | const workspacePath = '/Users/dev/MyApp.xcworkspace'; 159 | mockExistsSync.mockReturnValue(true); 160 | 161 | const result = ProjectPath.create(workspacePath); 162 | 163 | expect(result.isWorkspace).toBe(true); 164 | }); 165 | 166 | it('should identify non-workspace files as projects', () => { 167 | const projectPath = '/Users/dev/MyApp.xcodeproj'; 168 | mockExistsSync.mockReturnValue(true); 169 | 170 | const result = ProjectPath.create(projectPath); 171 | 172 | expect(result.isWorkspace).toBe(false); 173 | }); 174 | }); 175 | 176 | describe('when converting to string', () => { 177 | it('should return the original path', () => { 178 | const originalPath = '/Users/dev/path/to/MyApp.xcodeproj'; 179 | mockExistsSync.mockReturnValue(true); 180 | 181 | const result = ProjectPath.create(originalPath); 182 | 183 | expect(result.toString()).toBe(originalPath); 184 | expect(`${result}`).toBe(originalPath); // Implicit string conversion 185 | }); 186 | }); 187 | 188 | describe('when path validation is called multiple times', () => { 189 | it('should check existence only once during creation', () => { 190 | const projectPath = '/Users/dev/MyApp.xcodeproj'; 191 | mockExistsSync.mockReturnValue(true); 192 | 193 | const result = ProjectPath.create(projectPath); 194 | 195 | // Access properties multiple times 196 | result.name; 197 | result.isWorkspace; 198 | result.toString(); 199 | 200 | // Should only check existence once during creation 201 | expect(mockExistsSync).toHaveBeenCalledTimes(1); 202 | }); 203 | }); 204 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/ShellCommandExecutorAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { ExecOptions } from 'child_process'; 3 | import { ShellCommandExecutorAdapter } from '../../infrastructure/ShellCommandExecutorAdapter.js'; 4 | 5 | /** 6 | * Unit tests for ShellCommandExecutor 7 | * 8 | * Following testing philosophy: 9 | * - Test behavior, not implementation 10 | * - Use dependency injection for clean testing 11 | */ 12 | describe('ShellCommandExecutor', () => { 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | // Factory method for creating the SUT with mocked exec function 18 | function createSUT() { 19 | const mockExecAsync = jest.fn<(command: string, options: ExecOptions) => Promise<{ stdout: string; stderr: string }>>(); 20 | const sut = new ShellCommandExecutorAdapter(mockExecAsync); 21 | return { sut, mockExecAsync }; 22 | } 23 | 24 | describe('execute', () => { 25 | describe('when executing successful commands', () => { 26 | it('should return stdout and stderr with exitCode 0', async () => { 27 | // Arrange 28 | const { sut, mockExecAsync } = createSUT(); 29 | const command = 'echo "hello world"'; 30 | mockExecAsync.mockResolvedValue({ 31 | stdout: 'hello world\n', 32 | stderr: '' 33 | }); 34 | 35 | // Act 36 | const result = await sut.execute(command); 37 | 38 | // Assert 39 | expect(result).toEqual({ 40 | stdout: 'hello world\n', 41 | stderr: '', 42 | exitCode: 0 43 | }); 44 | expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ 45 | maxBuffer: 50 * 1024 * 1024, 46 | timeout: 300000, 47 | shell: '/bin/bash' 48 | })); 49 | }); 50 | 51 | it('should handle large output', async () => { 52 | // Arrange 53 | const { sut, mockExecAsync } = createSUT(); 54 | const command = 'cat large_file.txt'; 55 | const largeOutput = 'x'.repeat(10 * 1024 * 1024); // 10MB 56 | mockExecAsync.mockResolvedValue({ 57 | stdout: largeOutput, 58 | stderr: '' 59 | }); 60 | 61 | // Act 62 | const result = await sut.execute(command); 63 | 64 | // Assert 65 | expect(result.stdout).toHaveLength(10 * 1024 * 1024); 66 | expect(result.exitCode).toBe(0); 67 | }); 68 | 69 | it('should handle both stdout and stderr', async () => { 70 | // Arrange 71 | const { sut, mockExecAsync } = createSUT(); 72 | const command = 'some-command'; 73 | mockExecAsync.mockResolvedValue({ 74 | stdout: 'standard output', 75 | stderr: 'warning: something happened' 76 | }); 77 | 78 | // Act 79 | const result = await sut.execute(command); 80 | 81 | // Assert 82 | expect(result.stdout).toBe('standard output'); 83 | expect(result.stderr).toBe('warning: something happened'); 84 | expect(result.exitCode).toBe(0); 85 | }); 86 | }); 87 | 88 | describe('when executing failing commands', () => { 89 | it('should return output with non-zero exit code', async () => { 90 | // Arrange 91 | const { sut, mockExecAsync } = createSUT(); 92 | const command = 'false'; 93 | const error: any = new Error('Command failed'); 94 | error.code = 1; 95 | error.stdout = ''; 96 | error.stderr = 'command failed'; 97 | mockExecAsync.mockRejectedValue(error); 98 | 99 | // Act 100 | const result = await sut.execute(command); 101 | 102 | // Assert 103 | expect(result).toEqual({ 104 | stdout: '', 105 | stderr: 'command failed', 106 | exitCode: 1 107 | }); 108 | }); 109 | 110 | it('should capture output even on failure', async () => { 111 | // Arrange 112 | const { sut, mockExecAsync } = createSUT(); 113 | const command = 'build-command'; 114 | const error: any = new Error('Build failed'); 115 | error.code = 65; 116 | error.stdout = 'Compiling...\nError at line 42'; 117 | error.stderr = 'error: undefined symbol'; 118 | mockExecAsync.mockRejectedValue(error); 119 | 120 | // Act 121 | const result = await sut.execute(command); 122 | 123 | // Assert 124 | expect(result.stdout).toContain('Compiling'); 125 | expect(result.stderr).toContain('undefined symbol'); 126 | expect(result.exitCode).toBe(65); 127 | }); 128 | 129 | it('should handle missing error code', async () => { 130 | // Arrange 131 | const { sut, mockExecAsync } = createSUT(); 132 | const command = 'unknown-command'; 133 | const error: any = new Error('Command not found'); 134 | // No error.code set 135 | error.stdout = ''; 136 | error.stderr = 'command not found'; 137 | mockExecAsync.mockRejectedValue(error); 138 | 139 | // Act 140 | const result = await sut.execute(command); 141 | 142 | // Assert 143 | expect(result.exitCode).toBe(1); // Default to 1 when no code 144 | }); 145 | }); 146 | 147 | describe('with custom options', () => { 148 | it('should pass maxBuffer option', async () => { 149 | // Arrange 150 | const { sut, mockExecAsync } = createSUT(); 151 | const command = 'echo test'; 152 | const options = { maxBuffer: 100 * 1024 * 1024 }; // 100MB 153 | mockExecAsync.mockResolvedValue({ stdout: 'test', stderr: '' }); 154 | 155 | // Act 156 | await sut.execute(command, options); 157 | 158 | // Assert 159 | expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ 160 | maxBuffer: 100 * 1024 * 1024 161 | })); 162 | }); 163 | 164 | it('should pass timeout option', async () => { 165 | // Arrange 166 | const { sut, mockExecAsync } = createSUT(); 167 | const command = 'long-running-command'; 168 | const options = { timeout: 600000 }; // 10 minutes 169 | mockExecAsync.mockResolvedValue({ stdout: 'done', stderr: '' }); 170 | 171 | // Act 172 | await sut.execute(command, options); 173 | 174 | // Assert 175 | expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ 176 | timeout: 600000 177 | })); 178 | }); 179 | 180 | it('should pass shell option', async () => { 181 | // Arrange 182 | const { sut, mockExecAsync } = createSUT(); 183 | const command = 'echo $SHELL'; 184 | const options = { shell: '/bin/zsh' }; 185 | mockExecAsync.mockResolvedValue({ stdout: '/bin/zsh', stderr: '' }); 186 | 187 | // Act 188 | await sut.execute(command, options); 189 | 190 | // Assert 191 | expect(mockExecAsync).toHaveBeenCalledWith(command, expect.objectContaining({ 192 | shell: '/bin/zsh' 193 | })); 194 | }); 195 | 196 | it('should use default options when not provided', async () => { 197 | // Arrange 198 | const { sut, mockExecAsync } = createSUT(); 199 | const command = 'echo test'; 200 | mockExecAsync.mockResolvedValue({ stdout: 'test', stderr: '' }); 201 | 202 | // Act 203 | await sut.execute(command); 204 | 205 | // Assert 206 | expect(mockExecAsync).toHaveBeenCalledWith(command, { 207 | maxBuffer: 50 * 1024 * 1024, 208 | timeout: 300000, 209 | shell: '/bin/bash' 210 | }); 211 | }); 212 | }); 213 | }); 214 | }); ``` -------------------------------------------------------------------------------- /src/utils/devices/SimulatorBoot.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | 4 | const logger = createModuleLogger('SimulatorBoot'); 5 | 6 | /** 7 | * Simple utility to boot simulators for Xcode builds 8 | * Extracted shared logic from BuildXcodeTool and RunXcodeTool 9 | */ 10 | export class SimulatorBoot { 11 | /** 12 | * Boot a simulator 13 | */ 14 | async boot(deviceId: string): Promise<void> { 15 | try { 16 | await execAsync(`xcrun simctl boot "${deviceId}"`); 17 | logger.debug({ deviceId }, 'Simulator booted successfully'); 18 | } catch (error: any) { 19 | // Check if already booted 20 | if (!error.message?.includes('Unable to boot device in current state: Booted')) { 21 | logger.error({ error: error.message, deviceId }, 'Failed to boot simulator'); 22 | throw new Error(`Failed to boot simulator: ${error.message}`); 23 | } 24 | logger.debug({ deviceId }, 'Simulator already booted'); 25 | } 26 | } 27 | 28 | /** 29 | * Shutdown a simulator 30 | */ 31 | async shutdown(deviceId: string): Promise<void> { 32 | try { 33 | await execAsync(`xcrun simctl shutdown "${deviceId}"`); 34 | logger.debug({ deviceId }, 'Simulator shutdown successfully'); 35 | } catch (error: any) { 36 | logger.error({ error: error.message, deviceId }, 'Failed to shutdown simulator'); 37 | throw new Error(`Failed to shutdown simulator: ${error.message}`); 38 | } 39 | } 40 | 41 | /** 42 | * Opens the Simulator app GUI (skipped during tests) 43 | */ 44 | async openSimulatorApp(): Promise<void> { 45 | // Skip opening GUI during tests 46 | if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID) { 47 | logger.debug('Skipping Simulator GUI in test environment'); 48 | return; 49 | } 50 | 51 | try { 52 | await execAsync('open -g -a Simulator'); 53 | logger.debug('Opened Simulator app'); 54 | } catch (error: any) { 55 | logger.warn({ error: error.message }, 'Failed to open Simulator app'); 56 | } 57 | } 58 | 59 | /** 60 | * Ensures a simulator is booted for the given platform and device 61 | * Returns the booted device ID (UDID) 62 | */ 63 | async ensureBooted(platform: string, deviceId?: string): Promise<string> { 64 | if (!deviceId) { 65 | // No specific device requested, use first available 66 | return this.bootFirstAvailable(platform); 67 | } 68 | 69 | // Check if the device exists and get its state 70 | try { 71 | const { stdout } = await execAsync('xcrun simctl list devices --json'); 72 | const data = JSON.parse(stdout); 73 | 74 | // Collect all matching devices first, then pick the best one 75 | const matchingDevices: any[] = []; 76 | 77 | for (const deviceList of Object.values(data.devices)) { 78 | for (const device of deviceList as any[]) { 79 | if (device.udid === deviceId || device.name === deviceId) { 80 | matchingDevices.push(device); 81 | } 82 | } 83 | } 84 | 85 | if (matchingDevices.length === 0) { 86 | throw new Error(`Device '${deviceId}' not found`); 87 | } 88 | 89 | // Sort devices: prefer available ones, then already booted ones 90 | matchingDevices.sort((a, b) => { 91 | // First priority: available devices 92 | if (a.isAvailable && !b.isAvailable) return -1; 93 | if (!a.isAvailable && b.isAvailable) return 1; 94 | // Second priority: booted devices 95 | if (a.state === 'Booted' && b.state !== 'Booted') return -1; 96 | if (a.state !== 'Booted' && b.state === 'Booted') return 1; 97 | return 0; 98 | }); 99 | 100 | // Use the best matching device 101 | const device = matchingDevices[0]; 102 | 103 | if (!device.isAvailable) { 104 | // All matching devices are unavailable 105 | const availableErrors = matchingDevices 106 | .map(d => `${d.name} (${d.udid}): ${d.availabilityError || 'unavailable'}`) 107 | .join(', '); 108 | throw new Error(`All devices named '${deviceId}' are unavailable: ${availableErrors}`); 109 | } 110 | 111 | if (device.state === 'Booted') { 112 | logger.debug({ deviceId: device.udid, name: device.name }, 'Device already booted'); 113 | // Still open the Simulator app to make it visible 114 | await this.openSimulatorApp(); 115 | return device.udid; 116 | } 117 | 118 | // Boot the device 119 | logger.info({ deviceId: device.udid, name: device.name }, 'Booting simulator'); 120 | try { 121 | await execAsync(`xcrun simctl boot "${device.udid}"`); 122 | } catch (error: any) { 123 | // Device might already be booted 124 | if (!error.message?.includes('Unable to boot device in current state: Booted')) { 125 | throw error; 126 | } 127 | } 128 | 129 | // Open the Simulator app to show the GUI 130 | await this.openSimulatorApp(); 131 | 132 | return device.udid; 133 | } catch (error: any) { 134 | logger.error({ error: error.message, deviceId }, 'Failed to boot simulator'); 135 | throw new Error(`Failed to boot simulator: ${error.message}`); 136 | } 137 | } 138 | 139 | /** 140 | * Boots the first available simulator for a platform 141 | */ 142 | private async bootFirstAvailable(platform: string): Promise<string> { 143 | try { 144 | const { stdout } = await execAsync('xcrun simctl list devices --json'); 145 | const data = JSON.parse(stdout); 146 | 147 | // Find first available device for the platform 148 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 149 | const runtimeLower = runtime.toLowerCase(); 150 | const platformLower = platform.toLowerCase(); 151 | 152 | // Handle visionOS which is internally called xrOS 153 | const isVisionOS = platformLower === 'visionos' && runtimeLower.includes('xros'); 154 | const isOtherPlatform = platformLower !== 'visionos' && runtimeLower.includes(platformLower); 155 | 156 | if (!isVisionOS && !isOtherPlatform) { 157 | continue; 158 | } 159 | 160 | for (const device of deviceList as any[]) { 161 | if (!device.isAvailable) continue; 162 | 163 | // Check if already booted 164 | if (device.state === 'Booted') { 165 | logger.debug({ deviceId: device.udid, name: device.name }, 'Using already booted device'); 166 | // Still open the Simulator app to make it visible 167 | await this.openSimulatorApp(); 168 | return device.udid; 169 | } 170 | 171 | // Boot this device 172 | logger.info({ deviceId: device.udid, name: device.name }, 'Booting first available simulator'); 173 | try { 174 | await execAsync(`xcrun simctl boot ${device.udid}`); 175 | } catch (error: any) { 176 | // Device might already be booted 177 | if (!error.message?.includes('Unable to boot device in current state: Booted')) { 178 | throw error; 179 | } 180 | } 181 | 182 | // Open the Simulator app to show the GUI 183 | await this.openSimulatorApp(); 184 | 185 | return device.udid; 186 | } 187 | } 188 | 189 | throw new Error(`No available simulators found for platform ${platform}`); 190 | } catch (error: any) { 191 | logger.error({ error: error.message, platform }, 'Failed to boot simulator'); 192 | throw new Error(`Failed to boot simulator: ${error.message}`); 193 | } 194 | } 195 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/BootSimulatorUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { BootSimulatorUseCase } from '../../use-cases/BootSimulatorUseCase.js'; 3 | import { BootRequest } from '../../domain/BootRequest.js'; 4 | import { DeviceId } from '../../../../shared/domain/DeviceId.js'; 5 | import { BootResult, BootOutcome, SimulatorNotFoundError, BootCommandFailedError, SimulatorBusyError } from '../../domain/BootResult.js'; 6 | import { SimulatorState } from '../../domain/SimulatorState.js'; 7 | import { ISimulatorLocator, ISimulatorControl, SimulatorInfo } from '../../../../application/ports/SimulatorPorts.js'; 8 | 9 | describe('BootSimulatorUseCase', () => { 10 | let useCase: BootSimulatorUseCase; 11 | let mockLocator: jest.Mocked<ISimulatorLocator>; 12 | let mockControl: jest.Mocked<ISimulatorControl>; 13 | 14 | beforeEach(() => { 15 | jest.clearAllMocks(); 16 | 17 | mockLocator = { 18 | findSimulator: jest.fn<ISimulatorLocator['findSimulator']>(), 19 | findBootedSimulator: jest.fn<ISimulatorLocator['findBootedSimulator']>() 20 | }; 21 | 22 | mockControl = { 23 | boot: jest.fn<ISimulatorControl['boot']>(), 24 | shutdown: jest.fn<ISimulatorControl['shutdown']>() 25 | }; 26 | 27 | useCase = new BootSimulatorUseCase(mockLocator, mockControl); 28 | }); 29 | 30 | describe('execute', () => { 31 | it('should boot a shutdown simulator', async () => { 32 | // Arrange 33 | const request = BootRequest.create(DeviceId.create('iPhone-15')); 34 | const simulatorInfo: SimulatorInfo = { 35 | id: 'ABC123', 36 | name: 'iPhone 15', 37 | state: SimulatorState.Shutdown, 38 | platform: 'iOS', 39 | runtime: 'iOS-17.0' 40 | }; 41 | 42 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 43 | mockControl.boot.mockResolvedValue(undefined); 44 | 45 | // Act 46 | const result = await useCase.execute(request); 47 | 48 | // Assert 49 | expect(mockLocator.findSimulator).toHaveBeenCalledWith('iPhone-15'); 50 | expect(mockControl.boot).toHaveBeenCalledWith('ABC123'); 51 | expect(result.outcome).toBe(BootOutcome.Booted); 52 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 53 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 54 | expect(result.diagnostics.platform).toBe('iOS'); 55 | expect(result.diagnostics.runtime).toBe('iOS-17.0'); 56 | }); 57 | 58 | it('should handle already booted simulator', async () => { 59 | // Arrange 60 | const request = BootRequest.create(DeviceId.create('iPhone-15')); 61 | const simulatorInfo: SimulatorInfo = { 62 | id: 'ABC123', 63 | name: 'iPhone 15', 64 | state: SimulatorState.Booted, 65 | platform: 'iOS', 66 | runtime: 'iOS-17.0' 67 | }; 68 | 69 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 70 | 71 | // Act 72 | const result = await useCase.execute(request); 73 | 74 | // Assert 75 | expect(mockControl.boot).not.toHaveBeenCalled(); 76 | expect(result.outcome).toBe(BootOutcome.AlreadyBooted); 77 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 78 | }); 79 | 80 | it('should return failure when simulator not found', async () => { 81 | // Arrange 82 | const request = BootRequest.create(DeviceId.create('non-existent')); 83 | mockLocator.findSimulator.mockResolvedValue(null); 84 | 85 | // Act 86 | const result = await useCase.execute(request); 87 | 88 | // Assert - Test behavior: simulator not found error 89 | expect(mockControl.boot).not.toHaveBeenCalled(); 90 | expect(result.outcome).toBe(BootOutcome.Failed); 91 | expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); 92 | expect((result.diagnostics.error as SimulatorNotFoundError).deviceId).toBe('non-existent'); 93 | }); 94 | 95 | it('should return failure on boot error', async () => { 96 | // Arrange 97 | const request = BootRequest.create(DeviceId.create('iPhone-15')); 98 | const simulatorInfo: SimulatorInfo = { 99 | id: 'ABC123', 100 | name: 'iPhone 15', 101 | state: SimulatorState.Shutdown, 102 | platform: 'iOS', 103 | runtime: 'iOS-17.0' 104 | }; 105 | 106 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 107 | mockControl.boot.mockRejectedValue(new Error('Boot failed')); 108 | 109 | // Act 110 | const result = await useCase.execute(request); 111 | 112 | // Assert - Test behavior: boot command failed error 113 | expect(result.outcome).toBe(BootOutcome.Failed); 114 | expect(result.diagnostics.error).toBeInstanceOf(BootCommandFailedError); 115 | expect((result.diagnostics.error as BootCommandFailedError).stderr).toBe('Boot failed'); 116 | }); 117 | 118 | it('should boot simulator found by UUID', async () => { 119 | // Arrange 120 | const uuid = '838C707D-5703-4AEE-AF43-4798E0BA1B05'; 121 | const request = BootRequest.create(DeviceId.create(uuid)); 122 | const simulatorInfo: SimulatorInfo = { 123 | id: uuid, 124 | name: 'iPhone 15', 125 | state: SimulatorState.Shutdown, 126 | platform: 'iOS', 127 | runtime: 'iOS-17.0' 128 | }; 129 | 130 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 131 | mockControl.boot.mockResolvedValue(undefined); 132 | 133 | // Act 134 | const result = await useCase.execute(request); 135 | 136 | // Assert 137 | expect(mockLocator.findSimulator).toHaveBeenCalledWith(uuid); 138 | expect(mockControl.boot).toHaveBeenCalledWith(uuid); 139 | expect(result.outcome).toBe(BootOutcome.Booted); 140 | expect(result.diagnostics.simulatorId).toBe(uuid); 141 | }); 142 | 143 | it('should handle simulator in Booting state as already booted', async () => { 144 | // Arrange 145 | const request = BootRequest.create(DeviceId.create('iPhone-15')); 146 | const simulatorInfo: SimulatorInfo = { 147 | id: 'ABC123', 148 | name: 'iPhone 15', 149 | state: SimulatorState.Booting, 150 | platform: 'iOS', 151 | runtime: 'iOS-17.0' 152 | }; 153 | 154 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 155 | 156 | // Act 157 | const result = await useCase.execute(request); 158 | 159 | // Assert 160 | expect(mockControl.boot).not.toHaveBeenCalled(); 161 | expect(result.outcome).toBe(BootOutcome.AlreadyBooted); 162 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 163 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 164 | }); 165 | 166 | it('should return failure when simulator is ShuttingDown', async () => { 167 | // Arrange 168 | const request = BootRequest.create(DeviceId.create('iPhone-15')); 169 | const simulatorInfo: SimulatorInfo = { 170 | id: 'ABC123', 171 | name: 'iPhone 15', 172 | state: SimulatorState.ShuttingDown, 173 | platform: 'iOS', 174 | runtime: 'iOS-17.0' 175 | }; 176 | 177 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 178 | 179 | // Act 180 | const result = await useCase.execute(request); 181 | 182 | // Assert 183 | expect(mockControl.boot).not.toHaveBeenCalled(); 184 | expect(result.outcome).toBe(BootOutcome.Failed); 185 | expect(result.diagnostics.error).toBeInstanceOf(SimulatorBusyError); 186 | expect((result.diagnostics.error as SimulatorBusyError).currentState).toBe(SimulatorState.ShuttingDown); 187 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 188 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 189 | }); 190 | 191 | }); 192 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/ListSimulatorsController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for ListSimulatorsController 3 | * 4 | * Tests CRITICAL USER PATH with REAL simulators: 5 | * - Can the controller actually list real simulators? 6 | * - Does filtering work with real simulator data? 7 | * - Does error handling work with real failures? 8 | * 9 | * NO MOCKS - uses real simulators 10 | * This is an E2E test (10% of test suite) for critical user journeys 11 | * 12 | * NOTE: This test requires Xcode and iOS simulators to be installed 13 | */ 14 | 15 | import { describe, it, expect, beforeAll, beforeEach } from '@jest/globals'; 16 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 17 | import { ListSimulatorsControllerFactory } from '../../factories/ListSimulatorsControllerFactory.js'; 18 | import { exec } from 'child_process'; 19 | import { promisify } from 'util'; 20 | 21 | const execAsync = promisify(exec); 22 | 23 | describe('ListSimulatorsController E2E', () => { 24 | let controller: MCPController; 25 | 26 | beforeAll(() => { 27 | controller = ListSimulatorsControllerFactory.create(); 28 | }); 29 | 30 | describe('list real simulators', () => { 31 | it('should list all available simulators', async () => { 32 | // Arrange, Act, Assert 33 | const result = await controller.execute({}); 34 | 35 | expect(result).toMatchObject({ 36 | content: expect.arrayContaining([ 37 | expect.objectContaining({ 38 | type: 'text', 39 | text: expect.any(String) 40 | }) 41 | ]) 42 | }); 43 | 44 | const text = result.content[0].text; 45 | expect(text).toMatch(/Found \d+ simulator/); 46 | 47 | // Verify actual device lines exist 48 | const deviceLines = text.split('\n').filter((line: string) => 49 | line.includes('(') && line.includes(')') && line.includes('-') 50 | ); 51 | expect(deviceLines.length).toBeGreaterThan(0); 52 | }); 53 | 54 | it('should filter by iOS platform', async () => { 55 | // Arrange, Act, Assert 56 | const result = await controller.execute({ platform: 'iOS' }); 57 | 58 | const text = result.content[0].text; 59 | expect(text).toMatch(/Found \d+ simulator/); 60 | 61 | const deviceLines = text.split('\n').filter((line: string) => 62 | line.includes('(') && line.includes(')') && line.includes('-') 63 | ); 64 | 65 | expect(deviceLines.length).toBeGreaterThan(0); 66 | for (const line of deviceLines) { 67 | // All devices should show iOS runtime since we filtered by iOS platform 68 | expect(line).toContain(' - iOS '); 69 | // Should not contain other platform devices 70 | expect(line).not.toMatch(/Apple TV|Apple Watch/); 71 | } 72 | }); 73 | 74 | it('should filter by booted state', async () => { 75 | // First, check if there are any booted simulators 76 | const checkResult = await execAsync('xcrun simctl list devices booted --json'); 77 | const bootedDevices = JSON.parse(checkResult.stdout); 78 | const hasBootedDevices = Object.values(bootedDevices.devices).some( 79 | (devices: any) => devices.length > 0 80 | ); 81 | 82 | // Act 83 | const result = await controller.execute({ state: 'Booted' }); 84 | 85 | // Assert 86 | const text = result.content[0].text; 87 | 88 | if (hasBootedDevices) { 89 | expect(text).toMatch(/Found \d+ simulator/); 90 | const lines = text.split('\n'); 91 | const deviceLines = lines.filter((line: string) => 92 | line.includes('(') && line.includes(')') && line.includes('-') 93 | ); 94 | 95 | for (const line of deviceLines) { 96 | expect(line).toContain('Booted'); 97 | } 98 | } else { 99 | expect(text).toBe('🔍 No simulators found'); 100 | } 101 | }); 102 | 103 | it('should show runtime information', async () => { 104 | // Arrange, Act, Assert 105 | const result = await controller.execute({}); 106 | 107 | const text = result.content[0].text; 108 | expect(text).toMatch(/Found \d+ simulator/); 109 | 110 | const deviceLines = text.split('\n').filter((line: string) => 111 | line.includes('(') && line.includes(')') && line.includes('-') 112 | ); 113 | 114 | expect(deviceLines.length).toBeGreaterThan(0); 115 | for (const line of deviceLines) { 116 | expect(line).toMatch(/iOS \d+\.\d+|tvOS \d+\.\d+|watchOS \d+\.\d+|visionOS \d+\.\d+/); 117 | } 118 | }); 119 | 120 | it('should filter by tvOS platform', async () => { 121 | // Arrange, Act, Assert 122 | const result = await controller.execute({ platform: 'tvOS' }); 123 | 124 | const text = result.content[0].text; 125 | 126 | // tvOS simulators might not exist in all environments 127 | if (text.includes('No simulators found')) { 128 | expect(text).toBe('🔍 No simulators found'); 129 | } else { 130 | expect(text).toMatch(/Found \d+ simulator/); 131 | const deviceLines = text.split('\n').filter((line: string) => 132 | line.includes('(') && line.includes(')') && line.includes('-') 133 | ); 134 | 135 | for (const line of deviceLines) { 136 | expect(line).toContain('Apple TV'); 137 | expect(line).toContain(' - tvOS '); 138 | } 139 | } 140 | }); 141 | 142 | it('should handle combined filters', async () => { 143 | // Arrange, Act, Assert 144 | const result = await controller.execute({ 145 | platform: 'iOS', 146 | state: 'Shutdown' 147 | }); 148 | 149 | const text = result.content[0].text; 150 | expect(text).toMatch(/Found \d+ simulator/); 151 | 152 | const deviceLines = text.split('\n').filter((line: string) => 153 | line.includes('(') && line.includes(')') && line.includes('-') 154 | ); 155 | 156 | expect(deviceLines.length).toBeGreaterThan(0); 157 | for (const line of deviceLines) { 158 | expect(line).toContain(' - iOS '); 159 | expect(line).toContain('Shutdown'); 160 | } 161 | }); 162 | }); 163 | 164 | describe('error handling', () => { 165 | it('should return error for invalid platform', async () => { 166 | // Arrange, Act, Assert 167 | const result = await controller.execute({ 168 | platform: 'Android' 169 | }); 170 | 171 | expect(result.content[0].text).toBe('❌ Invalid platform: Android. Valid values are: iOS, macOS, tvOS, watchOS, visionOS'); 172 | }); 173 | 174 | it('should return error for invalid state', async () => { 175 | // Arrange, Act, Assert 176 | const result = await controller.execute({ 177 | state: 'Running' 178 | }); 179 | 180 | expect(result.content[0].text).toBe('❌ Invalid simulator state: Running. Valid values are: Booted, Booting, Shutdown, Shutting Down'); 181 | }); 182 | 183 | it('should return error for invalid input types', async () => { 184 | // Arrange, Act, Assert 185 | const result1 = await controller.execute({ 186 | platform: 123 187 | }); 188 | 189 | expect(result1.content[0].text).toBe('❌ Platform must be a string (one of: iOS, macOS, tvOS, watchOS, visionOS), got number'); 190 | 191 | const result2 = await controller.execute({ 192 | state: true 193 | }); 194 | 195 | expect(result2.content[0].text).toBe('❌ Simulator state must be a string (one of: Booted, Booting, Shutdown, Shutting Down), got boolean'); 196 | }); 197 | }); 198 | 199 | describe('output formatting', () => { 200 | it('should format simulator list properly', async () => { 201 | // Arrange, Act, Assert 202 | const result = await controller.execute({}); 203 | 204 | const text = result.content[0].text; 205 | expect(text).toMatch(/^✅ Found \d+ simulator/); 206 | 207 | const lines = text.split('\n'); 208 | expect(lines[0]).toMatch(/^✅ Found \d+ simulator/); 209 | expect(lines[1]).toBe(''); 210 | 211 | const deviceLines = lines.filter((line: string) => 212 | line.includes('(') && line.includes(')') && line.includes('-') 213 | ); 214 | 215 | expect(deviceLines.length).toBeGreaterThan(0); 216 | for (const line of deviceLines) { 217 | expect(line).toMatch(/^• .+ \([A-F0-9-]+\) - (Booted|Booting|Shutdown|Shutting Down|Unknown) - (iOS|tvOS|watchOS|visionOS|macOS|Unknown) \d+\.\d+$/); 218 | } 219 | }); 220 | 221 | it('should use warning emoji for no results', async () => { 222 | // Act - filter that likely returns no results 223 | const result = await controller.execute({ 224 | platform: 'visionOS', 225 | state: 'Booted' 226 | }); 227 | 228 | // Assert 229 | const text = result.content[0].text; 230 | 231 | if (text.includes('No simulators found')) { 232 | expect(text).toBe('🔍 No simulators found'); 233 | } 234 | }); 235 | }); 236 | }); ``` -------------------------------------------------------------------------------- /src/utils/errors/xcbeautify-parser.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Unified parser for xcbeautify output 3 | * 4 | * This replaces all the complex parsers and handlers since all our output 5 | * goes through xcbeautify which already formats it nicely. 6 | * 7 | * xcbeautify output format: 8 | * - ❌ for errors 9 | * - ⚠️ for warnings 10 | * - ✔ for test passes 11 | * - ✖ for test failures 12 | */ 13 | 14 | import { createModuleLogger } from '../../logger.js'; 15 | 16 | const logger = createModuleLogger('XcbeautifyParser'); 17 | 18 | export interface Issue { 19 | type: 'error' | 'warning'; 20 | file?: string; 21 | line?: number; 22 | column?: number; 23 | message: string; 24 | rawLine: string; 25 | } 26 | 27 | export interface Test { 28 | name: string; 29 | passed: boolean; 30 | duration?: number; 31 | failureReason?: string; 32 | } 33 | 34 | export interface XcbeautifyOutput { 35 | errors: Issue[]; 36 | warnings: Issue[]; 37 | tests: Test[]; 38 | buildSucceeded: boolean; 39 | testsPassed: boolean; 40 | totalTests: number; 41 | failedTests: number; 42 | } 43 | 44 | 45 | /** 46 | * Parse a line with error or warning from xcbeautify 47 | */ 48 | function parseErrorLine(line: string, isError: boolean): Issue { 49 | // Remove the emoji prefix (❌ or ⚠️) and any color codes 50 | const cleanLine = line 51 | .replace(/^[❌⚠]\s*/, '') // Character class with individual emojis 52 | .replace(/^️\s*/, '') // Remove any lingering emoji variation selectors 53 | .replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI color codes 54 | 55 | // Try to extract file:line:column information 56 | // Format: /path/to/file.swift:10:15: error message 57 | const fileMatch = cleanLine.match(/^([^:]+):(\d+):(\d+):\s*(.*)$/); 58 | 59 | if (fileMatch) { 60 | const [, file, lineStr, columnStr, message] = fileMatch; 61 | 62 | return { 63 | type: isError ? 'error' : 'warning', 64 | file, 65 | line: parseInt(lineStr, 10), 66 | column: parseInt(columnStr, 10), 67 | message, 68 | rawLine: line 69 | }; 70 | } 71 | 72 | // No file information 73 | return { 74 | type: isError ? 'error' : 'warning', 75 | message: cleanLine, 76 | rawLine: line 77 | }; 78 | } 79 | 80 | /** 81 | * Parse test results from xcbeautify output 82 | */ 83 | function parseTestLine(line: string): Test | null { 84 | // Remove color codes 85 | const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, ''); 86 | 87 | // Test passed: ✔ testName (0.123 seconds) 88 | const passMatch = cleanLine.match(/✔\s+(\w+)\s*\(([0-9.]+)\s+seconds?\)/); 89 | if (passMatch) { 90 | return { 91 | name: passMatch[1], 92 | passed: true, 93 | duration: parseFloat(passMatch[2]) 94 | }; 95 | } 96 | 97 | // Test failed: ✖ testName, failure reason 98 | const failMatch = cleanLine.match(/✖\s+(\w+)(?:,\s*(.*))?/); 99 | if (failMatch) { 100 | return { 101 | name: failMatch[1], 102 | passed: false, 103 | failureReason: failMatch[2] || 'Test failed' 104 | }; 105 | } 106 | 107 | // XCTest format: Test Case '-[ClassName testName]' passed/failed (X.XXX seconds) 108 | const xcTestMatch = cleanLine.match(/Test Case\s+'-\[(\w+)\s+(\w+)\]'\s+(passed|failed)\s*\(([0-9.]+)\s+seconds\)/); 109 | if (xcTestMatch) { 110 | return { 111 | name: `${xcTestMatch[1]}.${xcTestMatch[2]}`, 112 | passed: xcTestMatch[3] === 'passed', 113 | duration: parseFloat(xcTestMatch[4]) 114 | }; 115 | } 116 | 117 | return null; 118 | } 119 | 120 | /** 121 | * Main parser for xcbeautify output 122 | */ 123 | export function parseXcbeautifyOutput(output: string): XcbeautifyOutput { 124 | const lines = output.split('\n'); 125 | 126 | // Use Maps to deduplicate errors/warnings (for multi-architecture builds) 127 | const errorMap = new Map<string, Issue>(); 128 | const warningMap = new Map<string, Issue>(); 129 | 130 | let buildSucceeded = true; 131 | let testsPassed = true; 132 | let totalTests = 0; 133 | let failedTests = 0; 134 | const tests: Test[] = []; 135 | 136 | for (const line of lines) { 137 | if (!line.trim()) continue; 138 | 139 | // Skip xcbeautify header 140 | if (line.includes('xcbeautify') || line.startsWith('---') || line.startsWith('Version:')) { 141 | continue; 142 | } 143 | 144 | // Parse errors (❌) 145 | if (line.includes('❌')) { 146 | const error = parseErrorLine(line, true); 147 | const key = `${error.file}:${error.line}:${error.column}:${error.message}`; 148 | errorMap.set(key, error); 149 | buildSucceeded = false; 150 | } 151 | // Parse warnings (⚠️) 152 | else if (line.includes('⚠️')) { 153 | const warning = parseErrorLine(line, false); 154 | const key = `${warning.file}:${warning.line}:${warning.column}:${warning.message}`; 155 | warningMap.set(key, warning); 156 | } 157 | // Parse test results (✔ or ✖) 158 | else if (line.includes('✔') || line.includes('✖')) { 159 | const test = parseTestLine(line); 160 | if (test) { 161 | tests.push(test); 162 | totalTests++; 163 | if (!test.passed) { 164 | failedTests++; 165 | testsPassed = false; 166 | } 167 | } 168 | } 169 | // Check for build/test failure indicators 170 | else if (line.includes('** BUILD FAILED **') || line.includes('BUILD FAILED')) { 171 | buildSucceeded = false; 172 | } 173 | else if (line.includes('** TEST FAILED **') || line.includes('TEST FAILED')) { 174 | testsPassed = false; 175 | } 176 | // Parse test summary: "Executed X tests, with Y failures" 177 | else if (line.includes('Executed') && line.includes('test')) { 178 | const summaryMatch = line.match(/Executed\s+(\d+)\s+tests?,\s+with\s+(\d+)\s+failures?/); 179 | if (summaryMatch) { 180 | totalTests = parseInt(summaryMatch[1], 10); 181 | failedTests = parseInt(summaryMatch[2], 10); 182 | testsPassed = failedTests === 0; 183 | } 184 | } 185 | } 186 | 187 | // Convert Maps to arrays 188 | const result: XcbeautifyOutput = { 189 | errors: Array.from(errorMap.values()), 190 | warnings: Array.from(warningMap.values()), 191 | tests, 192 | buildSucceeded, 193 | testsPassed, 194 | totalTests, 195 | failedTests 196 | }; 197 | 198 | // Log summary 199 | logger.debug({ 200 | errors: result.errors.length, 201 | warnings: result.warnings.length, 202 | tests: result.tests.length, 203 | buildSucceeded: result.buildSucceeded, 204 | testsPassed: result.testsPassed 205 | }, 'Parsed xcbeautify output'); 206 | 207 | return result; 208 | } 209 | 210 | /** 211 | * Format parsed output for display 212 | */ 213 | export function formatParsedOutput(parsed: XcbeautifyOutput): string { 214 | const lines: string[] = []; 215 | 216 | // Build status 217 | if (!parsed.buildSucceeded) { 218 | lines.push(`❌ Build failed with ${parsed.errors.length} error${parsed.errors.length !== 1 ? 's' : ''}`); 219 | } else if (parsed.errors.length === 0 && parsed.warnings.length === 0) { 220 | lines.push('✅ Build succeeded'); 221 | } else { 222 | lines.push(`⚠️ Build succeeded with ${parsed.warnings.length} warning${parsed.warnings.length !== 1 ? 's' : ''}`); 223 | } 224 | 225 | // Errors 226 | if (parsed.errors.length > 0) { 227 | lines.push('\nErrors:'); 228 | for (const error of parsed.errors.slice(0, 10)) { // Show first 10 229 | if (error.file) { 230 | lines.push(` ❌ ${error.file}:${error.line}:${error.column} - ${error.message}`); 231 | } else { 232 | lines.push(` ❌ ${error.message}`); 233 | } 234 | } 235 | if (parsed.errors.length > 10) { 236 | lines.push(` ... and ${parsed.errors.length - 10} more errors`); 237 | } 238 | } 239 | 240 | // Warnings (only show if no errors) 241 | if (parsed.errors.length === 0 && parsed.warnings.length > 0) { 242 | lines.push('\nWarnings:'); 243 | for (const warning of parsed.warnings.slice(0, 5)) { // Show first 5 244 | if (warning.file) { 245 | lines.push(` ⚠️ ${warning.file}:${warning.line}:${warning.column} - ${warning.message}`); 246 | } else { 247 | lines.push(` ⚠️ ${warning.message}`); 248 | } 249 | } 250 | if (parsed.warnings.length > 5) { 251 | lines.push(` ... and ${parsed.warnings.length - 5} more warnings`); 252 | } 253 | } 254 | 255 | // Test results 256 | if (parsed.tests.length > 0) { 257 | lines.push('\nTest Results:'); 258 | if (parsed.testsPassed) { 259 | lines.push(` ✅ All ${parsed.totalTests} tests passed`); 260 | } else { 261 | lines.push(` ❌ ${parsed.failedTests} of ${parsed.totalTests} tests failed`); 262 | 263 | // Show failed tests 264 | const failedTests = parsed.tests.filter(t => !t.passed); 265 | for (const test of failedTests.slice(0, 5)) { 266 | lines.push(` ✖ ${test.name}: ${test.failureReason || 'Failed'}`); 267 | } 268 | if (failedTests.length > 5) { 269 | lines.push(` ... and ${failedTests.length - 5} more failures`); 270 | } 271 | } 272 | } 273 | 274 | return lines.join('\n'); 275 | } ``` -------------------------------------------------------------------------------- /docs/ERROR-HANDLING.md: -------------------------------------------------------------------------------- ```markdown 1 | # Error Handling and Presentation Patterns 2 | 3 | ## Overview 4 | 5 | This document describes the error handling patterns and presentation conventions used across the MCP Xcode Server codebase. Following these patterns ensures consistent user experience and maintainable error handling. 6 | 7 | ## Core Principles 8 | 9 | ### 1. Separation of Concerns 10 | - **Domain Layer**: Creates typed error objects with data only (no formatting) 11 | - **Use Cases**: Return domain errors without formatting messages 12 | - **Presentation Layer**: Formats errors for user display with consistent styling 13 | 14 | ### 2. Typed Domain Errors 15 | Each domain has its own error types that extend a base error class: 16 | 17 | ```typescript 18 | // Base class for domain-specific errors 19 | export abstract class BootError extends Error {} 20 | 21 | // Specific error types with relevant data 22 | export class SimulatorNotFoundError extends BootError { 23 | constructor(public readonly deviceId: string) { 24 | super(deviceId); // Just store the data, no formatting 25 | this.name = 'SimulatorNotFoundError'; 26 | } 27 | } 28 | 29 | export class BootCommandFailedError extends BootError { 30 | constructor(public readonly stderr: string) { 31 | super(stderr); // Just store the stderr output 32 | this.name = 'BootCommandFailedError'; 33 | } 34 | } 35 | ``` 36 | 37 | **Important**: Domain errors should NEVER contain user-facing messages. They should only contain data. The presentation layer is responsible for formatting messages based on the error type and data. 38 | 39 | ### 3. Error Type Checking 40 | Use `instanceof` to check error types in the presentation layer: 41 | 42 | ```typescript 43 | if (error instanceof SimulatorNotFoundError) { 44 | return `❌ Simulator not found: ${error.deviceId}`; 45 | } 46 | 47 | if (error instanceof BootCommandFailedError) { 48 | return `❌ ${ErrorFormatter.format(error)}`; 49 | } 50 | ``` 51 | 52 | ## Presentation Patterns 53 | 54 | ### Visual Indicators (Emojis) 55 | 56 | All tools use consistent emoji prefixes for different outcomes: 57 | 58 | - **✅ Success**: Successful operations 59 | - **❌ Error**: Failed operations 60 | - **⚠️ Warning**: Operations with warnings 61 | - **📁 Info**: Additional information (like log paths) 62 | 63 | Examples: 64 | ``` 65 | ✅ Successfully booted simulator: iPhone 15 (ABC123) 66 | ✅ Build succeeded: MyApp 67 | 68 | ❌ Simulator not found: iPhone-16 69 | ❌ Build failed 70 | 71 | ⚠️ Warnings (3): 72 | • Deprecated API usage 73 | • Unused variable 'x' 74 | 75 | 📁 Full logs saved to: /path/to/logs 76 | ``` 77 | 78 | ### Error Message Format 79 | 80 | 1. **Simple Errors**: Direct message with emoji 81 | ``` 82 | ❌ Simulator not found: iPhone-16 83 | ❌ Unable to boot device 84 | ``` 85 | 86 | 2. **Complex Errors** (builds, tests): Structured format 87 | ``` 88 | ❌ Build failed: MyApp 89 | Platform: iOS 90 | Configuration: Debug 91 | 92 | ❌ Errors (3): 93 | • /path/file.swift:10: Cannot find type 'Foo' 94 | • /path/file.swift:20: Missing return statement 95 | ``` 96 | 97 | ### ErrorFormatter Usage 98 | 99 | The `ErrorFormatter` class provides consistent error formatting across all tools: 100 | 101 | ```typescript 102 | import { ErrorFormatter } from '../formatters/ErrorFormatter.js'; 103 | 104 | // In controller or presenter 105 | const message = ErrorFormatter.format(error); 106 | return `❌ ${message}`; 107 | ``` 108 | 109 | The ErrorFormatter: 110 | - Formats domain validation errors 111 | - Formats build issues 112 | - Cleans up common error prefixes 113 | - Provides fallback for unknown errors 114 | 115 | ## Implementation Guidelines 116 | 117 | ### Controllers 118 | 119 | Controllers should format results consistently: 120 | 121 | ```typescript 122 | private formatResult(result: DomainResult): string { 123 | switch (result.outcome) { 124 | case Outcome.Success: 125 | return `✅ Successfully completed: ${result.name}`; 126 | 127 | case Outcome.Failed: 128 | if (result.error instanceof SpecificError) { 129 | return `❌ Specific error: ${result.error.details}`; 130 | } 131 | return `❌ ${ErrorFormatter.format(result.error)}`; 132 | } 133 | } 134 | ``` 135 | 136 | ### Use Cases 137 | 138 | Use cases should NOT format error messages: 139 | 140 | ```typescript 141 | // ❌ BAD: Formatting in use case 142 | return Result.failed( 143 | `Simulator not found: ${deviceId}` // Don't format here! 144 | ); 145 | 146 | // ✅ GOOD: Return typed error 147 | return Result.failed( 148 | new SimulatorNotFoundError(deviceId) // Just the error object 149 | ); 150 | ``` 151 | 152 | ### Presenters 153 | 154 | For complex formatting (like build results), use a dedicated presenter: 155 | 156 | ```typescript 157 | export class BuildXcodePresenter { 158 | presentError(error: Error): MCPResponse { 159 | const message = ErrorFormatter.format(error); 160 | return { 161 | content: [{ 162 | type: 'text', 163 | text: `❌ ${message}` 164 | }] 165 | }; 166 | } 167 | } 168 | ``` 169 | 170 | ## Testing Error Handling 171 | 172 | ### Unit Tests 173 | 174 | Test that controllers format errors correctly: 175 | 176 | ```typescript 177 | it('should handle boot failure', async () => { 178 | // Arrange 179 | const error = new BootCommandFailedError('Device is locked'); 180 | const result = BootResult.failed('123', 'iPhone', error); 181 | 182 | // Act 183 | const response = controller.execute({ deviceId: 'iPhone' }); 184 | 185 | // Assert - Check for emoji and message 186 | expect(response.text).toBe('❌ Device is locked'); 187 | }); 188 | ``` 189 | 190 | ### Integration Tests 191 | 192 | Test behavior, not specific formatting: 193 | 194 | ```typescript 195 | it('should handle simulator not found', async () => { 196 | // Act 197 | const result = await controller.execute({ deviceId: 'NonExistent' }); 198 | 199 | // Assert - Test behavior: error message shown 200 | expect(result.content[0].text).toContain('❌'); 201 | expect(result.content[0].text).toContain('not found'); 202 | }); 203 | ``` 204 | 205 | ## Common Error Scenarios 206 | 207 | ### 1. Resource Not Found 208 | ```typescript 209 | export class ResourceNotFoundError extends DomainError { 210 | constructor(public readonly resourceId: string) { 211 | super(resourceId); 212 | } 213 | } 214 | 215 | // Presentation 216 | `❌ Resource not found: ${error.resourceId}` 217 | ``` 218 | 219 | ### 2. Command Execution Failed 220 | ```typescript 221 | export class CommandFailedError extends DomainError { 222 | constructor(public readonly stderr: string, public readonly exitCode?: number) { 223 | super(stderr); 224 | } 225 | } 226 | 227 | // Presentation 228 | `❌ Command failed: ${error.stderr}` 229 | ``` 230 | 231 | ### 3. Resource Busy/State Conflicts 232 | ```typescript 233 | export class SimulatorBusyError extends DomainError { 234 | constructor(public readonly currentState: string) { 235 | super(currentState); // Just store the state, no message 236 | } 237 | } 238 | 239 | // Presentation layer formats the message 240 | `❌ Cannot boot simulator: currently ${error.currentState.toLowerCase()}` 241 | ``` 242 | 243 | ### 4. Validation Failed 244 | Domain value objects handle their own validation with consistent error types: 245 | 246 | ```typescript 247 | // Domain value objects validate themselves 248 | export class AppPath { 249 | static create(path: unknown): AppPath { 250 | // Check for missing field 251 | if (path === undefined || path === null) { 252 | throw new AppPath.RequiredError(); // "App path is required" 253 | } 254 | 255 | // Check type 256 | if (typeof path !== 'string') { 257 | throw new AppPath.InvalidTypeError(path); // "App path must be a string" 258 | } 259 | 260 | // Check empty 261 | if (path.trim() === '') { 262 | throw new AppPath.EmptyError(path); // "App path cannot be empty" 263 | } 264 | 265 | // Check format 266 | if (!path.endsWith('.app')) { 267 | throw new AppPath.InvalidFormatError(path); // "App path must end with .app" 268 | } 269 | 270 | return new AppPath(path); 271 | } 272 | } 273 | ``` 274 | 275 | #### Validation Error Hierarchy 276 | Each value object follows this consistent validation order: 277 | 1. **Required**: `undefined`/`null` → "X is required" 278 | 2. **Type**: Wrong type → "X must be a {type}" 279 | 3. **Empty**: Empty string → "X cannot be empty" 280 | 4. **Format**: Invalid format → Specific format message 281 | 282 | ```typescript 283 | // Consistent error base classes 284 | export abstract class DomainRequiredError extends DomainError { 285 | constructor(fieldDisplayName: string) { 286 | super(`${fieldDisplayName} is required`); 287 | } 288 | } 289 | 290 | export abstract class DomainEmptyError extends DomainError { 291 | constructor(fieldDisplayName: string) { 292 | super(`${fieldDisplayName} cannot be empty`); 293 | } 294 | } 295 | 296 | export abstract class DomainInvalidTypeError extends DomainError { 297 | constructor(fieldDisplayName: string, expectedType: string) { 298 | super(`${fieldDisplayName} must be a ${expectedType}`); 299 | } 300 | } 301 | ``` 302 | 303 | ## Benefits 304 | 305 | 1. **Consistency**: Users see consistent error formatting across all tools 306 | 2. **Maintainability**: Error formatting logic is centralized 307 | 3. **Testability**: Domain logic doesn't depend on presentation 308 | 4. **Flexibility**: Easy to change formatting without touching business logic 309 | 5. **Type Safety**: TypeScript ensures error types are handled correctly ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestErrorInjector.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { readFileSync, writeFileSync, existsSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { createModuleLogger } from '../../../logger'; 4 | import { gitResetTestArtifacts, gitResetFile } from './gitResetTestArtifacts'; 5 | 6 | const logger = createModuleLogger('TestErrorInjector'); 7 | 8 | /** 9 | * Helper class to inject specific error conditions into test projects 10 | * for testing error handling and display 11 | */ 12 | export class TestErrorInjector { 13 | private originalFiles: Map<string, string> = new Map(); 14 | 15 | /** 16 | * Inject a compile error into a Swift file 17 | */ 18 | injectCompileError(filePath: string, errorType: 'type-mismatch' | 'syntax' | 'missing-member' = 'type-mismatch') { 19 | this.backupFile(filePath); 20 | 21 | let content = readFileSync(filePath, 'utf8'); 22 | 23 | switch (errorType) { 24 | case 'type-mismatch': 25 | // Add a type mismatch error 26 | content = content.replace( 27 | 'let newItem = Item(timestamp: Date())', 28 | 'let x: String = 123 // Type mismatch error\n let newItem = Item(timestamp: Date())' 29 | ); 30 | break; 31 | 32 | case 'syntax': 33 | // Add a syntax error 34 | content = content.replace( 35 | 'import SwiftUI', 36 | 'import SwiftUI\nlet incomplete = // Syntax error' 37 | ); 38 | break; 39 | 40 | case 'missing-member': 41 | // Reference a non-existent property 42 | content = content.replace( 43 | 'modelContext.insert(newItem)', 44 | 'modelContext.insert(newItem)\n let _ = newItem.nonExistentProperty // Missing member error' 45 | ); 46 | break; 47 | } 48 | 49 | writeFileSync(filePath, content); 50 | logger.debug({ filePath, errorType }, 'Injected compile error'); 51 | } 52 | 53 | /** 54 | * Inject multiple compile errors into a file 55 | */ 56 | injectMultipleCompileErrors(filePath: string) { 57 | if (!existsSync(filePath)) { 58 | throw new Error(`File not found: ${filePath}`); 59 | } 60 | 61 | this.backupFile(filePath); 62 | 63 | let content = readFileSync(filePath, 'utf8'); 64 | 65 | // Add multiple different types of errors 66 | content = content.replace( 67 | 'let newItem = Item(timestamp: Date())', 68 | `let x: String = 123 // Type mismatch error 1 69 | let y: Int = "hello" // Type mismatch error 2 70 | let z = nonExistentFunction() // Undefined function error 71 | let newItem = Item(timestamp: Date())` 72 | ); 73 | 74 | writeFileSync(filePath, content); 75 | logger.debug({ filePath }, 'Injected multiple compile errors'); 76 | } 77 | 78 | /** 79 | * Remove code signing from a project to trigger signing errors 80 | */ 81 | injectCodeSigningError(projectPath: string) { 82 | const pbxprojPath = join(projectPath, 'project.pbxproj'); 83 | if (!existsSync(pbxprojPath)) { 84 | throw new Error(`Project file not found: ${pbxprojPath}`); 85 | } 86 | 87 | this.backupFile(pbxprojPath); 88 | 89 | let content = readFileSync(pbxprojPath, 'utf8'); 90 | 91 | // Change code signing settings to trigger errors 92 | content = content.replace( 93 | /CODE_SIGN_STYLE = Automatic;/g, 94 | 'CODE_SIGN_STYLE = Manual;' 95 | ); 96 | content = content.replace( 97 | /DEVELOPMENT_TEAM = [A-Z0-9]+;/g, 98 | 'DEVELOPMENT_TEAM = "";' 99 | ); 100 | 101 | writeFileSync(pbxprojPath, content); 102 | logger.debug({ projectPath }, 'Injected code signing error'); 103 | } 104 | 105 | /** 106 | * Inject a provisioning profile error by requiring a non-existent profile 107 | */ 108 | injectProvisioningError(projectPath: string) { 109 | const pbxprojPath = join(projectPath, 'project.pbxproj'); 110 | if (!existsSync(pbxprojPath)) { 111 | throw new Error(`Project file not found: ${pbxprojPath}`); 112 | } 113 | 114 | this.backupFile(pbxprojPath); 115 | 116 | let content = readFileSync(pbxprojPath, 'utf8'); 117 | 118 | // Add a non-existent provisioning profile requirement 119 | content = content.replace( 120 | /PRODUCT_BUNDLE_IDENTIFIER = /g, 121 | 'PROVISIONING_PROFILE_SPECIFIER = "NonExistent Profile";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = ' 122 | ); 123 | 124 | writeFileSync(pbxprojPath, content); 125 | logger.debug({ projectPath }, 'Injected provisioning profile error'); 126 | } 127 | 128 | /** 129 | * Inject a missing dependency error into a Swift package 130 | */ 131 | injectMissingDependency(packagePath: string) { 132 | const packageSwiftPath = join(packagePath, 'Package.swift'); 133 | if (!existsSync(packageSwiftPath)) { 134 | throw new Error(`Package.swift not found: ${packageSwiftPath}`); 135 | } 136 | 137 | this.backupFile(packageSwiftPath); 138 | 139 | let content = readFileSync(packageSwiftPath, 'utf8'); 140 | 141 | // In Swift Package Manager, the Package initializer arguments must be in order: 142 | // name, platforms?, products, dependencies?, targets 143 | // We need to insert dependencies after products but before targets 144 | 145 | // Find the end of products array and beginning of targets 146 | // Use [\s\S]*? for non-greedy multiline matching 147 | const regex = /(products:\s*\[[\s\S]*?\])(,\s*)(targets:)/; 148 | const match = content.match(regex); 149 | 150 | if (match) { 151 | // Insert dependencies between products and targets 152 | // Use a non-existent package to trigger dependency resolution error 153 | content = content.replace( 154 | regex, 155 | `$1,\n dependencies: [\n .package(url: "https://github.com/nonexistent-org/nonexistent-package.git", from: "1.0.0")\n ]$2$3` 156 | ); 157 | } 158 | 159 | // Now add the dependency to a target so it tries to use it 160 | // Find the first target that doesn't have dependencies yet 161 | const targetRegex = /\.target\(\s*name:\s*"([^"]+)"\s*\)/; 162 | const targetMatch = content.match(targetRegex); 163 | 164 | if (targetMatch) { 165 | const targetName = targetMatch[1]; 166 | // Replace the target to add dependencies 167 | content = content.replace( 168 | targetMatch[0], 169 | `.target(\n name: "${targetName}",\n dependencies: [\n .product(name: "NonExistentPackage", package: "nonexistent-package")\n ])` 170 | ); 171 | 172 | // Also add import to a source file to trigger the error at compile time 173 | const sourcePath = join(packagePath, 'Sources', targetName); 174 | if (existsSync(sourcePath)) { 175 | const sourceFiles = require('fs').readdirSync(sourcePath, { recursive: true }) 176 | .filter((f: string) => f.endsWith('.swift')); 177 | 178 | if (sourceFiles.length > 0) { 179 | const sourceFile = join(sourcePath, sourceFiles[0]); 180 | this.backupFile(sourceFile); 181 | let sourceContent = readFileSync(sourceFile, 'utf8'); 182 | sourceContent = 'import NonExistentPackage\n' + sourceContent; 183 | writeFileSync(sourceFile, sourceContent); 184 | } 185 | } 186 | } 187 | 188 | writeFileSync(packageSwiftPath, content); 189 | logger.debug({ packagePath }, 'Injected missing dependency error'); 190 | } 191 | 192 | /** 193 | * Inject a platform compatibility error 194 | */ 195 | injectPlatformError(projectPath: string) { 196 | const pbxprojPath = join(projectPath, 'project.pbxproj'); 197 | if (!existsSync(pbxprojPath)) { 198 | throw new Error(`Project file not found: ${pbxprojPath}`); 199 | } 200 | 201 | this.backupFile(pbxprojPath); 202 | 203 | let content = readFileSync(pbxprojPath, 'utf8'); 204 | 205 | // Set incompatible deployment targets 206 | content = content.replace( 207 | /IPHONEOS_DEPLOYMENT_TARGET = \d+\.\d+;/g, 208 | 'IPHONEOS_DEPLOYMENT_TARGET = 99.0;' // Impossible iOS version 209 | ); 210 | 211 | writeFileSync(pbxprojPath, content); 212 | logger.debug({ projectPath }, 'Injected platform compatibility error'); 213 | } 214 | 215 | /** 216 | * Backup a file before modifying it 217 | */ 218 | private backupFile(filePath: string) { 219 | if (!this.originalFiles.has(filePath)) { 220 | const content = readFileSync(filePath, 'utf8'); 221 | this.originalFiles.set(filePath, content); 222 | logger.debug({ filePath }, 'Backed up original file'); 223 | } 224 | } 225 | 226 | /** 227 | * Restore all modified files to their original state 228 | */ 229 | restoreAll() { 230 | // Use git to reset all test_artifacts 231 | gitResetTestArtifacts(); 232 | // Clear our tracking 233 | this.originalFiles.clear(); 234 | } 235 | 236 | /** 237 | * Restore a specific file 238 | */ 239 | restoreFile(filePath: string) { 240 | // Use git to reset the specific file 241 | gitResetFile(filePath); 242 | // Remove from our tracking 243 | this.originalFiles.delete(filePath); 244 | } 245 | } ``` -------------------------------------------------------------------------------- /src/presentation/tests/unit/DefaultErrorStrategy.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { DefaultErrorStrategy } from '../../formatters/strategies/DefaultErrorStrategy.js'; 2 | 3 | describe('DefaultErrorStrategy', () => { 4 | function createSUT(): DefaultErrorStrategy { 5 | return new DefaultErrorStrategy(); 6 | } 7 | 8 | describe('canFormat', () => { 9 | it('should always return true as the fallback strategy', () => { 10 | const sut = createSUT(); 11 | 12 | expect(sut.canFormat(new Error('Any error'))).toBe(true); 13 | expect(sut.canFormat({ message: 'Plain object' })).toBe(true); 14 | expect(sut.canFormat('String error')).toBe(true); 15 | expect(sut.canFormat(123)).toBe(true); 16 | expect(sut.canFormat(null)).toBe(true); 17 | expect(sut.canFormat(undefined)).toBe(true); 18 | expect(sut.canFormat({})).toBe(true); 19 | expect(sut.canFormat([])).toBe(true); 20 | }); 21 | }); 22 | 23 | describe('format', () => { 24 | describe('when formatting errors with messages', () => { 25 | it('should return plain message without modification', () => { 26 | const sut = createSUT(); 27 | const error = new Error('Simple error message'); 28 | 29 | const result = sut.format(error); 30 | 31 | expect(result).toBe('Simple error message'); 32 | }); 33 | 34 | it('should preserve message with special characters', () => { 35 | const sut = createSUT(); 36 | const error = { message: 'Error: with @#$% special chars!' }; 37 | 38 | const result = sut.format(error); 39 | 40 | expect(result).toBe('with @#$% special chars!'); 41 | }); 42 | 43 | it('should handle multiline messages', () => { 44 | const sut = createSUT(); 45 | const error = new Error('First line\nSecond line\nThird line'); 46 | 47 | const result = sut.format(error); 48 | 49 | expect(result).toBe('First line\nSecond line\nThird line'); 50 | }); 51 | }); 52 | 53 | describe('when cleaning common prefixes', () => { 54 | it('should remove "Error:" prefix (case insensitive)', () => { 55 | const sut = createSUT(); 56 | 57 | expect(sut.format(new Error('Error: Something went wrong'))).toBe('Something went wrong'); 58 | expect(sut.format(new Error('error: lowercase prefix'))).toBe('lowercase prefix'); 59 | expect(sut.format(new Error('ERROR: uppercase prefix'))).toBe('uppercase prefix'); 60 | expect(sut.format(new Error('ErRoR: mixed case'))).toBe('mixed case'); 61 | }); 62 | 63 | it('should remove "Invalid arguments:" prefix (case insensitive)', () => { 64 | const sut = createSUT(); 65 | 66 | expect(sut.format(new Error('Invalid arguments: Missing required field'))).toBe('Missing required field'); 67 | expect(sut.format(new Error('invalid arguments: lowercase'))).toBe('lowercase'); 68 | expect(sut.format(new Error('INVALID ARGUMENTS: uppercase'))).toBe('uppercase'); 69 | }); 70 | 71 | it('should remove "Validation failed:" prefix (case insensitive)', () => { 72 | const sut = createSUT(); 73 | 74 | expect(sut.format(new Error('Validation failed: Bad input'))).toBe('Bad input'); 75 | expect(sut.format(new Error('validation failed: lowercase'))).toBe('lowercase'); 76 | expect(sut.format(new Error('VALIDATION FAILED: uppercase'))).toBe('uppercase'); 77 | }); 78 | 79 | it('should handle multiple spaces after prefix', () => { 80 | const sut = createSUT(); 81 | 82 | expect(sut.format(new Error('Error: Multiple spaces'))).toBe('Multiple spaces'); 83 | expect(sut.format(new Error('Invalid arguments: Extra spaces'))).toBe('Extra spaces'); 84 | }); 85 | 86 | it('should only remove prefix at start of message', () => { 87 | const sut = createSUT(); 88 | const error = new Error('Something Error: in the middle'); 89 | 90 | const result = sut.format(error); 91 | 92 | expect(result).toBe('Something Error: in the middle'); 93 | }); 94 | 95 | it('should handle messages that are only the prefix', () => { 96 | const sut = createSUT(); 97 | 98 | expect(sut.format(new Error('Error:'))).toBe(''); 99 | expect(sut.format(new Error('Error: '))).toBe(''); 100 | expect(sut.format(new Error('Invalid arguments:'))).toBe(''); 101 | expect(sut.format(new Error('Validation failed:'))).toBe(''); 102 | }); 103 | 104 | it('should clean all matching prefixes', () => { 105 | const sut = createSUT(); 106 | const error = new Error('Error: Invalid arguments: Something'); 107 | 108 | const result = sut.format(error); 109 | 110 | // Both "Error:" and "Invalid arguments:" are cleaned 111 | expect(result).toBe('Something'); 112 | }); 113 | }); 114 | 115 | describe('when handling errors without messages', () => { 116 | it('should return default message for error without message property', () => { 117 | const sut = createSUT(); 118 | const error = {}; 119 | 120 | const result = sut.format(error); 121 | 122 | expect(result).toBe('An error occurred'); 123 | }); 124 | 125 | it('should return default message for null error', () => { 126 | const sut = createSUT(); 127 | 128 | const result = sut.format(null); 129 | 130 | expect(result).toBe('An error occurred'); 131 | }); 132 | 133 | it('should return default message for undefined error', () => { 134 | const sut = createSUT(); 135 | 136 | const result = sut.format(undefined); 137 | 138 | expect(result).toBe('An error occurred'); 139 | }); 140 | 141 | it('should return default message for non-object errors', () => { 142 | const sut = createSUT(); 143 | 144 | expect(sut.format('string error')).toBe('An error occurred'); 145 | expect(sut.format(123)).toBe('An error occurred'); 146 | expect(sut.format(true)).toBe('An error occurred'); 147 | expect(sut.format([])).toBe('An error occurred'); 148 | }); 149 | 150 | it('should handle error with null message', () => { 151 | const sut = createSUT(); 152 | const error = { message: null }; 153 | 154 | const result = sut.format(error); 155 | 156 | expect(result).toBe('An error occurred'); 157 | }); 158 | 159 | it('should handle error with undefined message', () => { 160 | const sut = createSUT(); 161 | const error = { message: undefined }; 162 | 163 | const result = sut.format(error); 164 | 165 | expect(result).toBe('An error occurred'); 166 | }); 167 | 168 | it('should handle error with empty string message', () => { 169 | const sut = createSUT(); 170 | const error = { message: '' }; 171 | 172 | const result = sut.format(error); 173 | 174 | expect(result).toBe('An error occurred'); 175 | }); 176 | 177 | it('should handle error with whitespace-only message', () => { 178 | const sut = createSUT(); 179 | const error = { message: ' ' }; 180 | 181 | const result = sut.format(error); 182 | 183 | expect(result).toBe(' '); // Preserves whitespace as it's truthy 184 | }); 185 | }); 186 | 187 | describe('when handling edge cases', () => { 188 | it('should handle very long messages', () => { 189 | const sut = createSUT(); 190 | const longMessage = 'A'.repeat(10000); 191 | const error = new Error(longMessage); 192 | 193 | const result = sut.format(error); 194 | 195 | expect(result).toBe(longMessage); 196 | }); 197 | 198 | it('should preserve unicode and emoji', () => { 199 | const sut = createSUT(); 200 | const error = new Error('Error: Failed to process 你好 🚫'); 201 | 202 | const result = sut.format(error); 203 | 204 | expect(result).toBe('Failed to process 你好 🚫'); 205 | }); 206 | 207 | it('should handle messages with only special characters', () => { 208 | const sut = createSUT(); 209 | const error = new Error('@#$%^&*()'); 210 | 211 | const result = sut.format(error); 212 | 213 | expect(result).toBe('@#$%^&*()'); 214 | }); 215 | 216 | it('should handle error-like objects with toString', () => { 217 | const sut = createSUT(); 218 | const error = { 219 | message: 'Custom error', 220 | toString: () => 'ToString output' 221 | }; 222 | 223 | const result = sut.format(error); 224 | 225 | expect(result).toBe('Custom error'); // Prefers message over toString 226 | }); 227 | 228 | it('should not modify messages without known prefixes', () => { 229 | const sut = createSUT(); 230 | const messages = [ 231 | 'Unknown prefix: Something', 232 | 'Warning: Something else', 233 | 'Notice: Important info', 234 | 'Failed: Operation incomplete' 235 | ]; 236 | 237 | messages.forEach(msg => { 238 | const error = new Error(msg); 239 | expect(sut.format(error)).toBe(msg); 240 | }); 241 | }); 242 | }); 243 | }); 244 | }); ``` -------------------------------------------------------------------------------- /src/utils/projects/Xcode.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { XcodeProject } from './XcodeProject.js'; 2 | import { SwiftPackage } from './SwiftPackage.js'; 3 | import { XcodeError, XcodeErrorType } from './XcodeErrors.js'; 4 | import { createModuleLogger } from '../../logger.js'; 5 | import { existsSync } from 'fs'; 6 | import { readdir } from 'fs/promises'; 7 | import path from 'path'; 8 | 9 | const logger = createModuleLogger('Xcode'); 10 | 11 | /** 12 | * Xcode project discovery and management. 13 | * Provides methods to find and open Xcode projects and Swift packages. 14 | */ 15 | export class Xcode { 16 | /** 17 | * Open a project at the specified path. 18 | * Automatically detects whether it's an Xcode project or Swift package. 19 | * @param projectPath Path to the project 20 | * @param expectedType Optional type to expect ('xcode' | 'swift-package' | 'auto') 21 | */ 22 | async open(projectPath: string, expectedType: 'xcode' | 'swift-package' | 'auto' = 'auto'): Promise<XcodeProject | SwiftPackage> { 23 | // If expecting Swift package specifically, only look for Package.swift 24 | if (expectedType === 'swift-package') { 25 | // Check if it's a Package.swift file directly 26 | if (projectPath.endsWith('Package.swift')) { 27 | if (!existsSync(projectPath)) { 28 | throw new Error(`No Package.swift found at: ${projectPath}`); 29 | } 30 | const packageDir = path.dirname(projectPath); 31 | logger.debug({ packageDir }, 'Opening Swift package from Package.swift'); 32 | return new SwiftPackage(packageDir); 33 | } 34 | 35 | // Check if directory contains Package.swift 36 | if (existsSync(projectPath)) { 37 | const packageSwiftPath = path.join(projectPath, 'Package.swift'); 38 | if (existsSync(packageSwiftPath)) { 39 | logger.debug({ projectPath }, 'Found Package.swift in directory'); 40 | return new SwiftPackage(projectPath); 41 | } 42 | } 43 | 44 | throw new Error(`No Package.swift found at: ${projectPath}`); 45 | } 46 | 47 | // If expecting Xcode project specifically, only look for .xcodeproj/.xcworkspace 48 | if (expectedType === 'xcode') { 49 | // Check if it's an Xcode project or workspace 50 | if (projectPath.endsWith('.xcodeproj') || projectPath.endsWith('.xcworkspace')) { 51 | if (!existsSync(projectPath)) { 52 | throw new Error(`No Xcode project found at: ${projectPath}`); 53 | } 54 | const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project'; 55 | logger.debug({ projectPath, type }, 'Opening Xcode project'); 56 | return new XcodeProject(projectPath, type); 57 | } 58 | 59 | // Check directory for Xcode projects 60 | if (existsSync(projectPath)) { 61 | const files = await readdir(projectPath); 62 | 63 | const workspace = files.find(f => f.endsWith('.xcworkspace')); 64 | if (workspace) { 65 | const workspacePath = path.join(projectPath, workspace); 66 | logger.debug({ workspacePath }, 'Found workspace in directory'); 67 | return new XcodeProject(workspacePath, 'workspace'); 68 | } 69 | 70 | const xcodeproj = files.find(f => f.endsWith('.xcodeproj')); 71 | if (xcodeproj) { 72 | const xcodeprojPath = path.join(projectPath, xcodeproj); 73 | logger.debug({ xcodeprojPath }, 'Found Xcode project in directory'); 74 | return new XcodeProject(xcodeprojPath, 'project'); 75 | } 76 | } 77 | 78 | throw new Error(`No Xcode project found at: ${projectPath}`); 79 | } 80 | 81 | // Auto mode - original behavior 82 | // Check if it's an Xcode project or workspace 83 | if (projectPath.endsWith('.xcodeproj') || projectPath.endsWith('.xcworkspace')) { 84 | if (!existsSync(projectPath)) { 85 | throw new Error(`Xcode project not found at: ${projectPath}`); 86 | } 87 | const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project'; 88 | logger.debug({ projectPath, type }, 'Opening Xcode project'); 89 | return new XcodeProject(projectPath, type); 90 | } 91 | 92 | // Check if it's a Swift package (directory containing Package.swift) 93 | const packagePath = path.join(projectPath, 'Package.swift'); 94 | if (existsSync(packagePath)) { 95 | logger.debug({ projectPath }, 'Opening Swift package'); 96 | return new SwiftPackage(projectPath); 97 | } 98 | 99 | // If it's a Package.swift file directly 100 | if (projectPath.endsWith('Package.swift') && existsSync(projectPath)) { 101 | const packageDir = path.dirname(projectPath); 102 | logger.debug({ packageDir }, 'Opening Swift package from Package.swift'); 103 | return new SwiftPackage(packageDir); 104 | } 105 | 106 | // Try to auto-detect in the directory 107 | if (existsSync(projectPath)) { 108 | // Look for .xcworkspace first (higher priority) 109 | const files = await readdir(projectPath); 110 | 111 | const workspace = files.find(f => f.endsWith('.xcworkspace')); 112 | if (workspace) { 113 | const workspacePath = path.join(projectPath, workspace); 114 | logger.debug({ workspacePath }, 'Found workspace in directory'); 115 | return new XcodeProject(workspacePath, 'workspace'); 116 | } 117 | 118 | const xcodeproj = files.find(f => f.endsWith('.xcodeproj')); 119 | if (xcodeproj) { 120 | const xcodeprojPath = path.join(projectPath, xcodeproj); 121 | logger.debug({ xcodeprojPath }, 'Found Xcode project in directory'); 122 | return new XcodeProject(xcodeprojPath, 'project'); 123 | } 124 | 125 | // Check for Package.swift 126 | if (files.includes('Package.swift')) { 127 | logger.debug({ projectPath }, 'Found Package.swift in directory'); 128 | return new SwiftPackage(projectPath); 129 | } 130 | } 131 | 132 | throw new XcodeError(XcodeErrorType.ProjectNotFound, projectPath); 133 | } 134 | 135 | /** 136 | * Find all Xcode projects in a directory 137 | */ 138 | async findProjects(directory: string): Promise<XcodeProject[]> { 139 | const projects: XcodeProject[] = []; 140 | 141 | try { 142 | const files = await readdir(directory, { withFileTypes: true }); 143 | 144 | for (const file of files) { 145 | const fullPath = path.join(directory, file.name); 146 | 147 | if (file.name.endsWith('.xcworkspace')) { 148 | projects.push(new XcodeProject(fullPath, 'workspace')); 149 | } else if (file.name.endsWith('.xcodeproj')) { 150 | // Only add if there's no workspace (workspace takes precedence) 151 | const workspaceName = file.name.replace('.xcodeproj', '.xcworkspace'); 152 | const hasWorkspace = files.some(f => f.name === workspaceName); 153 | if (!hasWorkspace) { 154 | projects.push(new XcodeProject(fullPath, 'project')); 155 | } 156 | } else if (file.isDirectory() && !file.name.startsWith('.')) { 157 | // Recursively search subdirectories 158 | const subProjects = await this.findProjects(fullPath); 159 | projects.push(...subProjects); 160 | } 161 | } 162 | } catch (error: any) { 163 | logger.error({ error: error.message, directory }, 'Failed to find projects'); 164 | throw new Error(`Failed to find projects in ${directory}: ${error.message}`); 165 | } 166 | 167 | logger.debug({ count: projects.length, directory }, 'Found Xcode projects'); 168 | return projects; 169 | } 170 | 171 | /** 172 | * Find all Swift packages in a directory 173 | */ 174 | async findPackages(directory: string): Promise<SwiftPackage[]> { 175 | const packages: SwiftPackage[] = []; 176 | 177 | try { 178 | const files = await readdir(directory, { withFileTypes: true }); 179 | 180 | // Check if this directory itself is a package 181 | if (files.some(f => f.name === 'Package.swift')) { 182 | packages.push(new SwiftPackage(directory)); 183 | } 184 | 185 | // Search subdirectories 186 | for (const file of files) { 187 | if (file.isDirectory() && !file.name.startsWith('.')) { 188 | const fullPath = path.join(directory, file.name); 189 | const subPackages = await this.findPackages(fullPath); 190 | packages.push(...subPackages); 191 | } 192 | } 193 | } catch (error: any) { 194 | logger.error({ error: error.message, directory }, 'Failed to find packages'); 195 | throw new Error(`Failed to find packages in ${directory}: ${error.message}`); 196 | } 197 | 198 | logger.debug({ count: packages.length, directory }, 'Found Swift packages'); 199 | return packages; 200 | } 201 | 202 | /** 203 | * Find all projects and packages in a directory 204 | */ 205 | async findAll(directory: string): Promise<(XcodeProject | SwiftPackage)[]> { 206 | const [projects, packages] = await Promise.all([ 207 | this.findProjects(directory), 208 | this.findPackages(directory) 209 | ]); 210 | 211 | const all = [...projects, ...packages]; 212 | logger.debug({ 213 | totalCount: all.length, 214 | projectCount: projects.length, 215 | packageCount: packages.length, 216 | directory 217 | }, 'Found all projects and packages'); 218 | 219 | return all; 220 | } 221 | } 222 | 223 | // Export a default instance for convenience 224 | export const xcode = new Xcode(); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ShutdownSimulatorUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { ShutdownSimulatorUseCase } from '../../use-cases/ShutdownSimulatorUseCase.js'; 3 | import { ShutdownRequest } from '../../domain/ShutdownRequest.js'; 4 | import { DeviceId } from '../../../../shared/domain/DeviceId.js'; 5 | import { ShutdownResult, ShutdownOutcome, SimulatorNotFoundError, ShutdownCommandFailedError } from '../../domain/ShutdownResult.js'; 6 | import { SimulatorState } from '../../domain/SimulatorState.js'; 7 | import { ISimulatorLocator, ISimulatorControl, SimulatorInfo } from '../../../../application/ports/SimulatorPorts.js'; 8 | 9 | describe('ShutdownSimulatorUseCase', () => { 10 | let useCase: ShutdownSimulatorUseCase; 11 | let mockLocator: jest.Mocked<ISimulatorLocator>; 12 | let mockControl: jest.Mocked<ISimulatorControl>; 13 | 14 | beforeEach(() => { 15 | jest.clearAllMocks(); 16 | 17 | mockLocator = { 18 | findSimulator: jest.fn<ISimulatorLocator['findSimulator']>(), 19 | findBootedSimulator: jest.fn<ISimulatorLocator['findBootedSimulator']>() 20 | }; 21 | 22 | mockControl = { 23 | boot: jest.fn<ISimulatorControl['boot']>(), 24 | shutdown: jest.fn<ISimulatorControl['shutdown']>() 25 | }; 26 | 27 | useCase = new ShutdownSimulatorUseCase(mockLocator, mockControl); 28 | }); 29 | 30 | describe('execute', () => { 31 | it('should shutdown a booted simulator', async () => { 32 | // Arrange 33 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 34 | const simulatorInfo: SimulatorInfo = { 35 | id: 'ABC123', 36 | name: 'iPhone 15', 37 | state: SimulatorState.Booted, 38 | platform: 'iOS', 39 | runtime: 'iOS-17.0' 40 | }; 41 | 42 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 43 | mockControl.shutdown.mockResolvedValue(undefined); 44 | 45 | // Act 46 | const result = await useCase.execute(request); 47 | 48 | // Assert 49 | expect(mockLocator.findSimulator).toHaveBeenCalledWith('iPhone-15'); 50 | expect(mockControl.shutdown).toHaveBeenCalledWith('ABC123'); 51 | expect(result.outcome).toBe(ShutdownOutcome.Shutdown); 52 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 53 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 54 | }); 55 | 56 | it('should handle already shutdown simulator', async () => { 57 | // Arrange 58 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 59 | const simulatorInfo: SimulatorInfo = { 60 | id: 'ABC123', 61 | name: 'iPhone 15', 62 | state: SimulatorState.Shutdown, 63 | platform: 'iOS', 64 | runtime: 'iOS-17.0' 65 | }; 66 | 67 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 68 | 69 | // Act 70 | const result = await useCase.execute(request); 71 | 72 | // Assert 73 | expect(mockControl.shutdown).not.toHaveBeenCalled(); 74 | expect(result.outcome).toBe(ShutdownOutcome.AlreadyShutdown); 75 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 76 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 77 | }); 78 | 79 | it('should shutdown a simulator in Booting state', async () => { 80 | // Arrange 81 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 82 | const simulatorInfo: SimulatorInfo = { 83 | id: 'ABC123', 84 | name: 'iPhone 15', 85 | state: SimulatorState.Booting, 86 | platform: 'iOS', 87 | runtime: 'iOS-17.0' 88 | }; 89 | 90 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 91 | mockControl.shutdown.mockResolvedValue(undefined); 92 | 93 | // Act 94 | const result = await useCase.execute(request); 95 | 96 | // Assert 97 | expect(mockControl.shutdown).toHaveBeenCalledWith('ABC123'); 98 | expect(result.outcome).toBe(ShutdownOutcome.Shutdown); 99 | }); 100 | 101 | it('should handle simulator in ShuttingDown state as already shutdown', async () => { 102 | // Arrange 103 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 104 | const simulatorInfo: SimulatorInfo = { 105 | id: 'ABC123', 106 | name: 'iPhone 15', 107 | state: SimulatorState.ShuttingDown, 108 | platform: 'iOS', 109 | runtime: 'iOS-17.0' 110 | }; 111 | 112 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 113 | 114 | // Act 115 | const result = await useCase.execute(request); 116 | 117 | // Assert 118 | expect(mockControl.shutdown).not.toHaveBeenCalled(); 119 | expect(result.outcome).toBe(ShutdownOutcome.AlreadyShutdown); 120 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 121 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 122 | }); 123 | 124 | it('should return failure when simulator not found', async () => { 125 | // Arrange 126 | const request = ShutdownRequest.create(DeviceId.create('non-existent')); 127 | mockLocator.findSimulator.mockResolvedValue(null); 128 | 129 | // Act 130 | const result = await useCase.execute(request); 131 | 132 | // Assert - Test behavior: simulator not found error 133 | expect(mockControl.shutdown).not.toHaveBeenCalled(); 134 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 135 | expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); 136 | expect((result.diagnostics.error as SimulatorNotFoundError).deviceId).toBe('non-existent'); 137 | }); 138 | 139 | it('should return failure on shutdown error', async () => { 140 | // Arrange 141 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 142 | const simulatorInfo: SimulatorInfo = { 143 | id: 'ABC123', 144 | name: 'iPhone 15', 145 | state: SimulatorState.Booted, 146 | platform: 'iOS', 147 | runtime: 'iOS-17.0' 148 | }; 149 | 150 | const shutdownError = new Error('Device is busy'); 151 | (shutdownError as any).stderr = 'Device is busy'; 152 | 153 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 154 | mockControl.shutdown.mockRejectedValue(shutdownError); 155 | 156 | // Act 157 | const result = await useCase.execute(request); 158 | 159 | // Assert 160 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 161 | expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); 162 | expect((result.diagnostics.error as ShutdownCommandFailedError).stderr).toBe('Device is busy'); 163 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 164 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 165 | }); 166 | 167 | it('should handle shutdown error without stderr', async () => { 168 | // Arrange 169 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 170 | const simulatorInfo: SimulatorInfo = { 171 | id: 'ABC123', 172 | name: 'iPhone 15', 173 | state: SimulatorState.Booted, 174 | platform: 'iOS', 175 | runtime: 'iOS-17.0' 176 | }; 177 | 178 | const shutdownError = new Error('Unknown error'); 179 | 180 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 181 | mockControl.shutdown.mockRejectedValue(shutdownError); 182 | 183 | // Act 184 | const result = await useCase.execute(request); 185 | 186 | // Assert 187 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 188 | expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); 189 | expect((result.diagnostics.error as ShutdownCommandFailedError).stderr).toBe('Unknown error'); 190 | }); 191 | 192 | it('should handle shutdown error with empty message', async () => { 193 | // Arrange 194 | const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); 195 | const simulatorInfo: SimulatorInfo = { 196 | id: 'ABC123', 197 | name: 'iPhone 15', 198 | state: SimulatorState.Booted, 199 | platform: 'iOS', 200 | runtime: 'iOS-17.0' 201 | }; 202 | 203 | const shutdownError = {}; 204 | 205 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 206 | mockControl.shutdown.mockRejectedValue(shutdownError); 207 | 208 | // Act 209 | const result = await useCase.execute(request); 210 | 211 | // Assert 212 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 213 | expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); 214 | expect((result.diagnostics.error as ShutdownCommandFailedError).stderr).toBe(''); 215 | }); 216 | 217 | it('should shutdown simulator by UUID', async () => { 218 | // Arrange 219 | const uuid = '550e8400-e29b-41d4-a716-446655440000'; 220 | const request = ShutdownRequest.create(DeviceId.create(uuid)); 221 | const simulatorInfo: SimulatorInfo = { 222 | id: uuid, 223 | name: 'iPhone 15 Pro', 224 | state: SimulatorState.Booted, 225 | platform: 'iOS', 226 | runtime: 'iOS-17.0' 227 | }; 228 | 229 | mockLocator.findSimulator.mockResolvedValue(simulatorInfo); 230 | mockControl.shutdown.mockResolvedValue(undefined); 231 | 232 | // Act 233 | const result = await useCase.execute(request); 234 | 235 | // Assert 236 | expect(mockLocator.findSimulator).toHaveBeenCalledWith(uuid); 237 | expect(mockControl.shutdown).toHaveBeenCalledWith(uuid); 238 | expect(result.outcome).toBe(ShutdownOutcome.Shutdown); 239 | expect(result.diagnostics.simulatorId).toBe(uuid); 240 | }); 241 | }); 242 | }); ```