This is page 2 of 5. Use http://codebase.md/stefan-nitu/mcp-xcode-server?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ └── settings.local.json ├── .github │ └── workflows │ └── ci.yml ├── .gitignore ├── .vscode │ └── settings.json ├── CLAUDE.md ├── CONTRIBUTING.md ├── docs │ ├── ARCHITECTURE.md │ ├── ERROR-HANDLING.md │ └── TESTING-PHILOSOPHY.md ├── examples │ └── screenshot-demo.js ├── jest.config.cjs ├── jest.e2e.config.cjs ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── scripts │ └── xcode-sync.swift ├── src │ ├── application │ │ └── ports │ │ ├── ArtifactPorts.ts │ │ ├── BuildPorts.ts │ │ ├── CommandPorts.ts │ │ ├── ConfigPorts.ts │ │ ├── LoggingPorts.ts │ │ ├── MappingPorts.ts │ │ ├── OutputFormatterPorts.ts │ │ ├── OutputParserPorts.ts │ │ └── SimulatorPorts.ts │ ├── cli.ts │ ├── config.ts │ ├── domain │ │ ├── errors │ │ │ └── DomainError.ts │ │ ├── services │ │ │ └── PlatformDetector.ts │ │ ├── shared │ │ │ └── Result.ts │ │ └── tests │ │ └── unit │ │ └── PlatformDetector.unit.test.ts │ ├── features │ │ ├── app-management │ │ │ ├── controllers │ │ │ │ └── InstallAppController.ts │ │ │ ├── domain │ │ │ │ ├── InstallRequest.ts │ │ │ │ └── InstallResult.ts │ │ │ ├── factories │ │ │ │ └── InstallAppControllerFactory.ts │ │ │ ├── index.ts │ │ │ ├── infrastructure │ │ │ │ └── AppInstallerAdapter.ts │ │ │ ├── tests │ │ │ │ ├── e2e │ │ │ │ │ ├── InstallAppController.e2e.test.ts │ │ │ │ │ └── InstallAppMCP.e2e.test.ts │ │ │ │ ├── integration │ │ │ │ │ └── InstallAppController.integration.test.ts │ │ │ │ └── unit │ │ │ │ ├── AppInstallerAdapter.unit.test.ts │ │ │ │ ├── InstallAppController.unit.test.ts │ │ │ │ ├── InstallAppUseCase.unit.test.ts │ │ │ │ ├── InstallRequest.unit.test.ts │ │ │ │ └── InstallResult.unit.test.ts │ │ │ └── use-cases │ │ │ └── InstallAppUseCase.ts │ │ ├── build │ │ │ ├── controllers │ │ │ │ └── BuildXcodeController.ts │ │ │ ├── domain │ │ │ │ ├── BuildDestination.ts │ │ │ │ ├── BuildIssue.ts │ │ │ │ ├── BuildRequest.ts │ │ │ │ ├── BuildResult.ts │ │ │ │ └── PlatformInfo.ts │ │ │ ├── factories │ │ │ │ └── BuildXcodeControllerFactory.ts │ │ │ ├── index.ts │ │ │ ├── infrastructure │ │ │ │ ├── BuildArtifactLocatorAdapter.ts │ │ │ │ ├── BuildDestinationMapperAdapter.ts │ │ │ │ ├── XcbeautifyFormatterAdapter.ts │ │ │ │ ├── XcbeautifyOutputParserAdapter.ts │ │ │ │ └── XcodeBuildCommandAdapter.ts │ │ │ ├── tests │ │ │ │ ├── e2e │ │ │ │ │ ├── BuildXcodeController.e2e.test.ts │ │ │ │ │ └── BuildXcodeMCP.e2e.test.ts │ │ │ │ ├── integration │ │ │ │ │ └── BuildXcodeController.integration.test.ts │ │ │ │ └── unit │ │ │ │ ├── BuildArtifactLocatorAdapter.unit.test.ts │ │ │ │ ├── BuildDestinationMapperAdapter.unit.test.ts │ │ │ │ ├── BuildIssue.unit.test.ts │ │ │ │ ├── BuildProjectUseCase.unit.test.ts │ │ │ │ ├── BuildRequest.unit.test.ts │ │ │ │ ├── BuildResult.unit.test.ts │ │ │ │ ├── BuildXcodeController.unit.test.ts │ │ │ │ ├── BuildXcodePresenter.unit.test.ts │ │ │ │ ├── PlatformInfo.unit.test.ts │ │ │ │ ├── XcbeautifyFormatterAdapter.unit.test.ts │ │ │ │ ├── XcbeautifyOutputParserAdapter.unit.test.ts │ │ │ │ └── XcodeBuildCommandAdapter.unit.test.ts │ │ │ └── use-cases │ │ │ └── BuildProjectUseCase.ts │ │ └── simulator │ │ ├── controllers │ │ │ ├── BootSimulatorController.ts │ │ │ ├── ListSimulatorsController.ts │ │ │ └── ShutdownSimulatorController.ts │ │ ├── domain │ │ │ ├── BootRequest.ts │ │ │ ├── BootResult.ts │ │ │ ├── ListSimulatorsRequest.ts │ │ │ ├── ListSimulatorsResult.ts │ │ │ ├── ShutdownRequest.ts │ │ │ ├── ShutdownResult.ts │ │ │ └── SimulatorState.ts │ │ ├── factories │ │ │ ├── BootSimulatorControllerFactory.ts │ │ │ ├── ListSimulatorsControllerFactory.ts │ │ │ └── ShutdownSimulatorControllerFactory.ts │ │ ├── index.ts │ │ ├── infrastructure │ │ │ ├── SimulatorControlAdapter.ts │ │ │ └── SimulatorLocatorAdapter.ts │ │ ├── tests │ │ │ ├── e2e │ │ │ │ ├── BootSimulatorController.e2e.test.ts │ │ │ │ ├── BootSimulatorMCP.e2e.test.ts │ │ │ │ ├── ListSimulatorsController.e2e.test.ts │ │ │ │ ├── ListSimulatorsMCP.e2e.test.ts │ │ │ │ ├── ShutdownSimulatorController.e2e.test.ts │ │ │ │ └── ShutdownSimulatorMCP.e2e.test.ts │ │ │ ├── integration │ │ │ │ ├── BootSimulatorController.integration.test.ts │ │ │ │ ├── ListSimulatorsController.integration.test.ts │ │ │ │ └── ShutdownSimulatorController.integration.test.ts │ │ │ └── unit │ │ │ ├── BootRequest.unit.test.ts │ │ │ ├── BootResult.unit.test.ts │ │ │ ├── BootSimulatorController.unit.test.ts │ │ │ ├── BootSimulatorUseCase.unit.test.ts │ │ │ ├── ListSimulatorsController.unit.test.ts │ │ │ ├── ListSimulatorsUseCase.unit.test.ts │ │ │ ├── ShutdownRequest.unit.test.ts │ │ │ ├── ShutdownResult.unit.test.ts │ │ │ ├── ShutdownSimulatorUseCase.unit.test.ts │ │ │ ├── SimulatorControlAdapter.unit.test.ts │ │ │ └── SimulatorLocatorAdapter.unit.test.ts │ │ └── use-cases │ │ ├── BootSimulatorUseCase.ts │ │ ├── ListSimulatorsUseCase.ts │ │ └── ShutdownSimulatorUseCase.ts │ ├── index.ts │ ├── infrastructure │ │ ├── repositories │ │ │ └── DeviceRepository.ts │ │ ├── services │ │ │ └── DependencyChecker.ts │ │ └── tests │ │ └── unit │ │ ├── DependencyChecker.unit.test.ts │ │ └── DeviceRepository.unit.test.ts │ ├── logger.ts │ ├── presentation │ │ ├── decorators │ │ │ └── DependencyCheckingDecorator.ts │ │ ├── formatters │ │ │ ├── ErrorFormatter.ts │ │ │ └── strategies │ │ │ ├── BuildIssuesStrategy.ts │ │ │ ├── DefaultErrorStrategy.ts │ │ │ ├── ErrorFormattingStrategy.ts │ │ │ └── OutputFormatterErrorStrategy.ts │ │ ├── interfaces │ │ │ ├── IDependencyChecker.ts │ │ │ ├── MCPController.ts │ │ │ └── MCPResponse.ts │ │ ├── presenters │ │ │ └── BuildXcodePresenter.ts │ │ └── tests │ │ └── unit │ │ ├── BuildIssuesStrategy.unit.test.ts │ │ ├── DefaultErrorStrategy.unit.test.ts │ │ ├── DependencyCheckingDecorator.unit.test.ts │ │ └── ErrorFormatter.unit.test.ts │ ├── shared │ │ ├── domain │ │ │ ├── AppPath.ts │ │ │ ├── DeviceId.ts │ │ │ ├── Platform.ts │ │ │ └── ProjectPath.ts │ │ ├── index.ts │ │ ├── infrastructure │ │ │ ├── ConfigProviderAdapter.ts │ │ │ └── ShellCommandExecutorAdapter.ts │ │ └── tests │ │ ├── mocks │ │ │ ├── promisifyExec.ts │ │ │ ├── selectiveExecMock.ts │ │ │ └── xcodebuildHelpers.ts │ │ ├── skipped │ │ │ ├── cli.e2e.test.skip │ │ │ ├── hook-e2e.test.skip │ │ │ ├── hook-path.e2e.test.skip │ │ │ └── hook.test.skip │ │ ├── types │ │ │ └── execTypes.ts │ │ ├── unit │ │ │ ├── AppPath.unit.test.ts │ │ │ ├── ConfigProviderAdapter.unit.test.ts │ │ │ ├── ProjectPath.unit.test.ts │ │ │ └── ShellCommandExecutorAdapter.unit.test.ts │ │ └── utils │ │ ├── gitResetTestArtifacts.ts │ │ ├── mockHelpers.ts │ │ ├── TestEnvironmentCleaner.ts │ │ ├── TestErrorInjector.ts │ │ ├── testHelpers.ts │ │ ├── TestProjectManager.ts │ │ └── TestSimulatorManager.ts │ ├── types.ts │ ├── utils │ │ ├── devices │ │ │ ├── Devices.ts │ │ │ ├── SimulatorApps.ts │ │ │ ├── SimulatorBoot.ts │ │ │ ├── SimulatorDevice.ts │ │ │ ├── SimulatorInfo.ts │ │ │ ├── SimulatorReset.ts │ │ │ └── SimulatorUI.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ └── xcbeautify-parser.ts │ │ ├── index.ts │ │ ├── LogManager.ts │ │ ├── LogManagerInstance.ts │ │ └── projects │ │ ├── SwiftBuild.ts │ │ ├── SwiftPackage.ts │ │ ├── SwiftPackageInfo.ts │ │ ├── Xcode.ts │ │ ├── XcodeArchive.ts │ │ ├── XcodeBuild.ts │ │ ├── XcodeErrors.ts │ │ ├── XcodeInfo.ts │ │ └── XcodeProject.ts │ └── utils.ts ├── test_artifacts │ ├── Test.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcuserdata │ │ └── stefan.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ ├── TestProjectSwiftTesting │ │ ├── TestProjectSwiftTesting │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── Item.swift │ │ │ ├── TestProjectSwiftTesting.entitlements │ │ │ └── TestProjectSwiftTestingApp.swift │ │ ├── TestProjectSwiftTesting.xcodeproj │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcuserdata │ │ │ │ └── stefan.xcuserdatad │ │ │ │ └── UserInterfaceState.xcuserstate │ │ │ └── xcuserdata │ │ │ └── stefan.xcuserdatad │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ │ ├── TestProjectSwiftTestingTests │ │ │ └── TestProjectSwiftTestingTests.swift │ │ └── TestProjectSwiftTestingUITests │ │ ├── TestProjectSwiftTestingUITests.swift │ │ └── TestProjectSwiftTestingUITestsLaunchTests.swift │ ├── TestProjectWatchOS │ │ ├── TestProjectWatchOS │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ └── TestProjectWatchOSApp.swift │ │ ├── TestProjectWatchOS Watch App │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ └── TestProjectWatchOSApp.swift │ │ ├── TestProjectWatchOS Watch AppTests │ │ │ └── TestProjectWatchOS_Watch_AppTests.swift │ │ ├── TestProjectWatchOS Watch AppUITests │ │ │ ├── TestProjectWatchOS_Watch_AppUITests.swift │ │ │ └── TestProjectWatchOS_Watch_AppUITestsLaunchTests.swift │ │ ├── TestProjectWatchOS.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── TestProjectWatchOSTests │ │ │ └── TestProjectWatchOSTests.swift │ │ └── TestProjectWatchOSUITests │ │ ├── TestProjectWatchOSUITests.swift │ │ └── TestProjectWatchOSUITestsLaunchTests.swift │ ├── TestProjectXCTest │ │ ├── TestProjectXCTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── Item.swift │ │ │ ├── TestProjectXCTest.entitlements │ │ │ └── TestProjectXCTestApp.swift │ │ ├── TestProjectXCTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcuserdata │ │ │ │ └── stefan.xcuserdatad │ │ │ │ └── UserInterfaceState.xcuserstate │ │ │ └── xcuserdata │ │ │ └── stefan.xcuserdatad │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ │ ├── TestProjectXCTestTests │ │ │ └── TestProjectXCTestTests.swift │ │ └── TestProjectXCTestUITests │ │ ├── TestProjectXCTestUITests.swift │ │ └── TestProjectXCTestUITestsLaunchTests.swift │ ├── TestSwiftPackageSwiftTesting │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── Sources │ │ │ ├── TestSwiftPackageSwiftTesting │ │ │ │ └── TestSwiftPackageSwiftTesting.swift │ │ │ └── TestSwiftPackageSwiftTestingExecutable │ │ │ └── main.swift │ │ └── Tests │ │ └── TestSwiftPackageSwiftTestingTests │ │ └── TestSwiftPackageSwiftTestingTests.swift │ └── TestSwiftPackageXCTest │ ├── .gitignore │ ├── Package.swift │ ├── Sources │ │ ├── TestSwiftPackageXCTest │ │ │ └── TestSwiftPackageXCTest.swift │ │ └── TestSwiftPackageXCTestExecutable │ │ └── main.swift │ └── Tests │ └── TestSwiftPackageXCTestTests │ └── TestSwiftPackageXCTestTests.swift ├── tsconfig.json └── XcodeProjectModifier ├── Package.resolved ├── Package.swift └── Sources └── XcodeProjectModifier └── main.swift ``` # Files -------------------------------------------------------------------------------- /src/utils/devices/SimulatorUI.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | import { readFileSync, existsSync, unlinkSync } from 'fs'; 4 | import { join } from 'path'; 5 | import { tmpdir } from 'os'; 6 | 7 | const logger = createModuleLogger('SimulatorUI'); 8 | 9 | /** 10 | * Handles simulator UI operations 11 | * Single responsibility: GUI operations and screenshots 12 | */ 13 | export class SimulatorUI { 14 | /** 15 | * Opens the Simulator app GUI (skipped during tests) 16 | */ 17 | async open(): Promise<void> { 18 | // Skip opening GUI during tests 19 | if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID) { 20 | logger.debug('Skipping Simulator GUI in test environment'); 21 | return; 22 | } 23 | 24 | try { 25 | await execAsync('open -g -a Simulator'); 26 | logger.debug('Opened Simulator app'); 27 | } catch (error: any) { 28 | logger.warn({ error: error.message }, 'Failed to open Simulator app'); 29 | } 30 | } 31 | 32 | /** 33 | * Capture a screenshot from the simulator 34 | */ 35 | async screenshot(outputPath: string, deviceId?: string): Promise<void> { 36 | let command = `xcrun simctl io `; 37 | if (deviceId) { 38 | command += `"${deviceId}" `; 39 | } else { 40 | command += 'booted '; 41 | } 42 | command += `screenshot "${outputPath}"`; 43 | 44 | try { 45 | await execAsync(command); 46 | logger.debug({ outputPath, deviceId }, 'Screenshot captured successfully'); 47 | } catch (error: any) { 48 | logger.error({ error: error.message, outputPath, deviceId }, 'Failed to capture screenshot'); 49 | throw new Error(`Failed to capture screenshot: ${error.message}`); 50 | } 51 | } 52 | 53 | /** 54 | * Capture a screenshot and return as base64 55 | */ 56 | async screenshotData(deviceId?: string): Promise<{ base64: string; mimeType: string }> { 57 | // Create a temporary file path 58 | const tempPath = join(tmpdir(), `simulator-screenshot-${Date.now()}.png`); 59 | 60 | try { 61 | // Capture the screenshot to temp file 62 | await this.screenshot(tempPath, deviceId); 63 | 64 | // Read the file and convert to base64 65 | const imageData = readFileSync(tempPath); 66 | const base64 = imageData.toString('base64'); 67 | 68 | return { 69 | base64, 70 | mimeType: 'image/png' 71 | }; 72 | } finally { 73 | // Clean up temp file 74 | if (existsSync(tempPath)) { 75 | unlinkSync(tempPath); 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * Set simulator appearance (light/dark mode) 82 | * May fail on older Xcode versions 83 | */ 84 | async setAppearance(appearance: 'light' | 'dark', deviceId?: string): Promise<void> { 85 | let command = `xcrun simctl ui `; 86 | if (deviceId) { 87 | command += `"${deviceId}" `; 88 | } else { 89 | command += 'booted '; 90 | } 91 | command += appearance; 92 | 93 | try { 94 | await execAsync(command); 95 | logger.debug({ appearance, deviceId }, 'Appearance set successfully'); 96 | } catch (error: any) { 97 | // This command may not be available on older Xcode versions 98 | logger.debug({ error: error.message }, 'Could not set appearance (may not be supported)'); 99 | } 100 | } 101 | } ``` -------------------------------------------------------------------------------- /src/shared/domain/AppPath.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { DomainEmptyError, DomainInvalidTypeError, DomainInvalidFormatError, DomainRequiredError } from '../../domain/errors/DomainError.js'; 2 | 3 | /** 4 | * Value object for an app bundle path 5 | * Ensures the path ends with .app extension 6 | */ 7 | export class AppPath { 8 | private constructor(private readonly value: string) {} 9 | 10 | static create(path: unknown): AppPath { 11 | // Required check (for undefined/null) 12 | if (path === undefined || path === null) { 13 | throw new AppPath.RequiredError(); 14 | } 15 | 16 | // Type checking 17 | if (typeof path !== 'string') { 18 | throw new AppPath.InvalidTypeError(path); 19 | } 20 | 21 | // Empty check 22 | if (path.trim() === '') { 23 | throw new AppPath.EmptyError(path); 24 | } 25 | 26 | const trimmed = path.trim(); 27 | 28 | // Security checks first (before format validation) 29 | if (trimmed.includes('..')) { 30 | throw new AppPath.TraversalError(trimmed); 31 | } 32 | 33 | if (trimmed.includes('\0')) { 34 | throw new AppPath.NullCharacterError(trimmed); 35 | } 36 | 37 | // Format validation 38 | if (!trimmed.endsWith('.app') && !trimmed.endsWith('.app/')) { 39 | throw new AppPath.InvalidFormatError(trimmed); 40 | } 41 | 42 | return new AppPath(trimmed); 43 | } 44 | 45 | toString(): string { 46 | return this.value; 47 | } 48 | 49 | get name(): string { 50 | // Handle both forward slash and backslash for cross-platform support 51 | const separatorPattern = /[/\\]/; 52 | const parts = this.value.split(separatorPattern); 53 | const lastPart = parts[parts.length - 1]; 54 | 55 | // If path ends with /, the last part will be empty, so take the second to last 56 | return lastPart || parts[parts.length - 2]; 57 | } 58 | } 59 | 60 | // Nested error classes under AppPath namespace 61 | export namespace AppPath { 62 | // All AppPath errors extend DomainError for consistency 63 | 64 | export class RequiredError extends DomainRequiredError { 65 | constructor() { 66 | super('App path'); 67 | this.name = 'AppPath.RequiredError'; 68 | } 69 | } 70 | 71 | export class InvalidTypeError extends DomainInvalidTypeError { 72 | constructor(public readonly providedValue: unknown) { 73 | super('App path', 'string'); 74 | this.name = 'AppPath.InvalidTypeError'; 75 | } 76 | } 77 | 78 | export class EmptyError extends DomainEmptyError { 79 | constructor(public readonly providedValue: unknown) { 80 | super('App path'); 81 | this.name = 'AppPath.EmptyError'; 82 | } 83 | } 84 | 85 | export class InvalidFormatError extends DomainInvalidFormatError { 86 | constructor(public readonly path: string) { 87 | super('App path must end with .app'); 88 | this.name = 'AppPath.InvalidFormatError'; 89 | } 90 | } 91 | 92 | export class TraversalError extends DomainInvalidFormatError { 93 | constructor(public readonly path: string) { 94 | super('App path cannot contain directory traversal'); 95 | this.name = 'AppPath.TraversalError'; 96 | } 97 | } 98 | 99 | export class NullCharacterError extends DomainInvalidFormatError { 100 | constructor(public readonly path: string) { 101 | super('App path cannot contain null characters'); 102 | this.name = 'AppPath.NullCharacterError'; 103 | } 104 | } 105 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/use-cases/ListSimulatorsUseCase.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ListSimulatorsRequest } from '../domain/ListSimulatorsRequest.js'; 2 | import { ListSimulatorsResult, SimulatorInfo, SimulatorListParseError } from '../domain/ListSimulatorsResult.js'; 3 | import { DeviceRepository } from '../../../infrastructure/repositories/DeviceRepository.js'; 4 | import { Platform } from '../../../shared/domain/Platform.js'; 5 | import { SimulatorState } from '../domain/SimulatorState.js'; 6 | 7 | /** 8 | * Use case for listing available simulators 9 | */ 10 | export class ListSimulatorsUseCase { 11 | constructor( 12 | private readonly deviceRepository: DeviceRepository 13 | ) {} 14 | 15 | async execute(request: ListSimulatorsRequest): Promise<ListSimulatorsResult> { 16 | try { 17 | let allDevices; 18 | try { 19 | allDevices = await this.deviceRepository.getAllDevices(); 20 | } catch (error) { 21 | // JSON parsing errors from the repository 22 | return ListSimulatorsResult.failed(new SimulatorListParseError()); 23 | } 24 | 25 | const simulatorInfos: SimulatorInfo[] = []; 26 | 27 | for (const [runtime, devices] of Object.entries(allDevices)) { 28 | const platform = this.extractPlatformFromRuntime(runtime); 29 | const runtimeVersion = this.extractVersionFromRuntime(runtime); 30 | 31 | for (const device of devices) { 32 | if (!device.isAvailable) continue; 33 | 34 | const simulatorInfo: SimulatorInfo = { 35 | udid: device.udid, 36 | name: device.name, 37 | state: SimulatorState.parse(device.state), 38 | platform: platform, 39 | runtime: `${platform} ${runtimeVersion}` 40 | }; 41 | 42 | if (this.matchesFilter(simulatorInfo, request)) { 43 | simulatorInfos.push(simulatorInfo); 44 | } 45 | } 46 | } 47 | 48 | return ListSimulatorsResult.success(simulatorInfos); 49 | } catch (error) { 50 | return ListSimulatorsResult.failed( 51 | error instanceof Error ? error : new Error(String(error)) 52 | ); 53 | } 54 | } 55 | 56 | private matchesFilter(simulator: SimulatorInfo, request: ListSimulatorsRequest): boolean { 57 | if (request.platform) { 58 | const platformString = Platform[request.platform]; 59 | if (simulator.platform !== platformString) { 60 | return false; 61 | } 62 | } 63 | 64 | if (request.state && simulator.state !== request.state) { 65 | return false; 66 | } 67 | 68 | if (request.name) { 69 | const nameLower = simulator.name.toLowerCase(); 70 | const filterLower = request.name.toLowerCase(); 71 | if (!nameLower.includes(filterLower)) { 72 | return false; 73 | } 74 | } 75 | 76 | return true; 77 | } 78 | 79 | private extractPlatformFromRuntime(runtime: string): string { 80 | if (runtime.includes('iOS')) return 'iOS'; 81 | if (runtime.includes('tvOS')) return 'tvOS'; 82 | if (runtime.includes('watchOS')) return 'watchOS'; 83 | if (runtime.includes('xrOS') || runtime.includes('visionOS')) return 'visionOS'; 84 | if (runtime.includes('macOS')) return 'macOS'; 85 | return 'Unknown'; 86 | } 87 | 88 | private extractVersionFromRuntime(runtime: string): string { 89 | const match = runtime.match(/(\d+[-.]?\d*(?:[-.]?\d+)?)/); 90 | return match ? match[1].replace(/-/g, '.') : 'Unknown'; 91 | } 92 | 93 | } ``` -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Structured logging configuration for production 3 | * Uses Pino for high-performance JSON logging 4 | */ 5 | 6 | import pino from 'pino'; 7 | 8 | // Environment-based configuration 9 | const isDevelopment = process.env.NODE_ENV !== 'production'; 10 | const isTest = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID; 11 | const logLevel = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'); 12 | 13 | // Create logger instance 14 | export const logger = pino({ 15 | level: logLevel, 16 | // Use pretty printing in development, JSON in production 17 | // Disable transport in test environment to avoid thread-stream issues 18 | transport: (isDevelopment) ? { 19 | target: 'pino-pretty', 20 | options: { 21 | colorize: true, 22 | ignore: 'pid,hostname', 23 | translateTime: 'SYS:standard', 24 | singleLine: false, 25 | sync: true // Make pino-pretty synchronous to avoid race conditions 26 | } 27 | } : undefined, 28 | // Add metadata to all logs 29 | base: { 30 | service: 'mcp-xcode', 31 | version: '2.2.0' 32 | }, 33 | // Redact sensitive information 34 | redact: { 35 | paths: ['deviceId', 'udid', '*.password', '*.secret', '*.token'], 36 | censor: '[REDACTED]' 37 | }, 38 | // Add timestamp 39 | timestamp: pino.stdTimeFunctions.isoTime, 40 | // Serializers for common objects 41 | serializers: { 42 | error: pino.stdSerializers.err, 43 | request: (req: any) => ({ 44 | tool: req.tool, 45 | platform: req.platform, 46 | projectPath: req.projectPath?.replace(/\/Users\/[^/]+/, '/Users/[USER]') 47 | }) 48 | } 49 | }); 50 | 51 | // Create child loggers for different modules 52 | export const createModuleLogger = (module: string) => { 53 | const moduleLogger = logger.child({ module }); 54 | 55 | // In test environment, wrap methods to add test name dynamically 56 | if (isTest) { 57 | const methods = ['info', 'error', 'warn', 'debug', 'trace', 'fatal'] as const; 58 | 59 | methods.forEach(method => { 60 | const originalMethod = moduleLogger[method].bind(moduleLogger); 61 | (moduleLogger as any)[method] = function(obj: any, ...rest: any[]) { 62 | try { 63 | // @ts-ignore - expect is only available in test environment 64 | const testName = global.expect?.getState?.()?.currentTestName; 65 | if (testName && obj && typeof obj === 'object') { 66 | // Add test name to the context object 67 | obj = { ...obj, testName }; 68 | } 69 | } catch { 70 | // Ignore if expect is not available 71 | } 72 | return originalMethod(obj, ...rest); 73 | }; 74 | }); 75 | } 76 | 77 | return moduleLogger; 78 | }; 79 | 80 | // Export log levels for use in code 81 | export const LogLevel = { 82 | FATAL: 'fatal', 83 | ERROR: 'error', 84 | WARN: 'warn', 85 | INFO: 'info', 86 | DEBUG: 'debug', 87 | TRACE: 'trace' 88 | } as const; 89 | 90 | // Helper for logging tool executions 91 | export const logToolExecution = (toolName: string, args: any, duration?: number) => { 92 | logger.info({ 93 | event: 'tool_execution', 94 | tool: toolName, 95 | args: args, 96 | duration_ms: duration 97 | }, `Executed tool: ${toolName}`); 98 | }; 99 | 100 | // Helper for logging errors with context 101 | export const logError = (error: Error, context: Record<string, any>) => { 102 | logger.error({ 103 | error, 104 | ...context 105 | }, error.message); 106 | }; ``` -------------------------------------------------------------------------------- /XcodeProjectModifier/Sources/XcodeProjectModifier/main.swift: -------------------------------------------------------------------------------- ```swift 1 | import Foundation 2 | import XcodeProj 3 | import PathKit 4 | import ArgumentParser 5 | 6 | struct XcodeProjectModifier: ParsableCommand { 7 | @Argument(help: "Path to the .xcodeproj file") 8 | var projectPath: String 9 | 10 | @Argument(help: "Action to perform: add or remove") 11 | var action: String 12 | 13 | @Argument(help: "Path to the file to add/remove") 14 | var filePath: String 15 | 16 | @Argument(help: "Target name") 17 | var targetName: String 18 | 19 | @Option(name: .long, help: "Group path for the file") 20 | var groupPath: String = "" 21 | 22 | func run() throws { 23 | let project = try XcodeProj(pathString: projectPath) 24 | let pbxproj = project.pbxproj 25 | 26 | guard let target = pbxproj.nativeTargets.first(where: { $0.name == targetName }) else { 27 | print("Error: Target '\(targetName)' not found") 28 | throw ExitCode.failure 29 | } 30 | 31 | let fileName = URL(fileURLWithPath: filePath).lastPathComponent 32 | 33 | if action == "remove" { 34 | // Remove file reference 35 | if let fileRef = pbxproj.fileReferences.first(where: { $0.path == fileName || $0.path == filePath }) { 36 | pbxproj.delete(object: fileRef) 37 | print("Removed \(fileName) from project") 38 | } 39 | } else if action == "add" { 40 | // Remove existing reference if it exists 41 | if let existingRef = pbxproj.fileReferences.first(where: { $0.path == fileName || $0.path == filePath }) { 42 | pbxproj.delete(object: existingRef) 43 | } 44 | 45 | // Add new file reference 46 | let fileRef = PBXFileReference( 47 | sourceTree: .group, 48 | name: fileName, 49 | path: filePath 50 | ) 51 | pbxproj.add(object: fileRef) 52 | 53 | // Add to appropriate build phase based on file type 54 | let fileExtension = URL(fileURLWithPath: filePath).pathExtension.lowercased() 55 | 56 | if ["swift", "m", "mm", "c", "cpp", "cc", "cxx"].contains(fileExtension) { 57 | // Add to sources build phase 58 | if let sourcesBuildPhase = target.buildPhases.compactMap({ $0 as? PBXSourcesBuildPhase }).first { 59 | let buildFile = PBXBuildFile(file: fileRef) 60 | pbxproj.add(object: buildFile) 61 | sourcesBuildPhase.files?.append(buildFile) 62 | } 63 | } else if ["png", "jpg", "jpeg", "gif", "pdf", "json", "plist", "xib", "storyboard", "xcassets"].contains(fileExtension) { 64 | // Add to resources build phase 65 | if let resourcesBuildPhase = target.buildPhases.compactMap({ $0 as? PBXResourcesBuildPhase }).first { 66 | let buildFile = PBXBuildFile(file: fileRef) 67 | pbxproj.add(object: buildFile) 68 | resourcesBuildPhase.files?.append(buildFile) 69 | } 70 | } 71 | 72 | // Add to group 73 | if let mainGroup = try? pbxproj.rootProject()?.mainGroup { 74 | mainGroup.children.append(fileRef) 75 | } 76 | 77 | print("Added \(fileName) to project") 78 | } 79 | 80 | try project.write(path: Path(projectPath)) 81 | } 82 | } 83 | 84 | XcodeProjectModifier.main() ``` -------------------------------------------------------------------------------- /src/utils/devices/SimulatorApps.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | import path from 'path'; 4 | 5 | const logger = createModuleLogger('SimulatorApps'); 6 | 7 | /** 8 | * Handles app management on simulators 9 | * Single responsibility: Install, uninstall, and launch apps 10 | */ 11 | export class SimulatorApps { 12 | /** 13 | * Install an app on the simulator 14 | */ 15 | async install(appPath: string, deviceId?: string): Promise<void> { 16 | let command = `xcrun simctl install `; 17 | if (deviceId) { 18 | command += `"${deviceId}" `; 19 | } else { 20 | command += 'booted '; 21 | } 22 | command += `"${appPath}"`; 23 | 24 | try { 25 | await execAsync(command); 26 | logger.debug({ appPath, deviceId }, 'App installed successfully'); 27 | } catch (error: any) { 28 | logger.error({ error: error.message, appPath, deviceId }, 'Failed to install app'); 29 | throw new Error(`Failed to install app: ${error.message}`); 30 | } 31 | } 32 | 33 | /** 34 | * Uninstall an app from the simulator 35 | */ 36 | async uninstall(bundleId: string, deviceId?: string): Promise<void> { 37 | // First check if the app exists 38 | let listCommand = `xcrun simctl listapps `; 39 | if (deviceId) { 40 | listCommand += `"${deviceId}"`; 41 | } else { 42 | listCommand += 'booted'; 43 | } 44 | 45 | try { 46 | const { stdout: listOutput } = await execAsync(listCommand); 47 | 48 | // Check if the bundle ID exists in the output 49 | if (!listOutput.includes(bundleId)) { 50 | throw new Error(`App with bundle ID '${bundleId}' is not installed`); 51 | } 52 | 53 | // Now uninstall the app 54 | let command = `xcrun simctl uninstall `; 55 | if (deviceId) { 56 | command += `"${deviceId}" `; 57 | } else { 58 | command += 'booted '; 59 | } 60 | command += `"${bundleId}"`; 61 | 62 | await execAsync(command); 63 | logger.debug({ bundleId, deviceId }, 'App uninstalled successfully'); 64 | } catch (error: any) { 65 | logger.error({ error: error.message, bundleId, deviceId }, 'Failed to uninstall app'); 66 | throw new Error(`Failed to uninstall app: ${error.message}`); 67 | } 68 | } 69 | 70 | /** 71 | * Launch an app on the simulator 72 | * Returns the process ID of the launched app 73 | */ 74 | async launch(bundleId: string, deviceId?: string): Promise<string> { 75 | let command = `xcrun simctl launch --terminate-running-process `; 76 | if (deviceId) { 77 | command += `"${deviceId}" `; 78 | } else { 79 | command += 'booted '; 80 | } 81 | command += `"${bundleId}"`; 82 | 83 | try { 84 | const { stdout } = await execAsync(command); 85 | const pid = stdout.trim(); 86 | logger.debug({ bundleId, deviceId, pid }, 'App launched successfully'); 87 | return pid; 88 | } catch (error: any) { 89 | logger.error({ error: error.message, bundleId, deviceId }, 'Failed to launch app'); 90 | throw new Error(`Failed to launch app: ${error.message}`); 91 | } 92 | } 93 | 94 | /** 95 | * Get bundle ID from an app bundle 96 | */ 97 | async getBundleId(appPath: string): Promise<string> { 98 | try { 99 | const plistPath = path.join(appPath, 'Info.plist'); 100 | const { stdout } = await execAsync( 101 | `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${plistPath}"` 102 | ); 103 | return stdout.trim(); 104 | } catch (error: any) { 105 | logger.error({ error: error.message, appPath }, 'Failed to get bundle ID'); 106 | throw new Error(`Failed to get bundle ID: ${error.message}`); 107 | } 108 | } 109 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/controllers/ShutdownSimulatorController.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ShutdownSimulatorUseCase } from '../use-cases/ShutdownSimulatorUseCase.js'; 2 | import { DeviceId } from '../../../shared/domain/DeviceId.js'; 3 | import { ShutdownRequest } from '../domain/ShutdownRequest.js'; 4 | import { ShutdownResult, ShutdownOutcome, SimulatorNotFoundError, ShutdownCommandFailedError } from '../domain/ShutdownResult.js'; 5 | import { ErrorFormatter } from '../../../presentation/formatters/ErrorFormatter.js'; 6 | import { MCPController } from '../../../presentation/interfaces/MCPController.js'; 7 | 8 | /** 9 | * Controller for the shutdown_simulator MCP tool 10 | * 11 | * Handles input validation and orchestrates the shutdown simulator use case 12 | */ 13 | export class ShutdownSimulatorController implements MCPController { 14 | readonly name = 'shutdown_simulator'; 15 | readonly description = 'Shutdown a simulator'; 16 | 17 | constructor( 18 | private useCase: ShutdownSimulatorUseCase 19 | ) {} 20 | 21 | get inputSchema() { 22 | return { 23 | type: 'object' as const, 24 | properties: { 25 | deviceId: { 26 | type: 'string' as const, 27 | description: 'Device UDID or name of the simulator to shutdown' 28 | } 29 | }, 30 | required: ['deviceId'] as const 31 | }; 32 | } 33 | 34 | getToolDefinition() { 35 | return { 36 | name: this.name, 37 | description: this.description, 38 | inputSchema: this.inputSchema 39 | }; 40 | } 41 | 42 | async execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }> { 43 | try { 44 | // Cast to expected shape 45 | const input = args as { deviceId: unknown }; 46 | 47 | // Create domain value object - will validate 48 | const deviceId = DeviceId.create(input.deviceId); 49 | 50 | // Create domain request 51 | const request = ShutdownRequest.create(deviceId); 52 | 53 | // Execute use case 54 | const result = await this.useCase.execute(request); 55 | 56 | // Format response 57 | return { 58 | content: [{ 59 | type: 'text', 60 | text: this.formatResult(result) 61 | }] 62 | }; 63 | } catch (error: any) { 64 | // Handle validation and other errors consistently 65 | const message = ErrorFormatter.format(error); 66 | return { 67 | content: [{ 68 | type: 'text', 69 | text: `❌ ${message}` 70 | }] 71 | }; 72 | } 73 | } 74 | 75 | private formatResult(result: ShutdownResult): string { 76 | const { outcome, diagnostics } = result; 77 | 78 | switch (outcome) { 79 | case ShutdownOutcome.Shutdown: 80 | return `✅ Successfully shutdown simulator: ${diagnostics.simulatorName} (${diagnostics.simulatorId})`; 81 | 82 | case ShutdownOutcome.AlreadyShutdown: 83 | return `✅ Simulator already shutdown: ${diagnostics.simulatorName} (${diagnostics.simulatorId})`; 84 | 85 | case ShutdownOutcome.Failed: 86 | const { error } = diagnostics; 87 | 88 | if (error instanceof SimulatorNotFoundError) { 89 | // Use consistent error formatting with ❌ emoji 90 | return `❌ Simulator not found: ${error.deviceId}`; 91 | } 92 | 93 | if (error instanceof ShutdownCommandFailedError) { 94 | const message = ErrorFormatter.format(error); 95 | // Include simulator context if available 96 | if (diagnostics.simulatorName && diagnostics.simulatorId) { 97 | return `❌ ${diagnostics.simulatorName} (${diagnostics.simulatorId}) - ${message}`; 98 | } 99 | return `❌ ${message}`; 100 | } 101 | 102 | // Shouldn't happen but handle gracefully 103 | const fallbackMessage = error ? ErrorFormatter.format(error) : 'Shutdown operation failed'; 104 | return `❌ ${fallbackMessage}`; 105 | } 106 | } 107 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/SimulatorControlAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { SimulatorControlAdapter } from '../../infrastructure/SimulatorControlAdapter.js'; 3 | import { ICommandExecutor } from '../../../../application/ports/CommandPorts.js'; 4 | 5 | describe('SimulatorControlAdapter', () => { 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | function createSUT() { 11 | const mockExecute = jest.fn<ICommandExecutor['execute']>(); 12 | const mockExecutor: ICommandExecutor = { 13 | execute: mockExecute 14 | }; 15 | const sut = new SimulatorControlAdapter(mockExecutor); 16 | return { sut, mockExecute }; 17 | } 18 | 19 | describe('boot', () => { 20 | it('should boot simulator successfully', async () => { 21 | // Arrange 22 | const { sut, mockExecute } = createSUT(); 23 | mockExecute.mockResolvedValue({ 24 | stdout: '', 25 | stderr: '', 26 | exitCode: 0 27 | }); 28 | 29 | // Act 30 | await sut.boot('ABC-123'); 31 | 32 | // Assert 33 | expect(mockExecute).toHaveBeenCalledWith('xcrun simctl boot "ABC-123"'); 34 | }); 35 | 36 | it('should handle already booted simulator gracefully', async () => { 37 | // Arrange 38 | const { sut, mockExecute } = createSUT(); 39 | mockExecute.mockResolvedValue({ 40 | stdout: '', 41 | stderr: 'Unable to boot device in current state: Booted', 42 | exitCode: 149 43 | }); 44 | 45 | // Act & Assert - should not throw 46 | await expect(sut.boot('ABC-123')).resolves.toBeUndefined(); 47 | }); 48 | 49 | it('should throw error for device not found', async () => { 50 | // Arrange 51 | const { sut, mockExecute } = createSUT(); 52 | mockExecute.mockResolvedValue({ 53 | stdout: '', 54 | stderr: 'Invalid device: ABC-123', 55 | exitCode: 164 56 | }); 57 | 58 | // Act & Assert 59 | await expect(sut.boot('ABC-123')) 60 | .rejects.toThrow('Invalid device: ABC-123'); 61 | }); 62 | 63 | it('should throw error when simulator runtime is not installed on system', async () => { 64 | // Arrange 65 | const { sut, mockExecute } = createSUT(); 66 | mockExecute.mockResolvedValue({ 67 | stdout: '', 68 | stderr: 'The device runtime is not available', 69 | exitCode: 1 70 | }); 71 | 72 | // Act & Assert 73 | await expect(sut.boot('ABC-123')) 74 | .rejects.toThrow('The device runtime is not available'); 75 | }); 76 | }); 77 | 78 | describe('shutdown', () => { 79 | it('should shutdown simulator successfully', async () => { 80 | // Arrange 81 | const { sut, mockExecute } = createSUT(); 82 | mockExecute.mockResolvedValue({ 83 | stdout: '', 84 | stderr: '', 85 | exitCode: 0 86 | }); 87 | 88 | // Act 89 | await sut.shutdown('ABC-123'); 90 | 91 | // Assert 92 | expect(mockExecute).toHaveBeenCalledWith('xcrun simctl shutdown "ABC-123"'); 93 | }); 94 | 95 | it('should handle already shutdown simulator gracefully', async () => { 96 | // Arrange 97 | const { sut, mockExecute } = createSUT(); 98 | mockExecute.mockResolvedValue({ 99 | stdout: '', 100 | stderr: 'Unable to shutdown device in current state: Shutdown', 101 | exitCode: 149 102 | }); 103 | 104 | // Act & Assert - should not throw 105 | await expect(sut.shutdown('ABC-123')).resolves.toBeUndefined(); 106 | }); 107 | 108 | it('should throw error for device not found', async () => { 109 | // Arrange 110 | const { sut, mockExecute } = createSUT(); 111 | mockExecute.mockResolvedValue({ 112 | stdout: '', 113 | stderr: 'Invalid device: ABC-123', 114 | exitCode: 164 115 | }); 116 | 117 | // Act & Assert 118 | await expect(sut.shutdown('ABC-123')) 119 | .rejects.toThrow('Invalid device: ABC-123'); 120 | }); 121 | }); 122 | }); ``` -------------------------------------------------------------------------------- /src/utils/devices/SimulatorInfo.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | import { SimulatorDevice, Platform } from '../../types.js'; 4 | 5 | const logger = createModuleLogger('SimulatorInfo'); 6 | 7 | /** 8 | * Provides information about simulators 9 | * Single responsibility: Query simulator state and logs 10 | */ 11 | export class SimulatorInfo { 12 | /** 13 | * List all available simulators, optionally filtered by platform 14 | */ 15 | async list(platform?: Platform, showAll = false): Promise<SimulatorDevice[]> { 16 | try { 17 | const { stdout } = await execAsync('xcrun simctl list devices --json'); 18 | const data = JSON.parse(stdout); 19 | 20 | const devices: SimulatorDevice[] = []; 21 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 22 | // Filter by platform if specified 23 | if (platform) { 24 | const runtimeLower = runtime.toLowerCase(); 25 | const platformLower = platform.toLowerCase(); 26 | 27 | // Handle visionOS which is internally called xrOS 28 | const isVisionOS = platformLower === 'visionos' && runtimeLower.includes('xros'); 29 | const isOtherPlatform = platformLower !== 'visionos' && runtimeLower.includes(platformLower); 30 | 31 | if (!isVisionOS && !isOtherPlatform) { 32 | continue; 33 | } 34 | } 35 | 36 | for (const device of deviceList as any[]) { 37 | if (!showAll && !device.isAvailable) { 38 | continue; 39 | } 40 | devices.push({ 41 | udid: device.udid, 42 | name: device.name, 43 | state: device.state, 44 | deviceTypeIdentifier: device.deviceTypeIdentifier, 45 | runtime: runtime.replace('com.apple.CoreSimulator.SimRuntime.', ''), 46 | isAvailable: device.isAvailable 47 | }); 48 | } 49 | } 50 | 51 | return devices; 52 | } catch (error: any) { 53 | logger.error({ error: error.message }, 'Failed to list simulators'); 54 | throw new Error(`Failed to list simulators: ${error.message}`); 55 | } 56 | } 57 | 58 | /** 59 | * Get device logs from the simulator 60 | */ 61 | async logs(deviceId?: string, predicate?: string, last: string = '5m'): Promise<string> { 62 | let command = `xcrun simctl spawn `; 63 | if (deviceId) { 64 | command += `"${deviceId}" `; 65 | } else { 66 | command += 'booted '; 67 | } 68 | command += `log show --style syslog --last ${last}`; 69 | 70 | if (predicate) { 71 | command += ` --predicate '${predicate}'`; 72 | } 73 | 74 | try { 75 | const { stdout } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 }); 76 | // Return last 100 lines to keep it manageable 77 | const lines = stdout.split('\n').slice(-100); 78 | return lines.join('\n'); 79 | } catch (error: any) { 80 | logger.error({ error: error.message, deviceId, predicate }, 'Failed to get device logs'); 81 | throw new Error(`Failed to get device logs: ${error.message}`); 82 | } 83 | } 84 | 85 | /** 86 | * Get the state of a specific device 87 | */ 88 | async getDeviceState(deviceId: string): Promise<string> { 89 | const devices = await this.list(undefined, true); 90 | const device = devices.find(d => d.udid === deviceId || d.name === deviceId); 91 | 92 | if (!device) { 93 | throw new Error(`Device '${deviceId}' not found`); 94 | } 95 | 96 | return device.state; 97 | } 98 | 99 | /** 100 | * Check if a device is available 101 | */ 102 | async isAvailable(deviceId: string): Promise<boolean> { 103 | const devices = await this.list(undefined, true); 104 | const device = devices.find(d => d.udid === deviceId || d.name === deviceId); 105 | 106 | return device?.isAvailable || false; 107 | } 108 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/controllers/BootSimulatorController.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { BootSimulatorUseCase } from '../use-cases/BootSimulatorUseCase.js'; 2 | import { BootRequest } from '../domain/BootRequest.js'; 3 | import { DeviceId } from '../../../shared/domain/DeviceId.js'; 4 | import { BootResult, BootOutcome, SimulatorNotFoundError, BootCommandFailedError, SimulatorBusyError } from '../domain/BootResult.js'; 5 | import { ErrorFormatter } from '../../../presentation/formatters/ErrorFormatter.js'; 6 | import { MCPController } from '../../../presentation/interfaces/MCPController.js'; 7 | 8 | /** 9 | * Controller for the boot_simulator MCP tool 10 | * 11 | * Handles input validation and orchestrates the boot simulator use case 12 | */ 13 | export class BootSimulatorController implements MCPController { 14 | readonly name = 'boot_simulator'; 15 | readonly description = 'Boot a simulator'; 16 | 17 | constructor( 18 | private useCase: BootSimulatorUseCase 19 | ) {} 20 | 21 | get inputSchema() { 22 | return { 23 | type: 'object' as const, 24 | properties: { 25 | deviceId: { 26 | type: 'string' as const, 27 | description: 'Device UDID or name of the simulator to boot' 28 | } 29 | }, 30 | required: ['deviceId'] as const 31 | }; 32 | } 33 | 34 | getToolDefinition() { 35 | return { 36 | name: this.name, 37 | description: this.description, 38 | inputSchema: this.inputSchema 39 | }; 40 | } 41 | 42 | async execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }> { 43 | try { 44 | // Cast to expected shape 45 | const input = args as { deviceId: unknown }; 46 | 47 | // Create domain value object - will validate 48 | const deviceId = DeviceId.create(input.deviceId); 49 | 50 | // Create domain request 51 | const request = BootRequest.create(deviceId); 52 | 53 | // Execute use case 54 | const result = await this.useCase.execute(request); 55 | 56 | // Format response 57 | return { 58 | content: [{ 59 | type: 'text', 60 | text: this.formatResult(result) 61 | }] 62 | }; 63 | } catch (error: any) { 64 | // Handle validation and other errors consistently 65 | const message = ErrorFormatter.format(error); 66 | return { 67 | content: [{ 68 | type: 'text', 69 | text: `❌ ${message}` 70 | }] 71 | }; 72 | } 73 | } 74 | 75 | private formatResult(result: BootResult): string { 76 | const { outcome, diagnostics } = result; 77 | 78 | switch (outcome) { 79 | case BootOutcome.Booted: 80 | return `✅ Successfully booted simulator: ${diagnostics.simulatorName} (${diagnostics.simulatorId})`; 81 | 82 | case BootOutcome.AlreadyBooted: 83 | return `✅ Simulator already booted: ${diagnostics.simulatorName} (${diagnostics.simulatorId})`; 84 | 85 | case BootOutcome.Failed: 86 | const { error } = diagnostics; 87 | 88 | if (error instanceof SimulatorNotFoundError) { 89 | // Use consistent error formatting with ❌ emoji 90 | return `❌ Simulator not found: ${error.deviceId}`; 91 | } 92 | 93 | if (error instanceof SimulatorBusyError) { 94 | // Handle simulator busy scenarios 95 | return `❌ Cannot boot simulator: currently ${error.currentState.toLowerCase()}`; 96 | } 97 | 98 | if (error instanceof BootCommandFailedError) { 99 | const message = ErrorFormatter.format(error); 100 | // Include simulator context if available 101 | if (diagnostics.simulatorName && diagnostics.simulatorId) { 102 | return `❌ ${diagnostics.simulatorName} (${diagnostics.simulatorId}) - ${message}`; 103 | } 104 | return `❌ ${message}`; 105 | } 106 | 107 | // Shouldn't happen but handle gracefully 108 | const fallbackMessage = error ? ErrorFormatter.format(error) : 'Boot operation failed'; 109 | return `❌ ${fallbackMessage}`; 110 | } 111 | } 112 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/infrastructure/SimulatorLocatorAdapter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { ISimulatorLocator, SimulatorInfo } from '../../../application/ports/SimulatorPorts.js'; 2 | import { ICommandExecutor } from '../../../application/ports/CommandPorts.js'; 3 | import { SimulatorState } from '../domain/SimulatorState.js'; 4 | 5 | /** 6 | * Locates simulators using xcrun simctl 7 | */ 8 | export class SimulatorLocatorAdapter implements ISimulatorLocator { 9 | constructor(private executor: ICommandExecutor) {} 10 | 11 | async findSimulator(idOrName: string): Promise<SimulatorInfo | null> { 12 | const result = await this.executor.execute('xcrun simctl list devices --json'); 13 | const data = JSON.parse(result.stdout); 14 | 15 | const allMatches: Array<{ 16 | device: any; 17 | runtime: string; 18 | }> = []; 19 | 20 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 21 | for (const device of deviceList as any[]) { 22 | if ((device.udid === idOrName || device.name === idOrName) && device.isAvailable) { 23 | allMatches.push({ device, runtime }); 24 | } 25 | } 26 | } 27 | 28 | if (allMatches.length === 0) { 29 | return null; 30 | } 31 | 32 | // Sort by: booted first, then newer runtime 33 | allMatches.sort((a, b) => { 34 | // Booted devices first 35 | if (a.device.state === SimulatorState.Booted && b.device.state !== SimulatorState.Booted) return -1; 36 | if (b.device.state === SimulatorState.Booted && a.device.state !== SimulatorState.Booted) return 1; 37 | 38 | // Then by runtime version (newer first) 39 | return this.compareRuntimeVersions(b.runtime, a.runtime); 40 | }); 41 | 42 | const selected = allMatches[0]; 43 | return { 44 | id: selected.device.udid, 45 | name: selected.device.name, 46 | state: SimulatorState.parse(selected.device.state), 47 | platform: this.extractPlatform(selected.runtime), 48 | runtime: selected.runtime 49 | }; 50 | } 51 | 52 | async findBootedSimulator(): Promise<SimulatorInfo | null> { 53 | const result = await this.executor.execute('xcrun simctl list devices --json'); 54 | const data = JSON.parse(result.stdout); 55 | 56 | const bootedDevices: SimulatorInfo[] = []; 57 | 58 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 59 | for (const device of deviceList as any[]) { 60 | if (device.state === SimulatorState.Booted && device.isAvailable) { 61 | bootedDevices.push({ 62 | id: device.udid, 63 | name: device.name, 64 | state: SimulatorState.Booted, 65 | platform: this.extractPlatform(runtime), 66 | runtime: runtime 67 | }); 68 | } 69 | } 70 | } 71 | 72 | if (bootedDevices.length === 0) { 73 | return null; 74 | } 75 | 76 | if (bootedDevices.length > 1) { 77 | throw new Error(`Multiple booted simulators found (${bootedDevices.length}). Please specify a simulator ID.`); 78 | } 79 | 80 | return bootedDevices[0]; 81 | } 82 | 83 | private extractPlatform(runtime: string): string { 84 | const runtimeLower = runtime.toLowerCase(); 85 | 86 | if (runtimeLower.includes('ios')) return 'iOS'; 87 | if (runtimeLower.includes('tvos')) return 'tvOS'; 88 | if (runtimeLower.includes('watchos')) return 'watchOS'; 89 | if (runtimeLower.includes('xros') || runtimeLower.includes('visionos')) return 'visionOS'; 90 | 91 | return 'iOS'; 92 | } 93 | 94 | private compareRuntimeVersions(runtimeA: string, runtimeB: string): number { 95 | const extractVersion = (runtime: string): number[] => { 96 | const match = runtime.match(/(\d+)-(\d+)/); 97 | if (!match) return [0, 0]; 98 | return [parseInt(match[1]), parseInt(match[2])]; 99 | }; 100 | 101 | const [majorA, minorA] = extractVersion(runtimeA); 102 | const [majorB, minorB] = extractVersion(runtimeB); 103 | 104 | if (majorA !== majorB) return majorA - majorB; 105 | return minorA - minorB; 106 | } 107 | } ``` -------------------------------------------------------------------------------- /src/features/app-management/controllers/InstallAppController.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { InstallAppUseCase } from '../use-cases/InstallAppUseCase.js'; 2 | import { InstallRequest } from '../domain/InstallRequest.js'; 3 | import { 4 | InstallResult, 5 | InstallOutcome, 6 | InstallCommandFailedError, 7 | SimulatorNotFoundError, 8 | NoBootedSimulatorError 9 | } from '../domain/InstallResult.js'; 10 | import { ErrorFormatter } from '../../../presentation/formatters/ErrorFormatter.js'; 11 | import { MCPController } from '../../../presentation/interfaces/MCPController.js'; 12 | 13 | /** 14 | * MCP Controller for installing apps on simulators 15 | * 16 | * Handles input validation and orchestrates the install app use case 17 | */ 18 | 19 | interface InstallAppArgs { 20 | appPath: unknown; 21 | simulatorId?: unknown; 22 | } 23 | 24 | export class InstallAppController implements MCPController { 25 | // MCP Tool metadata 26 | readonly name = 'install_app'; 27 | readonly description = 'Install an app on the simulator'; 28 | 29 | constructor( 30 | private useCase: InstallAppUseCase 31 | ) {} 32 | 33 | get inputSchema() { 34 | return { 35 | type: 'object' as const, 36 | properties: { 37 | appPath: { 38 | type: 'string', 39 | description: 'Path to the .app bundle' 40 | }, 41 | simulatorId: { 42 | type: 'string', 43 | description: 'Device UDID or name of the simulator (optional, uses booted device if not specified)' 44 | } 45 | }, 46 | required: ['appPath'] 47 | }; 48 | } 49 | 50 | getToolDefinition() { 51 | return { 52 | name: this.name, 53 | description: this.description, 54 | inputSchema: this.inputSchema 55 | }; 56 | } 57 | 58 | async execute(args: unknown): Promise<{ content: Array<{ type: string; text: string }> }> { 59 | try { 60 | // Type guard for input 61 | if (!args || typeof args !== 'object') { 62 | throw new Error('Invalid input: expected an object'); 63 | } 64 | 65 | const input = args as InstallAppArgs; 66 | 67 | // Create domain request (validation happens here) 68 | const request = InstallRequest.create(input.appPath, input.simulatorId); 69 | 70 | // Execute use case 71 | const result = await this.useCase.execute(request); 72 | 73 | // Format response 74 | return { 75 | content: [{ 76 | type: 'text', 77 | text: this.formatResult(result) 78 | }] 79 | }; 80 | } catch (error: any) { 81 | // Handle validation and use case errors consistently 82 | const message = ErrorFormatter.format(error); 83 | return { 84 | content: [{ 85 | type: 'text', 86 | text: `❌ ${message}` 87 | }] 88 | }; 89 | } 90 | } 91 | 92 | private formatResult(result: InstallResult): string { 93 | const { outcome, diagnostics } = result; 94 | 95 | switch (outcome) { 96 | case InstallOutcome.Succeeded: 97 | return `✅ Successfully installed ${diagnostics.bundleId} on ${diagnostics.simulatorName} (${diagnostics.simulatorId?.toString()})`; 98 | 99 | case InstallOutcome.Failed: 100 | const { error } = diagnostics; 101 | 102 | if (error instanceof NoBootedSimulatorError) { 103 | return `❌ No booted simulator found. Please boot a simulator first or specify a simulator ID.`; 104 | } 105 | 106 | if (error instanceof SimulatorNotFoundError) { 107 | return `❌ Simulator not found: ${error.simulatorId}`; 108 | } 109 | 110 | if (error instanceof InstallCommandFailedError) { 111 | const message = ErrorFormatter.format(error); 112 | // Include simulator context if available 113 | if (diagnostics.simulatorName && diagnostics.simulatorId) { 114 | return `❌ ${diagnostics.simulatorName} (${diagnostics.simulatorId.toString()}) - ${message}`; 115 | } 116 | return `❌ ${message}`; 117 | } 118 | 119 | // Shouldn't happen but handle gracefully 120 | const fallbackMessage = error ? ErrorFormatter.format(error) : 'Install operation failed'; 121 | return `❌ ${fallbackMessage}`; 122 | } 123 | } 124 | } ``` -------------------------------------------------------------------------------- /src/features/app-management/use-cases/InstallAppUseCase.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { SimulatorState } from '../../simulator/domain/SimulatorState.js'; 2 | import { InstallRequest } from '../domain/InstallRequest.js'; 3 | import { DeviceId } from '../../../shared/domain/DeviceId.js'; 4 | import { 5 | InstallResult, 6 | InstallCommandFailedError, 7 | SimulatorNotFoundError, 8 | NoBootedSimulatorError 9 | } from '../domain/InstallResult.js'; 10 | import { 11 | ISimulatorLocator, 12 | ISimulatorControl, 13 | IAppInstaller 14 | } from '../../../application/ports/SimulatorPorts.js'; 15 | import { ILogManager } from '../../../application/ports/LoggingPorts.js'; 16 | 17 | /** 18 | * Use Case: Install an app on a simulator 19 | * Orchestrates finding the target simulator, booting if needed, and installing the app 20 | */ 21 | export class InstallAppUseCase { 22 | constructor( 23 | private simulatorLocator: ISimulatorLocator, 24 | private simulatorControl: ISimulatorControl, 25 | private appInstaller: IAppInstaller, 26 | private logManager: ILogManager 27 | ) {} 28 | 29 | async execute(request: InstallRequest): Promise<InstallResult> { 30 | // Get app name from the AppPath value object 31 | const appName = request.appPath.name; 32 | 33 | // Find target simulator 34 | const simulator = request.simulatorId 35 | ? await this.simulatorLocator.findSimulator(request.simulatorId.toString()) 36 | : await this.simulatorLocator.findBootedSimulator(); 37 | 38 | if (!simulator) { 39 | this.logManager.saveDebugData('install-app-failed', { 40 | reason: 'simulator_not_found', 41 | requestedId: request.simulatorId?.toString() 42 | }, appName); 43 | 44 | const error = request.simulatorId 45 | ? new SimulatorNotFoundError(request.simulatorId) 46 | : new NoBootedSimulatorError(); 47 | return InstallResult.failed(error, request.appPath, request.simulatorId); 48 | } 49 | 50 | // Boot simulator if needed (only when specific ID provided) 51 | if (request.simulatorId) { 52 | if (simulator.state === SimulatorState.Shutdown) { 53 | try { 54 | await this.simulatorControl.boot(simulator.id); 55 | this.logManager.saveDebugData('simulator-auto-booted', { 56 | simulatorId: simulator.id, 57 | simulatorName: simulator.name 58 | }, appName); 59 | } catch (error: any) { 60 | this.logManager.saveDebugData('simulator-boot-failed', { 61 | simulatorId: simulator.id, 62 | error: error.message 63 | }, appName); 64 | const installError = new InstallCommandFailedError(error.message || error.toString()); 65 | return InstallResult.failed( 66 | installError, 67 | request.appPath, 68 | DeviceId.create(simulator.id), 69 | simulator.name 70 | ); 71 | } 72 | } 73 | } 74 | 75 | // Install the app 76 | try { 77 | await this.appInstaller.installApp( 78 | request.appPath.toString(), 79 | simulator.id 80 | ); 81 | 82 | this.logManager.saveDebugData('install-app-success', { 83 | simulator: simulator.name, 84 | simulatorId: simulator.id, 85 | app: appName 86 | }, appName); 87 | 88 | // Try to get bundle ID from app (could be enhanced later) 89 | const bundleId = appName; // For now, use app name as bundle ID 90 | 91 | return InstallResult.succeeded( 92 | bundleId, 93 | DeviceId.create(simulator.id), 94 | simulator.name, 95 | request.appPath 96 | ); 97 | } catch (error: any) { 98 | this.logManager.saveDebugData('install-app-error', { 99 | simulator: simulator.name, 100 | simulatorId: simulator.id, 101 | app: appName, 102 | error: error.message 103 | }, appName); 104 | 105 | const installError = new InstallCommandFailedError(error.message || error.toString()); 106 | return InstallResult.failed( 107 | installError, 108 | request.appPath, 109 | DeviceId.create(simulator.id), 110 | simulator.name 111 | ); 112 | } 113 | } 114 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/BootResult.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { 3 | BootResult, 4 | BootOutcome, 5 | SimulatorNotFoundError, 6 | BootCommandFailedError 7 | } from '../../domain/BootResult.js'; 8 | 9 | describe('BootResult', () => { 10 | describe('booted', () => { 11 | it('should create a result for newly booted simulator', () => { 12 | // Arrange 13 | const simulatorId = 'ABC123'; 14 | const simulatorName = 'iPhone 15'; 15 | 16 | // Act 17 | const result = BootResult.booted(simulatorId, simulatorName); 18 | 19 | // Assert - Test behavior: result indicates success 20 | expect(result.outcome).toBe(BootOutcome.Booted); 21 | expect(result.diagnostics.simulatorId).toBe(simulatorId); 22 | expect(result.diagnostics.simulatorName).toBe(simulatorName); 23 | }); 24 | 25 | it('should include optional diagnostics', () => { 26 | // Arrange 27 | const diagnostics = { platform: 'iOS', runtime: 'iOS-17.0' }; 28 | 29 | // Act 30 | const result = BootResult.booted('ABC123', 'iPhone 15', diagnostics); 31 | 32 | // Assert - Test behavior: diagnostics are preserved 33 | expect(result.diagnostics.platform).toBe('iOS'); 34 | expect(result.diagnostics.runtime).toBe('iOS-17.0'); 35 | }); 36 | }); 37 | 38 | describe('alreadyBooted', () => { 39 | it('should create a result for already running simulator', () => { 40 | // Arrange & Act 41 | const result = BootResult.alreadyBooted('ABC123', 'iPhone 15'); 42 | 43 | // Assert - Test behavior: result indicates already running 44 | expect(result.outcome).toBe(BootOutcome.AlreadyBooted); 45 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 46 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 47 | }); 48 | }); 49 | 50 | describe('failed', () => { 51 | it('should create a failure result with error', () => { 52 | // Arrange 53 | const error = new BootCommandFailedError('Device is locked'); 54 | 55 | // Act 56 | const result = BootResult.failed('ABC123', 'iPhone 15', error); 57 | 58 | // Assert - Test behavior: result indicates failure with error 59 | expect(result.outcome).toBe(BootOutcome.Failed); 60 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 61 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 62 | expect(result.diagnostics.error).toBe(error); 63 | }); 64 | 65 | it('should handle simulator not found error', () => { 66 | // Arrange 67 | const error = new SimulatorNotFoundError('iPhone-16'); 68 | 69 | // Act 70 | const result = BootResult.failed('iPhone-16', '', error); 71 | 72 | // Assert - Test behavior: error type is preserved 73 | expect(result.outcome).toBe(BootOutcome.Failed); 74 | expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); 75 | expect((result.diagnostics.error as SimulatorNotFoundError).deviceId).toBe('iPhone-16'); 76 | }); 77 | 78 | it('should include optional diagnostics on failure', () => { 79 | // Arrange 80 | const error = new BootCommandFailedError('Boot failed'); 81 | const diagnostics = { runtime: 'iOS-17.0' }; 82 | 83 | // Act 84 | const result = BootResult.failed('ABC123', 'iPhone 15', error, diagnostics); 85 | 86 | // Assert - Test behavior: diagnostics preserved even on failure 87 | expect(result.diagnostics.runtime).toBe('iOS-17.0'); 88 | expect(result.diagnostics.error).toBe(error); 89 | }); 90 | }); 91 | 92 | describe('immutability', () => { 93 | it('should create immutable results', () => { 94 | // Arrange 95 | const result = BootResult.booted('ABC123', 'iPhone 15'); 96 | 97 | // Act & Assert - Test behavior: results cannot be modified 98 | expect(() => { 99 | (result as any).outcome = BootOutcome.Failed; 100 | }).toThrow(); 101 | 102 | expect(() => { 103 | (result.diagnostics as any).simulatorId = 'XYZ789'; 104 | }).toThrow(); 105 | }); 106 | }); 107 | }); ``` -------------------------------------------------------------------------------- /src/infrastructure/tests/unit/DeviceRepository.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest } from '@jest/globals'; 2 | import { DeviceRepository, DeviceList } from '../../repositories/DeviceRepository.js'; 3 | import { ICommandExecutor } from '../../../application/ports/CommandPorts.js'; 4 | 5 | describe('DeviceRepository', () => { 6 | function createSUT() { 7 | const mockExecute = jest.fn<ICommandExecutor['execute']>(); 8 | const mockExecutor: ICommandExecutor = { execute: mockExecute }; 9 | const sut = new DeviceRepository(mockExecutor); 10 | return { sut, mockExecute }; 11 | } 12 | 13 | describe('getAllDevices', () => { 14 | it('should return parsed device list from xcrun simctl', async () => { 15 | // Arrange 16 | const { sut, mockExecute } = createSUT(); 17 | 18 | const mockDevices: DeviceList = { 19 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 20 | { 21 | udid: 'test-uuid-1', 22 | name: 'iPhone 15', 23 | state: 'Booted', 24 | isAvailable: true, 25 | deviceTypeIdentifier: 'com.apple.iPhone15' 26 | } 27 | ], 28 | 'com.apple.CoreSimulator.SimRuntime.iOS-16-4': [ 29 | { 30 | udid: 'test-uuid-2', 31 | name: 'iPhone 14', 32 | state: 'Shutdown', 33 | isAvailable: true 34 | } 35 | ] 36 | }; 37 | 38 | mockExecute.mockResolvedValue({ 39 | stdout: JSON.stringify({ devices: mockDevices }), 40 | stderr: '', 41 | exitCode: 0 42 | }); 43 | 44 | // Act 45 | const result = await sut.getAllDevices(); 46 | 47 | // Assert 48 | expect(mockExecute).toHaveBeenCalledWith('xcrun simctl list devices --json'); 49 | expect(result).toEqual(mockDevices); 50 | }); 51 | 52 | it('should handle empty device list', async () => { 53 | // Arrange 54 | const { sut, mockExecute } = createSUT(); 55 | const emptyDevices: DeviceList = {}; 56 | 57 | mockExecute.mockResolvedValue({ 58 | stdout: JSON.stringify({ devices: emptyDevices }), 59 | stderr: '', 60 | exitCode: 0 61 | }); 62 | 63 | // Act 64 | const result = await sut.getAllDevices(); 65 | 66 | // Assert 67 | expect(result).toEqual(emptyDevices); 68 | }); 69 | 70 | it('should propagate executor errors', async () => { 71 | // Arrange 72 | const { sut, mockExecute } = createSUT(); 73 | const error = new Error('Command failed'); 74 | mockExecute.mockRejectedValue(error); 75 | 76 | // Act & Assert 77 | await expect(sut.getAllDevices()).rejects.toThrow('Command failed'); 78 | }); 79 | 80 | it('should throw on invalid JSON response', async () => { 81 | // Arrange 82 | const { sut, mockExecute } = createSUT(); 83 | mockExecute.mockResolvedValue({ 84 | stdout: 'not valid json', 85 | stderr: '', 86 | exitCode: 0 87 | }); 88 | 89 | // Act & Assert 90 | await expect(sut.getAllDevices()).rejects.toThrow(); 91 | }); 92 | 93 | it('should handle devices with all optional fields', async () => { 94 | // Arrange 95 | const { sut, mockExecute } = createSUT(); 96 | 97 | const deviceWithAllFields: DeviceList = { 98 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 99 | { 100 | udid: 'full-uuid', 101 | name: 'iPhone 15 Pro', 102 | state: 'Booted', 103 | isAvailable: true, 104 | deviceTypeIdentifier: 'com.apple.iPhone15Pro', 105 | dataPath: '/path/to/data', 106 | dataPathSize: 1024000, 107 | logPath: '/path/to/logs' 108 | } 109 | ] 110 | }; 111 | 112 | mockExecute.mockResolvedValue({ 113 | stdout: JSON.stringify({ devices: deviceWithAllFields }), 114 | stderr: '', 115 | exitCode: 0 116 | }); 117 | 118 | // Act 119 | const result = await sut.getAllDevices(); 120 | 121 | // Assert 122 | expect(result).toEqual(deviceWithAllFields); 123 | const device = result['com.apple.CoreSimulator.SimRuntime.iOS-17-0'][0]; 124 | expect(device.dataPath).toBe('/path/to/data'); 125 | expect(device.dataPathSize).toBe(1024000); 126 | expect(device.logPath).toBe('/path/to/logs'); 127 | }); 128 | }); 129 | }); ``` -------------------------------------------------------------------------------- /src/infrastructure/tests/unit/DependencyChecker.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest } from '@jest/globals'; 2 | import { DependencyChecker } from '../../services/DependencyChecker.js'; 3 | import { ICommandExecutor } from '../../../application/ports/CommandPorts.js'; 4 | 5 | describe('DependencyChecker', () => { 6 | function createSUT() { 7 | const mockExecute = jest.fn<ICommandExecutor['execute']>(); 8 | const mockExecutor: ICommandExecutor = { execute: mockExecute }; 9 | const sut = new DependencyChecker(mockExecutor); 10 | return { sut, mockExecute }; 11 | } 12 | 13 | describe('check', () => { 14 | it('should return empty array when all dependencies are installed', async () => { 15 | // Arrange 16 | const { sut, mockExecute } = createSUT(); 17 | 18 | // All which commands succeed 19 | mockExecute.mockResolvedValue({ 20 | stdout: '/usr/bin/xcodebuild', 21 | stderr: '', 22 | exitCode: 0 23 | }); 24 | 25 | // Act 26 | const result = await sut.check(['xcodebuild', 'xcbeautify']); 27 | 28 | // Assert - behavior: no missing dependencies 29 | expect(result).toEqual([]); 30 | }); 31 | 32 | it('should return missing dependencies with install commands', async () => { 33 | // Arrange 34 | const { sut, mockExecute } = createSUT(); 35 | 36 | // xcbeautify not found 37 | mockExecute.mockResolvedValue({ 38 | stdout: '', 39 | stderr: 'xcbeautify not found', 40 | exitCode: 1 41 | }); 42 | 43 | // Act 44 | const result = await sut.check(['xcbeautify']); 45 | 46 | // Assert - behavior: returns missing dependency with install command 47 | expect(result).toEqual([ 48 | { 49 | name: 'xcbeautify', 50 | installCommand: 'brew install xcbeautify' 51 | } 52 | ]); 53 | }); 54 | 55 | it('should handle mix of installed and missing dependencies', async () => { 56 | // Arrange 57 | const { sut, mockExecute } = createSUT(); 58 | 59 | // xcodebuild found, xcbeautify not found 60 | mockExecute 61 | .mockResolvedValueOnce({ 62 | stdout: '/usr/bin/xcodebuild', 63 | stderr: '', 64 | exitCode: 0 65 | }) 66 | .mockResolvedValueOnce({ 67 | stdout: '', 68 | stderr: 'xcbeautify not found', 69 | exitCode: 1 70 | }); 71 | 72 | // Act 73 | const result = await sut.check(['xcodebuild', 'xcbeautify']); 74 | 75 | // Assert - behavior: only missing dependencies returned 76 | expect(result).toEqual([ 77 | { 78 | name: 'xcbeautify', 79 | installCommand: 'brew install xcbeautify' 80 | } 81 | ]); 82 | }); 83 | 84 | it('should handle unknown dependencies', async () => { 85 | // Arrange 86 | const { sut, mockExecute } = createSUT(); 87 | 88 | // Unknown tool not found 89 | mockExecute.mockResolvedValue({ 90 | stdout: '', 91 | stderr: 'unknowntool not found', 92 | exitCode: 1 93 | }); 94 | 95 | // Act 96 | const result = await sut.check(['unknowntool']); 97 | 98 | // Assert - behavior: returns missing dependency without install command 99 | expect(result).toEqual([ 100 | { 101 | name: 'unknowntool' 102 | } 103 | ]); 104 | }); 105 | 106 | it('should provide appropriate install commands for known tools', async () => { 107 | // Arrange 108 | const { sut, mockExecute } = createSUT(); 109 | 110 | // All tools missing 111 | mockExecute.mockResolvedValue({ 112 | stdout: '', 113 | stderr: 'not found', 114 | exitCode: 1 115 | }); 116 | 117 | // Act 118 | const result = await sut.check(['xcodebuild', 'xcrun', 'xcbeautify']); 119 | 120 | // Assert - behavior: each tool has appropriate install command 121 | expect(result).toContainEqual({ 122 | name: 'xcodebuild', 123 | installCommand: 'Install Xcode from the App Store' 124 | }); 125 | expect(result).toContainEqual({ 126 | name: 'xcrun', 127 | installCommand: 'Install Xcode Command Line Tools: xcode-select --install' 128 | }); 129 | expect(result).toContainEqual({ 130 | name: 'xcbeautify', 131 | installCommand: 'brew install xcbeautify' 132 | }); 133 | }); 134 | }); 135 | }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/e2e/InstallAppMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for Install App through MCP Protocol 3 | * 4 | * Tests critical user journey: Installing an app on simulator through MCP 5 | * Following testing philosophy: E2E tests for critical paths only (10%) 6 | * 7 | * Focus: MCP protocol interaction, not app installation logic 8 | * The controller tests already verify installation works with real simulators 9 | * This test verifies the MCP transport/serialization/protocol works 10 | * 11 | * NO MOCKS - Uses real MCP server, real simulators, real apps 12 | */ 13 | 14 | import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from '@jest/globals'; 15 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 16 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 17 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 18 | import { createAndConnectClient, cleanupClientAndTransport, bootAndWaitForSimulator } from '../../../../shared/tests/utils/testHelpers.js'; 19 | import { TestProjectManager } from '../../../../shared/tests/utils/TestProjectManager.js'; 20 | import { exec } from 'child_process'; 21 | import { promisify } from 'util'; 22 | import * as fs from 'fs'; 23 | 24 | const execAsync = promisify(exec); 25 | 26 | describe('Install App MCP E2E', () => { 27 | let client: Client; 28 | let transport: StdioClientTransport; 29 | let testManager: TestProjectManager; 30 | let testDeviceId: string; 31 | let testAppPath: string; 32 | 33 | beforeAll(async () => { 34 | // Prepare test projects 35 | testManager = new TestProjectManager(); 36 | await testManager.setup(); 37 | 38 | // Build the server 39 | const { execSync } = await import('child_process'); 40 | execSync('npm run build', { stdio: 'inherit' }); 41 | 42 | // Build the test app using TestProjectManager 43 | testAppPath = await testManager.buildApp('xcodeProject'); 44 | 45 | // Get the latest iOS runtime 46 | const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); 47 | const runtimes = JSON.parse(runtimesResult.stdout); 48 | const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); 49 | 50 | if (!iosRuntime) { 51 | throw new Error('No iOS runtime found. Please install an iOS simulator runtime.'); 52 | } 53 | 54 | // Create and boot a test simulator 55 | const createResult = await execAsync( 56 | `xcrun simctl create "TestSimulator-InstallAppMCP" "iPhone 15" "${iosRuntime.identifier}"` 57 | ); 58 | testDeviceId = createResult.stdout.trim(); 59 | 60 | // Boot the simulator and wait for it to be ready 61 | await bootAndWaitForSimulator(testDeviceId, 30); 62 | }); 63 | 64 | afterAll(async () => { 65 | // Clean up simulator 66 | if (testDeviceId) { 67 | try { 68 | await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); 69 | await execAsync(`xcrun simctl delete "${testDeviceId}"`); 70 | } catch (error) { 71 | // Ignore cleanup errors 72 | } 73 | } 74 | 75 | // Clean up test project 76 | await testManager.cleanup(); 77 | }); 78 | 79 | beforeEach(async () => { 80 | ({ client, transport } = await createAndConnectClient()); 81 | }); 82 | 83 | afterEach(async () => { 84 | await cleanupClientAndTransport(client, transport); 85 | }); 86 | 87 | it('should complete install workflow through MCP', async () => { 88 | // This tests the critical user journey: 89 | // User connects via MCP → calls install_app → receives result 90 | 91 | const result = await client.request( 92 | { 93 | method: 'tools/call', 94 | params: { 95 | name: 'install_app', 96 | arguments: { 97 | appPath: testAppPath, 98 | simulatorId: testDeviceId 99 | } 100 | } 101 | }, 102 | CallToolResultSchema, 103 | { timeout: 120000 } 104 | ); 105 | 106 | expect(result).toBeDefined(); 107 | expect(result.content).toBeInstanceOf(Array); 108 | 109 | const textContent = result.content.find((c: any) => c.type === 'text'); 110 | expect(textContent?.text).toContain('Successfully installed'); 111 | }); 112 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/BootSimulatorMCP.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for Boot Simulator through MCP Protocol 3 | * 4 | * Tests critical user journey: Booting a simulator through MCP 5 | * Following testing philosophy: E2E tests for critical paths only (10%) 6 | * 7 | * Focus: MCP protocol interaction, not simulator boot logic 8 | * The controller tests already verify boot works with real simulators 9 | * This test verifies the MCP transport/serialization/protocol works 10 | * 11 | * NO MOCKS - Uses real MCP server, real simulators 12 | */ 13 | 14 | import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; 15 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 16 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 17 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 18 | import { createAndConnectClient, cleanupClientAndTransport } from '../../../../shared/tests/utils/testHelpers.js'; 19 | import { TestSimulatorManager } from '../../../../shared/tests/utils/TestSimulatorManager.js'; 20 | import { exec } from 'child_process'; 21 | import { promisify } from 'util'; 22 | 23 | const execAsync = promisify(exec); 24 | 25 | describe('Boot Simulator MCP E2E', () => { 26 | let client: Client; 27 | let transport: StdioClientTransport; 28 | let testSimManager: TestSimulatorManager; 29 | 30 | beforeAll(async () => { 31 | // Build the server 32 | const { execSync } = await import('child_process'); 33 | execSync('npm run build', { stdio: 'inherit' }); 34 | 35 | // Set up test simulator 36 | testSimManager = new TestSimulatorManager(); 37 | await testSimManager.getOrCreateSimulator('TestSimulator-BootMCP'); 38 | 39 | // Connect to MCP server 40 | ({ client, transport } = await createAndConnectClient()); 41 | }); 42 | 43 | beforeEach(async () => { 44 | // Ensure simulator is shutdown before each test 45 | await testSimManager.shutdownAndWait(5); 46 | }); 47 | 48 | afterAll(async () => { 49 | // Cleanup test simulator 50 | await testSimManager.cleanup(); 51 | 52 | // Cleanup MCP connection 53 | await cleanupClientAndTransport(client, transport); 54 | }); 55 | 56 | describe('boot simulator through MCP', () => { 57 | it('should boot simulator via MCP protocol', async () => { 58 | // Act - Call tool through MCP 59 | const result = await client.callTool({ 60 | name: 'boot_simulator', 61 | arguments: { 62 | deviceId: testSimManager.getSimulatorName() 63 | } 64 | }); 65 | 66 | // Assert - Verify MCP response 67 | const parsed = CallToolResultSchema.parse(result); 68 | expect(parsed.content[0].type).toBe('text'); 69 | expect(parsed.content[0].text).toBe(`✅ Successfully booted simulator: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 70 | 71 | // Verify simulator is actually booted 72 | const isBooted = await testSimManager.isBooted(); 73 | expect(isBooted).toBe(true); 74 | }); 75 | 76 | it('should handle already booted simulator via MCP', async () => { 77 | // Arrange - boot the simulator first 78 | await testSimManager.bootAndWait(5); 79 | await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for boot 80 | 81 | // Act - Call tool through MCP 82 | const result = await client.callTool({ 83 | name: 'boot_simulator', 84 | arguments: { 85 | deviceId: testSimManager.getSimulatorId() 86 | } 87 | }); 88 | 89 | // Assert 90 | const parsed = CallToolResultSchema.parse(result); 91 | expect(parsed.content[0].text).toBe(`✅ Simulator already booted: ${testSimManager.getSimulatorName()} (${testSimManager.getSimulatorId()})`); 92 | }); 93 | }); 94 | 95 | describe('error handling through MCP', () => { 96 | it('should return error for non-existent simulator', async () => { 97 | // Act 98 | const result = await client.callTool({ 99 | name: 'boot_simulator', 100 | arguments: { 101 | deviceId: 'NonExistentSimulator-MCP' 102 | } 103 | }); 104 | 105 | // Assert 106 | const parsed = CallToolResultSchema.parse(result); 107 | expect(parsed.content[0].text).toBe('❌ Simulator not found: NonExistentSimulator-MCP'); 108 | }); 109 | }); 110 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/unit/AppPath.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { AppPath } from '../../domain/AppPath.js'; 3 | 4 | describe('AppPath', () => { 5 | describe('create', () => { 6 | it('should create valid AppPath with .app extension', () => { 7 | // Arrange & Act 8 | const appPath = AppPath.create('/path/to/MyApp.app'); 9 | 10 | // Assert 11 | expect(appPath.toString()).toBe('/path/to/MyApp.app'); 12 | expect(appPath.name).toBe('MyApp.app'); 13 | }); 14 | 15 | it('should accept paths with spaces', () => { 16 | // Arrange & Act 17 | const appPath = AppPath.create('/path/to/My Cool App.app'); 18 | 19 | // Assert 20 | expect(appPath.toString()).toBe('/path/to/My Cool App.app'); 21 | expect(appPath.name).toBe('My Cool App.app'); 22 | }); 23 | 24 | it('should accept relative paths', () => { 25 | // Arrange & Act 26 | const appPath = AppPath.create('./build/Debug/TestApp.app'); 27 | 28 | // Assert 29 | expect(appPath.toString()).toBe('./build/Debug/TestApp.app'); 30 | expect(appPath.name).toBe('TestApp.app'); 31 | }); 32 | 33 | it('should throw error for empty path', () => { 34 | // Arrange, Act & Assert 35 | expect(() => AppPath.create('')).toThrow('App path cannot be empty'); 36 | }); 37 | 38 | it('should throw error for path without .app extension', () => { 39 | // Arrange, Act & Assert 40 | expect(() => AppPath.create('/path/to/MyApp')).toThrow('App path must end with .app'); 41 | expect(() => AppPath.create('/path/to/MyApp.ipa')).toThrow('App path must end with .app'); 42 | expect(() => AppPath.create('/path/to/binary')).toThrow('App path must end with .app'); 43 | }); 44 | 45 | it('should throw error for path with directory traversal', () => { 46 | // Arrange, Act & Assert 47 | expect(() => AppPath.create('../../../etc/passwd.app')).toThrow('App path cannot contain directory traversal'); 48 | expect(() => AppPath.create('/path/../../../etc/evil.app')).toThrow('App path cannot contain directory traversal'); 49 | expect(() => AppPath.create('/valid/path/../../sneaky.app')).toThrow('App path cannot contain directory traversal'); 50 | }); 51 | 52 | it('should throw error for path with null characters', () => { 53 | // Arrange, Act & Assert 54 | expect(() => AppPath.create('/path/to/MyApp.app\0')).toThrow('App path cannot contain null characters'); 55 | expect(() => AppPath.create('/path\0/to/MyApp.app')).toThrow('App path cannot contain null characters'); 56 | }); 57 | 58 | it('should handle paths ending with slash after .app', () => { 59 | // Arrange & Act 60 | const appPath = AppPath.create('/path/to/MyApp.app/'); 61 | 62 | // Assert 63 | expect(appPath.toString()).toBe('/path/to/MyApp.app/'); 64 | expect(appPath.name).toBe('MyApp.app'); 65 | }); 66 | }); 67 | 68 | describe('name property', () => { 69 | it('should extract app name from simple path', () => { 70 | // Arrange & Act 71 | const appPath = AppPath.create('/Users/dev/MyApp.app'); 72 | 73 | // Assert 74 | expect(appPath.name).toBe('MyApp.app'); 75 | }); 76 | 77 | it('should extract app name from Windows-style path', () => { 78 | // Arrange & Act 79 | const appPath = AppPath.create('C:\\Users\\dev\\MyApp.app'); 80 | 81 | // Assert 82 | expect(appPath.name).toBe('MyApp.app'); 83 | }); 84 | 85 | it('should handle app name with special characters', () => { 86 | // Arrange & Act 87 | const appPath = AppPath.create('/path/to/My-App_v1.2.3.app'); 88 | 89 | // Assert 90 | expect(appPath.name).toBe('My-App_v1.2.3.app'); 91 | }); 92 | 93 | it('should handle just the app name without path', () => { 94 | // Arrange & Act 95 | const appPath = AppPath.create('MyApp.app'); 96 | 97 | // Assert 98 | expect(appPath.name).toBe('MyApp.app'); 99 | }); 100 | }); 101 | 102 | describe('toString', () => { 103 | it('should return the original path', () => { 104 | // Arrange 105 | const originalPath = '/path/to/MyApp.app'; 106 | 107 | // Act 108 | const appPath = AppPath.create(originalPath); 109 | 110 | // Assert 111 | expect(appPath.toString()).toBe(originalPath); 112 | }); 113 | }); 114 | }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/AppInstallerAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { AppInstallerAdapter } from '../../infrastructure/AppInstallerAdapter.js'; 3 | import { ICommandExecutor } from '../../../../application/ports/CommandPorts.js'; 4 | 5 | describe('AppInstallerAdapter', () => { 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | function createSUT() { 11 | const mockExecute = jest.fn<ICommandExecutor['execute']>(); 12 | const mockExecutor: ICommandExecutor = { 13 | execute: mockExecute 14 | }; 15 | const sut = new AppInstallerAdapter(mockExecutor); 16 | return { sut, mockExecute }; 17 | } 18 | 19 | describe('installApp', () => { 20 | it('should install app successfully', async () => { 21 | // Arrange 22 | const { sut, mockExecute } = createSUT(); 23 | mockExecute.mockResolvedValue({ 24 | stdout: '', 25 | stderr: '', 26 | exitCode: 0 27 | }); 28 | 29 | // Act 30 | await sut.installApp('/path/to/MyApp.app', 'ABC-123'); 31 | 32 | // Assert 33 | expect(mockExecute).toHaveBeenCalledWith( 34 | 'xcrun simctl install "ABC-123" "/path/to/MyApp.app"' 35 | ); 36 | }); 37 | 38 | it('should handle paths with spaces', async () => { 39 | // Arrange 40 | const { sut, mockExecute } = createSUT(); 41 | mockExecute.mockResolvedValue({ 42 | stdout: '', 43 | stderr: '', 44 | exitCode: 0 45 | }); 46 | 47 | // Act 48 | await sut.installApp('/path/to/My Cool App.app', 'ABC-123'); 49 | 50 | // Assert 51 | expect(mockExecute).toHaveBeenCalledWith( 52 | 'xcrun simctl install "ABC-123" "/path/to/My Cool App.app"' 53 | ); 54 | }); 55 | 56 | it('should throw error for invalid app bundle', async () => { 57 | // Arrange 58 | const { sut, mockExecute } = createSUT(); 59 | mockExecute.mockResolvedValue({ 60 | stdout: '', 61 | stderr: 'An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=2):\nFailed to install "/path/to/NotAnApp.app"', 62 | exitCode: 1 63 | }); 64 | 65 | // Act & Assert 66 | await expect(sut.installApp('/path/to/NotAnApp.app', 'ABC-123')) 67 | .rejects.toThrow('An error was encountered processing the command'); 68 | }); 69 | 70 | it('should throw error when device not found', async () => { 71 | // Arrange 72 | const { sut, mockExecute } = createSUT(); 73 | mockExecute.mockResolvedValue({ 74 | stdout: '', 75 | stderr: 'Invalid device: NON-EXISTENT', 76 | exitCode: 164 77 | }); 78 | 79 | // Act & Assert 80 | await expect(sut.installApp('/path/to/MyApp.app', 'NON-EXISTENT')) 81 | .rejects.toThrow('Invalid device: NON-EXISTENT'); 82 | }); 83 | 84 | it('should throw error when simulator not booted', async () => { 85 | // Arrange 86 | const { sut, mockExecute } = createSUT(); 87 | mockExecute.mockResolvedValue({ 88 | stdout: '', 89 | stderr: 'Unable to install "/path/to/MyApp.app"\nAn error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to install applications when the device is not booted.', 90 | exitCode: 149 91 | }); 92 | 93 | // Act & Assert 94 | await expect(sut.installApp('/path/to/MyApp.app', 'ABC-123')) 95 | .rejects.toThrow('Unable to install applications when the device is not booted'); 96 | }); 97 | 98 | it('should throw generic error when stderr is empty', async () => { 99 | // Arrange 100 | const { sut, mockExecute } = createSUT(); 101 | mockExecute.mockResolvedValue({ 102 | stdout: '', 103 | stderr: '', 104 | exitCode: 1 105 | }); 106 | 107 | // Act & Assert 108 | await expect(sut.installApp('/path/to/MyApp.app', 'ABC-123')) 109 | .rejects.toThrow('Failed to install app'); 110 | }); 111 | 112 | it('should throw error for app with invalid signature', async () => { 113 | // Arrange 114 | const { sut, mockExecute } = createSUT(); 115 | mockExecute.mockResolvedValue({ 116 | stdout: '', 117 | stderr: 'The code signature version is no longer supported', 118 | exitCode: 1 119 | }); 120 | 121 | // Act & Assert 122 | await expect(sut.installApp('/path/to/MyApp.app', 'ABC-123')) 123 | .rejects.toThrow('The code signature version is no longer supported'); 124 | }); 125 | }); 126 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ShutdownResult.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { 3 | ShutdownResult, 4 | ShutdownOutcome, 5 | SimulatorNotFoundError, 6 | ShutdownCommandFailedError 7 | } from '../../domain/ShutdownResult.js'; 8 | 9 | describe('ShutdownResult', () => { 10 | describe('shutdown', () => { 11 | it('should create a successful shutdown result', () => { 12 | // Arrange 13 | const simulatorId = 'ABC123'; 14 | const simulatorName = 'iPhone 15'; 15 | 16 | // Act 17 | const result = ShutdownResult.shutdown(simulatorId, simulatorName); 18 | 19 | // Assert 20 | expect(result.outcome).toBe(ShutdownOutcome.Shutdown); 21 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 22 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 23 | expect(result.diagnostics.error).toBeUndefined(); 24 | }); 25 | }); 26 | 27 | describe('alreadyShutdown', () => { 28 | it('should create an already shutdown result', () => { 29 | // Arrange 30 | const simulatorId = 'ABC123'; 31 | const simulatorName = 'iPhone 15'; 32 | 33 | // Act 34 | const result = ShutdownResult.alreadyShutdown(simulatorId, simulatorName); 35 | 36 | // Assert 37 | expect(result.outcome).toBe(ShutdownOutcome.AlreadyShutdown); 38 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 39 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 40 | expect(result.diagnostics.error).toBeUndefined(); 41 | }); 42 | }); 43 | 44 | describe('failed', () => { 45 | it('should create a failed result with SimulatorNotFoundError', () => { 46 | // Arrange 47 | const error = new SimulatorNotFoundError('non-existent'); 48 | 49 | // Act 50 | const result = ShutdownResult.failed(undefined, undefined, error); 51 | 52 | // Assert 53 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 54 | expect(result.diagnostics.simulatorId).toBeUndefined(); 55 | expect(result.diagnostics.simulatorName).toBeUndefined(); 56 | expect(result.diagnostics.error).toBe(error); 57 | expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); 58 | }); 59 | 60 | it('should create a failed result with ShutdownCommandFailedError', () => { 61 | // Arrange 62 | const error = new ShutdownCommandFailedError('Device is busy'); 63 | const simulatorId = 'ABC123'; 64 | const simulatorName = 'iPhone 15'; 65 | 66 | // Act 67 | const result = ShutdownResult.failed(simulatorId, simulatorName, error); 68 | 69 | // Assert 70 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 71 | expect(result.diagnostics.simulatorId).toBe('ABC123'); 72 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 73 | expect(result.diagnostics.error).toBe(error); 74 | expect(result.diagnostics.error).toBeInstanceOf(ShutdownCommandFailedError); 75 | }); 76 | 77 | it('should handle generic errors', () => { 78 | // Arrange 79 | const error = new Error('Unknown error'); 80 | 81 | // Act 82 | const result = ShutdownResult.failed('123', 'Test Device', error); 83 | 84 | // Assert 85 | expect(result.outcome).toBe(ShutdownOutcome.Failed); 86 | expect(result.diagnostics.error).toBe(error); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('SimulatorNotFoundError', () => { 92 | it('should store device ID', () => { 93 | // Arrange & Act 94 | const error = new SimulatorNotFoundError('iPhone-16'); 95 | 96 | // Assert 97 | expect(error.deviceId).toBe('iPhone-16'); 98 | expect(error.name).toBe('SimulatorNotFoundError'); 99 | expect(error.message).toBe('iPhone-16'); 100 | }); 101 | 102 | it('should be an instance of Error', () => { 103 | // Arrange & Act 104 | const error = new SimulatorNotFoundError('test'); 105 | 106 | // Assert 107 | expect(error).toBeInstanceOf(Error); 108 | }); 109 | }); 110 | 111 | describe('ShutdownCommandFailedError', () => { 112 | it('should store stderr output', () => { 113 | // Arrange & Act 114 | const error = new ShutdownCommandFailedError('Device is locked'); 115 | 116 | // Assert 117 | expect(error.stderr).toBe('Device is locked'); 118 | expect(error.name).toBe('ShutdownCommandFailedError'); 119 | expect(error.message).toBe('Device is locked'); 120 | }); 121 | 122 | it('should handle empty stderr', () => { 123 | // Arrange & Act 124 | const error = new ShutdownCommandFailedError(''); 125 | 126 | // Assert 127 | expect(error.stderr).toBe(''); 128 | expect(error.message).toBe(''); 129 | }); 130 | 131 | it('should be an instance of Error', () => { 132 | // Arrange & Act 133 | const error = new ShutdownCommandFailedError('test'); 134 | 135 | // Assert 136 | expect(error).toBeInstanceOf(Error); 137 | }); 138 | }); ``` -------------------------------------------------------------------------------- /src/utils/projects/XcodeArchive.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | import { Platform } from '../../types.js'; 4 | import { PlatformInfo } from '../../features/build/domain/PlatformInfo.js'; 5 | import path from 'path'; 6 | 7 | const logger = createModuleLogger('XcodeArchive'); 8 | 9 | export interface ArchiveOptions { 10 | scheme: string; 11 | configuration?: string; 12 | platform?: Platform; 13 | archivePath?: string; 14 | } 15 | 16 | export interface ExportOptions { 17 | exportMethod?: 'app-store' | 'ad-hoc' | 'enterprise' | 'development'; 18 | exportPath?: string; 19 | } 20 | 21 | /** 22 | * Handles archiving and exporting for Xcode projects 23 | */ 24 | export class XcodeArchive { 25 | /** 26 | * Archive an Xcode project 27 | */ 28 | async archive( 29 | projectPath: string, 30 | isWorkspace: boolean, 31 | options: ArchiveOptions 32 | ): Promise<{ success: boolean; archivePath: string }> { 33 | const { 34 | scheme, 35 | configuration = 'Release', 36 | platform = Platform.iOS, 37 | archivePath 38 | } = options; 39 | 40 | // Generate archive path if not provided 41 | const finalArchivePath = archivePath || 42 | `./build/${scheme}-${new Date().toISOString().split('T')[0]}.xcarchive`; 43 | 44 | const projectFlag = isWorkspace ? '-workspace' : '-project'; 45 | let command = `xcodebuild archive ${projectFlag} "${projectPath}"`; 46 | command += ` -scheme "${scheme}"`; 47 | command += ` -configuration "${configuration}"`; 48 | command += ` -archivePath "${finalArchivePath}"`; 49 | 50 | // Add platform-specific destination 51 | const platformInfo = PlatformInfo.fromPlatform(platform); 52 | const destination = platformInfo.generateGenericDestination(); 53 | command += ` -destination "${destination}"`; 54 | 55 | logger.debug({ command }, 'Archive command'); 56 | 57 | try { 58 | const { stdout } = await execAsync(command, { 59 | maxBuffer: 50 * 1024 * 1024 60 | }); 61 | 62 | logger.info({ 63 | projectPath, 64 | scheme, 65 | archivePath: finalArchivePath 66 | }, 'Archive succeeded'); 67 | 68 | return { 69 | success: true, 70 | archivePath: finalArchivePath 71 | }; 72 | } catch (error: any) { 73 | logger.error({ error: error.message, projectPath }, 'Archive failed'); 74 | throw new Error(`Archive failed: ${error.message}`); 75 | } 76 | } 77 | 78 | /** 79 | * Export an IPA from an archive 80 | */ 81 | async exportIPA( 82 | archivePath: string, 83 | options: ExportOptions = {} 84 | ): Promise<{ success: boolean; ipaPath: string }> { 85 | const { 86 | exportMethod = 'development', 87 | exportPath = './build' 88 | } = options; 89 | 90 | // Create export options plist 91 | const exportPlist = `<?xml version="1.0" encoding="UTF-8"?> 92 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 93 | <plist version="1.0"> 94 | <dict> 95 | <key>method</key> 96 | <string>${exportMethod}</string> 97 | <key>compileBitcode</key> 98 | <false/> 99 | </dict> 100 | </plist>`; 101 | 102 | // Write plist to temp file 103 | const tempPlistPath = path.join(exportPath, 'ExportOptions.plist'); 104 | const { writeFile, mkdir } = await import('fs/promises'); 105 | await mkdir(exportPath, { recursive: true }); 106 | await writeFile(tempPlistPath, exportPlist); 107 | 108 | const command = `xcodebuild -exportArchive -archivePath "${archivePath}" -exportPath "${exportPath}" -exportOptionsPlist "${tempPlistPath}"`; 109 | 110 | logger.debug({ command }, 'Export command'); 111 | 112 | try { 113 | const { stdout } = await execAsync(command, { 114 | maxBuffer: 10 * 1024 * 1024 115 | }); 116 | 117 | // Find the IPA file in the export directory 118 | const { readdir } = await import('fs/promises'); 119 | const files = await readdir(exportPath); 120 | const ipaFile = files.find(f => f.endsWith('.ipa')); 121 | 122 | if (!ipaFile) { 123 | throw new Error('IPA file not found in export directory'); 124 | } 125 | 126 | const ipaPath = path.join(exportPath, ipaFile); 127 | 128 | // Clean up temp plist 129 | const { unlink } = await import('fs/promises'); 130 | await unlink(tempPlistPath).catch(() => {}); 131 | 132 | logger.info({ 133 | archivePath, 134 | ipaPath, 135 | exportMethod 136 | }, 'IPA export succeeded'); 137 | 138 | return { 139 | success: true, 140 | ipaPath 141 | }; 142 | } catch (error: any) { 143 | logger.error({ error: error.message, archivePath }, 'Export failed'); 144 | 145 | // Clean up temp plist 146 | const { unlink } = await import('fs/promises'); 147 | await unlink(tempPlistPath).catch(() => {}); 148 | 149 | throw new Error(`Export failed: ${error.message}`); 150 | } 151 | } 152 | } ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/InstallAppController.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { InstallAppController } from '../../controllers/InstallAppController.js'; 3 | import { InstallAppUseCase } from '../../use-cases/InstallAppUseCase.js'; 4 | import { InstallRequest } from '../../domain/InstallRequest.js'; 5 | import { InstallResult } from '../../domain/InstallResult.js'; 6 | import { AppPath } from '../../../../shared/domain/AppPath.js'; 7 | import { DeviceId } from '../../../../shared/domain/DeviceId.js'; 8 | 9 | describe('InstallAppController', () => { 10 | function createSUT() { 11 | const mockExecute = jest.fn<(request: InstallRequest) => Promise<InstallResult>>(); 12 | const mockUseCase: Partial<InstallAppUseCase> = { 13 | execute: mockExecute 14 | }; 15 | const sut = new InstallAppController(mockUseCase as InstallAppUseCase); 16 | return { sut, mockExecute }; 17 | } 18 | 19 | describe('MCP tool interface', () => { 20 | it('should define correct tool metadata', () => { 21 | const { sut } = createSUT(); 22 | 23 | const definition = sut.getToolDefinition(); 24 | 25 | expect(definition.name).toBe('install_app'); 26 | expect(definition.description).toBe('Install an app on the simulator'); 27 | expect(definition.inputSchema).toBeDefined(); 28 | }); 29 | 30 | it('should define correct input schema', () => { 31 | const { sut } = createSUT(); 32 | 33 | const schema = sut.inputSchema; 34 | 35 | expect(schema.type).toBe('object'); 36 | expect(schema.properties.appPath).toBeDefined(); 37 | expect(schema.properties.simulatorId).toBeDefined(); 38 | expect(schema.required).toEqual(['appPath']); 39 | }); 40 | }); 41 | 42 | describe('execute', () => { 43 | it('should install app on specified simulator', async () => { 44 | // Arrange 45 | const { sut, mockExecute } = createSUT(); 46 | const mockResult = InstallResult.succeeded( 47 | 'com.example.app', 48 | DeviceId.create('iPhone-15-Simulator'), 49 | 'iPhone 15', 50 | AppPath.create('/path/to/app.app') 51 | ); 52 | mockExecute.mockResolvedValue(mockResult); 53 | 54 | // Act 55 | const result = await sut.execute({ 56 | appPath: '/path/to/app.app', 57 | simulatorId: 'iPhone-15-Simulator' 58 | }); 59 | 60 | // Assert 61 | expect(result.content[0].text).toBe('✅ Successfully installed com.example.app on iPhone 15 (iPhone-15-Simulator)'); 62 | }); 63 | 64 | it('should install app on booted simulator when no ID specified', async () => { 65 | // Arrange 66 | const { sut, mockExecute } = createSUT(); 67 | const mockResult = InstallResult.succeeded( 68 | 'com.example.app', 69 | DeviceId.create('Booted-iPhone-15'), 70 | 'iPhone 15', 71 | AppPath.create('/path/to/app.app') 72 | ); 73 | mockExecute.mockResolvedValue(mockResult); 74 | 75 | // Act 76 | const result = await sut.execute({ 77 | appPath: '/path/to/app.app' 78 | }); 79 | 80 | // Assert 81 | expect(result.content[0].text).toBe('✅ Successfully installed com.example.app on iPhone 15 (Booted-iPhone-15)'); 82 | }); 83 | 84 | it('should handle validation errors', async () => { 85 | // Arrange 86 | const { sut } = createSUT(); 87 | 88 | // Act 89 | const result = await sut.execute({ 90 | // Missing required appPath 91 | simulatorId: 'test-sim' 92 | }); 93 | 94 | // Assert 95 | expect(result.content[0].text).toBe('❌ App path is required'); 96 | }); 97 | 98 | it('should handle use case errors', async () => { 99 | // Arrange 100 | const { sut, mockExecute } = createSUT(); 101 | mockExecute.mockRejectedValue(new Error('Simulator not found')); 102 | 103 | // Act 104 | const result = await sut.execute({ 105 | appPath: '/path/to/app.app', 106 | simulatorId: 'non-existent' 107 | }); 108 | 109 | // Assert 110 | expect(result.content[0].text).toBe('❌ Simulator not found'); 111 | }); 112 | 113 | it('should validate app path format', async () => { 114 | // Arrange 115 | const { sut } = createSUT(); 116 | 117 | // Act 118 | const result = await sut.execute({ 119 | appPath: '../../../etc/passwd' // Path traversal attempt 120 | }); 121 | 122 | // Assert 123 | expect(result.content[0].text).toBe('❌ App path cannot contain directory traversal'); 124 | }); 125 | 126 | it('should handle app not found errors', async () => { 127 | // Arrange 128 | const { sut, mockExecute } = createSUT(); 129 | mockExecute.mockRejectedValue(new Error('App bundle not found at path')); 130 | 131 | // Act 132 | const result = await sut.execute({ 133 | appPath: '/non/existent/app.app' 134 | }); 135 | 136 | // Assert 137 | expect(result.content[0].text).toBe('❌ App bundle not found at path'); 138 | }); 139 | }); 140 | 141 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/BootSimulatorController.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest } from '@jest/globals'; 2 | import { BootSimulatorController } from '../../controllers/BootSimulatorController.js'; 3 | import { BootSimulatorUseCase } from '../../use-cases/BootSimulatorUseCase.js'; 4 | import { BootRequest } from '../../domain/BootRequest.js'; 5 | import { BootResult, BootOutcome, BootCommandFailedError } from '../../domain/BootResult.js'; 6 | 7 | describe('BootSimulatorController', () => { 8 | function createSUT() { 9 | const mockExecute = jest.fn<(request: BootRequest) => Promise<BootResult>>(); 10 | const mockUseCase: Partial<BootSimulatorUseCase> = { 11 | execute: mockExecute 12 | }; 13 | const sut = new BootSimulatorController(mockUseCase as BootSimulatorUseCase); 14 | return { sut, mockExecute }; 15 | } 16 | 17 | describe('MCP tool interface', () => { 18 | it('should define correct tool metadata', () => { 19 | // Arrange 20 | const { sut } = createSUT(); 21 | 22 | // Act 23 | const definition = sut.getToolDefinition(); 24 | 25 | // Assert 26 | expect(definition.name).toBe('boot_simulator'); 27 | expect(definition.description).toBe('Boot a simulator'); 28 | expect(definition.inputSchema).toBeDefined(); 29 | }); 30 | 31 | it('should define correct input schema', () => { 32 | // Arrange 33 | const { sut } = createSUT(); 34 | 35 | // Act 36 | const schema = sut.inputSchema; 37 | 38 | // Assert 39 | expect(schema.type).toBe('object'); 40 | expect(schema.properties.deviceId).toBeDefined(); 41 | expect(schema.properties.deviceId.type).toBe('string'); 42 | expect(schema.required).toEqual(['deviceId']); 43 | }); 44 | }); 45 | 46 | describe('execute', () => { 47 | it('should boot simulator successfully', async () => { 48 | // Arrange 49 | const { sut, mockExecute } = createSUT(); 50 | const mockResult = BootResult.booted('ABC123', 'iPhone 15', { 51 | platform: 'iOS', 52 | runtime: 'iOS-17.0' 53 | }); 54 | mockExecute.mockResolvedValue(mockResult); 55 | 56 | // Act 57 | const result = await sut.execute({ 58 | deviceId: 'iPhone-15' 59 | }); 60 | 61 | // Assert 62 | expect(result.content[0].text).toBe('✅ Successfully booted simulator: iPhone 15 (ABC123)'); 63 | }); 64 | 65 | it('should handle already booted simulator', async () => { 66 | // Arrange 67 | const { sut, mockExecute } = createSUT(); 68 | const mockResult = BootResult.alreadyBooted('ABC123', 'iPhone 15'); 69 | mockExecute.mockResolvedValue(mockResult); 70 | 71 | // Act 72 | const result = await sut.execute({ 73 | deviceId: 'iPhone-15' 74 | }); 75 | 76 | // Assert 77 | expect(result.content[0].text).toBe('✅ Simulator already booted: iPhone 15 (ABC123)'); 78 | }); 79 | 80 | it('should handle boot failure', async () => { 81 | // Arrange 82 | const { sut, mockExecute } = createSUT(); 83 | const mockResult = BootResult.failed( 84 | 'ABC123', 85 | 'iPhone 15', 86 | new BootCommandFailedError('Device is locked') 87 | ); 88 | mockExecute.mockResolvedValue(mockResult); 89 | 90 | // Act 91 | const result = await sut.execute({ 92 | deviceId: 'iPhone-15' 93 | }); 94 | 95 | // Assert - Error with ❌ emoji prefix and simulator context 96 | expect(result.content[0].text).toBe('❌ iPhone 15 (ABC123) - Device is locked'); 97 | }); 98 | 99 | it('should validate required deviceId', async () => { 100 | // Arrange 101 | const { sut } = createSUT(); 102 | 103 | // Act 104 | const result = await sut.execute({} as any); 105 | 106 | // Assert 107 | expect(result.content[0].text).toBe('❌ Device ID is required'); 108 | }); 109 | 110 | it('should validate empty deviceId', async () => { 111 | // Arrange 112 | const { sut } = createSUT(); 113 | 114 | // Act 115 | const result = await sut.execute({ deviceId: '' }); 116 | 117 | // Assert 118 | expect(result.content[0].text).toBe('❌ Device ID cannot be empty'); 119 | }); 120 | 121 | it('should validate whitespace-only deviceId', async () => { 122 | // Arrange 123 | const { sut } = createSUT(); 124 | 125 | // Act 126 | const result = await sut.execute({ deviceId: ' ' }); 127 | 128 | // Assert 129 | expect(result.content[0].text).toBe('❌ Device ID cannot be whitespace only'); 130 | }); 131 | 132 | it('should pass UUID directly to use case', async () => { 133 | // Arrange 134 | const { sut, mockExecute } = createSUT(); 135 | const uuid = '838C707D-5703-4AEE-AF43-4798E0BA1B05'; 136 | const mockResult = BootResult.booted(uuid, 'iPhone 15'); 137 | mockExecute.mockResolvedValue(mockResult); 138 | 139 | // Act 140 | await sut.execute({ deviceId: uuid }); 141 | 142 | // Assert 143 | const calledWith = mockExecute.mock.calls[0][0]; 144 | expect(calledWith.deviceId).toBe(uuid); 145 | }); 146 | }); 147 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestEnvironmentCleaner.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execSync } from 'child_process'; 2 | import { createModuleLogger } from '../../../logger'; 3 | 4 | const logger = createModuleLogger('TestEnvironmentCleaner'); 5 | 6 | /** 7 | * Utility class for cleaning up test environment (simulators, processes, etc.) 8 | * Separate from TestProjectManager to maintain single responsibility 9 | */ 10 | export class TestEnvironmentCleaner { 11 | /** 12 | * Shutdown all running simulators 13 | * Faster than erasing simulators, just powers them off 14 | */ 15 | static shutdownAllSimulators(): void { 16 | try { 17 | execSync('xcrun simctl shutdown all', { stdio: 'ignore' }); 18 | } catch (error) { 19 | logger.debug('No simulators to shutdown or shutdown failed (normal)'); 20 | } 21 | } 22 | 23 | /** 24 | * Kill a running macOS app by name 25 | * @param appName Name of the app process to kill 26 | */ 27 | static killMacOSApp(appName: string): void { 28 | try { 29 | execSync(`pkill -f ${appName}`, { stdio: 'ignore' }); 30 | } catch (error) { 31 | // Ignore errors - app might not be running 32 | } 33 | } 34 | 35 | /** 36 | * Kill the test project app if it's running on macOS 37 | */ 38 | static killTestProjectApp(): void { 39 | this.killMacOSApp('TestProjectXCTest'); 40 | } 41 | 42 | /** 43 | * Clean DerivedData and SPM build artifacts for test projects 44 | */ 45 | static cleanDerivedData(): void { 46 | try { 47 | // Clean MCP-Xcode DerivedData location (where our tests actually write) 48 | // This includes Logs/Test/*.xcresult files 49 | execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/MCP-Xcode/TestProjectSwiftTesting', { 50 | shell: '/bin/bash', 51 | stdio: 'ignore' 52 | }); 53 | 54 | execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/MCP-Xcode/TestProjectXCTest', { 55 | shell: '/bin/bash', 56 | stdio: 'ignore' 57 | }); 58 | 59 | execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/MCP-Xcode/TestSwiftPackage*', { 60 | shell: '/bin/bash', 61 | stdio: 'ignore' 62 | }); 63 | 64 | // Also clean standard Xcode DerivedData locations (in case xcodebuild uses them directly) 65 | execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/TestProjectSwiftTesting-*', { 66 | shell: '/bin/bash', 67 | stdio: 'ignore' 68 | }); 69 | 70 | execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/TestProjectXCTest-*', { 71 | shell: '/bin/bash', 72 | stdio: 'ignore' 73 | }); 74 | 75 | execSync('rm -rf ~/Library/Developer/Xcode/DerivedData/TestSwiftPackage-*', { 76 | shell: '/bin/bash', 77 | stdio: 'ignore' 78 | }); 79 | 80 | // Clean SPM .build directories in test artifacts 81 | const testArtifactsDir = process.cwd() + '/test_artifacts'; 82 | execSync(`find ${testArtifactsDir} -name .build -type d -exec rm -rf {} + 2>/dev/null || true`, { 83 | shell: '/bin/bash', 84 | stdio: 'ignore' 85 | }); 86 | 87 | // Clean .swiftpm directories 88 | execSync(`find ${testArtifactsDir} -name .swiftpm -type d -exec rm -rf {} + 2>/dev/null || true`, { 89 | shell: '/bin/bash', 90 | stdio: 'ignore' 91 | }); 92 | 93 | } catch (error) { 94 | logger.debug('DerivedData cleanup failed or nothing to clean (normal)'); 95 | } 96 | } 97 | 98 | /** 99 | * Full cleanup of test environment 100 | * Shuts down simulators, kills test apps, and cleans DerivedData 101 | */ 102 | static cleanupTestEnvironment(): void { 103 | // Shutdown all simulators 104 | this.shutdownAllSimulators(); 105 | 106 | // Kill any running test apps 107 | this.killTestProjectApp(); 108 | 109 | // Clean DerivedData for test projects 110 | this.cleanDerivedData(); 111 | } 112 | 113 | /** 114 | * Reset a specific simulator by erasing its contents 115 | * @param deviceId The simulator device ID to reset 116 | */ 117 | static resetSimulator(deviceId: string): void { 118 | try { 119 | execSync(`xcrun simctl erase "${deviceId}"`); 120 | } catch (error: any) { 121 | // Log the actual error message for debugging 122 | logger.warn({ deviceId, error: error.message, stderr: error.stderr?.toString() }, 'Failed to erase simulator'); 123 | } 124 | } 125 | 126 | /** 127 | * Boot a specific simulator 128 | * @param deviceId The simulator device ID to boot 129 | */ 130 | static bootSimulator(deviceId: string): void { 131 | try { 132 | execSync(`xcrun simctl boot "${deviceId}"`, { stdio: 'ignore' }); 133 | } catch (error) { 134 | logger.warn({ deviceId }, 'Simulator already booted or boot failed'); 135 | } 136 | } 137 | 138 | /** 139 | * Shutdown a specific simulator 140 | * @param deviceId The simulator device ID to boot 141 | */ 142 | static shutdownSimulator(deviceId: string): void { 143 | try { 144 | execSync(`xcrun simctl shutdown "${deviceId}"`, { stdio: 'ignore' }); 145 | } catch (error) { 146 | logger.warn({ deviceId }, 'Simulator already booted or boot failed'); 147 | } 148 | } 149 | } ``` -------------------------------------------------------------------------------- /src/utils/projects/XcodeProject.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { XcodeBuild, BuildOptions, TestOptions } from './XcodeBuild.js'; 2 | import { XcodeArchive, ArchiveOptions, ExportOptions } from './XcodeArchive.js'; 3 | import { XcodeInfo } from './XcodeInfo.js'; 4 | import { Issue } from '../errors/index.js'; 5 | import { createModuleLogger } from '../../logger.js'; 6 | import * as pathModule from 'path'; 7 | import { Platform } from '../../types.js'; 8 | 9 | const logger = createModuleLogger('XcodeProject'); 10 | 11 | /** 12 | * Represents an Xcode project (.xcodeproj) or workspace (.xcworkspace) 13 | */ 14 | export class XcodeProject { 15 | public readonly name: string; 16 | private build: XcodeBuild; 17 | private archive: XcodeArchive; 18 | private info: XcodeInfo; 19 | 20 | constructor( 21 | public readonly path: string, 22 | public readonly type: 'project' | 'workspace', 23 | components?: { 24 | build?: XcodeBuild; 25 | archive?: XcodeArchive; 26 | info?: XcodeInfo; 27 | } 28 | ) { 29 | // Extract name from path 30 | const ext = type === 'workspace' ? '.xcworkspace' : '.xcodeproj'; 31 | this.name = pathModule.basename(this.path, ext); 32 | 33 | // Initialize components 34 | this.build = components?.build || new XcodeBuild(); 35 | this.archive = components?.archive || new XcodeArchive(); 36 | this.info = components?.info || new XcodeInfo(); 37 | 38 | logger.debug({ path: this.path, type, name: this.name }, 'XcodeProject created'); 39 | } 40 | 41 | /** 42 | * Build the project 43 | */ 44 | async buildProject(options: BuildOptions = {}): Promise<{ 45 | success: boolean; 46 | output: string; 47 | appPath?: string; 48 | logPath?: string; 49 | errors?: Issue[]; 50 | }> { 51 | logger.info({ path: this.path, options }, 'Building Xcode project'); 52 | 53 | const isWorkspace = this.type === 'workspace'; 54 | return await this.build.build(this.path, isWorkspace, options); 55 | } 56 | 57 | /** 58 | * Run tests for the project 59 | */ 60 | async test(options: TestOptions = {}): Promise<{ 61 | success: boolean; 62 | output: string; 63 | passed: number; 64 | failed: number; 65 | failingTests?: Array<{ identifier: string; reason: string }>; 66 | compileErrors?: Issue[]; 67 | compileWarnings?: Issue[]; 68 | buildErrors?: Issue[]; 69 | logPath: string; 70 | }> { 71 | logger.info({ path: this.path, options }, 'Testing Xcode project'); 72 | 73 | const isWorkspace = this.type === 'workspace'; 74 | return await this.build.test(this.path, isWorkspace, options); 75 | } 76 | 77 | /** 78 | * Archive the project for distribution 79 | */ 80 | async archiveProject(options: ArchiveOptions): Promise<{ 81 | success: boolean; 82 | archivePath: string; 83 | }> { 84 | logger.info({ path: this.path, options }, 'Archiving Xcode project'); 85 | 86 | const isWorkspace = this.type === 'workspace'; 87 | return await this.archive.archive(this.path, isWorkspace, options); 88 | } 89 | 90 | /** 91 | * Export an IPA from an archive 92 | */ 93 | async exportIPA( 94 | archivePath: string, 95 | options: ExportOptions = {} 96 | ): Promise<{ 97 | success: boolean; 98 | ipaPath: string; 99 | }> { 100 | logger.info({ archivePath, options }, 'Exporting IPA'); 101 | 102 | return await this.archive.exportIPA(archivePath, options); 103 | } 104 | 105 | /** 106 | * Clean build artifacts 107 | */ 108 | async clean(options: { 109 | scheme?: string; 110 | configuration?: string; 111 | } = {}): Promise<void> { 112 | logger.info({ path: this.path, options }, 'Cleaning Xcode project'); 113 | 114 | const isWorkspace = this.type === 'workspace'; 115 | await this.build.clean(this.path, isWorkspace, options); 116 | } 117 | 118 | /** 119 | * Get list of schemes 120 | */ 121 | async getSchemes(): Promise<string[]> { 122 | const isWorkspace = this.type === 'workspace'; 123 | return await this.info.getSchemes(this.path, isWorkspace); 124 | } 125 | 126 | /** 127 | * Get list of targets 128 | */ 129 | async getTargets(): Promise<string[]> { 130 | const isWorkspace = this.type === 'workspace'; 131 | return await this.info.getTargets(this.path, isWorkspace); 132 | } 133 | 134 | /** 135 | * Get build settings for a scheme 136 | */ 137 | async getBuildSettings( 138 | scheme: string, 139 | configuration?: string, 140 | platform?: Platform 141 | ): Promise<any> { 142 | const isWorkspace = this.type === 'workspace'; 143 | return await this.info.getBuildSettings( 144 | this.path, 145 | isWorkspace, 146 | scheme, 147 | configuration, 148 | platform 149 | ); 150 | } 151 | 152 | /** 153 | * Get comprehensive project information 154 | */ 155 | async getProjectInfo(): Promise<{ 156 | name: string; 157 | schemes: string[]; 158 | targets: string[]; 159 | configurations: string[]; 160 | }> { 161 | const isWorkspace = this.type === 'workspace'; 162 | return await this.info.getProjectInfo(this.path, isWorkspace); 163 | } 164 | 165 | /** 166 | * Check if this is a workspace 167 | */ 168 | isWorkspace(): boolean { 169 | return this.type === 'workspace'; 170 | } 171 | 172 | /** 173 | * Get the project directory 174 | */ 175 | getDirectory(): string { 176 | return pathModule.dirname(this.path); 177 | } 178 | } ``` -------------------------------------------------------------------------------- /src/utils/projects/SwiftPackage.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { SwiftBuild, SwiftBuildOptions, SwiftRunOptions, SwiftTestOptions } from './SwiftBuild.js'; 2 | import { SwiftPackageInfo, Dependency, Product } from './SwiftPackageInfo.js'; 3 | import { Issue } from '../errors/index.js'; 4 | import { createModuleLogger } from '../../logger.js'; 5 | import * as pathModule from 'path'; 6 | import { existsSync } from 'fs'; 7 | 8 | const logger = createModuleLogger('SwiftPackage'); 9 | 10 | /** 11 | * Represents a Swift package (Package.swift) 12 | */ 13 | export class SwiftPackage { 14 | public readonly name: string; 15 | private build: SwiftBuild; 16 | private info: SwiftPackageInfo; 17 | 18 | constructor( 19 | public readonly path: string, 20 | components?: { 21 | build?: SwiftBuild; 22 | info?: SwiftPackageInfo; 23 | } 24 | ) { 25 | // Validate that Package.swift exists 26 | const packageSwiftPath = pathModule.join(this.path, 'Package.swift'); 27 | if (!existsSync(packageSwiftPath)) { 28 | throw new Error(`No Package.swift found at: ${this.path}`); 29 | } 30 | 31 | // Extract name from directory 32 | this.name = pathModule.basename(this.path); 33 | 34 | // Initialize components 35 | this.build = components?.build || new SwiftBuild(); 36 | this.info = components?.info || new SwiftPackageInfo(); 37 | 38 | logger.debug({ path: this.path, name: this.name }, 'SwiftPackage created'); 39 | } 40 | 41 | /** 42 | * Build the package 43 | */ 44 | async buildPackage(options: SwiftBuildOptions = {}): Promise<{ 45 | success: boolean; 46 | output: string; 47 | logPath?: string; 48 | compileErrors?: Issue[]; 49 | buildErrors?: Issue[]; 50 | }> { 51 | logger.info({ path: this.path, options }, 'Building Swift package'); 52 | 53 | return await this.build.build(this.path, options); 54 | } 55 | 56 | /** 57 | * Run an executable from the package 58 | */ 59 | async run(options: SwiftRunOptions = {}): Promise<{ 60 | success: boolean; 61 | output: string; 62 | logPath?: string; 63 | compileErrors?: Issue[]; 64 | buildErrors?: Issue[]; 65 | }> { 66 | logger.info({ path: this.path, options }, 'Running Swift package'); 67 | 68 | return await this.build.run(this.path, options); 69 | } 70 | 71 | /** 72 | * Test the package 73 | */ 74 | async test(options: SwiftTestOptions = {}): Promise<{ 75 | success: boolean; 76 | output: string; 77 | passed: number; 78 | failed: number; 79 | failingTests?: Array<{ identifier: string; reason: string }>; 80 | compileErrors?: Issue[]; 81 | buildErrors?: Issue[]; 82 | logPath: string; 83 | }> { 84 | logger.info({ path: this.path, options }, 'Testing Swift package'); 85 | 86 | return await this.build.test(this.path, options); 87 | } 88 | 89 | /** 90 | * Clean build artifacts 91 | */ 92 | async clean(): Promise<void> { 93 | logger.info({ path: this.path }, 'Cleaning Swift package'); 94 | 95 | await this.build.clean(this.path); 96 | } 97 | 98 | /** 99 | * Get list of products (executables and libraries) 100 | */ 101 | async getProducts(): Promise<Product[]> { 102 | return await this.info.getProducts(this.path); 103 | } 104 | 105 | /** 106 | * Get list of targets 107 | */ 108 | async getTargets(): Promise<string[]> { 109 | return await this.info.getTargets(this.path); 110 | } 111 | 112 | /** 113 | * Get list of dependencies 114 | */ 115 | async getDependencies(): Promise<Dependency[]> { 116 | return await this.info.getDependencies(this.path); 117 | } 118 | 119 | /** 120 | * Add a dependency 121 | */ 122 | async addDependency( 123 | url: string, 124 | options: { 125 | version?: string; 126 | branch?: string; 127 | exact?: boolean; 128 | from?: string; 129 | upToNextMajor?: string; 130 | } = {} 131 | ): Promise<void> { 132 | logger.info({ path: this.path, url, options }, 'Adding dependency'); 133 | 134 | await this.info.addDependency(this.path, url, options); 135 | } 136 | 137 | /** 138 | * Remove a dependency 139 | */ 140 | async removeDependency(name: string): Promise<void> { 141 | logger.info({ path: this.path, name }, 'Removing dependency'); 142 | 143 | await this.info.removeDependency(this.path, name); 144 | } 145 | 146 | /** 147 | * Update all dependencies 148 | */ 149 | async updateDependencies(): Promise<void> { 150 | logger.info({ path: this.path }, 'Updating dependencies'); 151 | 152 | await this.info.updateDependencies(this.path); 153 | } 154 | 155 | /** 156 | * Resolve dependencies 157 | */ 158 | async resolveDependencies(): Promise<void> { 159 | logger.info({ path: this.path }, 'Resolving dependencies'); 160 | 161 | await this.info.resolveDependencies(this.path); 162 | } 163 | 164 | /** 165 | * Get the package directory 166 | */ 167 | getDirectory(): string { 168 | return this.path; 169 | } 170 | 171 | /** 172 | * Check if this is an executable package 173 | */ 174 | async isExecutable(): Promise<boolean> { 175 | const products = await this.getProducts(); 176 | return products.some(p => p.type === 'executable'); 177 | } 178 | 179 | /** 180 | * Get executable products 181 | */ 182 | async getExecutables(): Promise<string[]> { 183 | const products = await this.getProducts(); 184 | return products 185 | .filter(p => p.type === 'executable') 186 | .map(p => p.name); 187 | } 188 | } ``` -------------------------------------------------------------------------------- /src/utils/LogManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | 5 | /** 6 | * Manages persistent logs for MCP server debugging 7 | * Stores logs in ~/.mcp-xcode-server/logs/ with daily rotation 8 | */ 9 | export class LogManager { 10 | private static readonly LOG_DIR = path.join(os.homedir(), '.mcp-xcode-server', 'logs'); 11 | private static readonly MAX_AGE_DAYS = 7; 12 | 13 | /** 14 | * Initialize log directory structure 15 | */ 16 | private init(): void { 17 | if (!fs.existsSync(LogManager.LOG_DIR)) { 18 | fs.mkdirSync(LogManager.LOG_DIR, { recursive: true }); 19 | } 20 | 21 | // Clean up old logs on startup 22 | this.cleanupOldLogs(); 23 | } 24 | 25 | /** 26 | * Get the log directory for today 27 | */ 28 | private getTodayLogDir(): string { 29 | const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD 30 | const dir = path.join(LogManager.LOG_DIR, today); 31 | 32 | if (!fs.existsSync(dir)) { 33 | fs.mkdirSync(dir, { recursive: true }); 34 | } 35 | 36 | return dir; 37 | } 38 | 39 | /** 40 | * Generate a log filename with timestamp 41 | */ 42 | private getLogFilename(operation: string, projectName?: string): string { 43 | const timestamp = new Date().toISOString() 44 | .replace(/:/g, '-') 45 | .replace(/\./g, '-') 46 | .split('T')[1] 47 | .slice(0, 8); // HH-MM-SS 48 | 49 | const name = projectName ? `${operation}-${projectName}` : operation; 50 | return `${timestamp}-${name}.log`; 51 | } 52 | 53 | /** 54 | * Save log content to a file 55 | * Returns the full path to the log file 56 | */ 57 | saveLog( 58 | operation: 'build' | 'test' | 'run' | 'archive' | 'clean', 59 | content: string, 60 | projectName?: string, 61 | metadata?: Record<string, any> 62 | ): string { 63 | const dir = this.getTodayLogDir(); 64 | const filename = this.getLogFilename(operation, projectName); 65 | const filepath = path.join(dir, filename); 66 | 67 | // Add metadata header if provided 68 | let fullContent = ''; 69 | if (metadata) { 70 | fullContent += '=== Log Metadata ===\n'; 71 | fullContent += JSON.stringify(metadata, null, 2) + '\n'; 72 | fullContent += '=== End Metadata ===\n\n'; 73 | } 74 | fullContent += content; 75 | 76 | fs.writeFileSync(filepath, fullContent, 'utf8'); 77 | 78 | // Also create/update a symlink to latest log 79 | const latestLink = path.join(LogManager.LOG_DIR, `latest-${operation}.log`); 80 | if (fs.existsSync(latestLink)) { 81 | fs.unlinkSync(latestLink); 82 | } 83 | 84 | // Create relative symlink for portability 85 | const relativePath = `./${new Date().toISOString().split('T')[0]}/${filename}`; 86 | try { 87 | // Use execSync to create symlink as fs.symlinkSync has issues on some systems 88 | const { execSync } = require('child_process'); 89 | execSync(`ln -sf "${relativePath}" "${latestLink}"`, { cwd: LogManager.LOG_DIR }); 90 | } catch { 91 | // Symlink creation failed, not critical 92 | } 93 | 94 | return filepath; 95 | } 96 | 97 | /** 98 | * Save debug data (like parsed xcresult) for analysis 99 | */ 100 | saveDebugData( 101 | operation: string, 102 | data: any, 103 | projectName?: string 104 | ): string { 105 | const dir = this.getTodayLogDir(); 106 | const timestamp = new Date().toISOString() 107 | .replace(/:/g, '-') 108 | .replace(/\./g, '-') 109 | .split('T')[1] 110 | .slice(0, 8); 111 | 112 | const name = projectName ? `${operation}-${projectName}` : operation; 113 | const filename = `${timestamp}-${name}-debug.json`; 114 | const filepath = path.join(dir, filename); 115 | 116 | fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); 117 | 118 | return filepath; 119 | } 120 | 121 | /** 122 | * Clean up logs older than MAX_AGE_DAYS 123 | */ 124 | cleanupOldLogs(): void { 125 | if (!fs.existsSync(LogManager.LOG_DIR)) { 126 | return; 127 | } 128 | 129 | const now = Date.now(); 130 | const maxAge = LogManager.MAX_AGE_DAYS * 24 * 60 * 60 * 1000; 131 | 132 | try { 133 | const entries = fs.readdirSync(LogManager.LOG_DIR); 134 | 135 | for (const entry of entries) { 136 | const fullPath = path.join(LogManager.LOG_DIR, entry); 137 | 138 | // Skip symlinks 139 | const stat = fs.statSync(fullPath); 140 | if (stat.isSymbolicLink()) { 141 | continue; 142 | } 143 | 144 | // Check if it's a date directory (YYYY-MM-DD format) 145 | if (stat.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry)) { 146 | const dirDate = new Date(entry).getTime(); 147 | 148 | if (now - dirDate > maxAge) { 149 | fs.rmSync(fullPath, { recursive: true, force: true }); 150 | } 151 | } 152 | } 153 | } catch (error) { 154 | // Cleanup failed, not critical 155 | } 156 | } 157 | 158 | /** 159 | * Get the user-friendly log path for display 160 | */ 161 | getDisplayPath(fullPath: string): string { 162 | // Replace home directory with ~ 163 | const home = os.homedir(); 164 | return fullPath.replace(home, '~'); 165 | } 166 | 167 | /** 168 | * Get the log directory path 169 | */ 170 | getLogDirectory(): string { 171 | return LogManager.LOG_DIR; 172 | } 173 | } ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/InstallResult.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { 3 | InstallResult, 4 | InstallOutcome, 5 | InstallCommandFailedError, 6 | SimulatorNotFoundError 7 | } from '../../domain/InstallResult.js'; 8 | import { DeviceId } from '../../../../shared/domain/DeviceId.js'; 9 | import { AppPath } from '../../../../shared/domain/AppPath.js'; 10 | 11 | describe('InstallResult', () => { 12 | describe('succeeded', () => { 13 | it('should create successful install result', () => { 14 | // Arrange & Act 15 | const simulatorId = DeviceId.create('iPhone-15-Simulator'); 16 | const appPath = AppPath.create('/path/to/app.app'); 17 | const result = InstallResult.succeeded( 18 | 'com.example.app', 19 | simulatorId, 20 | 'iPhone 15', 21 | appPath 22 | ); 23 | 24 | // Assert 25 | expect(result.outcome).toBe(InstallOutcome.Succeeded); 26 | expect(result.diagnostics.bundleId).toBe('com.example.app'); 27 | expect(result.diagnostics.simulatorId?.toString()).toBe('iPhone-15-Simulator'); 28 | expect(result.diagnostics.simulatorName).toBe('iPhone 15'); 29 | expect(result.diagnostics.appPath.toString()).toBe('/path/to/app.app'); 30 | expect(result.diagnostics.error).toBeUndefined(); 31 | }); 32 | 33 | it('should include install timestamp', () => { 34 | // Arrange & Act 35 | const before = Date.now(); 36 | const simulatorId = DeviceId.create('test-sim'); 37 | const appPath = AppPath.create('/path/to/app.app'); 38 | const result = InstallResult.succeeded( 39 | 'com.example.app', 40 | simulatorId, 41 | 'Test Simulator', 42 | appPath 43 | ); 44 | const after = Date.now(); 45 | 46 | // Assert 47 | expect(result.diagnostics.installedAt.getTime()).toBeGreaterThanOrEqual(before); 48 | expect(result.diagnostics.installedAt.getTime()).toBeLessThanOrEqual(after); 49 | }); 50 | }); 51 | 52 | describe('failed', () => { 53 | it('should create failed install result with SimulatorNotFoundError', () => { 54 | // Arrange 55 | const simulatorId = DeviceId.create('non-existent-sim'); 56 | const error = new SimulatorNotFoundError(simulatorId); 57 | 58 | // Act 59 | const appPath = AppPath.create('/path/to/app.app'); 60 | const result = InstallResult.failed( 61 | error, 62 | appPath, 63 | simulatorId, 64 | 'Unknown Simulator' 65 | ); 66 | 67 | // Assert 68 | expect(result.outcome).toBe(InstallOutcome.Failed); 69 | expect(result.diagnostics.error).toBe(error); 70 | expect(result.diagnostics.appPath.toString()).toBe('/path/to/app.app'); 71 | expect(result.diagnostics.simulatorId?.toString()).toBe('non-existent-sim'); 72 | expect(result.diagnostics.bundleId).toBeUndefined(); 73 | }); 74 | 75 | it('should handle failure without simulator ID', () => { 76 | // Arrange 77 | const simulatorId = DeviceId.create('booted'); 78 | const error = new SimulatorNotFoundError(simulatorId); 79 | 80 | // Act 81 | const appPath = AppPath.create('/path/to/app.app'); 82 | const result = InstallResult.failed( 83 | error, 84 | appPath 85 | ); 86 | 87 | // Assert 88 | expect(result.outcome).toBe(InstallOutcome.Failed); 89 | expect(result.diagnostics.error).toBe(error); 90 | expect(result.diagnostics.appPath.toString()).toBe('/path/to/app.app'); 91 | expect(result.diagnostics.simulatorId).toBeUndefined(); 92 | }); 93 | 94 | it('should create failed install result with InstallCommandFailedError', () => { 95 | // Arrange 96 | const error = new InstallCommandFailedError('App bundle not found'); 97 | 98 | // Act 99 | const appPath = AppPath.create('/path/to/app.app'); 100 | const simulatorId = DeviceId.create('test-sim'); 101 | const result = InstallResult.failed( 102 | error, 103 | appPath, 104 | simulatorId, 105 | 'Test Simulator' 106 | ); 107 | 108 | // Assert 109 | expect(result.outcome).toBe(InstallOutcome.Failed); 110 | expect(result.diagnostics.error).toBe(error); 111 | expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('App bundle not found'); 112 | }); 113 | }); 114 | 115 | describe('outcome checking', () => { 116 | it('should identify successful installation', () => { 117 | // Arrange & Act 118 | const simulatorId = DeviceId.create('sim-id'); 119 | const appPath = AppPath.create('/app.app'); 120 | const result = InstallResult.succeeded( 121 | 'com.example.app', 122 | simulatorId, 123 | 'Simulator', 124 | appPath 125 | ); 126 | 127 | // Assert 128 | expect(result.outcome).toBe(InstallOutcome.Succeeded); 129 | }); 130 | 131 | it('should identify failed installation', () => { 132 | // Arrange 133 | const error = new InstallCommandFailedError('Installation failed'); 134 | 135 | // Act 136 | const appPath = AppPath.create('/app.app'); 137 | const result = InstallResult.failed( 138 | error, 139 | appPath 140 | ); 141 | 142 | // Assert 143 | expect(result.outcome).toBe(InstallOutcome.Failed); 144 | }); 145 | }); 146 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/e2e/BootSimulatorController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for BootSimulatorController 3 | * 4 | * Tests the controller with REAL simulators and REAL system commands 5 | * Following testing philosophy: E2E tests for critical paths only (10%) 6 | * 7 | * NO MOCKS - Uses real xcrun simctl commands with actual simulators 8 | */ 9 | 10 | import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; 11 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 12 | import { BootSimulatorControllerFactory } from '../../factories/BootSimulatorControllerFactory.js'; 13 | import { exec } from 'child_process'; 14 | import { promisify } from 'util'; 15 | 16 | const execAsync = promisify(exec); 17 | 18 | describe('BootSimulatorController E2E', () => { 19 | let controller: MCPController; 20 | let testDeviceId: string; 21 | let testSimulatorName: string; 22 | 23 | beforeAll(async () => { 24 | // Create controller with REAL components 25 | controller = BootSimulatorControllerFactory.create(); 26 | 27 | // Find or create a test simulator 28 | const listResult = await execAsync('xcrun simctl list devices --json'); 29 | const devices = JSON.parse(listResult.stdout); 30 | 31 | // Look for an existing test simulator 32 | for (const runtime of Object.values(devices.devices) as any[]) { 33 | const testSim = runtime.find((d: any) => d.name.includes('TestSimulator-Boot')); 34 | if (testSim) { 35 | testDeviceId = testSim.udid; 36 | testSimulatorName = testSim.name; 37 | break; 38 | } 39 | } 40 | 41 | // Create one if not found 42 | if (!testDeviceId) { 43 | // Get available runtime 44 | const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); 45 | const runtimes = JSON.parse(runtimesResult.stdout); 46 | const iosRuntime = runtimes.runtimes.find((r: any) => r.platform === 'iOS'); 47 | 48 | if (!iosRuntime) { 49 | throw new Error('No iOS runtime available. Please install Xcode with iOS simulator support.'); 50 | } 51 | 52 | const createResult = await execAsync( 53 | `xcrun simctl create "TestSimulator-Boot" "com.apple.CoreSimulator.SimDeviceType.iPhone-15" "${iosRuntime.identifier}"` 54 | ); 55 | testDeviceId = createResult.stdout.trim(); 56 | testSimulatorName = 'TestSimulator-Boot'; 57 | } 58 | }); 59 | 60 | beforeEach(async () => { 61 | // Ensure simulator is shutdown before each test 62 | try { 63 | await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); 64 | } catch { 65 | // Ignore if already shutdown 66 | } 67 | // Wait for shutdown to complete 68 | await new Promise(resolve => setTimeout(resolve, 1000)); 69 | }); 70 | 71 | afterAll(async () => { 72 | // Shutdown the test simulator 73 | try { 74 | await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); 75 | } catch { 76 | // Ignore if already shutdown 77 | } 78 | }); 79 | 80 | describe('boot real simulators', () => { 81 | it('should boot a shutdown simulator', async () => { 82 | // Act 83 | const result = await controller.execute({ 84 | deviceId: testSimulatorName 85 | }); 86 | 87 | // Assert 88 | expect(result.content[0].text).toBe(`✅ Successfully booted simulator: ${testSimulatorName} (${testDeviceId})`); 89 | 90 | // Verify simulator is actually booted 91 | const listResult = await execAsync('xcrun simctl list devices --json'); 92 | const devices = JSON.parse(listResult.stdout); 93 | let found = false; 94 | for (const runtime of Object.values(devices.devices) as any[]) { 95 | const device = runtime.find((d: any) => d.udid === testDeviceId); 96 | if (device) { 97 | expect(device.state).toBe('Booted'); 98 | found = true; 99 | break; 100 | } 101 | } 102 | expect(found).toBe(true); 103 | }); 104 | 105 | it('should handle already booted simulator', async () => { 106 | // Arrange - boot the simulator first 107 | await execAsync(`xcrun simctl boot "${testDeviceId}"`); 108 | await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for boot 109 | 110 | // Act 111 | const result = await controller.execute({ 112 | deviceId: testDeviceId 113 | }); 114 | 115 | // Assert 116 | expect(result.content[0].text).toBe(`✅ Simulator already booted: ${testSimulatorName} (${testDeviceId})`); 117 | }); 118 | 119 | it('should boot simulator by UUID', async () => { 120 | // Act - use UUID directly 121 | const result = await controller.execute({ 122 | deviceId: testDeviceId 123 | }); 124 | 125 | // Assert 126 | expect(result.content[0].text).toBe(`✅ Successfully booted simulator: ${testSimulatorName} (${testDeviceId})`); 127 | }); 128 | }); 129 | 130 | describe('error handling with real simulators', () => { 131 | it('should fail when simulator does not exist', async () => { 132 | // Act 133 | const result = await controller.execute({ 134 | deviceId: 'NonExistentSimulator-12345' 135 | }); 136 | 137 | // Assert 138 | expect(result.content[0].text).toBe('❌ Simulator not found: NonExistentSimulator-12345'); 139 | }); 140 | }); 141 | }); ``` -------------------------------------------------------------------------------- /src/presentation/tests/unit/DependencyCheckingDecorator.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest } from '@jest/globals'; 2 | import { DependencyCheckingDecorator } from '../../decorators/DependencyCheckingDecorator.js'; 3 | import { MCPController } from '../../interfaces/MCPController.js'; 4 | import { MCPResponse } from '../../interfaces/MCPResponse.js'; 5 | import { IDependencyChecker, MissingDependency } from '../../interfaces/IDependencyChecker.js'; 6 | 7 | describe('DependencyCheckingDecorator', () => { 8 | function createSUT(missingDeps: MissingDependency[] = []) { 9 | // Create mock controller 10 | const mockExecute = jest.fn<(args: unknown) => Promise<MCPResponse>>(); 11 | const mockController: MCPController = { 12 | name: 'test_tool', 13 | description: 'Test tool', 14 | inputSchema: {}, 15 | execute: mockExecute, 16 | getToolDefinition: () => ({ 17 | name: 'test_tool', 18 | description: 'Test tool', 19 | inputSchema: {} 20 | }) 21 | }; 22 | 23 | // Create mock dependency checker 24 | const mockCheck = jest.fn<IDependencyChecker['check']>(); 25 | mockCheck.mockResolvedValue(missingDeps); 26 | const mockChecker: IDependencyChecker = { 27 | check: mockCheck 28 | }; 29 | 30 | // Create decorator 31 | const sut = new DependencyCheckingDecorator( 32 | mockController, 33 | ['xcodebuild', 'xcbeautify'], 34 | mockChecker 35 | ); 36 | 37 | return { sut, mockExecute, mockCheck }; 38 | } 39 | 40 | describe('execute', () => { 41 | it('should execute controller when all dependencies are available', async () => { 42 | // Arrange 43 | const { sut, mockExecute } = createSUT([]); // No missing dependencies 44 | const args = { someArg: 'value' }; 45 | const expectedResponse = { 46 | content: [{ type: 'text', text: 'Success' }] 47 | }; 48 | mockExecute.mockResolvedValue(expectedResponse); 49 | 50 | // Act 51 | const result = await sut.execute(args); 52 | 53 | // Assert - behavior: delegates to controller 54 | expect(result).toBe(expectedResponse); 55 | expect(mockExecute).toHaveBeenCalledWith(args); 56 | }); 57 | 58 | it('should return error when dependencies are missing', async () => { 59 | // Arrange 60 | const missingDeps: MissingDependency[] = [ 61 | { name: 'xcbeautify', installCommand: 'brew install xcbeautify' } 62 | ]; 63 | const { sut, mockExecute } = createSUT(missingDeps); 64 | 65 | // Act 66 | const result = await sut.execute({}); 67 | 68 | // Assert - behavior: returns error, doesn't execute controller 69 | expect(result.content[0].text).toContain('Missing required dependencies'); 70 | expect(result.content[0].text).toContain('xcbeautify'); 71 | expect(result.content[0].text).toContain('brew install xcbeautify'); 72 | expect(mockExecute).not.toHaveBeenCalled(); 73 | }); 74 | 75 | it('should format multiple missing dependencies clearly', async () => { 76 | // Arrange 77 | const missingDeps: MissingDependency[] = [ 78 | { name: 'xcodebuild', installCommand: 'Install Xcode from the App Store' }, 79 | { name: 'xcbeautify', installCommand: 'brew install xcbeautify' } 80 | ]; 81 | const { sut, mockExecute } = createSUT(missingDeps); 82 | 83 | // Act 84 | const result = await sut.execute({}); 85 | 86 | // Assert - behavior: shows all missing dependencies 87 | expect(result.content[0].text).toContain('xcodebuild'); 88 | expect(result.content[0].text).toContain('Install Xcode from the App Store'); 89 | expect(result.content[0].text).toContain('xcbeautify'); 90 | expect(result.content[0].text).toContain('brew install xcbeautify'); 91 | expect(mockExecute).not.toHaveBeenCalled(); 92 | }); 93 | 94 | it('should handle dependencies without install commands', async () => { 95 | // Arrange 96 | const missingDeps: MissingDependency[] = [ 97 | { name: 'customtool' } // No install command 98 | ]; 99 | const { sut, mockExecute } = createSUT(missingDeps); 100 | 101 | // Act 102 | const result = await sut.execute({}); 103 | 104 | // Assert - behavior: shows tool name without install command 105 | expect(result.content[0].text).toContain('customtool'); 106 | expect(result.content[0].text).not.toContain('undefined'); 107 | expect(mockExecute).not.toHaveBeenCalled(); 108 | }); 109 | }); 110 | 111 | describe('getToolDefinition', () => { 112 | it('should delegate to decoratee', () => { 113 | // Arrange 114 | const { sut } = createSUT(); 115 | 116 | // Act 117 | const definition = sut.getToolDefinition(); 118 | 119 | // Assert - behavior: returns controller's definition 120 | expect(definition).toEqual({ 121 | name: 'test_tool', 122 | description: 'Test tool', 123 | inputSchema: {} 124 | }); 125 | }); 126 | }); 127 | 128 | describe('properties', () => { 129 | it('should delegate properties to decoratee', () => { 130 | // Arrange 131 | const { sut } = createSUT(); 132 | 133 | // Act & Assert - behavior: properties match controller 134 | expect(sut.name).toBe('test_tool'); 135 | expect(sut.description).toBe('Test tool'); 136 | expect(sut.inputSchema).toEqual({}); 137 | }); 138 | }); 139 | }); ``` -------------------------------------------------------------------------------- /src/utils/LogManagerInstance.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | import { ILogManager } from '../application/ports/LoggingPorts.js'; 5 | 6 | /** 7 | * Instance-based log manager for dependency injection 8 | * Manages persistent logs for MCP server debugging 9 | */ 10 | export class LogManagerInstance implements ILogManager { 11 | private readonly LOG_DIR: string; 12 | private readonly MAX_AGE_DAYS = 7; 13 | 14 | constructor(logDir?: string) { 15 | this.LOG_DIR = logDir || path.join(os.homedir(), '.mcp-xcode-server', 'logs'); 16 | this.init(); 17 | } 18 | 19 | /** 20 | * Initialize log directory structure 21 | */ 22 | private init(): void { 23 | if (!fs.existsSync(this.LOG_DIR)) { 24 | fs.mkdirSync(this.LOG_DIR, { recursive: true }); 25 | } 26 | 27 | // Clean up old logs on startup 28 | this.cleanupOldLogs(); 29 | } 30 | 31 | /** 32 | * Get the log directory for today 33 | */ 34 | private getTodayLogDir(): string { 35 | const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD 36 | const dir = path.join(this.LOG_DIR, today); 37 | 38 | if (!fs.existsSync(dir)) { 39 | fs.mkdirSync(dir, { recursive: true }); 40 | } 41 | 42 | return dir; 43 | } 44 | 45 | /** 46 | * Generate a log filename with timestamp 47 | */ 48 | private getLogFilename(operation: string, projectName?: string): string { 49 | const timestamp = new Date().toISOString() 50 | .replace(/:/g, '-') 51 | .replace(/\./g, '-') 52 | .split('T')[1] 53 | .slice(0, 8); // HH-MM-SS 54 | 55 | const name = projectName ? `${operation}-${projectName}` : operation; 56 | return `${timestamp}-${name}.log`; 57 | } 58 | 59 | /** 60 | * Save log content to a file 61 | * Returns the full path to the log file 62 | */ 63 | saveLog( 64 | operation: 'build' | 'test' | 'run' | 'archive' | 'clean', 65 | content: string, 66 | projectName?: string, 67 | metadata?: Record<string, any> 68 | ): string { 69 | const dir = this.getTodayLogDir(); 70 | const filename = this.getLogFilename(operation, projectName); 71 | const filepath = path.join(dir, filename); 72 | 73 | // Add metadata header if provided 74 | let fullContent = ''; 75 | if (metadata) { 76 | fullContent += '=== Log Metadata ===\n'; 77 | fullContent += JSON.stringify(metadata, null, 2) + '\n'; 78 | fullContent += '=== End Metadata ===\n\n'; 79 | } 80 | fullContent += content; 81 | 82 | fs.writeFileSync(filepath, fullContent, 'utf8'); 83 | 84 | // Also create/update a symlink to latest log 85 | const latestLink = path.join(this.LOG_DIR, `latest-${operation}.log`); 86 | if (fs.existsSync(latestLink)) { 87 | fs.unlinkSync(latestLink); 88 | } 89 | 90 | // Create relative symlink for portability 91 | const relativePath = `./${new Date().toISOString().split('T')[0]}/${filename}`; 92 | try { 93 | // Use execSync to create symlink as fs.symlinkSync has issues on some systems 94 | const { execSync } = require('child_process'); 95 | execSync(`ln -sf "${relativePath}" "${latestLink}"`, { cwd: this.LOG_DIR }); 96 | } catch { 97 | // Symlink creation failed, not critical 98 | } 99 | 100 | return filepath; 101 | } 102 | 103 | /** 104 | * Save debug data (like parsed xcresult) for analysis 105 | */ 106 | saveDebugData( 107 | operation: string, 108 | data: any, 109 | projectName?: string 110 | ): string { 111 | const dir = this.getTodayLogDir(); 112 | const timestamp = new Date().toISOString() 113 | .replace(/:/g, '-') 114 | .replace(/\./g, '-') 115 | .split('T')[1] 116 | .slice(0, 8); 117 | 118 | const name = projectName ? `${operation}-${projectName}` : operation; 119 | const filename = `${timestamp}-${name}-debug.json`; 120 | const filepath = path.join(dir, filename); 121 | 122 | fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); 123 | 124 | return filepath; 125 | } 126 | 127 | /** 128 | * Clean up logs older than MAX_AGE_DAYS 129 | */ 130 | cleanupOldLogs(): void { 131 | if (!fs.existsSync(this.LOG_DIR)) { 132 | return; 133 | } 134 | 135 | const now = Date.now(); 136 | const maxAge = this.MAX_AGE_DAYS * 24 * 60 * 60 * 1000; 137 | 138 | try { 139 | const entries = fs.readdirSync(this.LOG_DIR); 140 | 141 | for (const entry of entries) { 142 | const fullPath = path.join(this.LOG_DIR, entry); 143 | 144 | // Skip symlinks 145 | const stat = fs.statSync(fullPath); 146 | if (stat.isSymbolicLink()) { 147 | continue; 148 | } 149 | 150 | // Check if it's a date directory (YYYY-MM-DD format) 151 | if (stat.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry)) { 152 | const dirDate = new Date(entry).getTime(); 153 | 154 | if (now - dirDate > maxAge) { 155 | fs.rmSync(fullPath, { recursive: true, force: true }); 156 | } 157 | } 158 | } 159 | } catch (error) { 160 | // Cleanup failed, not critical 161 | } 162 | } 163 | 164 | /** 165 | * Get the user-friendly log path for display 166 | */ 167 | getDisplayPath(fullPath: string): string { 168 | // Replace home directory with ~ 169 | const home = os.homedir(); 170 | return fullPath.replace(home, '~'); 171 | } 172 | 173 | /** 174 | * Get the log directory path 175 | */ 176 | getLogDirectory(): string { 177 | return this.LOG_DIR; 178 | } 179 | } ``` -------------------------------------------------------------------------------- /src/presentation/presenters/BuildXcodePresenter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { BuildResult, BuildOutcome, OutputFormatterError } from '../../features/build/domain/BuildResult.js'; 2 | import { Platform } from '../../shared/domain/Platform.js'; 3 | import { ErrorFormatter } from '../formatters/ErrorFormatter.js'; 4 | import { MCPResponse } from '../interfaces/MCPResponse.js'; 5 | 6 | /** 7 | * Presenter for build results 8 | * 9 | * Single Responsibility: Format BuildResult for MCP display 10 | * - Success formatting 11 | * - Failure formatting with errors/warnings 12 | * - Log path information 13 | */ 14 | 15 | export class BuildXcodePresenter { 16 | private readonly maxErrorsToShow = 50; 17 | private readonly maxWarningsToShow = 20; 18 | 19 | present(result: BuildResult, metadata: { 20 | scheme: string; 21 | platform: Platform; 22 | configuration: string; 23 | showWarningDetails?: boolean; 24 | }): MCPResponse { 25 | if (result.outcome === BuildOutcome.Succeeded) { 26 | return this.presentSuccess(result, metadata); 27 | } 28 | return this.presentFailure(result, metadata); 29 | } 30 | 31 | private presentSuccess( 32 | result: BuildResult, 33 | metadata: { scheme: string; platform: Platform; configuration: string; showWarningDetails?: boolean } 34 | ): MCPResponse { 35 | const warnings = BuildResult.getWarnings(result); 36 | 37 | let text = `✅ Build succeeded: ${metadata.scheme} 38 | 39 | Platform: ${metadata.platform} 40 | Configuration: ${metadata.configuration}`; 41 | 42 | // Show warning count if there are any 43 | if (warnings.length > 0) { 44 | text += `\nWarnings: ${warnings.length}`; 45 | 46 | // Show warning details if requested 47 | if (metadata.showWarningDetails) { 48 | text += '\n\n⚠️ Warnings:'; 49 | const warningsToShow = Math.min(warnings.length, this.maxWarningsToShow); 50 | warnings.slice(0, warningsToShow).forEach(warning => { 51 | text += `\n • ${this.formatIssue(warning)}`; 52 | }); 53 | if (warnings.length > this.maxWarningsToShow) { 54 | text += `\n ... and ${warnings.length - this.maxWarningsToShow} more warnings`; 55 | } 56 | } 57 | } 58 | 59 | text += `\nApp path: ${result.diagnostics.appPath || 'N/A'}${result.diagnostics.logPath ? ` 60 | 61 | 📁 Full logs saved to: ${result.diagnostics.logPath}` : ''}`; 62 | 63 | return { 64 | content: [{ type: 'text', text }] 65 | }; 66 | } 67 | 68 | private presentFailure( 69 | result: BuildResult, 70 | metadata: { scheme: string; platform: Platform; configuration: string; showWarningDetails?: boolean } 71 | ): MCPResponse { 72 | // Check if this is a dependency/tool error (not an actual build failure) 73 | if (result.diagnostics.error && result.diagnostics.error instanceof OutputFormatterError) { 74 | // Tool dependency missing - show only that error 75 | const text = `❌ ${ErrorFormatter.format(result.diagnostics.error)}`; 76 | return { 77 | content: [{ type: 'text', text }] 78 | }; 79 | } 80 | 81 | const errors = BuildResult.getErrors(result); 82 | const warnings = BuildResult.getWarnings(result); 83 | 84 | let text = `❌ Build failed: ${metadata.scheme}\n`; 85 | text += `Platform: ${metadata.platform}\n`; 86 | text += `Configuration: ${metadata.configuration}\n`; 87 | 88 | // Check for other errors in diagnostics 89 | if (result.diagnostics.error) { 90 | text += `\n❌ ${ErrorFormatter.format(result.diagnostics.error)}\n`; 91 | } 92 | 93 | if (errors.length > 0) { 94 | text += `\n❌ Errors (${errors.length}):\n`; 95 | // Show up to maxErrorsToShow errors 96 | const errorsToShow = Math.min(errors.length, this.maxErrorsToShow); 97 | errors.slice(0, errorsToShow).forEach(error => { 98 | text += ` • ${this.formatIssue(error)}\n`; 99 | }); 100 | if (errors.length > this.maxErrorsToShow) { 101 | text += ` ... and ${errors.length - this.maxErrorsToShow} more errors\n`; 102 | } 103 | } 104 | 105 | // Always show warning count if there are warnings 106 | if (warnings.length > 0) { 107 | if (metadata.showWarningDetails) { 108 | // Show detailed warnings 109 | text += `\n⚠️ Warnings (${warnings.length}):\n`; 110 | const warningsToShow = Math.min(warnings.length, this.maxWarningsToShow); 111 | warnings.slice(0, warningsToShow).forEach(warning => { 112 | text += ` • ${this.formatIssue(warning)}\n`; 113 | }); 114 | if (warnings.length > this.maxWarningsToShow) { 115 | text += ` ... and ${warnings.length - this.maxWarningsToShow} more warnings\n`; 116 | } 117 | } else { 118 | // Just show count 119 | text += `\n⚠️ Warnings: ${warnings.length}\n`; 120 | } 121 | } 122 | 123 | if (result.diagnostics.logPath) { 124 | text += `\n📁 Full logs saved to: ${result.diagnostics.logPath}\n`; 125 | } 126 | 127 | return { 128 | content: [{ type: 'text', text }] 129 | }; 130 | } 131 | 132 | private formatIssue(issue: any): string { 133 | if (issue.file && issue.line) { 134 | if (issue.column) { 135 | return `${issue.file}:${issue.line}:${issue.column}: ${issue.message}`; 136 | } 137 | return `${issue.file}:${issue.line}: ${issue.message}`; 138 | } 139 | return issue.message; 140 | } 141 | 142 | presentError(error: Error): MCPResponse { 143 | const message = ErrorFormatter.format(error); 144 | return { 145 | content: [{ 146 | type: 'text', 147 | text: `❌ ${message}` 148 | }] 149 | }; 150 | } 151 | } ``` -------------------------------------------------------------------------------- /src/utils/projects/XcodeInfo.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { createModuleLogger } from '../../logger.js'; 3 | import { Platform } from '../../types.js'; 4 | import path from 'path'; 5 | 6 | const logger = createModuleLogger('XcodeInfo'); 7 | 8 | /** 9 | * Queries information about Xcode projects 10 | */ 11 | export class XcodeInfo { 12 | /** 13 | * Get list of schemes in a project 14 | */ 15 | async getSchemes( 16 | projectPath: string, 17 | isWorkspace: boolean 18 | ): Promise<string[]> { 19 | const projectFlag = isWorkspace ? '-workspace' : '-project'; 20 | const command = `xcodebuild -list -json ${projectFlag} "${projectPath}"`; 21 | 22 | logger.debug({ command }, 'List schemes command'); 23 | 24 | try { 25 | const { stdout } = await execAsync(command); 26 | const data = JSON.parse(stdout); 27 | 28 | // Get schemes from the appropriate property 29 | let schemes: string[] = []; 30 | if (isWorkspace && data.workspace?.schemes) { 31 | schemes = data.workspace.schemes; 32 | } else if (!isWorkspace && data.project?.schemes) { 33 | schemes = data.project.schemes; 34 | } 35 | 36 | logger.debug({ projectPath, schemes }, 'Found schemes'); 37 | return schemes; 38 | } catch (error: any) { 39 | logger.error({ error: error.message, projectPath }, 'Failed to get schemes'); 40 | throw new Error(`Failed to get schemes: ${error.message}`); 41 | } 42 | } 43 | 44 | /** 45 | * Get list of targets in a project 46 | */ 47 | async getTargets( 48 | projectPath: string, 49 | isWorkspace: boolean 50 | ): Promise<string[]> { 51 | const projectFlag = isWorkspace ? '-workspace' : '-project'; 52 | const command = `xcodebuild -list -json ${projectFlag} "${projectPath}"`; 53 | 54 | logger.debug({ command }, 'List targets command'); 55 | 56 | try { 57 | const { stdout } = await execAsync(command); 58 | const data = JSON.parse(stdout); 59 | 60 | // Get targets from the project (even for workspaces, targets come from projects) 61 | const targets = data.project?.targets || []; 62 | 63 | logger.debug({ projectPath, targets }, 'Found targets'); 64 | return targets; 65 | } catch (error: any) { 66 | logger.error({ error: error.message, projectPath }, 'Failed to get targets'); 67 | throw new Error(`Failed to get targets: ${error.message}`); 68 | } 69 | } 70 | 71 | /** 72 | * Get build settings for a scheme 73 | */ 74 | async getBuildSettings( 75 | projectPath: string, 76 | isWorkspace: boolean, 77 | scheme: string, 78 | configuration?: string, 79 | platform?: Platform 80 | ): Promise<any> { 81 | const projectFlag = isWorkspace ? '-workspace' : '-project'; 82 | let command = `xcodebuild -showBuildSettings ${projectFlag} "${projectPath}"`; 83 | command += ` -scheme "${scheme}"`; 84 | 85 | if (configuration) { 86 | command += ` -configuration "${configuration}"`; 87 | } 88 | 89 | if (platform) { 90 | // Add a generic destination for the platform to get appropriate settings 91 | const { PlatformInfo } = await import('../../features/build/domain/PlatformInfo.js'); 92 | const platformInfo = PlatformInfo.fromPlatform(platform); 93 | const destination = platformInfo.generateGenericDestination(); 94 | command += ` -destination '${destination}'`; 95 | } 96 | 97 | command += ' -json'; 98 | 99 | logger.debug({ command }, 'Get build settings command'); 100 | 101 | try { 102 | const { stdout } = await execAsync(command, { 103 | maxBuffer: 10 * 1024 * 1024 104 | }); 105 | 106 | const settings = JSON.parse(stdout); 107 | logger.debug({ projectPath, scheme }, 'Got build settings'); 108 | 109 | return settings; 110 | } catch (error: any) { 111 | logger.error({ error: error.message, projectPath, scheme }, 'Failed to get build settings'); 112 | throw new Error(`Failed to get build settings: ${error.message}`); 113 | } 114 | } 115 | 116 | /** 117 | * Get comprehensive project information 118 | */ 119 | async getProjectInfo( 120 | projectPath: string, 121 | isWorkspace: boolean 122 | ): Promise<{ 123 | name: string; 124 | schemes: string[]; 125 | targets: string[]; 126 | configurations: string[]; 127 | }> { 128 | const projectFlag = isWorkspace ? '-workspace' : '-project'; 129 | const command = `xcodebuild -list -json ${projectFlag} "${projectPath}"`; 130 | 131 | logger.debug({ command }, 'Get project info command'); 132 | 133 | try { 134 | const { stdout } = await execAsync(command); 135 | const data = JSON.parse(stdout); 136 | 137 | // Extract info based on project type 138 | let info; 139 | if (isWorkspace) { 140 | info = { 141 | name: data.workspace?.name || path.basename(projectPath, '.xcworkspace'), 142 | schemes: data.workspace?.schemes || [], 143 | targets: data.project?.targets || [], 144 | configurations: data.project?.configurations || [] 145 | }; 146 | } else { 147 | info = { 148 | name: data.project?.name || path.basename(projectPath, '.xcodeproj'), 149 | schemes: data.project?.schemes || [], 150 | targets: data.project?.targets || [], 151 | configurations: data.project?.configurations || [] 152 | }; 153 | } 154 | 155 | logger.debug({ projectPath, info }, 'Got project info'); 156 | return info; 157 | } catch (error: any) { 158 | logger.error({ error: error.message, projectPath }, 'Failed to get project info'); 159 | throw new Error(`Failed to get project info: ${error.message}`); 160 | } 161 | } 162 | } ```