This is page 3 of 4. Use http://codebase.md/stefan-nitu/mcp-xcode-server?lines=false&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 -------------------------------------------------------------------------------- /docs/ERROR-HANDLING.md: -------------------------------------------------------------------------------- ```markdown # Error Handling and Presentation Patterns ## Overview 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. ## Core Principles ### 1. Separation of Concerns - **Domain Layer**: Creates typed error objects with data only (no formatting) - **Use Cases**: Return domain errors without formatting messages - **Presentation Layer**: Formats errors for user display with consistent styling ### 2. Typed Domain Errors Each domain has its own error types that extend a base error class: ```typescript // Base class for domain-specific errors export abstract class BootError extends Error {} // Specific error types with relevant data export class SimulatorNotFoundError extends BootError { constructor(public readonly deviceId: string) { super(deviceId); // Just store the data, no formatting this.name = 'SimulatorNotFoundError'; } } export class BootCommandFailedError extends BootError { constructor(public readonly stderr: string) { super(stderr); // Just store the stderr output this.name = 'BootCommandFailedError'; } } ``` **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. ### 3. Error Type Checking Use `instanceof` to check error types in the presentation layer: ```typescript if (error instanceof SimulatorNotFoundError) { return `❌ Simulator not found: ${error.deviceId}`; } if (error instanceof BootCommandFailedError) { return `❌ ${ErrorFormatter.format(error)}`; } ``` ## Presentation Patterns ### Visual Indicators (Emojis) All tools use consistent emoji prefixes for different outcomes: - **✅ Success**: Successful operations - **❌ Error**: Failed operations - **⚠️ Warning**: Operations with warnings - **📁 Info**: Additional information (like log paths) Examples: ``` ✅ Successfully booted simulator: iPhone 15 (ABC123) ✅ Build succeeded: MyApp ❌ Simulator not found: iPhone-16 ❌ Build failed ⚠️ Warnings (3): • Deprecated API usage • Unused variable 'x' 📁 Full logs saved to: /path/to/logs ``` ### Error Message Format 1. **Simple Errors**: Direct message with emoji ``` ❌ Simulator not found: iPhone-16 ❌ Unable to boot device ``` 2. **Complex Errors** (builds, tests): Structured format ``` ❌ Build failed: MyApp Platform: iOS Configuration: Debug ❌ Errors (3): • /path/file.swift:10: Cannot find type 'Foo' • /path/file.swift:20: Missing return statement ``` ### ErrorFormatter Usage The `ErrorFormatter` class provides consistent error formatting across all tools: ```typescript import { ErrorFormatter } from '../formatters/ErrorFormatter.js'; // In controller or presenter const message = ErrorFormatter.format(error); return `❌ ${message}`; ``` The ErrorFormatter: - Formats domain validation errors - Formats build issues - Cleans up common error prefixes - Provides fallback for unknown errors ## Implementation Guidelines ### Controllers Controllers should format results consistently: ```typescript private formatResult(result: DomainResult): string { switch (result.outcome) { case Outcome.Success: return `✅ Successfully completed: ${result.name}`; case Outcome.Failed: if (result.error instanceof SpecificError) { return `❌ Specific error: ${result.error.details}`; } return `❌ ${ErrorFormatter.format(result.error)}`; } } ``` ### Use Cases Use cases should NOT format error messages: ```typescript // ❌ BAD: Formatting in use case return Result.failed( `Simulator not found: ${deviceId}` // Don't format here! ); // ✅ GOOD: Return typed error return Result.failed( new SimulatorNotFoundError(deviceId) // Just the error object ); ``` ### Presenters For complex formatting (like build results), use a dedicated presenter: ```typescript export class BuildXcodePresenter { presentError(error: Error): MCPResponse { const message = ErrorFormatter.format(error); return { content: [{ type: 'text', text: `❌ ${message}` }] }; } } ``` ## Testing Error Handling ### Unit Tests Test that controllers format errors correctly: ```typescript it('should handle boot failure', async () => { // Arrange const error = new BootCommandFailedError('Device is locked'); const result = BootResult.failed('123', 'iPhone', error); // Act const response = controller.execute({ deviceId: 'iPhone' }); // Assert - Check for emoji and message expect(response.text).toBe('❌ Device is locked'); }); ``` ### Integration Tests Test behavior, not specific formatting: ```typescript it('should handle simulator not found', async () => { // Act const result = await controller.execute({ deviceId: 'NonExistent' }); // Assert - Test behavior: error message shown expect(result.content[0].text).toContain('❌'); expect(result.content[0].text).toContain('not found'); }); ``` ## Common Error Scenarios ### 1. Resource Not Found ```typescript export class ResourceNotFoundError extends DomainError { constructor(public readonly resourceId: string) { super(resourceId); } } // Presentation `❌ Resource not found: ${error.resourceId}` ``` ### 2. Command Execution Failed ```typescript export class CommandFailedError extends DomainError { constructor(public readonly stderr: string, public readonly exitCode?: number) { super(stderr); } } // Presentation `❌ Command failed: ${error.stderr}` ``` ### 3. Resource Busy/State Conflicts ```typescript export class SimulatorBusyError extends DomainError { constructor(public readonly currentState: string) { super(currentState); // Just store the state, no message } } // Presentation layer formats the message `❌ Cannot boot simulator: currently ${error.currentState.toLowerCase()}` ``` ### 4. Validation Failed Domain value objects handle their own validation with consistent error types: ```typescript // Domain value objects validate themselves export class AppPath { static create(path: unknown): AppPath { // Check for missing field if (path === undefined || path === null) { throw new AppPath.RequiredError(); // "App path is required" } // Check type if (typeof path !== 'string') { throw new AppPath.InvalidTypeError(path); // "App path must be a string" } // Check empty if (path.trim() === '') { throw new AppPath.EmptyError(path); // "App path cannot be empty" } // Check format if (!path.endsWith('.app')) { throw new AppPath.InvalidFormatError(path); // "App path must end with .app" } return new AppPath(path); } } ``` #### Validation Error Hierarchy Each value object follows this consistent validation order: 1. **Required**: `undefined`/`null` → "X is required" 2. **Type**: Wrong type → "X must be a {type}" 3. **Empty**: Empty string → "X cannot be empty" 4. **Format**: Invalid format → Specific format message ```typescript // Consistent error base classes export abstract class DomainRequiredError extends DomainError { constructor(fieldDisplayName: string) { super(`${fieldDisplayName} is required`); } } export abstract class DomainEmptyError extends DomainError { constructor(fieldDisplayName: string) { super(`${fieldDisplayName} cannot be empty`); } } export abstract class DomainInvalidTypeError extends DomainError { constructor(fieldDisplayName: string, expectedType: string) { super(`${fieldDisplayName} must be a ${expectedType}`); } } ``` ## Benefits 1. **Consistency**: Users see consistent error formatting across all tools 2. **Maintainability**: Error formatting logic is centralized 3. **Testability**: Domain logic doesn't depend on presentation 4. **Flexibility**: Easy to change formatting without touching business logic 5. **Type Safety**: TypeScript ensures error types are handled correctly ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestErrorInjector.ts: -------------------------------------------------------------------------------- ```typescript import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; import { createModuleLogger } from '../../../logger'; import { gitResetTestArtifacts, gitResetFile } from './gitResetTestArtifacts'; const logger = createModuleLogger('TestErrorInjector'); /** * Helper class to inject specific error conditions into test projects * for testing error handling and display */ export class TestErrorInjector { private originalFiles: Map<string, string> = new Map(); /** * Inject a compile error into a Swift file */ injectCompileError(filePath: string, errorType: 'type-mismatch' | 'syntax' | 'missing-member' = 'type-mismatch') { this.backupFile(filePath); let content = readFileSync(filePath, 'utf8'); switch (errorType) { case 'type-mismatch': // Add a type mismatch error content = content.replace( 'let newItem = Item(timestamp: Date())', 'let x: String = 123 // Type mismatch error\n let newItem = Item(timestamp: Date())' ); break; case 'syntax': // Add a syntax error content = content.replace( 'import SwiftUI', 'import SwiftUI\nlet incomplete = // Syntax error' ); break; case 'missing-member': // Reference a non-existent property content = content.replace( 'modelContext.insert(newItem)', 'modelContext.insert(newItem)\n let _ = newItem.nonExistentProperty // Missing member error' ); break; } writeFileSync(filePath, content); logger.debug({ filePath, errorType }, 'Injected compile error'); } /** * Inject multiple compile errors into a file */ injectMultipleCompileErrors(filePath: string) { if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } this.backupFile(filePath); let content = readFileSync(filePath, 'utf8'); // Add multiple different types of errors content = content.replace( 'let newItem = Item(timestamp: Date())', `let x: String = 123 // Type mismatch error 1 let y: Int = "hello" // Type mismatch error 2 let z = nonExistentFunction() // Undefined function error let newItem = Item(timestamp: Date())` ); writeFileSync(filePath, content); logger.debug({ filePath }, 'Injected multiple compile errors'); } /** * Remove code signing from a project to trigger signing errors */ injectCodeSigningError(projectPath: string) { const pbxprojPath = join(projectPath, 'project.pbxproj'); if (!existsSync(pbxprojPath)) { throw new Error(`Project file not found: ${pbxprojPath}`); } this.backupFile(pbxprojPath); let content = readFileSync(pbxprojPath, 'utf8'); // Change code signing settings to trigger errors content = content.replace( /CODE_SIGN_STYLE = Automatic;/g, 'CODE_SIGN_STYLE = Manual;' ); content = content.replace( /DEVELOPMENT_TEAM = [A-Z0-9]+;/g, 'DEVELOPMENT_TEAM = "";' ); writeFileSync(pbxprojPath, content); logger.debug({ projectPath }, 'Injected code signing error'); } /** * Inject a provisioning profile error by requiring a non-existent profile */ injectProvisioningError(projectPath: string) { const pbxprojPath = join(projectPath, 'project.pbxproj'); if (!existsSync(pbxprojPath)) { throw new Error(`Project file not found: ${pbxprojPath}`); } this.backupFile(pbxprojPath); let content = readFileSync(pbxprojPath, 'utf8'); // Add a non-existent provisioning profile requirement content = content.replace( /PRODUCT_BUNDLE_IDENTIFIER = /g, 'PROVISIONING_PROFILE_SPECIFIER = "NonExistent Profile";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = ' ); writeFileSync(pbxprojPath, content); logger.debug({ projectPath }, 'Injected provisioning profile error'); } /** * Inject a missing dependency error into a Swift package */ injectMissingDependency(packagePath: string) { const packageSwiftPath = join(packagePath, 'Package.swift'); if (!existsSync(packageSwiftPath)) { throw new Error(`Package.swift not found: ${packageSwiftPath}`); } this.backupFile(packageSwiftPath); let content = readFileSync(packageSwiftPath, 'utf8'); // In Swift Package Manager, the Package initializer arguments must be in order: // name, platforms?, products, dependencies?, targets // We need to insert dependencies after products but before targets // Find the end of products array and beginning of targets // Use [\s\S]*? for non-greedy multiline matching const regex = /(products:\s*\[[\s\S]*?\])(,\s*)(targets:)/; const match = content.match(regex); if (match) { // Insert dependencies between products and targets // Use a non-existent package to trigger dependency resolution error content = content.replace( regex, `$1,\n dependencies: [\n .package(url: "https://github.com/nonexistent-org/nonexistent-package.git", from: "1.0.0")\n ]$2$3` ); } // Now add the dependency to a target so it tries to use it // Find the first target that doesn't have dependencies yet const targetRegex = /\.target\(\s*name:\s*"([^"]+)"\s*\)/; const targetMatch = content.match(targetRegex); if (targetMatch) { const targetName = targetMatch[1]; // Replace the target to add dependencies content = content.replace( targetMatch[0], `.target(\n name: "${targetName}",\n dependencies: [\n .product(name: "NonExistentPackage", package: "nonexistent-package")\n ])` ); // Also add import to a source file to trigger the error at compile time const sourcePath = join(packagePath, 'Sources', targetName); if (existsSync(sourcePath)) { const sourceFiles = require('fs').readdirSync(sourcePath, { recursive: true }) .filter((f: string) => f.endsWith('.swift')); if (sourceFiles.length > 0) { const sourceFile = join(sourcePath, sourceFiles[0]); this.backupFile(sourceFile); let sourceContent = readFileSync(sourceFile, 'utf8'); sourceContent = 'import NonExistentPackage\n' + sourceContent; writeFileSync(sourceFile, sourceContent); } } } writeFileSync(packageSwiftPath, content); logger.debug({ packagePath }, 'Injected missing dependency error'); } /** * Inject a platform compatibility error */ injectPlatformError(projectPath: string) { const pbxprojPath = join(projectPath, 'project.pbxproj'); if (!existsSync(pbxprojPath)) { throw new Error(`Project file not found: ${pbxprojPath}`); } this.backupFile(pbxprojPath); let content = readFileSync(pbxprojPath, 'utf8'); // Set incompatible deployment targets content = content.replace( /IPHONEOS_DEPLOYMENT_TARGET = \d+\.\d+;/g, 'IPHONEOS_DEPLOYMENT_TARGET = 99.0;' // Impossible iOS version ); writeFileSync(pbxprojPath, content); logger.debug({ projectPath }, 'Injected platform compatibility error'); } /** * Backup a file before modifying it */ private backupFile(filePath: string) { if (!this.originalFiles.has(filePath)) { const content = readFileSync(filePath, 'utf8'); this.originalFiles.set(filePath, content); logger.debug({ filePath }, 'Backed up original file'); } } /** * Restore all modified files to their original state */ restoreAll() { // Use git to reset all test_artifacts gitResetTestArtifacts(); // Clear our tracking this.originalFiles.clear(); } /** * Restore a specific file */ restoreFile(filePath: string) { // Use git to reset the specific file gitResetFile(filePath); // Remove from our tracking this.originalFiles.delete(filePath); } } ``` -------------------------------------------------------------------------------- /src/presentation/tests/unit/DefaultErrorStrategy.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { DefaultErrorStrategy } from '../../formatters/strategies/DefaultErrorStrategy.js'; describe('DefaultErrorStrategy', () => { function createSUT(): DefaultErrorStrategy { return new DefaultErrorStrategy(); } describe('canFormat', () => { it('should always return true as the fallback strategy', () => { const sut = createSUT(); expect(sut.canFormat(new Error('Any error'))).toBe(true); expect(sut.canFormat({ message: 'Plain object' })).toBe(true); expect(sut.canFormat('String error')).toBe(true); expect(sut.canFormat(123)).toBe(true); expect(sut.canFormat(null)).toBe(true); expect(sut.canFormat(undefined)).toBe(true); expect(sut.canFormat({})).toBe(true); expect(sut.canFormat([])).toBe(true); }); }); describe('format', () => { describe('when formatting errors with messages', () => { it('should return plain message without modification', () => { const sut = createSUT(); const error = new Error('Simple error message'); const result = sut.format(error); expect(result).toBe('Simple error message'); }); it('should preserve message with special characters', () => { const sut = createSUT(); const error = { message: 'Error: with @#$% special chars!' }; const result = sut.format(error); expect(result).toBe('with @#$% special chars!'); }); it('should handle multiline messages', () => { const sut = createSUT(); const error = new Error('First line\nSecond line\nThird line'); const result = sut.format(error); expect(result).toBe('First line\nSecond line\nThird line'); }); }); describe('when cleaning common prefixes', () => { it('should remove "Error:" prefix (case insensitive)', () => { const sut = createSUT(); expect(sut.format(new Error('Error: Something went wrong'))).toBe('Something went wrong'); expect(sut.format(new Error('error: lowercase prefix'))).toBe('lowercase prefix'); expect(sut.format(new Error('ERROR: uppercase prefix'))).toBe('uppercase prefix'); expect(sut.format(new Error('ErRoR: mixed case'))).toBe('mixed case'); }); it('should remove "Invalid arguments:" prefix (case insensitive)', () => { const sut = createSUT(); expect(sut.format(new Error('Invalid arguments: Missing required field'))).toBe('Missing required field'); expect(sut.format(new Error('invalid arguments: lowercase'))).toBe('lowercase'); expect(sut.format(new Error('INVALID ARGUMENTS: uppercase'))).toBe('uppercase'); }); it('should remove "Validation failed:" prefix (case insensitive)', () => { const sut = createSUT(); expect(sut.format(new Error('Validation failed: Bad input'))).toBe('Bad input'); expect(sut.format(new Error('validation failed: lowercase'))).toBe('lowercase'); expect(sut.format(new Error('VALIDATION FAILED: uppercase'))).toBe('uppercase'); }); it('should handle multiple spaces after prefix', () => { const sut = createSUT(); expect(sut.format(new Error('Error: Multiple spaces'))).toBe('Multiple spaces'); expect(sut.format(new Error('Invalid arguments: Extra spaces'))).toBe('Extra spaces'); }); it('should only remove prefix at start of message', () => { const sut = createSUT(); const error = new Error('Something Error: in the middle'); const result = sut.format(error); expect(result).toBe('Something Error: in the middle'); }); it('should handle messages that are only the prefix', () => { const sut = createSUT(); expect(sut.format(new Error('Error:'))).toBe(''); expect(sut.format(new Error('Error: '))).toBe(''); expect(sut.format(new Error('Invalid arguments:'))).toBe(''); expect(sut.format(new Error('Validation failed:'))).toBe(''); }); it('should clean all matching prefixes', () => { const sut = createSUT(); const error = new Error('Error: Invalid arguments: Something'); const result = sut.format(error); // Both "Error:" and "Invalid arguments:" are cleaned expect(result).toBe('Something'); }); }); describe('when handling errors without messages', () => { it('should return default message for error without message property', () => { const sut = createSUT(); const error = {}; const result = sut.format(error); expect(result).toBe('An error occurred'); }); it('should return default message for null error', () => { const sut = createSUT(); const result = sut.format(null); expect(result).toBe('An error occurred'); }); it('should return default message for undefined error', () => { const sut = createSUT(); const result = sut.format(undefined); expect(result).toBe('An error occurred'); }); it('should return default message for non-object errors', () => { const sut = createSUT(); expect(sut.format('string error')).toBe('An error occurred'); expect(sut.format(123)).toBe('An error occurred'); expect(sut.format(true)).toBe('An error occurred'); expect(sut.format([])).toBe('An error occurred'); }); it('should handle error with null message', () => { const sut = createSUT(); const error = { message: null }; const result = sut.format(error); expect(result).toBe('An error occurred'); }); it('should handle error with undefined message', () => { const sut = createSUT(); const error = { message: undefined }; const result = sut.format(error); expect(result).toBe('An error occurred'); }); it('should handle error with empty string message', () => { const sut = createSUT(); const error = { message: '' }; const result = sut.format(error); expect(result).toBe('An error occurred'); }); it('should handle error with whitespace-only message', () => { const sut = createSUT(); const error = { message: ' ' }; const result = sut.format(error); expect(result).toBe(' '); // Preserves whitespace as it's truthy }); }); describe('when handling edge cases', () => { it('should handle very long messages', () => { const sut = createSUT(); const longMessage = 'A'.repeat(10000); const error = new Error(longMessage); const result = sut.format(error); expect(result).toBe(longMessage); }); it('should preserve unicode and emoji', () => { const sut = createSUT(); const error = new Error('Error: Failed to process 你好 🚫'); const result = sut.format(error); expect(result).toBe('Failed to process 你好 🚫'); }); it('should handle messages with only special characters', () => { const sut = createSUT(); const error = new Error('@#$%^&*()'); const result = sut.format(error); expect(result).toBe('@#$%^&*()'); }); it('should handle error-like objects with toString', () => { const sut = createSUT(); const error = { message: 'Custom error', toString: () => 'ToString output' }; const result = sut.format(error); expect(result).toBe('Custom error'); // Prefers message over toString }); it('should not modify messages without known prefixes', () => { const sut = createSUT(); const messages = [ 'Unknown prefix: Something', 'Warning: Something else', 'Notice: Important info', 'Failed: Operation incomplete' ]; messages.forEach(msg => { const error = new Error(msg); expect(sut.format(error)).toBe(msg); }); }); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/projects/Xcode.ts: -------------------------------------------------------------------------------- ```typescript import { XcodeProject } from './XcodeProject.js'; import { SwiftPackage } from './SwiftPackage.js'; import { XcodeError, XcodeErrorType } from './XcodeErrors.js'; import { createModuleLogger } from '../../logger.js'; import { existsSync } from 'fs'; import { readdir } from 'fs/promises'; import path from 'path'; const logger = createModuleLogger('Xcode'); /** * Xcode project discovery and management. * Provides methods to find and open Xcode projects and Swift packages. */ export class Xcode { /** * Open a project at the specified path. * Automatically detects whether it's an Xcode project or Swift package. * @param projectPath Path to the project * @param expectedType Optional type to expect ('xcode' | 'swift-package' | 'auto') */ async open(projectPath: string, expectedType: 'xcode' | 'swift-package' | 'auto' = 'auto'): Promise<XcodeProject | SwiftPackage> { // If expecting Swift package specifically, only look for Package.swift if (expectedType === 'swift-package') { // Check if it's a Package.swift file directly if (projectPath.endsWith('Package.swift')) { if (!existsSync(projectPath)) { throw new Error(`No Package.swift found at: ${projectPath}`); } const packageDir = path.dirname(projectPath); logger.debug({ packageDir }, 'Opening Swift package from Package.swift'); return new SwiftPackage(packageDir); } // Check if directory contains Package.swift if (existsSync(projectPath)) { const packageSwiftPath = path.join(projectPath, 'Package.swift'); if (existsSync(packageSwiftPath)) { logger.debug({ projectPath }, 'Found Package.swift in directory'); return new SwiftPackage(projectPath); } } throw new Error(`No Package.swift found at: ${projectPath}`); } // If expecting Xcode project specifically, only look for .xcodeproj/.xcworkspace if (expectedType === 'xcode') { // Check if it's an Xcode project or workspace if (projectPath.endsWith('.xcodeproj') || projectPath.endsWith('.xcworkspace')) { if (!existsSync(projectPath)) { throw new Error(`No Xcode project found at: ${projectPath}`); } const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project'; logger.debug({ projectPath, type }, 'Opening Xcode project'); return new XcodeProject(projectPath, type); } // Check directory for Xcode projects if (existsSync(projectPath)) { const files = await readdir(projectPath); const workspace = files.find(f => f.endsWith('.xcworkspace')); if (workspace) { const workspacePath = path.join(projectPath, workspace); logger.debug({ workspacePath }, 'Found workspace in directory'); return new XcodeProject(workspacePath, 'workspace'); } const xcodeproj = files.find(f => f.endsWith('.xcodeproj')); if (xcodeproj) { const xcodeprojPath = path.join(projectPath, xcodeproj); logger.debug({ xcodeprojPath }, 'Found Xcode project in directory'); return new XcodeProject(xcodeprojPath, 'project'); } } throw new Error(`No Xcode project found at: ${projectPath}`); } // Auto mode - original behavior // Check if it's an Xcode project or workspace if (projectPath.endsWith('.xcodeproj') || projectPath.endsWith('.xcworkspace')) { if (!existsSync(projectPath)) { throw new Error(`Xcode project not found at: ${projectPath}`); } const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project'; logger.debug({ projectPath, type }, 'Opening Xcode project'); return new XcodeProject(projectPath, type); } // Check if it's a Swift package (directory containing Package.swift) const packagePath = path.join(projectPath, 'Package.swift'); if (existsSync(packagePath)) { logger.debug({ projectPath }, 'Opening Swift package'); return new SwiftPackage(projectPath); } // If it's a Package.swift file directly if (projectPath.endsWith('Package.swift') && existsSync(projectPath)) { const packageDir = path.dirname(projectPath); logger.debug({ packageDir }, 'Opening Swift package from Package.swift'); return new SwiftPackage(packageDir); } // Try to auto-detect in the directory if (existsSync(projectPath)) { // Look for .xcworkspace first (higher priority) const files = await readdir(projectPath); const workspace = files.find(f => f.endsWith('.xcworkspace')); if (workspace) { const workspacePath = path.join(projectPath, workspace); logger.debug({ workspacePath }, 'Found workspace in directory'); return new XcodeProject(workspacePath, 'workspace'); } const xcodeproj = files.find(f => f.endsWith('.xcodeproj')); if (xcodeproj) { const xcodeprojPath = path.join(projectPath, xcodeproj); logger.debug({ xcodeprojPath }, 'Found Xcode project in directory'); return new XcodeProject(xcodeprojPath, 'project'); } // Check for Package.swift if (files.includes('Package.swift')) { logger.debug({ projectPath }, 'Found Package.swift in directory'); return new SwiftPackage(projectPath); } } throw new XcodeError(XcodeErrorType.ProjectNotFound, projectPath); } /** * Find all Xcode projects in a directory */ async findProjects(directory: string): Promise<XcodeProject[]> { const projects: XcodeProject[] = []; try { const files = await readdir(directory, { withFileTypes: true }); for (const file of files) { const fullPath = path.join(directory, file.name); if (file.name.endsWith('.xcworkspace')) { projects.push(new XcodeProject(fullPath, 'workspace')); } else if (file.name.endsWith('.xcodeproj')) { // Only add if there's no workspace (workspace takes precedence) const workspaceName = file.name.replace('.xcodeproj', '.xcworkspace'); const hasWorkspace = files.some(f => f.name === workspaceName); if (!hasWorkspace) { projects.push(new XcodeProject(fullPath, 'project')); } } else if (file.isDirectory() && !file.name.startsWith('.')) { // Recursively search subdirectories const subProjects = await this.findProjects(fullPath); projects.push(...subProjects); } } } catch (error: any) { logger.error({ error: error.message, directory }, 'Failed to find projects'); throw new Error(`Failed to find projects in ${directory}: ${error.message}`); } logger.debug({ count: projects.length, directory }, 'Found Xcode projects'); return projects; } /** * Find all Swift packages in a directory */ async findPackages(directory: string): Promise<SwiftPackage[]> { const packages: SwiftPackage[] = []; try { const files = await readdir(directory, { withFileTypes: true }); // Check if this directory itself is a package if (files.some(f => f.name === 'Package.swift')) { packages.push(new SwiftPackage(directory)); } // Search subdirectories for (const file of files) { if (file.isDirectory() && !file.name.startsWith('.')) { const fullPath = path.join(directory, file.name); const subPackages = await this.findPackages(fullPath); packages.push(...subPackages); } } } catch (error: any) { logger.error({ error: error.message, directory }, 'Failed to find packages'); throw new Error(`Failed to find packages in ${directory}: ${error.message}`); } logger.debug({ count: packages.length, directory }, 'Found Swift packages'); return packages; } /** * Find all projects and packages in a directory */ async findAll(directory: string): Promise<(XcodeProject | SwiftPackage)[]> { const [projects, packages] = await Promise.all([ this.findProjects(directory), this.findPackages(directory) ]); const all = [...projects, ...packages]; logger.debug({ totalCount: all.length, projectCount: projects.length, packageCount: packages.length, directory }, 'Found all projects and packages'); return all; } } // Export a default instance for convenience export const xcode = new Xcode(); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ShutdownSimulatorUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { ShutdownSimulatorUseCase } from '../../use-cases/ShutdownSimulatorUseCase.js'; import { ShutdownRequest } from '../../domain/ShutdownRequest.js'; import { DeviceId } from '../../../../shared/domain/DeviceId.js'; import { ShutdownResult, ShutdownOutcome, SimulatorNotFoundError, ShutdownCommandFailedError } from '../../domain/ShutdownResult.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; import { ISimulatorLocator, ISimulatorControl, SimulatorInfo } from '../../../../application/ports/SimulatorPorts.js'; describe('ShutdownSimulatorUseCase', () => { let useCase: ShutdownSimulatorUseCase; let mockLocator: jest.Mocked<ISimulatorLocator>; let mockControl: jest.Mocked<ISimulatorControl>; beforeEach(() => { jest.clearAllMocks(); mockLocator = { findSimulator: jest.fn<ISimulatorLocator['findSimulator']>(), findBootedSimulator: jest.fn<ISimulatorLocator['findBootedSimulator']>() }; mockControl = { boot: jest.fn<ISimulatorControl['boot']>(), shutdown: jest.fn<ISimulatorControl['shutdown']>() }; useCase = new ShutdownSimulatorUseCase(mockLocator, mockControl); }); describe('execute', () => { it('should shutdown a booted simulator', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.shutdown.mockResolvedValue(undefined); // Act const result = await useCase.execute(request); // Assert expect(mockLocator.findSimulator).toHaveBeenCalledWith('iPhone-15'); expect(mockControl.shutdown).toHaveBeenCalledWith('ABC123'); expect(result.outcome).toBe(ShutdownOutcome.Shutdown); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); }); it('should handle already shutdown simulator', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Shutdown, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); // Act const result = await useCase.execute(request); // Assert expect(mockControl.shutdown).not.toHaveBeenCalled(); expect(result.outcome).toBe(ShutdownOutcome.AlreadyShutdown); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); }); it('should shutdown a simulator in Booting state', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booting, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.shutdown.mockResolvedValue(undefined); // Act const result = await useCase.execute(request); // Assert expect(mockControl.shutdown).toHaveBeenCalledWith('ABC123'); expect(result.outcome).toBe(ShutdownOutcome.Shutdown); }); it('should handle simulator in ShuttingDown state as already shutdown', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.ShuttingDown, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); // Act const result = await useCase.execute(request); // Assert expect(mockControl.shutdown).not.toHaveBeenCalled(); expect(result.outcome).toBe(ShutdownOutcome.AlreadyShutdown); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); }); it('should return failure when simulator not found', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('non-existent')); mockLocator.findSimulator.mockResolvedValue(null); // Act const result = await useCase.execute(request); // Assert - Test behavior: simulator not found error expect(mockControl.shutdown).not.toHaveBeenCalled(); expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); expect((result.diagnostics.error as SimulatorNotFoundError).deviceId).toBe('non-existent'); }); it('should return failure on shutdown error', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS-17.0' }; const shutdownError = new Error('Device is busy'); (shutdownError as any).stderr = 'Device is busy'; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.shutdown.mockRejectedValue(shutdownError); // Act const result = await useCase.execute(request); // Assert expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); expect((result.diagnostics.error as ShutdownCommandFailedError).stderr).toBe('Device is busy'); expect(result.diagnostics.simulatorId).toBe('ABC123'); expect(result.diagnostics.simulatorName).toBe('iPhone 15'); }); it('should handle shutdown error without stderr', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS-17.0' }; const shutdownError = new Error('Unknown error'); mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.shutdown.mockRejectedValue(shutdownError); // Act const result = await useCase.execute(request); // Assert expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); expect((result.diagnostics.error as ShutdownCommandFailedError).stderr).toBe('Unknown error'); }); it('should handle shutdown error with empty message', async () => { // Arrange const request = ShutdownRequest.create(DeviceId.create('iPhone-15')); const simulatorInfo: SimulatorInfo = { id: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS-17.0' }; const shutdownError = {}; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.shutdown.mockRejectedValue(shutdownError); // Act const result = await useCase.execute(request); // Assert expect(result.outcome).toBe(ShutdownOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); expect((result.diagnostics.error as ShutdownCommandFailedError).stderr).toBe(''); }); it('should shutdown simulator by UUID', async () => { // Arrange const uuid = '550e8400-e29b-41d4-a716-446655440000'; const request = ShutdownRequest.create(DeviceId.create(uuid)); const simulatorInfo: SimulatorInfo = { id: uuid, name: 'iPhone 15 Pro', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS-17.0' }; mockLocator.findSimulator.mockResolvedValue(simulatorInfo); mockControl.shutdown.mockResolvedValue(undefined); // Act const result = await useCase.execute(request); // Assert expect(mockLocator.findSimulator).toHaveBeenCalledWith(uuid); expect(mockControl.shutdown).toHaveBeenCalledWith(uuid); expect(result.outcome).toBe(ShutdownOutcome.Shutdown); expect(result.diagnostics.simulatorId).toBe(uuid); }); }); }); ``` -------------------------------------------------------------------------------- /src/presentation/tests/unit/BuildIssuesStrategy.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { BuildIssuesStrategy } from '../../formatters/strategies/BuildIssuesStrategy.js'; import { BuildIssue } from '../../../features/build/domain/BuildIssue.js'; describe('BuildIssuesStrategy', () => { function createSUT(): BuildIssuesStrategy { return new BuildIssuesStrategy(); } function createErrorWithIssues(issues: BuildIssue[], message?: string) { return { issues, message }; } describe('canFormat', () => { it('should return true for error with BuildIssue array', () => { const sut = createSUT(); const error = createErrorWithIssues([ BuildIssue.error('Test error') ]); const result = sut.canFormat(error); expect(result).toBe(true); }); it('should return true when at least one issue is BuildIssue instance', () => { const sut = createSUT(); const error = { issues: [ BuildIssue.error('Real issue'), { message: 'Not a BuildIssue' } ] }; const result = sut.canFormat(error); expect(result).toBe(true); }); it('should return false for error without issues property', () => { const sut = createSUT(); const error = { message: 'Plain error' }; const result = sut.canFormat(error); expect(result).toBe(false); }); it('should return false when issues is not an array', () => { const sut = createSUT(); const error = { issues: 'not an array' }; const result = sut.canFormat(error); expect(result).toBe(false); }); it('should return false when issues array is empty', () => { const sut = createSUT(); const error = { issues: [] }; const result = sut.canFormat(error); expect(result).toBe(false); }); it('should return false when no issues are BuildIssue instances', () => { const sut = createSUT(); const error = { issues: [ { message: 'Plain object 1' }, { message: 'Plain object 2' } ] }; const result = sut.canFormat(error); expect(result).toBe(false); }); }); describe('format', () => { describe('when formatting errors only', () => { it('should format single error correctly', () => { const sut = createSUT(); const error = createErrorWithIssues([ BuildIssue.error('Cannot find module', 'src/main.ts', 10, 5) ]); const result = sut.format(error); const expected = `❌ Errors (1): • src/main.ts:10:5: Cannot find module`; expect(result).toBe(expected); }); it('should format multiple errors with file information', () => { const sut = createSUT(); const error = createErrorWithIssues([ BuildIssue.error('Cannot find module', 'src/main.ts', 10, 5), BuildIssue.error('Type mismatch', 'src/utils.ts', 20, 3) ]); const result = sut.format(error); const expected = `❌ Errors (2): • src/main.ts:10:5: Cannot find module • src/utils.ts:20:3: Type mismatch`; expect(result).toBe(expected); }); it('should limit to 5 errors and show count for more', () => { const sut = createSUT(); const issues = Array.from({ length: 10 }, (_, i) => BuildIssue.error(`Error ${i + 1}`, `file${i}.ts`) ); const error = createErrorWithIssues(issues); const result = sut.format(error); expect(result).toContain('❌ Errors (10):'); expect(result).toContain('file0.ts: Error 1'); expect(result).toContain('file4.ts: Error 5'); expect(result).not.toContain('file5.ts: Error 6'); expect(result).toContain('... and 5 more errors'); }); it('should handle errors without file information', () => { const sut = createSUT(); const error = createErrorWithIssues([ BuildIssue.error('General build error'), BuildIssue.error('Another error without file') ]); const result = sut.format(error); const expected = `❌ Errors (2): • General build error • Another error without file`; expect(result).toBe(expected); }); }); describe('when formatting warnings only', () => { it('should format single warning correctly', () => { const sut = createSUT(); const error = createErrorWithIssues([ BuildIssue.warning('Deprecated API usage', 'src/legacy.ts', 15) ]); const result = sut.format(error); const expected = `⚠️ Warnings (1): • src/legacy.ts:15: Deprecated API usage`; expect(result).toBe(expected); }); it('should limit to 3 warnings and show count for more', () => { const sut = createSUT(); const issues = Array.from({ length: 6 }, (_, i) => BuildIssue.warning(`Warning ${i + 1}`, `file${i}.ts`) ); const error = createErrorWithIssues(issues); const result = sut.format(error); expect(result).toContain('⚠️ Warnings (6):'); expect(result).toContain('file0.ts: Warning 1'); expect(result).toContain('file2.ts: Warning 3'); expect(result).not.toContain('file3.ts: Warning 4'); expect(result).toContain('... and 3 more warnings'); }); }); describe('when formatting mixed errors and warnings', () => { it('should show both sections separated by blank line', () => { const sut = createSUT(); const error = createErrorWithIssues([ BuildIssue.error('Error message', 'error.ts'), BuildIssue.warning('Warning message', 'warning.ts') ]); const result = sut.format(error); const expected = `❌ Errors (1): • error.ts: Error message ⚠️ Warnings (1): • warning.ts: Warning message`; expect(result).toBe(expected); }); it('should handle many mixed issues correctly', () => { const sut = createSUT(); const issues = [ ...Array.from({ length: 7 }, (_, i) => BuildIssue.error(`Error ${i + 1}`, `error${i}.ts`) ), ...Array.from({ length: 5 }, (_, i) => BuildIssue.warning(`Warning ${i + 1}`, `warn${i}.ts`) ) ]; const error = createErrorWithIssues(issues); const result = sut.format(error); expect(result).toContain('❌ Errors (7):'); expect(result).toContain('... and 2 more errors'); expect(result).toContain('⚠️ Warnings (5):'); expect(result).toContain('... and 2 more warnings'); expect(result.split('\n\n')).toHaveLength(2); // Two sections }); }); describe('when handling edge cases', () => { it('should return fallback message when no issues', () => { const sut = createSUT(); const error = createErrorWithIssues([]); const result = sut.format(error); expect(result).toBe('Build failed'); }); it('should use provided message as fallback when no issues', () => { const sut = createSUT(); const error = createErrorWithIssues([], 'Custom build failure'); const result = sut.format(error); expect(result).toBe('Custom build failure'); }); it('should handle mix of BuildIssue and non-BuildIssue objects', () => { const sut = createSUT(); const error = { issues: [ BuildIssue.error('Real error'), { type: 'error', message: 'Not a BuildIssue' }, // Will be filtered out BuildIssue.warning('Real warning') ] }; const result = sut.format(error); // Only real BuildIssues should be processed expect(result).toContain('❌ Errors (1):'); expect(result).toContain('Real error'); expect(result).toContain('⚠️ Warnings (1):'); expect(result).toContain('Real warning'); }); it('should handle issues with unknown types gracefully', () => { const sut = createSUT(); const issues = [ BuildIssue.error('Error'), BuildIssue.warning('Warning'), Object.assign(BuildIssue.error('Info'), { type: 'info' as any }) // Unknown type ]; const error = createErrorWithIssues(issues); const result = sut.format(error); // Unknown type should be ignored expect(result).toContain('❌ Errors (1):'); expect(result).toContain('⚠️ Warnings (1):'); expect(result).not.toContain('info'); }); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/devices/Devices.ts: -------------------------------------------------------------------------------- ```typescript import { execAsync } from '../../utils.js'; import { SimulatorDevice } from './SimulatorDevice.js'; import { createModuleLogger } from '../../logger.js'; import { Platform } from '../../types.js'; const logger = createModuleLogger('Devices'); /** * Device discovery and management. * Provides methods to find and list simulator devices. * Future-ready for physical device support. */ export class Devices { /** * Find a device by name or UDID */ async find(nameOrId: string): Promise<SimulatorDevice | null> { try { const { stdout } = await execAsync('xcrun simctl list devices --json'); const data = JSON.parse(stdout); // Collect all matching devices with their raw data for sorting const matchingDevices: Array<{device: SimulatorDevice, state: string, isAvailable: boolean}> = []; for (const [runtime, deviceList] of Object.entries(data.devices)) { for (const device of deviceList as any[]) { if (device.udid === nameOrId || device.name === nameOrId) { matchingDevices.push({ device: new SimulatorDevice( device.udid, device.name, this.extractPlatformFromRuntime(runtime), runtime ), state: device.state, isAvailable: device.isAvailable }); } } } if (matchingDevices.length === 0) { logger.debug({ nameOrId }, 'Device not found'); return null; } // Sort to prefer available, booted, and newer devices this.sortDevices(matchingDevices); const selected = matchingDevices[0]; // Warn if selected device is not available if (!selected.isAvailable) { logger.warn({ nameOrId, deviceId: selected.device.id, runtime: selected.device.runtime }, 'Selected device is not available - may fail to boot'); } return selected.device; } catch (error: any) { logger.error({ error: error.message }, 'Failed to find device'); throw new Error(`Failed to find device: ${error.message}`); } } /** * List all available simulators, optionally filtered by platform */ async listSimulators(platform?: Platform): Promise<SimulatorDevice[]> { try { const { stdout } = await execAsync('xcrun simctl list devices --json'); const data = JSON.parse(stdout); const devices: SimulatorDevice[] = []; for (const [runtime, deviceList] of Object.entries(data.devices)) { const extractedPlatform = this.extractPlatformFromRuntime(runtime); // Filter by platform if specified if (platform && !this.matchesPlatform(extractedPlatform, platform)) { continue; } for (const device of deviceList as any[]) { if (device.isAvailable) { devices.push(new SimulatorDevice( device.udid, device.name, extractedPlatform, runtime )); } } } return devices; } catch (error: any) { logger.error({ error: error.message }, 'Failed to list simulators'); throw new Error(`Failed to list simulators: ${error.message}`); } } /** * Get the currently booted simulator, if any */ async getBooted(): Promise<SimulatorDevice | null> { try { const { stdout } = await execAsync('xcrun simctl list devices --json'); const data = JSON.parse(stdout); for (const [runtime, deviceList] of Object.entries(data.devices)) { for (const device of deviceList as any[]) { if (device.state === 'Booted' && device.isAvailable) { return new SimulatorDevice( device.udid, device.name, this.extractPlatformFromRuntime(runtime), runtime ); } } } logger.debug('No booted simulator found'); return null; } catch (error: any) { logger.error({ error: error.message }, 'Failed to get booted device'); throw new Error(`Failed to get booted device: ${error.message}`); } } /** * Find the first available device for a platform */ async findFirstAvailable(platform: Platform): Promise<SimulatorDevice | null> { const devices = await this.listSimulators(platform); // First, look for an already booted device const booted = devices.find((d: SimulatorDevice) => d.isBooted()); if (booted) { logger.debug({ device: booted.name, id: booted.id }, 'Using already booted device'); return booted; } // Otherwise, return the first available device const available = devices[0]; if (available) { logger.debug({ device: available.name, id: available.id }, 'Found available device'); return available; } logger.debug({ platform }, 'No available devices for platform'); return null; } /** * Extract platform from runtime string */ private extractPlatformFromRuntime(runtime: string): string { const runtimeLower = runtime.toLowerCase(); if (runtimeLower.includes('ios')) return 'iOS'; if (runtimeLower.includes('tvos')) return 'tvOS'; if (runtimeLower.includes('watchos')) return 'watchOS'; if (runtimeLower.includes('xros') || runtimeLower.includes('visionos')) return 'visionOS'; // Default fallback return 'iOS'; } /** * Extract version number from runtime string */ private getVersionFromRuntime(runtime: string): number { const match = runtime.match(/(\d+)[.-](\d+)/); return match ? parseFloat(`${match[1]}.${match[2]}`) : 0; } /** * Sort devices preferring: available > booted > newer iOS versions */ private sortDevices(devices: Array<{device: SimulatorDevice, state: string, isAvailable: boolean}>): void { devices.sort((a, b) => { if (a.isAvailable !== b.isAvailable) return a.isAvailable ? -1 : 1; if (a.state === 'Booted' !== (b.state === 'Booted')) return a.state === 'Booted' ? -1 : 1; return this.getVersionFromRuntime(b.device.runtime) - this.getVersionFromRuntime(a.device.runtime); }); } /** * Check if a runtime matches a platform */ private matchesPlatform(extractedPlatform: string, targetPlatform: Platform): boolean { const extractedLower = extractedPlatform.toLowerCase(); const targetLower = targetPlatform.toLowerCase(); // Handle visionOS special case (internally called xrOS) if (targetLower === 'visionos') { return extractedLower === 'visionos' || extractedLower === 'xros'; } return extractedLower === targetLower; } /** * Find an available device for a specific platform * Returns the first available device, preferring already booted ones */ async findForPlatform(platform: Platform): Promise<SimulatorDevice | null> { logger.debug({ platform }, 'Finding device for platform'); try { const devices = await this.listSimulators(); // Filter devices for the requested platform const platformDevices = devices.filter((device: SimulatorDevice) => this.matchesPlatform(this.extractPlatformFromRuntime(device.runtime), platform) ); if (platformDevices.length === 0) { logger.warn({ platform }, 'No devices found for platform'); return null; } // Try to find a booted device first const booted = await this.getBooted(); if (booted && platformDevices.some(d => d.id === booted.id)) { logger.debug({ device: booted.name, id: booted.id }, 'Selected already booted device for platform'); return booted; } // Sort by runtime version (prefer newer) and return the first platformDevices.sort((a, b) => this.getVersionFromRuntime(b.runtime) - this.getVersionFromRuntime(a.runtime) ); const selected = platformDevices[0]; logger.debug({ device: selected.name, id: selected.id }, 'Selected device for platform'); return selected; } catch (error: any) { logger.error({ error: error.message, platform }, 'Failed to find device for platform'); throw new Error(`Failed to find device for platform ${platform}: ${error.message}`); } } /** * Future: List physical devices connected to the system * Currently returns empty array as physical device support is not yet implemented */ async listPhysical(): Promise<any[]> { // Future implementation for physical devices // Would use xcrun devicectl or ios-deploy return []; } } // Export a default instance for convenience export const devices = new Devices(); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/e2e/InstallAppController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript /** * E2E Test for InstallAppController * * Tests CRITICAL USER PATH with REAL simulators: * - Can the controller actually install apps on real simulators? * - Does it properly validate inputs through Clean Architecture layers? * - Does error handling work with real simulator failures? * * NO MOCKS - uses real simulators and real test apps * This is an E2E test (10% of test suite) for critical user journeys * * NOTE: This test requires Xcode and iOS simulators to be installed * It may be skipped in CI environments without proper setup */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { InstallAppControllerFactory } from '../../factories/InstallAppControllerFactory.js'; import { TestProjectManager } from '../../../../shared/tests/utils/TestProjectManager.js'; import { exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs'; import { SimulatorState } from '../../../simulator/domain/SimulatorState.js'; import { bootAndWaitForSimulator } from '../../../../shared/tests/utils/testHelpers.js'; const execAsync = promisify(exec); describe('InstallAppController E2E', () => { let controller: MCPController; let testManager: TestProjectManager; let testDeviceId: string; let testAppPath: string; beforeAll(async () => { // Set up test project with built app testManager = new TestProjectManager(); await testManager.setup(); // Build the test app using TestProjectManager testAppPath = await testManager.buildApp('xcodeProject'); // Get the latest iOS runtime const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); const runtimes = JSON.parse(runtimesResult.stdout); const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); if (!iosRuntime) { throw new Error('No iOS runtime found. Please install an iOS simulator runtime.'); } // Create and boot a test simulator const createResult = await execAsync( `xcrun simctl create "TestSimulator-InstallApp" "iPhone 15" "${iosRuntime.identifier}"` ); testDeviceId = createResult.stdout.trim(); // Boot the simulator and wait for it to be ready await bootAndWaitForSimulator(testDeviceId, 30); }); afterAll(async () => { // Clean up simulator if (testDeviceId) { try { await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); await execAsync(`xcrun simctl delete "${testDeviceId}"`); } catch (error) { // Ignore cleanup errors } } // Clean up test project await testManager.cleanup(); }); beforeEach(() => { // Create controller with all real dependencies controller = InstallAppControllerFactory.create(); }); describe('install real apps on simulators', () => { it('should successfully install app on booted simulator', async () => { // Arrange - simulator is already booted from beforeAll // Act const result = await controller.execute({ appPath: testAppPath, simulatorId: testDeviceId }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); // Verify app is actually installed const listAppsResult = await execAsync( `xcrun simctl listapps "${testDeviceId}" | grep -i test || true` ); expect(listAppsResult.stdout).toBeTruthy(); }); it('should install app on booted simulator when no ID specified', async () => { // Arrange - ensure our test simulator is the only booted one const devicesResult = await execAsync('xcrun simctl list devices --json'); const devices = JSON.parse(devicesResult.stdout); // Shutdown all other booted simulators interface Device { state: string; udid: string; } for (const runtime of Object.values(devices.devices) as Device[][]) { for (const device of runtime) { if (device.state === SimulatorState.Booted && device.udid !== testDeviceId) { await execAsync(`xcrun simctl shutdown "${device.udid}"`); } } } // Act - install without specifying simulator ID const result = await controller.execute({ appPath: testAppPath }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); }); it('should boot and install when simulator is shutdown', async () => { // Arrange - get iOS runtime for creating simulator const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); const runtimes = JSON.parse(runtimesResult.stdout); const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); // Create a new shutdown simulator const createResult = await execAsync( `xcrun simctl create "TestSimulator-Shutdown" "iPhone 14" "${iosRuntime.identifier}"` ); const shutdownSimId = createResult.stdout.trim(); try { // Act const result = await controller.execute({ appPath: testAppPath, simulatorId: shutdownSimId }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); // Verify simulator was booted const stateResult = await execAsync( `xcrun simctl list devices --json | jq -r '.devices[][] | select(.udid=="${shutdownSimId}") | .state'` ); expect(stateResult.stdout.trim()).toBe(SimulatorState.Booted); } finally { // Clean up await execAsync(`xcrun simctl shutdown "${shutdownSimId}" || true`); await execAsync(`xcrun simctl delete "${shutdownSimId}"`); } }, 300000); }); describe('error handling with real simulators', () => { it('should fail when app path does not exist', async () => { // Arrange const nonExistentPath = '/path/that/does/not/exist.app'; // Act const result = await controller.execute({ appPath: nonExistentPath, simulatorId: testDeviceId }); // Assert - error message from xcrun simctl install (multi-line in real E2E) expect(result.content[0].text).toContain('❌'); expect(result.content[0].text).toContain('No such file or directory'); }); it('should fail when app path is not an app bundle', async () => { // Arrange - use a regular file instead of .app const invalidAppPath = testManager.paths.xcodeProjectXCTestPath; // Act const result = await controller.execute({ appPath: invalidAppPath, simulatorId: testDeviceId }); // Assert - validation error formatted with ❌ expect(result.content[0].text).toBe('❌ App path must end with .app'); }); it('should fail when simulator does not exist', async () => { // Arrange const nonExistentSimulator = 'non-existent-simulator-id'; // Act const result = await controller.execute({ appPath: testAppPath, simulatorId: nonExistentSimulator }); // Assert expect(result.content[0].text).toBe('❌ Simulator not found: non-existent-simulator-id'); }); it('should fail when no booted simulator and no ID specified', async () => { // Arrange - shutdown all simulators await execAsync('xcrun simctl shutdown all'); try { // Act const result = await controller.execute({ appPath: testAppPath }); // Assert expect(result.content[0].text).toBe('❌ No booted simulator found. Please boot a simulator first or specify a simulator ID.'); } finally { // Re-boot our test simulator for other tests await execAsync(`xcrun simctl boot "${testDeviceId}"`); await new Promise(resolve => setTimeout(resolve, 3000)); } }); }); describe('simulator name handling', () => { it('should handle simulator specified by name', async () => { // Act - use simulator name instead of UUID const result = await controller.execute({ appPath: testAppPath, simulatorId: 'TestSimulator-InstallApp' }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); }); }); }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/mockHelpers.ts: -------------------------------------------------------------------------------- ```typescript /** * Mock helpers for unit testing * Provides utilities to mock subprocess execution, filesystem operations, and MCP interactions */ import { jest } from '@jest/globals'; import type { ExecSyncOptions } from 'child_process'; /** * Mock response builder for subprocess commands */ export class SubprocessMock { private responses = new Map<string, { stdout?: string; stderr?: string; error?: Error }>(); /** * Register a mock response for a command pattern */ mockCommand(pattern: string | RegExp, response: { stdout?: string; stderr?: string; error?: Error }) { const key = pattern instanceof RegExp ? pattern.source : pattern; this.responses.set(key, response); } /** * Get mock implementation for execSync */ getExecSyncMock() { return jest.fn((command: string, options?: ExecSyncOptions) => { // Find matching response for (const [pattern, response] of this.responses) { const regex = new RegExp(pattern); if (regex.test(command)) { if (response.error) { throw response.error; } return response.stdout || ''; } } throw new Error(`No mock defined for command: ${command}`); }); } /** * Get mock implementation for spawn */ getSpawnMock() { return jest.fn((command: string, args: string[], options?: any) => { const fullCommand = `${command} ${args.join(' ')}`; // Find matching response for (const [pattern, response] of this.responses) { const regex = new RegExp(pattern); if (regex.test(fullCommand)) { return { stdout: { on: jest.fn((event: string, cb: Function) => { if (event === 'data' && response.stdout) { cb(Buffer.from(response.stdout)); } }) }, stderr: { on: jest.fn((event: string, cb: Function) => { if (event === 'data' && response.stderr) { cb(Buffer.from(response.stderr)); } }) }, on: jest.fn((event: string, cb: Function) => { if (event === 'close') { cb(response.error ? 1 : 0); } if (event === 'error' && response.error) { cb(response.error); } }), kill: jest.fn() }; } } throw new Error(`No mock defined for command: ${fullCommand}`); }); } /** * Clear all mocked responses */ clear() { this.responses.clear(); } } /** * Mock filesystem operations */ export class FilesystemMock { private files = new Map<string, string | Buffer>(); private directories = new Set<string>(); /** * Mock a file with content */ mockFile(path: string, content: string | Buffer) { this.files.set(path, content); // Also add parent directories const parts = path.split('/'); for (let i = 1; i < parts.length; i++) { this.directories.add(parts.slice(0, i).join('/')); } } /** * Mock a directory */ mockDirectory(path: string) { this.directories.add(path); } /** * Get mock for existsSync */ getExistsSyncMock() { return jest.fn((path: string) => { return this.files.has(path) || this.directories.has(path); }); } /** * Get mock for readFileSync */ getReadFileSyncMock() { return jest.fn((path: string, encoding?: BufferEncoding) => { if (!this.files.has(path)) { const error: any = new Error(`ENOENT: no such file or directory, open '${path}'`); error.code = 'ENOENT'; throw error; } const content = this.files.get(path)!; return encoding && content instanceof Buffer ? content.toString(encoding) : content; }); } /** * Get mock for readdirSync */ getReaddirSyncMock() { return jest.fn((path: string) => { if (!this.directories.has(path)) { const error: any = new Error(`ENOENT: no such file or directory, scandir '${path}'`); error.code = 'ENOENT'; throw error; } // Return files and subdirectories in this directory const items = new Set<string>(); const pathWithSlash = path.endsWith('/') ? path : `${path}/`; for (const file of this.files.keys()) { if (file.startsWith(pathWithSlash)) { const relative = file.slice(pathWithSlash.length); const firstPart = relative.split('/')[0]; items.add(firstPart); } } for (const dir of this.directories) { if (dir.startsWith(pathWithSlash) && dir !== path) { const relative = dir.slice(pathWithSlash.length); const firstPart = relative.split('/')[0]; items.add(firstPart); } } return Array.from(items); }); } /** * Clear all mocked files and directories */ clear() { this.files.clear(); this.directories.clear(); } } /** * Common mock responses for Xcode/simulator commands */ export const commonMockResponses = { /** * Mock successful xcodebuild */ xcodebuildSuccess: (scheme: string = 'TestApp') => ({ stdout: `Build succeeded\nScheme: ${scheme}\n** BUILD SUCCEEDED **`, stderr: '' }), /** * Mock xcodebuild failure */ xcodebuildFailure: (error: string = 'Build failed') => ({ stdout: '', stderr: `error: ${error}\n** BUILD FAILED **`, error: new Error(`Command failed: xcodebuild\n${error}`) }), /** * Mock scheme not found error */ schemeNotFound: (scheme: string) => ({ stdout: '', stderr: `xcodebuild: error: The project does not contain a scheme named "${scheme}".`, error: new Error(`xcodebuild: error: The project does not contain a scheme named "${scheme}".`) }), /** * Mock simulator list */ simulatorList: (devices: Array<{ name: string; udid: string; state: string }> = []) => ({ stdout: JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': devices.map(d => ({ ...d, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' })) } }), stderr: '' }), /** * Mock simulator boot success */ simulatorBootSuccess: (deviceId: string) => ({ stdout: `Device ${deviceId} booted successfully`, stderr: '' }), /** * Mock simulator already booted */ simulatorAlreadyBooted: (deviceId: string) => ({ stdout: '', stderr: `Device ${deviceId} is already booted`, error: new Error(`Device ${deviceId} is already booted`) }), /** * Mock app installation success */ appInstallSuccess: (appPath: string, deviceId: string) => ({ stdout: `Successfully installed ${appPath} on ${deviceId}`, stderr: '' }), /** * Mock list schemes */ schemesList: (schemes: string[] = ['TestApp', 'TestAppTests']) => ({ stdout: JSON.stringify({ project: { schemes: schemes } }), stderr: '' }), /** * Mock swift build success */ swiftBuildSuccess: () => ({ stdout: 'Building for debugging...\nBuild complete!', stderr: '' }), /** * Mock swift test success */ swiftTestSuccess: (passed: number = 10, failed: number = 0) => ({ stdout: `Test Suite 'All tests' passed at 2024-01-01\nExecuted ${passed + failed} tests, with ${failed} failures`, stderr: '' }) }; /** * Create a mock MCP client for testing */ export function createMockMCPClient() { return { request: jest.fn(), notify: jest.fn(), close: jest.fn(), on: jest.fn(), off: jest.fn() }; } /** * Helper to setup common mocks for a test */ export function setupCommonMocks() { const subprocess = new SubprocessMock(); const filesystem = new FilesystemMock(); // Mock child_process jest.mock('child_process', () => ({ execSync: subprocess.getExecSyncMock(), spawn: subprocess.getSpawnMock() })); // Mock fs jest.mock('fs', () => ({ existsSync: filesystem.getExistsSyncMock(), readFileSync: filesystem.getReadFileSyncMock(), readdirSync: filesystem.getReaddirSyncMock() })); return { subprocess, filesystem }; } /** * Helper to create a mock Xcode instance */ export function createMockXcode() { return { open: jest.fn().mockReturnValue({ buildWithConfiguration: jest.fn<() => Promise<any>>().mockResolvedValue({ success: true, stdout: 'Build succeeded', stderr: '' }), test: jest.fn<() => Promise<any>>().mockResolvedValue({ success: true, stdout: 'Test succeeded', stderr: '' }), run: jest.fn<() => Promise<any>>().mockResolvedValue({ success: true, stdout: 'Run succeeded', stderr: '' }), clean: jest.fn<() => Promise<any>>().mockResolvedValue({ success: true, stdout: 'Clean succeeded', stderr: '' }), archive: jest.fn<() => Promise<any>>().mockResolvedValue({ success: true, stdout: 'Archive succeeded', stderr: '' }) }) }; } ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestProjectManager.ts: -------------------------------------------------------------------------------- ```typescript import { existsSync, rmSync, readdirSync, statSync } from 'fs'; import { join, resolve } from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import { createModuleLogger } from '../../../logger'; import { config } from '../../../config'; import { TestEnvironmentCleaner } from './TestEnvironmentCleaner'; import { gitResetTestArtifacts } from './gitResetTestArtifacts'; const execAsync = promisify(exec); const logger = createModuleLogger('TestProjectManager'); export class TestProjectManager { private testArtifactsDir: string; private xcodeProjectPath: string; private xcodeProjectSwiftTestingPath: string; private swiftPackageXCTestPath: string; private swiftPackageSwiftTestingPath: string; private workspacePath: string; private watchOSProjectPath: string; constructor() { // Use the actual test artifacts directory this.testArtifactsDir = resolve(process.cwd(), 'test_artifacts'); // Set up paths to real test projects // Xcode projects this.xcodeProjectPath = join(this.testArtifactsDir, 'TestProjectXCTest', 'TestProjectXCTest.xcodeproj'); this.xcodeProjectSwiftTestingPath = join(this.testArtifactsDir, 'TestProjectSwiftTesting', 'TestProjectSwiftTesting.xcodeproj'); // Swift packages this.swiftPackageXCTestPath = join(this.testArtifactsDir, 'TestSwiftPackageXCTest'); this.swiftPackageSwiftTestingPath = join(this.testArtifactsDir, 'TestSwiftPackageSwiftTesting'); // Workspace and other projects this.workspacePath = join(this.testArtifactsDir, 'Test.xcworkspace'); this.watchOSProjectPath = join(this.testArtifactsDir, 'TestProjectWatchOS', 'TestProjectWatchOS.xcodeproj'); } get paths() { return { testProjectDir: this.testArtifactsDir, // Xcode projects xcodeProjectXCTestDir: join(this.testArtifactsDir, 'TestProjectXCTest'), xcodeProjectXCTestPath: this.xcodeProjectPath, xcodeProjectSwiftTestingDir: join(this.testArtifactsDir, 'TestProjectSwiftTesting'), xcodeProjectSwiftTestingPath: this.xcodeProjectSwiftTestingPath, // Swift packages swiftPackageXCTestDir: this.swiftPackageXCTestPath, // Default to XCTest for backward compat swiftPackageSwiftTestingDir: this.swiftPackageSwiftTestingPath, // Other workspaceDir: this.testArtifactsDir, derivedDataPath: join(this.testArtifactsDir, 'DerivedData'), workspacePath: this.workspacePath, watchOSProjectPath: this.watchOSProjectPath, watchOSProjectDir: join(this.testArtifactsDir, 'TestProjectWatchOS') }; } get schemes() { return { xcodeProject: 'TestProjectXCTest', xcodeProjectSwiftTesting: 'TestProjectSwiftTesting', workspace: 'TestProjectXCTest', // The workspace uses the same scheme swiftPackageXCTest: 'TestSwiftPackageXCTest', swiftPackageSwiftTesting: 'TestSwiftPackageSwiftTesting', watchOSProject: 'TestProjectWatchOS Watch App' // The watchOS app scheme }; } get targets() { return { xcodeProject: { app: 'TestProjectXCTest', unitTests: 'TestProjectXCTestTests', uiTests: 'TestProjectXCTestUITests' }, xcodeProjectSwiftTesting: { app: 'TestProjectSwiftTesting', unitTests: 'TestProjectSwiftTestingTests', uiTests: 'TestProjectSwiftTestingUITests' }, watchOSProject: { app: 'TestProjectWatchOS Watch App', tests: 'TestProjectWatchOS Watch AppTests' } }; } async setup() { // Clean up any leftover build artifacts before starting this.cleanBuildArtifacts(); } /** * Build a test app for simulator testing * Uses optimized settings to avoid hanging on code signing or large output * @param projectType Which test project to build (defaults to 'xcodeProject') * @returns Path to the built .app bundle */ async buildApp(projectType: 'xcodeProject' | 'xcodeProjectSwiftTesting' | 'watchOSProject' = 'xcodeProject'): Promise<string> { let projectPath: string; let scheme: string; switch (projectType) { case 'xcodeProject': projectPath = this.xcodeProjectPath; scheme = this.schemes.xcodeProject; break; case 'xcodeProjectSwiftTesting': projectPath = this.xcodeProjectSwiftTestingPath; scheme = this.schemes.xcodeProjectSwiftTesting; break; case 'watchOSProject': projectPath = this.watchOSProjectPath; scheme = this.schemes.watchOSProject; break; } // Build with optimized settings for testing // Use generic/platform but with ONLY_ACTIVE_ARCH to build for current architecture only await execAsync( `xcodebuild -project "${projectPath}" ` + `-scheme "${scheme}" ` + `-configuration Debug ` + `-destination 'generic/platform=iOS Simulator' ` + `-derivedDataPath "${this.paths.derivedDataPath}" ` + `ONLY_ACTIVE_ARCH=YES ` + `CODE_SIGNING_ALLOWED=NO ` + `CODE_SIGNING_REQUIRED=NO ` + `build`, { maxBuffer: 50 * 1024 * 1024 } ); // Find the built app const findResult = await execAsync( `find "${this.paths.derivedDataPath}" -name "*.app" -type d | head -1` ); const appPath = findResult.stdout.trim(); if (!appPath || !existsSync(appPath)) { throw new Error('Failed to find built app'); } return appPath; } private cleanBuildArtifacts() { // Clean DerivedData TestEnvironmentCleaner.cleanupTestEnvironment() // Clean .build directories (for SPM) const buildDirs = [ join(this.swiftPackageXCTestPath, '.build'), join(this.swiftPackageSwiftTestingPath, '.build'), join(this.testArtifactsDir, '.build') ]; buildDirs.forEach(dir => { if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); } }); // Clean xcresult bundles (test results) this.cleanTestResults(); // Clean any .swiftpm directories const swiftpmDirs = this.findDirectories(this.testArtifactsDir, '.swiftpm'); swiftpmDirs.forEach(dir => { rmSync(dir, { recursive: true, force: true }); }); // Clean build folders in Xcode projects const xcodeProjects = [ join(this.testArtifactsDir, 'TestProjectXCTest'), join(this.testArtifactsDir, 'TestProjectSwiftTesting'), join(this.testArtifactsDir, 'TestProjectWatchOS') ]; xcodeProjects.forEach(projectDir => { const buildDir = join(projectDir, 'build'); if (existsSync(buildDir)) { rmSync(buildDir, { recursive: true, force: true }); } }); } cleanTestResults() { // Find and remove all .xcresult bundles const xcresultFiles = this.findFiles(this.testArtifactsDir, '.xcresult'); xcresultFiles.forEach(file => { rmSync(file, { recursive: true, force: true }); }); // Clean test output files const testOutputFiles = [ join(this.swiftPackageXCTestPath, 'test-output.txt'), join(this.swiftPackageSwiftTestingPath, 'test-output.txt'), join(this.testArtifactsDir, 'test-results.json') ]; testOutputFiles.forEach(file => { if (existsSync(file)) { rmSync(file, { force: true }); } }); } cleanup() { // Use git to restore test_artifacts to pristine state gitResetTestArtifacts(); // ALWAYS clean build artifacts including MCP-Xcode DerivedData this.cleanBuildArtifacts(); // Also clean DerivedData in project root const projectDerivedData = join(process.cwd(), 'DerivedData'); if (existsSync(projectDerivedData)) { rmSync(projectDerivedData, { recursive: true, force: true }); } } private findFiles(dir: string, extension: string): string[] { const results: string[] = []; if (!existsSync(dir)) { return results; } try { const files = readdirSync(dir); for (const file of files) { const fullPath = join(dir, file); const stat = statSync(fullPath); if (stat.isDirectory()) { // Skip hidden directories and node_modules if (!file.startsWith('.') && file !== 'node_modules') { results.push(...this.findFiles(fullPath, extension)); } } else if (file.endsWith(extension)) { results.push(fullPath); } } } catch (error) { logger.error({ error, dir }, 'Error scanning directory'); } return results; } private findDirectories(dir: string, name: string): string[] { const results: string[] = []; if (!existsSync(dir)) { return results; } try { const files = readdirSync(dir); for (const file of files) { const fullPath = join(dir, file); const stat = statSync(fullPath); if (stat.isDirectory()) { if (file === name) { results.push(fullPath); } else if (!file.startsWith('.') && file !== 'node_modules') { // Recursively search subdirectories results.push(...this.findDirectories(fullPath, name)); } } } } catch (error) { logger.error({ error, dir }, 'Error scanning directory'); } return results; } } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/SimulatorLocatorAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { SimulatorLocatorAdapter } from '../../infrastructure/SimulatorLocatorAdapter.js'; import { ICommandExecutor } from '../../../../application/ports/CommandPorts.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; describe('SimulatorLocatorAdapter', () => { beforeEach(() => { jest.clearAllMocks(); }); function createSUT() { const mockExecute = jest.fn<ICommandExecutor['execute']>(); const mockExecutor: ICommandExecutor = { execute: mockExecute }; const sut = new SimulatorLocatorAdapter(mockExecutor); return { sut, mockExecute }; } function createDeviceListOutput(devices: any = {}) { return JSON.stringify({ devices }); } describe('findSimulator', () => { describe('finding by UUID', () => { it('should find simulator by exact UUID match', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC-123-EXACT', name: 'iPhone 15', state: 'Shutdown', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('ABC-123-EXACT'); // Assert expect(result).toEqual({ id: 'ABC-123-EXACT', name: 'iPhone 15', state: SimulatorState.Shutdown, platform: 'iOS', runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-17-0' }); }); }); describe('finding by name with multiple matches', () => { it('should prefer booted device when multiple devices have same name', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [{ udid: 'OLD-123', name: 'iPhone 15 Pro', state: 'Shutdown', isAvailable: true }], 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'NEW-456', name: 'iPhone 15 Pro', state: 'Booted', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('iPhone 15 Pro'); // Assert expect(result?.id).toBe('NEW-456'); // Should pick booted one }); it('should prefer newer runtime when multiple shutdown devices have same name', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-16-4': [{ udid: 'OLD-123', name: 'iPhone 14', state: 'Shutdown', isAvailable: true }], 'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [{ udid: 'NEW-456', name: 'iPhone 14', state: 'Shutdown', isAvailable: true }], 'com.apple.CoreSimulator.SimRuntime.iOS-15-0': [{ udid: 'OLDER-789', name: 'iPhone 14', state: 'Shutdown', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('iPhone 14'); // Assert expect(result?.id).toBe('NEW-456'); // Should pick iOS 17.2 expect(result?.runtime).toContain('iOS-17-2'); }); }); describe('availability handling', () => { it('should skip unavailable devices', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'UNAVAIL-123', name: 'iPhone 15', state: 'Shutdown', isAvailable: false }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('iPhone 15'); // Assert expect(result).toBeNull(); }); }); describe('platform extraction', () => { it('should correctly identify iOS platform', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'IOS-123', name: 'iPhone 15', state: 'Shutdown', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('IOS-123'); // Assert expect(result?.platform).toBe('iOS'); }); it('should correctly identify tvOS platform', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [{ udid: 'TV-123', name: 'Apple TV', state: 'Shutdown', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('TV-123'); // Assert expect(result?.platform).toBe('tvOS'); }); it('should correctly identify visionOS platform', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0': [{ udid: 'VISION-123', name: 'Apple Vision Pro', state: 'Shutdown', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findSimulator('VISION-123'); // Assert expect(result?.platform).toBe('visionOS'); }); }); }); describe('findBootedSimulator', () => { describe('with single booted simulator', () => { it('should return the booted simulator', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'SHUT-123', name: 'iPhone 15', state: 'Shutdown', isAvailable: true }, { udid: 'BOOT-456', name: 'iPhone 14', state: 'Booted', isAvailable: true } ] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findBootedSimulator(); // Assert expect(result).toEqual({ id: 'BOOT-456', name: 'iPhone 14', state: SimulatorState.Booted, platform: 'iOS', runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-17-0' }); }); }); describe('with multiple booted simulators', () => { it('should throw error indicating multiple booted simulators', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'BOOT-1', name: 'iPhone 15', state: 'Booted', isAvailable: true }, { udid: 'BOOT-2', name: 'iPhone 14', state: 'Booted', isAvailable: true } ] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act & Assert await expect(sut.findBootedSimulator()) .rejects.toThrow('Multiple booted simulators found (2). Please specify a simulator ID.'); }); }); describe('with no booted simulators', () => { it('should return null', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'SHUT-123', name: 'iPhone 15', state: 'Shutdown', isAvailable: true }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findBootedSimulator(); // Assert expect(result).toBeNull(); }); }); describe('with unavailable booted device', () => { it('should skip unavailable devices even if booted', async () => { // Arrange const { sut, mockExecute } = createSUT(); const deviceList = { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'BOOT-123', name: 'iPhone 15', state: 'Booted', isAvailable: false }] }; mockExecute.mockResolvedValue({ stdout: createDeviceListOutput(deviceList), stderr: '', exitCode: 0 }); // Act const result = await sut.findBootedSimulator(); // Assert expect(result).toBeNull(); }); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/integration/BootSimulatorController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { BootSimulatorControllerFactory } from '../../factories/BootSimulatorControllerFactory.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; import { exec } from 'child_process'; import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; // Mock ONLY external boundaries jest.mock('child_process'); // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) jest.mock('util', () => { const actualUtil = jest.requireActual('util') as typeof import('util'); const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); return { ...actualUtil, promisify: (fn: Function) => fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) }; }); // Mock DependencyChecker to always report dependencies are available in tests jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ DependencyChecker: jest.fn().mockImplementation(() => ({ check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies })) })); const mockExec = exec as jest.MockedFunction<typeof exec>; /** * Integration tests for BootSimulatorController * * Tests the integration between: * - Controller → Use Case → Adapters * - Input validation → Domain logic → Output formatting * * Mocks only external boundaries (shell commands) * Tests behavior, not implementation details */ describe('BootSimulatorController Integration', () => { let controller: MCPController; let execCallIndex: number; let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; beforeEach(() => { jest.clearAllMocks(); execCallIndex = 0; execMockResponses = []; // Setup exec mock to return responses sequentially mockExec.mockImplementation((( _cmd: string, _options: any, callback: (error: Error | null, stdout: string, stderr: string) => void ) => { const response = execMockResponses[execCallIndex++] || { stdout: '', stderr: '' }; if (response.error) { callback(response.error, response.stdout, response.stderr); } else { callback(null, response.stdout, response.stderr); } }) as any); // Create controller with REAL components using factory controller = BootSimulatorControllerFactory.create(); }); describe('boot simulator workflow', () => { it('should boot a shutdown simulator', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: '' } // boot command succeeds ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: simulator was successfully booted expect(result.content[0].text).toBe('✅ Successfully booted simulator: iPhone 15 (ABC123)'); }); it('should handle already booted simulator', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' } // list devices - already booted ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: reports simulator is already running expect(result.content[0].text).toBe('✅ Simulator already booted: iPhone 15 (ABC123)'); }); it('should boot simulator by UUID', async () => { // Arrange const uuid = '550e8400-e29b-41d4-a716-446655440000'; const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: uuid, name: 'iPhone 15 Pro', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: '' } // boot command succeeds ]; // Act const result = await controller.execute({ deviceId: uuid }); // Assert - Test behavior: simulator was booted using UUID expect(result.content[0].text).toBe(`✅ Successfully booted simulator: iPhone 15 Pro (${uuid})`); }); }); describe('error handling', () => { it('should handle simulator not found', async () => { // Arrange execMockResponses = [ { stdout: JSON.stringify({ devices: {} }), stderr: '' } // empty device list ]; // Act const result = await controller.execute({ deviceId: 'NonExistent' }); // Assert - Test behavior: appropriate error message shown expect(result.content[0].text).toBe('❌ Simulator not found: NonExistent'); }); it('should handle boot command failure', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; const bootError: NodeExecError = new Error('Command failed') as NodeExecError; bootError.code = 1; bootError.stdout = ''; bootError.stderr = 'Unable to boot device'; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: 'Unable to boot device', error: bootError } // boot fails ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: error message includes context for found simulator expect(result.content[0].text).toBe('❌ iPhone 15 (ABC123) - Unable to boot device'); }); }); describe('validation', () => { it('should validate required deviceId', async () => { // Act const result = await controller.execute({} as any); // Assert expect(result.content[0].text).toBe('❌ Device ID is required'); }); it('should validate empty deviceId', async () => { // Act const result = await controller.execute({ deviceId: '' }); // Assert expect(result.content[0].text).toBe('❌ Device ID cannot be empty'); }); it('should validate whitespace-only deviceId', async () => { // Act const result = await controller.execute({ deviceId: ' ' }); // Assert expect(result.content[0].text).toBe('❌ Device ID cannot be whitespace only'); }); }); describe('complex scenarios', () => { it('should boot specific simulator when multiple exist with similar names', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'AAA111', name: 'iPhone 15', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }, { udid: 'BBB222', name: 'iPhone 15 Pro', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' }, { udid: 'CCC333', name: 'iPhone 15 Pro Max', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro-Max' } ] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: '' } // boot command succeeds ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15 Pro' }); // Assert - Test behavior: correct simulator was booted expect(result.content[0].text).toBe('✅ Successfully booted simulator: iPhone 15 Pro (BBB222)'); }); it('should handle mixed state simulators across runtimes', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [{ udid: 'OLD123', name: 'iPhone 14', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' }], 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'NEW456', name: 'iPhone 14', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' } // list shows iOS 16 one is booted ]; // Act - should find the first matching by name regardless of runtime const result = await controller.execute({ deviceId: 'iPhone 14' }); // Assert - Test behavior: finds already booted simulator from any runtime expect(result.content[0].text).toBe('✅ Simulator already booted: iPhone 14 (OLD123)'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/InstallAppUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { InstallResult, InstallOutcome, InstallCommandFailedError, SimulatorNotFoundError, NoBootedSimulatorError } from '../../domain/InstallResult.js'; import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { InstallAppUseCase } from '../../use-cases/InstallAppUseCase.js'; import { InstallRequest } from '../../domain/InstallRequest.js'; import { SimulatorState } from '../../../simulator/domain/SimulatorState.js'; import { ISimulatorLocator, ISimulatorControl, IAppInstaller, SimulatorInfo } from '../../../../application/ports/SimulatorPorts.js'; import { ILogManager } from '../../../../application/ports/LoggingPorts.js'; describe('InstallAppUseCase', () => { beforeEach(() => { jest.clearAllMocks(); }); function createSUT() { const mockFindSimulator = jest.fn<ISimulatorLocator['findSimulator']>(); const mockFindBootedSimulator = jest.fn<ISimulatorLocator['findBootedSimulator']>(); const mockSimulatorLocator: ISimulatorLocator = { findSimulator: mockFindSimulator, findBootedSimulator: mockFindBootedSimulator }; const mockBoot = jest.fn<ISimulatorControl['boot']>(); const mockShutdown = jest.fn<ISimulatorControl['shutdown']>(); const mockSimulatorControl: ISimulatorControl = { boot: mockBoot, shutdown: mockShutdown }; const mockInstallApp = jest.fn<IAppInstaller['installApp']>(); const mockAppInstaller: IAppInstaller = { installApp: mockInstallApp }; const mockSaveDebugData = jest.fn<ILogManager['saveDebugData']>(); const mockSaveLog = jest.fn<ILogManager['saveLog']>(); const mockLogManager: ILogManager = { saveDebugData: mockSaveDebugData, saveLog: mockSaveLog }; const sut = new InstallAppUseCase( mockSimulatorLocator, mockSimulatorControl, mockAppInstaller, mockLogManager ); return { sut, mockFindSimulator, mockFindBootedSimulator, mockBoot, mockInstallApp, mockSaveDebugData, mockSaveLog }; } function createTestSimulator(state: SimulatorState = SimulatorState.Booted): SimulatorInfo { return { id: 'test-simulator-id', name: 'iPhone 15', state, platform: 'iOS', runtime: 'iOS 17.0' }; } describe('when installing with specific simulator ID', () => { it('should install app on already booted simulator', async () => { // Arrange const { sut, mockFindSimulator, mockInstallApp } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Booted); mockFindSimulator.mockResolvedValue(simulator); mockInstallApp.mockResolvedValue(undefined); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Succeeded); expect(result.diagnostics.bundleId).toBe('MyApp.app'); expect(result.diagnostics.simulatorId?.toString()).toBe('test-simulator-id'); expect(mockInstallApp).toHaveBeenCalledWith('/path/to/MyApp.app', 'test-simulator-id'); }); it('should auto-boot shutdown simulator before installing', async () => { // Arrange const { sut, mockFindSimulator, mockBoot, mockInstallApp } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Shutdown); mockFindSimulator.mockResolvedValue(simulator); mockBoot.mockResolvedValue(undefined); mockInstallApp.mockResolvedValue(undefined); // Act const result = await sut.execute(request); // Assert expect(mockBoot).toHaveBeenCalledWith('test-simulator-id'); expect(mockInstallApp).toHaveBeenCalledWith('/path/to/MyApp.app', 'test-simulator-id'); expect(result.outcome).toBe(InstallOutcome.Succeeded); }); it('should return failure when simulator not found', async () => { // Arrange const { sut, mockFindSimulator, mockSaveDebugData } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'non-existent-id'); mockFindSimulator.mockResolvedValue(null); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); expect((result.diagnostics.error as SimulatorNotFoundError).simulatorId.toString()).toBe('non-existent-id'); expect(mockSaveDebugData).toHaveBeenCalledWith( 'install-app-failed', expect.objectContaining({ reason: 'simulator_not_found' }), 'MyApp.app' ); }); it('should return failure when boot fails', async () => { // Arrange const { sut, mockFindSimulator, mockBoot, mockSaveDebugData } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Shutdown); mockFindSimulator.mockResolvedValue(simulator); mockBoot.mockRejectedValue(new Error('Boot failed')); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(InstallCommandFailedError); expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('Boot failed'); expect(mockSaveDebugData).toHaveBeenCalledWith( 'simulator-boot-failed', expect.objectContaining({ error: 'Boot failed' }), 'MyApp.app' ); }); }); describe('when installing without simulator ID', () => { it('should use booted simulator', async () => { // Arrange const { sut, mockFindBootedSimulator, mockInstallApp } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app'); const simulator = createTestSimulator(SimulatorState.Booted); mockFindBootedSimulator.mockResolvedValue(simulator); mockInstallApp.mockResolvedValue(undefined); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Succeeded); expect(result.diagnostics.simulatorId?.toString()).toBe('test-simulator-id'); expect(mockFindBootedSimulator).toHaveBeenCalled(); expect(mockInstallApp).toHaveBeenCalledWith('/path/to/MyApp.app', 'test-simulator-id'); }); it('should return failure when no booted simulator found', async () => { // Arrange const { sut, mockFindBootedSimulator, mockSaveDebugData } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app'); mockFindBootedSimulator.mockResolvedValue(null); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(NoBootedSimulatorError); expect(mockSaveDebugData).toHaveBeenCalledWith( 'install-app-failed', expect.objectContaining({ reason: 'simulator_not_found' }), 'MyApp.app' ); }); }); describe('when installation fails', () => { it('should return failure with error message', async () => { // Arrange const { sut, mockFindSimulator, mockInstallApp, mockSaveDebugData } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Booted); mockFindSimulator.mockResolvedValue(simulator); mockInstallApp.mockRejectedValue(new Error('Code signing error')); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(InstallCommandFailedError); expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('Code signing error'); expect(mockSaveDebugData).toHaveBeenCalledWith( 'install-app-error', expect.objectContaining({ error: 'Code signing error' }), 'MyApp.app' ); }); it('should handle generic error', async () => { // Arrange const { sut, mockFindSimulator, mockInstallApp } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Booted); mockFindSimulator.mockResolvedValue(simulator); mockInstallApp.mockRejectedValue('String error'); // Act const result = await sut.execute(request); // Assert expect(result.outcome).toBe(InstallOutcome.Failed); expect(result.diagnostics.error).toBeInstanceOf(InstallCommandFailedError); expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('String error'); }); }); describe('debug data logging', () => { it('should log success with app name and simulator info', async () => { // Arrange const { sut, mockFindSimulator, mockInstallApp, mockSaveDebugData } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Booted); mockFindSimulator.mockResolvedValue(simulator); mockInstallApp.mockResolvedValue(undefined); // Act await sut.execute(request); // Assert expect(mockSaveDebugData).toHaveBeenCalledWith( 'install-app-success', expect.objectContaining({ simulator: 'iPhone 15', simulatorId: 'test-simulator-id', app: 'MyApp.app' }), 'MyApp.app' ); }); it('should log auto-boot event', async () => { // Arrange const { sut, mockFindSimulator, mockBoot, mockInstallApp, mockSaveDebugData } = createSUT(); const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); const simulator = createTestSimulator(SimulatorState.Shutdown); mockFindSimulator.mockResolvedValue(simulator); mockBoot.mockResolvedValue(undefined); mockInstallApp.mockResolvedValue(undefined); // Act await sut.execute(request); // Assert expect(mockSaveDebugData).toHaveBeenCalledWith( 'simulator-auto-booted', expect.objectContaining({ simulatorId: 'test-simulator-id', simulatorName: 'iPhone 15' }), 'MyApp.app' ); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/integration/ShutdownSimulatorController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { ShutdownSimulatorControllerFactory } from '../../factories/ShutdownSimulatorControllerFactory.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; import { exec } from 'child_process'; import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; // Mock ONLY external boundaries jest.mock('child_process'); // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) jest.mock('util', () => { const actualUtil = jest.requireActual('util') as typeof import('util'); const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); return { ...actualUtil, promisify: (fn: Function) => fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) }; }); // Mock DependencyChecker to always report dependencies are available in tests jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ DependencyChecker: jest.fn().mockImplementation(() => ({ check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies })) })); const mockExec = exec as jest.MockedFunction<typeof exec>; /** * Integration tests for ShutdownSimulatorController * * Tests the integration between: * - Controller → Use Case → Adapters * - Input validation → Domain logic → Output formatting * * Mocks only external boundaries (shell commands) * Tests behavior, not implementation details */ describe('ShutdownSimulatorController Integration', () => { let controller: MCPController; let execCallIndex: number; let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; beforeEach(() => { jest.clearAllMocks(); execCallIndex = 0; execMockResponses = []; // Setup exec mock to return responses sequentially mockExec.mockImplementation((( _cmd: string, _options: any, callback: (error: Error | null, stdout: string, stderr: string) => void ) => { const response = execMockResponses[execCallIndex++] || { stdout: '', stderr: '' }; if (response.error) { callback(response.error, response.stdout, response.stderr); } else { callback(null, response.stdout, response.stderr); } }) as any); // Create controller with REAL components using factory controller = ShutdownSimulatorControllerFactory.create(); }); describe('shutdown simulator workflow', () => { it('should shutdown a booted simulator', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: '' } // shutdown command succeeds ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: simulator was successfully shutdown expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 15 (ABC123)'); }); it('should handle already shutdown simulator', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' } // list devices - already shutdown ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: reports simulator is already shutdown expect(result.content[0].text).toBe('✅ Simulator already shutdown: iPhone 15 (ABC123)'); }); it('should shutdown simulator by UUID', async () => { // Arrange const uuid = '550e8400-e29b-41d4-a716-446655440000'; const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: uuid, name: 'iPhone 15 Pro', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: '' } // shutdown command succeeds ]; // Act const result = await controller.execute({ deviceId: uuid }); // Assert - Test behavior: simulator was shutdown using UUID expect(result.content[0].text).toBe(`✅ Successfully shutdown simulator: iPhone 15 Pro (${uuid})`); }); }); describe('error handling', () => { it('should handle simulator not found', async () => { // Arrange execMockResponses = [ { stdout: JSON.stringify({ devices: {} }), stderr: '' } // empty device list ]; // Act const result = await controller.execute({ deviceId: 'NonExistent' }); // Assert - Test behavior: appropriate error message shown expect(result.content[0].text).toBe('❌ Simulator not found: NonExistent'); }); it('should handle shutdown command failure', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; const shutdownError: NodeExecError = new Error('Command failed') as NodeExecError; shutdownError.code = 1; shutdownError.stdout = ''; shutdownError.stderr = 'Unable to shutdown device'; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: 'Unable to shutdown device', error: shutdownError } // shutdown fails ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: error message includes context for found simulator expect(result.content[0].text).toBe('❌ iPhone 15 (ABC123) - Unable to shutdown device'); }); }); describe('validation', () => { it('should validate required deviceId', async () => { // Act const result = await controller.execute({} as any); // Assert expect(result.content[0].text).toBe('❌ Device ID is required'); }); it('should validate empty deviceId', async () => { // Act const result = await controller.execute({ deviceId: '' }); // Assert expect(result.content[0].text).toBe('❌ Device ID cannot be empty'); }); it('should validate whitespace-only deviceId', async () => { // Act const result = await controller.execute({ deviceId: ' ' }); // Assert expect(result.content[0].text).toBe('❌ Device ID cannot be whitespace only'); }); }); describe('complex scenarios', () => { it('should shutdown specific simulator when multiple exist with similar names', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'AAA111', name: 'iPhone 15', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }, { udid: 'BBB222', name: 'iPhone 15 Pro', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' }, { udid: 'CCC333', name: 'iPhone 15 Pro Max', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro-Max' } ] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices { stdout: '', stderr: '' } // shutdown command succeeds ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15 Pro' }); // Assert - Test behavior: correct simulator was shutdown expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 15 Pro (BBB222)'); }); it('should handle mixed state simulators across runtimes', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [{ udid: 'OLD123', name: 'iPhone 14', state: SimulatorState.Shutdown, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' }], 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'NEW456', name: 'iPhone 14', state: SimulatorState.Booted, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list shows iOS 17 one is booted { stdout: '', stderr: '' } // shutdown succeeds ]; // Act - should find the first matching by name (prioritizes newer runtime) const result = await controller.execute({ deviceId: 'iPhone 14' }); // Assert - Test behavior: finds and shuts down the iOS 17 device (newer runtime) expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 14 (NEW456)'); }); it('should shutdown simulator in Booting state', async () => { // Arrange const simulatorData = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ udid: 'BOOT123', name: 'iPhone 15', state: SimulatorState.Booting, isAvailable: true, deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' }] } }; execMockResponses = [ { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices - in Booting state { stdout: '', stderr: '' } // shutdown command succeeds ]; // Act const result = await controller.execute({ deviceId: 'iPhone 15' }); // Assert - Test behavior: can shutdown a simulator that's booting expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 15 (BOOT123)'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/integration/InstallAppController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Integration Test for InstallAppController * * Tests the controller with REAL use case, presenter, and adapters * but MOCKS external boundaries (filesystem, subprocess). * * Following testing philosophy: * - Integration tests (60% of suite) test component interactions * - Mock only external boundaries * - Test behavior, not implementation */ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { InstallAppControllerFactory } from '../../factories/InstallAppControllerFactory.js'; import { exec } from 'child_process'; import { existsSync, statSync } from 'fs'; import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; // Mock ONLY external boundaries jest.mock('child_process'); jest.mock('fs'); // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) jest.mock('util', () => { const actualUtil = jest.requireActual('util') as typeof import('util'); const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); return { ...actualUtil, promisify: (fn: Function) => fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) }; }); // Mock DependencyChecker to always report dependencies are available in tests jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ DependencyChecker: jest.fn().mockImplementation(() => ({ check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies })) })); const mockExec = exec as jest.MockedFunction<typeof exec>; const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>; const mockStatSync = statSync as jest.MockedFunction<typeof statSync>; describe('InstallAppController Integration', () => { let controller: MCPController; let execCallIndex: number; let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; // Helper to create device list JSON response const createDeviceListResponse = (devices: Array<{udid: string, name: string, state: string}>) => ({ stdout: JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': devices.map(d => ({ ...d, isAvailable: true })) } }), stderr: '' }); beforeEach(() => { jest.clearAllMocks(); execCallIndex = 0; execMockResponses = []; // Setup selective exec mock for xcrun simctl commands const actualExec = (jest.requireActual('child_process') as typeof import('child_process')).exec; const { createSelectiveExecMock } = require('../../../../shared/tests/mocks/selectiveExecMock'); const isSimctlCommand = (cmd: string) => cmd.includes('xcrun simctl'); mockExec.mockImplementation( createSelectiveExecMock( isSimctlCommand, () => execMockResponses[execCallIndex++], actualExec ) ); // Default filesystem mocks mockExistsSync.mockImplementation((path) => { const pathStr = String(path); return pathStr.endsWith('.app'); }); mockStatSync.mockImplementation((path) => ({ isDirectory: () => String(path).endsWith('.app'), isFile: () => false, // Add other stat properties as needed } as any)); // Create controller with REAL components using factory controller = InstallAppControllerFactory.create(); }); describe('successful app installation', () => { it('should install app on booted simulator', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; const simulatorId = 'test-simulator-id'; execMockResponses = [ // Find simulator createDeviceListResponse([ { udid: simulatorId, name: 'iPhone 15', state: 'Booted' } ]), // Install app { stdout: '', stderr: '' } ]; // Act const result = await controller.execute({ appPath, simulatorId }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); }); it('should find and use booted simulator when no ID specified', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; execMockResponses = [ // xcrun simctl list devices --json (to find booted simulator) { stdout: JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'booted-sim-id', name: 'iPhone 15', state: 'Booted', isAvailable: true }, { udid: 'shutdown-sim-id', name: 'iPhone 14', state: 'Shutdown', isAvailable: true } ] } }), stderr: '' }, // xcrun simctl install command { stdout: '', stderr: '' } ]; // Act const result = await controller.execute({ appPath }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); }); it('should boot simulator if shutdown', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; const simulatorId = 'shutdown-sim-id'; execMockResponses = [ // Find simulator { stdout: JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: simulatorId, name: 'iPhone 15', state: 'Shutdown', isAvailable: true } ] } }), stderr: '' }, // Boot simulator { stdout: '', stderr: '' }, // Install app { stdout: '', stderr: '' } ]; // Act const result = await controller.execute({ appPath, simulatorId }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); }); }); describe('error handling', () => { it('should fail when app path does not exist', async () => { // Arrange const nonExistentPath = '/path/that/does/not/exist.app'; mockExistsSync.mockReturnValue(false); execMockResponses = [ // Find simulator createDeviceListResponse([ { udid: 'test-sim', name: 'iPhone 15', state: 'Booted' } ]), // Install command would fail with file not found { error: Object.assign(new Error('Failed to install app'), { code: 1, stdout: '', stderr: 'xcrun simctl install: No such file or directory' }), stdout: '', stderr: 'xcrun simctl install: No such file or directory' } ]; // Act const result = await controller.execute({ appPath: nonExistentPath, simulatorId: 'test-sim' }); // Assert expect(result.content[0].text).toBe('❌ iPhone 15 (test-sim) - xcrun simctl install: No such file or directory'); }); it('should fail when app path is not an app bundle', async () => { // Arrange const invalidPath = '/Users/dev/file.txt'; mockExistsSync.mockReturnValue(true); mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as any); // Act const result = await controller.execute({ appPath: invalidPath, simulatorId: 'test-sim' }); // Assert expect(result.content[0].text).toBe('❌ App path must end with .app'); }); it('should fail when simulator does not exist', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; const nonExistentSim = 'non-existent-id'; execMockResponses = [ // List devices - simulator not found { stdout: JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [] } }), stderr: '' } ]; // Act const result = await controller.execute({ appPath, simulatorId: nonExistentSim }); // Assert expect(result.content[0].text).toBe(`❌ Simulator not found: ${nonExistentSim}`); }); it('should fail when no booted simulator and no ID specified', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; execMockResponses = [ // List devices - no booted simulators { stdout: JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'shutdown-sim', name: 'iPhone 15', state: 'Shutdown', isAvailable: true } ] } }), stderr: '' } ]; // Act const result = await controller.execute({ appPath }); // Assert expect(result.content[0].text).toBe('❌ No booted simulator found. Please boot a simulator first or specify a simulator ID.'); }); it('should handle installation failure gracefully', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; const simulatorId = 'test-sim'; const error = new Error('Failed to install app: incompatible architecture') as NodeExecError; error.code = 1; error.stdout = ''; error.stderr = 'Error: incompatible architecture'; execMockResponses = [ // Find simulator createDeviceListResponse([ { udid: simulatorId, name: 'iPhone 15', state: 'Booted' } ]), // Install fails { error, stdout: '', stderr: error.stderr } ]; // Act const result = await controller.execute({ appPath, simulatorId }); // Assert expect(result.content[0].text).toBe('❌ iPhone 15 (test-sim) - incompatible architecture'); }); }); describe('input validation', () => { it('should accept simulator name instead of UUID', async () => { // Arrange const appPath = '/Users/dev/MyApp.app'; const simulatorName = 'iPhone 15 Pro'; execMockResponses = [ // Find simulator by name createDeviceListResponse([ { udid: 'sim-id-123', name: simulatorName, state: 'Booted' } ]), // Install succeeds { stdout: '', stderr: '' } ]; // Act const result = await controller.execute({ appPath, simulatorId: simulatorName }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); // Should show both simulator name and ID in format: "name (id)" expect(result.content[0].text).toContain(`${simulatorName} (sim-id-123)`); }); it('should handle paths with spaces', async () => { // Arrange const appPath = '/Users/dev/My iOS App/MyApp.app'; const simulatorId = 'test-sim'; execMockResponses = [ // Find simulator createDeviceListResponse([ { udid: simulatorId, name: 'iPhone 15', state: 'Booted' } ]), // Install app { stdout: '', stderr: '' } ]; // Act const result = await controller.execute({ appPath, simulatorId }); // Assert expect(result).toMatchObject({ content: expect.arrayContaining([ expect.objectContaining({ type: 'text', text: expect.stringContaining('Successfully installed') }) ]) }); // Path with spaces should be handled correctly expect(result.content[0].text).toContain('iPhone 15 (test-sim)'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/integration/ListSimulatorsController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; import { ListSimulatorsControllerFactory } from '../../factories/ListSimulatorsControllerFactory.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; import { exec } from 'child_process'; import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; // Mock ONLY external boundaries jest.mock('child_process'); // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) jest.mock('util', () => { const actualUtil = jest.requireActual('util') as typeof import('util'); const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); return { ...actualUtil, promisify: (fn: Function) => fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) }; }); // Mock DependencyChecker to always report dependencies are available in tests jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ DependencyChecker: jest.fn().mockImplementation(() => ({ check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies })) })); const mockExec = exec as jest.MockedFunction<typeof exec>; describe('ListSimulatorsController Integration', () => { let controller: MCPController; let execCallIndex: number; let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; beforeEach(() => { jest.clearAllMocks(); execCallIndex = 0; execMockResponses = []; // Setup exec mock to return responses sequentially mockExec.mockImplementation((( _cmd: string, _options: any, callback: (error: Error | null, stdout: string, stderr: string) => void ) => { const response = execMockResponses[execCallIndex++] || { stdout: '', stderr: '' }; if (response.error) { callback(response.error, response.stdout, response.stderr); } else { callback(null, response.stdout, response.stderr); } }) as any); // Create controller with REAL components using factory controller = ListSimulatorsControllerFactory.create(); }); describe('with mocked shell commands', () => { it('should list all simulators', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { dataPath: '/path/to/data', dataPathSize: 1000000, logPath: '/path/to/logs', udid: 'ABC123', isAvailable: true, deviceTypeIdentifier: 'com.apple.iPhone15', state: 'Booted', name: 'iPhone 15' }, { dataPath: '/path/to/data2', dataPathSize: 2000000, logPath: '/path/to/logs2', udid: 'DEF456', isAvailable: true, deviceTypeIdentifier: 'com.apple.iPadPro', state: 'Shutdown', name: 'iPad Pro' } ], 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ { dataPath: '/path/to/data3', dataPathSize: 3000000, logPath: '/path/to/logs3', udid: 'GHI789', isAvailable: true, deviceTypeIdentifier: 'com.apple.AppleTV', state: 'Shutdown', name: 'Apple TV' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({}); // Assert - Test behavior: lists all simulators expect(result.content[0].text).toContain('Found 3 simulators'); expect(result.content[0].text).toContain('iPhone 15'); expect(result.content[0].text).toContain('iPad Pro'); expect(result.content[0].text).toContain('Apple TV'); }); it('should filter by iOS platform', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15' } ], 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ { udid: 'GHI789', isAvailable: true, state: 'Shutdown', name: 'Apple TV' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ platform: 'iOS' }); // Assert expect(result.content[0].text).toContain('Found 1 simulator'); expect(result.content[0].text).toContain('iPhone 15'); expect(result.content[0].text).not.toContain('Apple TV'); }); it('should filter by booted state', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15' }, { udid: 'DEF456', isAvailable: true, state: 'Shutdown', name: 'iPad Pro' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ state: 'Booted' }); // Assert expect(result.content[0].text).toContain('Found 1 simulator'); expect(result.content[0].text).toContain('iPhone 15'); expect(result.content[0].text).not.toContain('iPad Pro'); }); it('should return error when command execution fails', async () => { // Arrange const error = new Error('xcrun not found') as NodeExecError; error.code = 1; execMockResponses = [ { stdout: '', stderr: 'xcrun not found', error } ]; // Act const result = await controller.execute({}); // Assert expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toMatch(/^❌.*JSON/); // Error about JSON parsing }); it('should show warning when no simulators exist', async () => { // Arrange execMockResponses = [ { stdout: JSON.stringify({ devices: {} }), stderr: '' } ]; // Act const result = await controller.execute({}); // Assert expect(result.content[0].text).toBe('🔍 No simulators found'); }); it('should filter by multiple criteria', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15' }, { udid: 'DEF456', isAvailable: true, state: 'Shutdown', name: 'iPad Pro' } ], 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ { udid: 'GHI789', isAvailable: true, state: 'Booted', name: 'Apple TV' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ platform: 'iOS', state: 'Booted' }); // Assert expect(result.content[0].text).toContain('Found 1 simulator'); expect(result.content[0].text).toContain('iPhone 15'); expect(result.content[0].text).not.toContain('iPad Pro'); expect(result.content[0].text).not.toContain('Apple TV'); }); it('should return JSON parse error for malformed response', async () => { // Arrange execMockResponses = [ { stdout: 'not valid json', stderr: '' } ]; // Act const result = await controller.execute({}); // Assert expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toMatch(/^❌.*not valid JSON/); }); it('should return error for invalid platform', async () => { // Arrange, Act, Assert const result = await controller.execute({ platform: 'Android' }); expect(result.content[0].text).toBe('❌ Invalid platform: Android. Valid values are: iOS, macOS, tvOS, watchOS, visionOS'); }); it('should return error for invalid state', async () => { // Arrange, Act, Assert const result = await controller.execute({ state: 'Running' }); expect(result.content[0].text).toBe('❌ Invalid simulator state: Running. Valid values are: Booted, Booting, Shutdown, Shutting Down'); }); it('should filter by device name with partial match', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15 Pro' }, { udid: 'DEF456', isAvailable: true, state: 'Shutdown', name: 'iPhone 14' }, { udid: 'GHI789', isAvailable: true, state: 'Shutdown', name: 'iPad Pro' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ name: '15' }); // Assert - Tests actual behavior: only devices with "15" in name expect(result.content[0].text).toContain('Found 1 simulator'); expect(result.content[0].text).toContain('iPhone 15 Pro'); expect(result.content[0].text).not.toContain('iPhone 14'); expect(result.content[0].text).not.toContain('iPad Pro'); }); it('should filter by device name case-insensitive', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15 Pro' }, { udid: 'DEF456', isAvailable: true, state: 'Shutdown', name: 'iPad Air' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ name: 'iphone' }); // Assert - Case-insensitive matching expect(result.content[0].text).toContain('Found 1 simulator'); expect(result.content[0].text).toContain('iPhone 15 Pro'); expect(result.content[0].text).not.toContain('iPad Air'); }); it('should combine all filters (platform, state, and name)', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15 Pro' }, { udid: 'DEF456', isAvailable: true, state: 'Shutdown', name: 'iPhone 15' }, { udid: 'GHI789', isAvailable: true, state: 'Booted', name: 'iPhone 14' } ], 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ { udid: 'TV123', isAvailable: true, state: 'Booted', name: 'Apple TV 15' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ platform: 'iOS', state: 'Booted', name: '15' }); // Assert - All filters applied together expect(result.content[0].text).toContain('Found 1 simulator'); expect(result.content[0].text).toContain('iPhone 15 Pro'); expect(result.content[0].text).not.toContain('iPhone 14'); // Wrong name expect(result.content[0].text).not.toContain('Apple TV'); // Wrong platform }); it('should show no simulators when name filter matches nothing', async () => { // Arrange const mockDeviceList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', isAvailable: true, state: 'Booted', name: 'iPhone 15 Pro' } ] } }; execMockResponses = [ { stdout: JSON.stringify(mockDeviceList), stderr: '' } ]; // Act const result = await controller.execute({ name: 'Galaxy' }); // Assert expect(result.content[0].text).toBe('🔍 No simulators found'); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ListSimulatorsUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { ListSimulatorsUseCase } from '../../use-cases/ListSimulatorsUseCase.js'; import { DeviceRepository } from '../../../../infrastructure/repositories/DeviceRepository.js'; import { ListSimulatorsRequest } from '../../domain/ListSimulatorsRequest.js'; import { SimulatorListParseError } from '../../domain/ListSimulatorsResult.js'; import { Platform } from '../../../../shared/domain/Platform.js'; import { SimulatorState } from '../../domain/SimulatorState.js'; describe('ListSimulatorsUseCase', () => { let mockDeviceRepository: jest.Mocked<DeviceRepository>; let sut: ListSimulatorsUseCase; beforeEach(() => { mockDeviceRepository = { getAllDevices: jest.fn() } as any; sut = new ListSimulatorsUseCase(mockDeviceRepository); }); describe('execute', () => { it('should return all available simulators when no filters', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', name: 'iPhone 15', state: 'Booted', isAvailable: true }, { udid: 'DEF456', name: 'iPad Pro', state: 'Shutdown', isAvailable: true }, { udid: 'NOTAVAIL', name: 'Old iPhone', state: 'Shutdown', isAvailable: false } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(2); // Only available devices expect(result.simulators).toHaveLength(2); expect(result.simulators[0]).toMatchObject({ udid: 'ABC123', name: 'iPhone 15', state: SimulatorState.Booted, platform: 'iOS', runtime: 'iOS 17.0' }); }); it('should filter by platform', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'IOS1', name: 'iPhone 15', state: 'Booted', isAvailable: true } ], 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ { udid: 'TV1', name: 'Apple TV', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(Platform.iOS); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(1); expect(result.simulators[0].udid).toBe('IOS1'); }); it('should filter by state', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'BOOTED1', name: 'iPhone 15', state: 'Booted', isAvailable: true }, { udid: 'SHUTDOWN1', name: 'iPad Pro', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(undefined, SimulatorState.Booted); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(1); expect(result.simulators[0].udid).toBe('BOOTED1'); }); it('should handle watchOS platform detection', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.watchOS-10-0': [ { udid: 'WATCH1', name: 'Apple Watch Series 9', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].platform).toBe('watchOS'); expect(result.simulators[0].runtime).toBe('watchOS 10.0'); }); it('should handle visionOS platform detection', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.visionOS-1-0': [ { udid: 'VISION1', name: 'Apple Vision Pro', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].platform).toBe('visionOS'); expect(result.simulators[0].runtime).toBe('visionOS 1.0'); }); it('should handle xrOS platform detection (legacy name for visionOS)', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0': [ { udid: 'XR1', name: 'Apple Vision Pro', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].platform).toBe('visionOS'); expect(result.simulators[0].runtime).toBe('visionOS 1.0'); }); it('should handle macOS platform detection', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.macOS-14-0': [ { udid: 'MAC1', name: 'Mac', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].platform).toBe('macOS'); expect(result.simulators[0].runtime).toBe('macOS 14.0'); }); it('should handle unknown platform', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.unknown-1-0': [ { udid: 'UNKNOWN1', name: 'Unknown Device', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].platform).toBe('Unknown'); expect(result.simulators[0].runtime).toBe('Unknown 1.0'); }); it('should handle Booting state', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'BOOT1', name: 'iPhone 15', state: 'Booting', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].state).toBe(SimulatorState.Booting); }); it('should handle Shutting Down state', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'SHUTTING1', name: 'iPhone 15', state: 'Shutting Down', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].state).toBe(SimulatorState.ShuttingDown); }); it('should handle unknown device state by throwing error', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'WEIRD1', name: 'iPhone 15', state: 'WeirdState', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert - should fail with error about unrecognized state expect(result.isSuccess).toBe(false); expect(result.error?.message).toContain('Invalid simulator state: WeirdState'); }); it('should handle runtime without version number', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS': [ { udid: 'NOVERSION1', name: 'iPhone', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.simulators[0].runtime).toBe('iOS Unknown'); }); it('should handle repository errors', async () => { // Arrange const error = new Error('Repository failed'); mockDeviceRepository.getAllDevices.mockRejectedValue(error); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(false); expect(result.error).toBeInstanceOf(SimulatorListParseError); expect(result.error?.message).toBe('Failed to parse simulator list: not valid JSON'); }); it('should return empty list when no simulators available', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({}); const request = ListSimulatorsRequest.create(); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(0); expect(result.simulators).toHaveLength(0); }); it('should filter by device name with partial match', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', name: 'iPhone 15 Pro', state: 'Booted', isAvailable: true }, { udid: 'DEF456', name: 'iPhone 14', state: 'Shutdown', isAvailable: true }, { udid: 'GHI789', name: 'iPad Pro', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(undefined, undefined, '15'); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(1); expect(result.simulators[0].name).toBe('iPhone 15 Pro'); }); it('should filter by device name case-insensitive', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', name: 'iPhone 15 Pro', state: 'Booted', isAvailable: true }, { udid: 'DEF456', name: 'iPad Air', state: 'Shutdown', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(undefined, undefined, 'iphone'); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(1); expect(result.simulators[0].name).toBe('iPhone 15 Pro'); }); it('should combine all filters (platform, state, and name)', async () => { // Arrange mockDeviceRepository.getAllDevices.mockResolvedValue({ 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { udid: 'ABC123', name: 'iPhone 15 Pro', state: 'Booted', isAvailable: true }, { udid: 'DEF456', name: 'iPhone 15', state: 'Shutdown', isAvailable: true } ], 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ { udid: 'GHI789', name: 'Apple TV 15', state: 'Booted', isAvailable: true } ] }); const request = ListSimulatorsRequest.create(Platform.iOS, SimulatorState.Booted, '15'); // Act const result = await sut.execute(request); // Assert expect(result.isSuccess).toBe(true); expect(result.count).toBe(1); expect(result.simulators[0].name).toBe('iPhone 15 Pro'); expect(result.simulators[0].platform).toBe('iOS'); expect(result.simulators[0].state).toBe(SimulatorState.Booted); }); }); }); ``` -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { program } from 'commander'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; import { execSync } from 'child_process'; import * as readline from 'readline/promises'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PACKAGE_ROOT = resolve(__dirname, '..'); interface ClaudeConfig { mcpServers?: Record<string, any>; [key: string]: any; } interface ClaudeSettings { hooks?: any; model?: string; [key: string]: any; } class MCPXcodeSetup { private rl = readline.createInterface({ input: process.stdin, output: process.stdout }); async setup() { console.log('🔧 MCP Xcode Setup\n'); // 1. Ask about MCP server console.log('📡 MCP Server Configuration'); const setupMCP = await this.askYesNo('Would you like to install the MCP Xcode server?'); let mcpScope: 'global' | 'project' | null = null; if (setupMCP) { mcpScope = await this.askMCPScope(); await this.setupMCPServer(mcpScope); } // 2. Ask about hooks (independent of MCP choice) console.log('\n📝 Xcode Sync Hook Configuration'); console.log('The hook will automatically sync file operations with Xcode projects.'); console.log('It syncs when:'); console.log(' - Files are created, modified, deleted, or moved'); console.log(' - An .xcodeproj file exists in the parent directories'); console.log(' - The project hasn\'t opted out (via .no-xcode-sync or .no-xcode-autoadd file)'); const setupHooks = await this.askYesNo('\nWould you like to enable Xcode file sync?'); let hookScope: 'global' | 'project' | null = null; if (setupHooks) { hookScope = await this.askHookScope(); await this.setupHooks(hookScope); } // 3. Build helper tools if anything was installed if (setupMCP || setupHooks) { console.log('\n📦 Building helper tools...'); await this.buildHelperTools(); } // 4. Show completion message console.log('\n✅ Setup complete!'); console.log('\nNext steps:'); console.log('1. Restart Claude Code for changes to take effect'); const hasProjectConfig = (mcpScope === 'project' || hookScope === 'project'); if (hasProjectConfig) { console.log('2. Commit .claude/settings.json to share with your team'); } this.rl.close(); } private async askMCPScope(): Promise<'global' | 'project'> { const answer = await this.rl.question( 'Where should the MCP server be installed?\n' + '1) Global (~/.claude.json)\n' + '2) Project (.claude/settings.json)\n' + 'Choice (1 or 2): ' ); return answer === '2' ? 'project' : 'global'; } private async askHookScope(): Promise<'global' | 'project'> { const answer = await this.rl.question( 'Where should the Xcode sync hook be installed?\n' + '1) Global (~/.claude/settings.json)\n' + '2) Project (.claude/settings.json)\n' + 'Choice (1 or 2): ' ); return answer === '2' ? 'project' : 'global'; } private async askYesNo(question: string): Promise<boolean> { const answer = await this.rl.question(`${question} (y/n): `); return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; } private getMCPConfigPath(scope: 'global' | 'project'): string { if (scope === 'global') { // MCP servers go in ~/.claude.json for global return join(homedir(), '.claude.json'); } else { // Project scope - everything in .claude/settings.json return join(process.cwd(), '.claude', 'settings.json'); } } private getHooksConfigPath(scope: 'global' | 'project'): string { if (scope === 'global') { // Hooks go in ~/.claude/settings.json for global return join(homedir(), '.claude', 'settings.json'); } else { // Project scope - everything in .claude/settings.json return join(process.cwd(), '.claude', 'settings.json'); } } private loadConfig(path: string): any { if (existsSync(path)) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { console.warn(`⚠️ Warning: Could not parse existing config at ${path}`); return {}; } } return {}; } private saveConfig(path: string, config: any) { const dir = dirname(path); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(path, JSON.stringify(config, null, 2), 'utf8'); } private async setupMCPServer(scope: 'global' | 'project') { const configPath = this.getMCPConfigPath(scope); const config = this.loadConfig(configPath); // Determine the command based on installation type const isGlobalInstall = await this.checkGlobalInstall(); const serverPath = isGlobalInstall ? 'mcp-xcode-server' : resolve(PACKAGE_ROOT, 'dist', 'index.js'); const serverConfig = { type: 'stdio', command: isGlobalInstall ? 'mcp-xcode-server' : 'node', args: isGlobalInstall ? ['serve'] : [serverPath], env: {} }; // Add to mcpServers if (!config.mcpServers) { config.mcpServers = {}; } if (config.mcpServers['mcp-xcode-server']) { const overwrite = await this.askYesNo('MCP Xcode server already configured. Overwrite?'); if (!overwrite) { console.log('Skipping MCP server configuration.'); return; } } config.mcpServers['mcp-xcode-server'] = serverConfig; this.saveConfig(configPath, config); console.log(`✅ MCP server configured in ${configPath}`); } private async setupHooks(scope: 'global' | 'project') { const configPath = this.getHooksConfigPath(scope); const config = this.loadConfig(configPath) as ClaudeSettings; const hookScriptPath = resolve(PACKAGE_ROOT, 'scripts', 'xcode-sync.swift'); // Set up hooks using the correct Claude settings format if (!config.hooks) { config.hooks = {}; } if (!config.hooks.PostToolUse) { config.hooks.PostToolUse = []; } // Check if hook already exists const existingHookIndex = config.hooks.PostToolUse.findIndex((hook: any) => hook.matcher === 'Write|Edit|MultiEdit|Bash' && (hook.hooks?.[0]?.command?.includes('xcode-sync.swift') || hook.hooks?.[0]?.command?.includes('xcode-sync.js')) ); if (existingHookIndex >= 0) { const overwrite = await this.askYesNo('PostToolUse hook for Xcode sync already exists. Overwrite?'); if (!overwrite) { console.log('Skipping hook configuration.'); return; } // Remove existing hook config.hooks.PostToolUse.splice(existingHookIndex, 1); } // Add the new hook in Claude's expected format config.hooks.PostToolUse.push({ matcher: 'Write|Edit|MultiEdit|Bash', hooks: [{ type: 'command', command: hookScriptPath }] }); this.saveConfig(configPath, config); console.log(`✅ Xcode sync hook configured in ${configPath}`); } private async checkGlobalInstall(): Promise<boolean> { try { execSync('which mcp-xcode-server', { stdio: 'ignore' }); return true; } catch { return false; } } private async buildHelperTools() { try { // Build TypeScript console.log(' Building TypeScript...'); execSync('npm run build', { cwd: PACKAGE_ROOT, stdio: 'inherit' }); // Build XcodeProjectModifier for the sync hook console.log(' Building XcodeProjectModifier for sync hook...'); await this.buildXcodeProjectModifier(); } catch (error) { console.error('❌ Failed to build:', error); process.exit(1); } } private async buildXcodeProjectModifier() { const modifierDir = '/tmp/XcodeProjectModifier'; const modifierBinary = join(modifierDir, '.build', 'release', 'XcodeProjectModifier'); // Check if already built if (existsSync(modifierBinary)) { // Check if it's the real modifier or just a mock try { const output = execSync(`"${modifierBinary}" --help 2>&1 || true`, { encoding: 'utf8' }); if (output.includes('Mock XcodeProjectModifier')) { console.log(' Detected mock modifier, rebuilding with real implementation...'); // Remove the mock execSync(`rm -rf "${modifierDir}"`, { stdio: 'ignore' }); } else { console.log(' XcodeProjectModifier already built'); return; } } catch { // If --help fails, rebuild execSync(`rm -rf "${modifierDir}"`, { stdio: 'ignore' }); } } console.log(' Creating XcodeProjectModifier...'); // Create directory structure mkdirSync(join(modifierDir, 'Sources', 'XcodeProjectModifier'), { recursive: true }); // Create Package.swift const packageSwift = `// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "XcodeProjectModifier", platforms: [.macOS(.v10_15)], dependencies: [ .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.0.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") ], targets: [ .executableTarget( name: "XcodeProjectModifier", dependencies: [ "XcodeProj", .product(name: "ArgumentParser", package: "swift-argument-parser") ] ) ] )`; writeFileSync(join(modifierDir, 'Package.swift'), packageSwift); // Create main.swift (simplified version for the hook) const mainSwift = `import Foundation import XcodeProj import ArgumentParser struct XcodeProjectModifier: ParsableCommand { @Argument(help: "Path to the .xcodeproj file") var projectPath: String @Argument(help: "Action to perform: add or remove") var action: String @Argument(help: "Path to the file to add/remove") var filePath: String @Argument(help: "Target name") var targetName: String @Option(name: .long, help: "Group path for the file") var groupPath: String = "" func run() throws { let project = try XcodeProj(pathString: projectPath) let pbxproj = project.pbxproj guard let target = pbxproj.nativeTargets.first(where: { $0.name == targetName }) else { print("Error: Target '\\(targetName)' not found") throw ExitCode.failure } let fileName = URL(fileURLWithPath: filePath).lastPathComponent if action == "remove" { // Remove file reference if let fileRef = pbxproj.fileReferences.first(where: { $0.path == fileName || $0.path == filePath }) { pbxproj.delete(object: fileRef) print("Removed \\(fileName) from project") } } else if action == "add" { // Remove existing reference if it exists if let existingRef = pbxproj.fileReferences.first(where: { $0.path == fileName || $0.path == filePath }) { pbxproj.delete(object: existingRef) } // Add new file reference let fileRef = PBXFileReference( sourceTree: .group, name: fileName, path: filePath ) pbxproj.add(object: fileRef) // Add to appropriate build phase based on file type let fileExtension = URL(fileURLWithPath: filePath).pathExtension.lowercased() if ["swift", "m", "mm", "c", "cpp", "cc", "cxx"].contains(fileExtension) { // Add to sources build phase if let sourcesBuildPhase = target.buildPhases.compactMap({ $0 as? PBXSourcesBuildPhase }).first { let buildFile = PBXBuildFile(file: fileRef) pbxproj.add(object: buildFile) sourcesBuildPhase.files?.append(buildFile) } } else if ["png", "jpg", "jpeg", "gif", "pdf", "json", "plist", "xib", "storyboard", "xcassets"].contains(fileExtension) { // Add to resources build phase if let resourcesBuildPhase = target.buildPhases.compactMap({ $0 as? PBXResourcesBuildPhase }).first { let buildFile = PBXBuildFile(file: fileRef) pbxproj.add(object: buildFile) resourcesBuildPhase.files?.append(buildFile) } } // Add to group if let mainGroup = try? pbxproj.rootProject()?.mainGroup { mainGroup.children.append(fileRef) } print("Added \\(fileName) to project") } try project.write(path: Path(projectPath)) } } XcodeProjectModifier.main()`; writeFileSync(join(modifierDir, 'Sources', 'XcodeProjectModifier', 'main.swift'), mainSwift); // Build the tool console.log(' Building with Swift Package Manager...'); try { execSync('swift build -c release', { cwd: modifierDir, stdio: 'pipe' }); console.log(' ✅ XcodeProjectModifier built successfully'); } catch (error) { console.warn(' ⚠️ Warning: Could not build XcodeProjectModifier. Sync hook may not work until first MCP server use.'); } } } // CLI Commands program .name('mcp-xcode-server') .description('MCP Xcode Server - Setup and management') .version('2.4.0'); program .command('setup') .description('Interactive setup for MCP Xcode server and hooks') .action(async () => { const setup = new MCPXcodeSetup(); await setup.setup(); }); program .command('serve') .description('Start the MCP server') .action(async () => { // Simply run the server await import('./index.js'); }); // Parse command line arguments program.parse(); // If no command specified, show help if (!process.argv.slice(2).length) { program.outputHelp(); } ```