This is page 4 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/presentation/tests/unit/BuildIssuesStrategy.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { BuildIssuesStrategy } from '../../formatters/strategies/BuildIssuesStrategy.js'; 2 | import { BuildIssue } from '../../../features/build/domain/BuildIssue.js'; 3 | 4 | describe('BuildIssuesStrategy', () => { 5 | function createSUT(): BuildIssuesStrategy { 6 | return new BuildIssuesStrategy(); 7 | } 8 | 9 | function createErrorWithIssues(issues: BuildIssue[], message?: string) { 10 | return { issues, message }; 11 | } 12 | 13 | describe('canFormat', () => { 14 | it('should return true for error with BuildIssue array', () => { 15 | const sut = createSUT(); 16 | const error = createErrorWithIssues([ 17 | BuildIssue.error('Test error') 18 | ]); 19 | 20 | const result = sut.canFormat(error); 21 | 22 | expect(result).toBe(true); 23 | }); 24 | 25 | it('should return true when at least one issue is BuildIssue instance', () => { 26 | const sut = createSUT(); 27 | const error = { 28 | issues: [ 29 | BuildIssue.error('Real issue'), 30 | { message: 'Not a BuildIssue' } 31 | ] 32 | }; 33 | 34 | const result = sut.canFormat(error); 35 | 36 | expect(result).toBe(true); 37 | }); 38 | 39 | it('should return false for error without issues property', () => { 40 | const sut = createSUT(); 41 | const error = { message: 'Plain error' }; 42 | 43 | const result = sut.canFormat(error); 44 | 45 | expect(result).toBe(false); 46 | }); 47 | 48 | it('should return false when issues is not an array', () => { 49 | const sut = createSUT(); 50 | const error = { issues: 'not an array' }; 51 | 52 | const result = sut.canFormat(error); 53 | 54 | expect(result).toBe(false); 55 | }); 56 | 57 | it('should return false when issues array is empty', () => { 58 | const sut = createSUT(); 59 | const error = { issues: [] }; 60 | 61 | const result = sut.canFormat(error); 62 | 63 | expect(result).toBe(false); 64 | }); 65 | 66 | it('should return false when no issues are BuildIssue instances', () => { 67 | const sut = createSUT(); 68 | const error = { 69 | issues: [ 70 | { message: 'Plain object 1' }, 71 | { message: 'Plain object 2' } 72 | ] 73 | }; 74 | 75 | const result = sut.canFormat(error); 76 | 77 | expect(result).toBe(false); 78 | }); 79 | }); 80 | 81 | describe('format', () => { 82 | describe('when formatting errors only', () => { 83 | it('should format single error correctly', () => { 84 | const sut = createSUT(); 85 | const error = createErrorWithIssues([ 86 | BuildIssue.error('Cannot find module', 'src/main.ts', 10, 5) 87 | ]); 88 | 89 | const result = sut.format(error); 90 | 91 | const expected = 92 | `❌ Errors (1): 93 | • src/main.ts:10:5: Cannot find module`; 94 | 95 | expect(result).toBe(expected); 96 | }); 97 | 98 | it('should format multiple errors with file information', () => { 99 | const sut = createSUT(); 100 | const error = createErrorWithIssues([ 101 | BuildIssue.error('Cannot find module', 'src/main.ts', 10, 5), 102 | BuildIssue.error('Type mismatch', 'src/utils.ts', 20, 3) 103 | ]); 104 | 105 | const result = sut.format(error); 106 | 107 | const expected = 108 | `❌ Errors (2): 109 | • src/main.ts:10:5: Cannot find module 110 | • src/utils.ts:20:3: Type mismatch`; 111 | 112 | expect(result).toBe(expected); 113 | }); 114 | 115 | it('should limit to 5 errors and show count for more', () => { 116 | const sut = createSUT(); 117 | const issues = Array.from({ length: 10 }, (_, i) => 118 | BuildIssue.error(`Error ${i + 1}`, `file${i}.ts`) 119 | ); 120 | const error = createErrorWithIssues(issues); 121 | 122 | const result = sut.format(error); 123 | 124 | expect(result).toContain('❌ Errors (10):'); 125 | expect(result).toContain('file0.ts: Error 1'); 126 | expect(result).toContain('file4.ts: Error 5'); 127 | expect(result).not.toContain('file5.ts: Error 6'); 128 | expect(result).toContain('... and 5 more errors'); 129 | }); 130 | 131 | it('should handle errors without file information', () => { 132 | const sut = createSUT(); 133 | const error = createErrorWithIssues([ 134 | BuildIssue.error('General build error'), 135 | BuildIssue.error('Another error without file') 136 | ]); 137 | 138 | const result = sut.format(error); 139 | 140 | const expected = 141 | `❌ Errors (2): 142 | • General build error 143 | • Another error without file`; 144 | 145 | expect(result).toBe(expected); 146 | }); 147 | }); 148 | 149 | describe('when formatting warnings only', () => { 150 | it('should format single warning correctly', () => { 151 | const sut = createSUT(); 152 | const error = createErrorWithIssues([ 153 | BuildIssue.warning('Deprecated API usage', 'src/legacy.ts', 15) 154 | ]); 155 | 156 | const result = sut.format(error); 157 | 158 | const expected = 159 | `⚠️ Warnings (1): 160 | • src/legacy.ts:15: Deprecated API usage`; 161 | 162 | expect(result).toBe(expected); 163 | }); 164 | 165 | it('should limit to 3 warnings and show count for more', () => { 166 | const sut = createSUT(); 167 | const issues = Array.from({ length: 6 }, (_, i) => 168 | BuildIssue.warning(`Warning ${i + 1}`, `file${i}.ts`) 169 | ); 170 | const error = createErrorWithIssues(issues); 171 | 172 | const result = sut.format(error); 173 | 174 | expect(result).toContain('⚠️ Warnings (6):'); 175 | expect(result).toContain('file0.ts: Warning 1'); 176 | expect(result).toContain('file2.ts: Warning 3'); 177 | expect(result).not.toContain('file3.ts: Warning 4'); 178 | expect(result).toContain('... and 3 more warnings'); 179 | }); 180 | }); 181 | 182 | describe('when formatting mixed errors and warnings', () => { 183 | it('should show both sections separated by blank line', () => { 184 | const sut = createSUT(); 185 | const error = createErrorWithIssues([ 186 | BuildIssue.error('Error message', 'error.ts'), 187 | BuildIssue.warning('Warning message', 'warning.ts') 188 | ]); 189 | 190 | const result = sut.format(error); 191 | 192 | const expected = 193 | `❌ Errors (1): 194 | • error.ts: Error message 195 | 196 | ⚠️ Warnings (1): 197 | • warning.ts: Warning message`; 198 | 199 | expect(result).toBe(expected); 200 | }); 201 | 202 | it('should handle many mixed issues correctly', () => { 203 | const sut = createSUT(); 204 | const issues = [ 205 | ...Array.from({ length: 7 }, (_, i) => 206 | BuildIssue.error(`Error ${i + 1}`, `error${i}.ts`) 207 | ), 208 | ...Array.from({ length: 5 }, (_, i) => 209 | BuildIssue.warning(`Warning ${i + 1}`, `warn${i}.ts`) 210 | ) 211 | ]; 212 | const error = createErrorWithIssues(issues); 213 | 214 | const result = sut.format(error); 215 | 216 | expect(result).toContain('❌ Errors (7):'); 217 | expect(result).toContain('... and 2 more errors'); 218 | expect(result).toContain('⚠️ Warnings (5):'); 219 | expect(result).toContain('... and 2 more warnings'); 220 | expect(result.split('\n\n')).toHaveLength(2); // Two sections 221 | }); 222 | }); 223 | 224 | describe('when handling edge cases', () => { 225 | it('should return fallback message when no issues', () => { 226 | const sut = createSUT(); 227 | const error = createErrorWithIssues([]); 228 | 229 | const result = sut.format(error); 230 | 231 | expect(result).toBe('Build failed'); 232 | }); 233 | 234 | it('should use provided message as fallback when no issues', () => { 235 | const sut = createSUT(); 236 | const error = createErrorWithIssues([], 'Custom build failure'); 237 | 238 | const result = sut.format(error); 239 | 240 | expect(result).toBe('Custom build failure'); 241 | }); 242 | 243 | it('should handle mix of BuildIssue and non-BuildIssue objects', () => { 244 | const sut = createSUT(); 245 | const error = { 246 | issues: [ 247 | BuildIssue.error('Real error'), 248 | { type: 'error', message: 'Not a BuildIssue' }, // Will be filtered out 249 | BuildIssue.warning('Real warning') 250 | ] 251 | }; 252 | 253 | const result = sut.format(error); 254 | 255 | // Only real BuildIssues should be processed 256 | expect(result).toContain('❌ Errors (1):'); 257 | expect(result).toContain('Real error'); 258 | expect(result).toContain('⚠️ Warnings (1):'); 259 | expect(result).toContain('Real warning'); 260 | }); 261 | 262 | it('should handle issues with unknown types gracefully', () => { 263 | const sut = createSUT(); 264 | const issues = [ 265 | BuildIssue.error('Error'), 266 | BuildIssue.warning('Warning'), 267 | Object.assign(BuildIssue.error('Info'), { type: 'info' as any }) // Unknown type 268 | ]; 269 | const error = createErrorWithIssues(issues); 270 | 271 | const result = sut.format(error); 272 | 273 | // Unknown type should be ignored 274 | expect(result).toContain('❌ Errors (1):'); 275 | expect(result).toContain('⚠️ Warnings (1):'); 276 | expect(result).not.toContain('info'); 277 | }); 278 | }); 279 | }); 280 | }); ``` -------------------------------------------------------------------------------- /src/utils/devices/Devices.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execAsync } from '../../utils.js'; 2 | import { SimulatorDevice } from './SimulatorDevice.js'; 3 | import { createModuleLogger } from '../../logger.js'; 4 | import { Platform } from '../../types.js'; 5 | 6 | const logger = createModuleLogger('Devices'); 7 | 8 | /** 9 | * Device discovery and management. 10 | * Provides methods to find and list simulator devices. 11 | * Future-ready for physical device support. 12 | */ 13 | export class Devices { 14 | /** 15 | * Find a device by name or UDID 16 | */ 17 | async find(nameOrId: string): Promise<SimulatorDevice | null> { 18 | try { 19 | const { stdout } = await execAsync('xcrun simctl list devices --json'); 20 | const data = JSON.parse(stdout); 21 | 22 | // Collect all matching devices with their raw data for sorting 23 | const matchingDevices: Array<{device: SimulatorDevice, state: string, isAvailable: boolean}> = []; 24 | 25 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 26 | for (const device of deviceList as any[]) { 27 | if (device.udid === nameOrId || device.name === nameOrId) { 28 | matchingDevices.push({ 29 | device: new SimulatorDevice( 30 | device.udid, 31 | device.name, 32 | this.extractPlatformFromRuntime(runtime), 33 | runtime 34 | ), 35 | state: device.state, 36 | isAvailable: device.isAvailable 37 | }); 38 | } 39 | } 40 | } 41 | 42 | if (matchingDevices.length === 0) { 43 | logger.debug({ nameOrId }, 'Device not found'); 44 | return null; 45 | } 46 | 47 | // Sort to prefer available, booted, and newer devices 48 | this.sortDevices(matchingDevices); 49 | 50 | const selected = matchingDevices[0]; 51 | 52 | // Warn if selected device is not available 53 | if (!selected.isAvailable) { 54 | logger.warn({ 55 | nameOrId, 56 | deviceId: selected.device.id, 57 | runtime: selected.device.runtime 58 | }, 'Selected device is not available - may fail to boot'); 59 | } 60 | 61 | return selected.device; 62 | } catch (error: any) { 63 | logger.error({ error: error.message }, 'Failed to find device'); 64 | throw new Error(`Failed to find device: ${error.message}`); 65 | } 66 | } 67 | 68 | /** 69 | * List all available simulators, optionally filtered by platform 70 | */ 71 | async listSimulators(platform?: Platform): Promise<SimulatorDevice[]> { 72 | try { 73 | const { stdout } = await execAsync('xcrun simctl list devices --json'); 74 | const data = JSON.parse(stdout); 75 | const devices: SimulatorDevice[] = []; 76 | 77 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 78 | const extractedPlatform = this.extractPlatformFromRuntime(runtime); 79 | 80 | // Filter by platform if specified 81 | if (platform && !this.matchesPlatform(extractedPlatform, platform)) { 82 | continue; 83 | } 84 | 85 | for (const device of deviceList as any[]) { 86 | if (device.isAvailable) { 87 | devices.push(new SimulatorDevice( 88 | device.udid, 89 | device.name, 90 | extractedPlatform, 91 | runtime 92 | )); 93 | } 94 | } 95 | } 96 | 97 | return devices; 98 | } catch (error: any) { 99 | logger.error({ error: error.message }, 'Failed to list simulators'); 100 | throw new Error(`Failed to list simulators: ${error.message}`); 101 | } 102 | } 103 | 104 | /** 105 | * Get the currently booted simulator, if any 106 | */ 107 | async getBooted(): Promise<SimulatorDevice | null> { 108 | try { 109 | const { stdout } = await execAsync('xcrun simctl list devices --json'); 110 | const data = JSON.parse(stdout); 111 | 112 | for (const [runtime, deviceList] of Object.entries(data.devices)) { 113 | for (const device of deviceList as any[]) { 114 | if (device.state === 'Booted' && device.isAvailable) { 115 | return new SimulatorDevice( 116 | device.udid, 117 | device.name, 118 | this.extractPlatformFromRuntime(runtime), 119 | runtime 120 | ); 121 | } 122 | } 123 | } 124 | 125 | logger.debug('No booted simulator found'); 126 | return null; 127 | } catch (error: any) { 128 | logger.error({ error: error.message }, 'Failed to get booted device'); 129 | throw new Error(`Failed to get booted device: ${error.message}`); 130 | } 131 | } 132 | 133 | /** 134 | * Find the first available device for a platform 135 | */ 136 | async findFirstAvailable(platform: Platform): Promise<SimulatorDevice | null> { 137 | const devices = await this.listSimulators(platform); 138 | 139 | // First, look for an already booted device 140 | const booted = devices.find((d: SimulatorDevice) => d.isBooted()); 141 | if (booted) { 142 | logger.debug({ device: booted.name, id: booted.id }, 'Using already booted device'); 143 | return booted; 144 | } 145 | 146 | // Otherwise, return the first available device 147 | const available = devices[0]; 148 | if (available) { 149 | logger.debug({ device: available.name, id: available.id }, 'Found available device'); 150 | return available; 151 | } 152 | 153 | logger.debug({ platform }, 'No available devices for platform'); 154 | return null; 155 | } 156 | 157 | /** 158 | * Extract platform from runtime string 159 | */ 160 | private extractPlatformFromRuntime(runtime: string): string { 161 | const runtimeLower = runtime.toLowerCase(); 162 | 163 | if (runtimeLower.includes('ios')) return 'iOS'; 164 | if (runtimeLower.includes('tvos')) return 'tvOS'; 165 | if (runtimeLower.includes('watchos')) return 'watchOS'; 166 | if (runtimeLower.includes('xros') || runtimeLower.includes('visionos')) return 'visionOS'; 167 | 168 | // Default fallback 169 | return 'iOS'; 170 | } 171 | 172 | /** 173 | * Extract version number from runtime string 174 | */ 175 | private getVersionFromRuntime(runtime: string): number { 176 | const match = runtime.match(/(\d+)[.-](\d+)/); 177 | return match ? parseFloat(`${match[1]}.${match[2]}`) : 0; 178 | } 179 | 180 | /** 181 | * Sort devices preferring: available > booted > newer iOS versions 182 | */ 183 | private sortDevices(devices: Array<{device: SimulatorDevice, state: string, isAvailable: boolean}>): void { 184 | devices.sort((a, b) => { 185 | if (a.isAvailable !== b.isAvailable) return a.isAvailable ? -1 : 1; 186 | if (a.state === 'Booted' !== (b.state === 'Booted')) return a.state === 'Booted' ? -1 : 1; 187 | return this.getVersionFromRuntime(b.device.runtime) - this.getVersionFromRuntime(a.device.runtime); 188 | }); 189 | } 190 | 191 | /** 192 | * Check if a runtime matches a platform 193 | */ 194 | private matchesPlatform(extractedPlatform: string, targetPlatform: Platform): boolean { 195 | const extractedLower = extractedPlatform.toLowerCase(); 196 | const targetLower = targetPlatform.toLowerCase(); 197 | 198 | // Handle visionOS special case (internally called xrOS) 199 | if (targetLower === 'visionos') { 200 | return extractedLower === 'visionos' || extractedLower === 'xros'; 201 | } 202 | 203 | return extractedLower === targetLower; 204 | } 205 | 206 | /** 207 | * Find an available device for a specific platform 208 | * Returns the first available device, preferring already booted ones 209 | */ 210 | async findForPlatform(platform: Platform): Promise<SimulatorDevice | null> { 211 | logger.debug({ platform }, 'Finding device for platform'); 212 | 213 | try { 214 | const devices = await this.listSimulators(); 215 | 216 | // Filter devices for the requested platform 217 | const platformDevices = devices.filter((device: SimulatorDevice) => 218 | this.matchesPlatform(this.extractPlatformFromRuntime(device.runtime), platform) 219 | ); 220 | 221 | if (platformDevices.length === 0) { 222 | logger.warn({ platform }, 'No devices found for platform'); 223 | return null; 224 | } 225 | 226 | // Try to find a booted device first 227 | const booted = await this.getBooted(); 228 | if (booted && platformDevices.some(d => d.id === booted.id)) { 229 | logger.debug({ 230 | device: booted.name, 231 | id: booted.id 232 | }, 'Selected already booted device for platform'); 233 | return booted; 234 | } 235 | 236 | // Sort by runtime version (prefer newer) and return the first 237 | platformDevices.sort((a, b) => 238 | this.getVersionFromRuntime(b.runtime) - this.getVersionFromRuntime(a.runtime) 239 | ); 240 | 241 | const selected = platformDevices[0]; 242 | logger.debug({ 243 | device: selected.name, 244 | id: selected.id 245 | }, 'Selected device for platform'); 246 | 247 | return selected; 248 | } catch (error: any) { 249 | logger.error({ error: error.message, platform }, 'Failed to find device for platform'); 250 | throw new Error(`Failed to find device for platform ${platform}: ${error.message}`); 251 | } 252 | } 253 | 254 | /** 255 | * Future: List physical devices connected to the system 256 | * Currently returns empty array as physical device support is not yet implemented 257 | */ 258 | async listPhysical(): Promise<any[]> { 259 | // Future implementation for physical devices 260 | // Would use xcrun devicectl or ios-deploy 261 | return []; 262 | } 263 | } 264 | 265 | // Export a default instance for convenience 266 | export const devices = new Devices(); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/e2e/InstallAppController.e2e.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * E2E Test for InstallAppController 3 | * 4 | * Tests CRITICAL USER PATH with REAL simulators: 5 | * - Can the controller actually install apps on real simulators? 6 | * - Does it properly validate inputs through Clean Architecture layers? 7 | * - Does error handling work with real simulator failures? 8 | * 9 | * NO MOCKS - uses real simulators and real test apps 10 | * This is an E2E test (10% of test suite) for critical user journeys 11 | * 12 | * NOTE: This test requires Xcode and iOS simulators to be installed 13 | * It may be skipped in CI environments without proper setup 14 | */ 15 | 16 | import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; 17 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 18 | import { InstallAppControllerFactory } from '../../factories/InstallAppControllerFactory.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 | import { SimulatorState } from '../../../simulator/domain/SimulatorState.js'; 24 | import { bootAndWaitForSimulator } from '../../../../shared/tests/utils/testHelpers.js'; 25 | 26 | const execAsync = promisify(exec); 27 | 28 | describe('InstallAppController E2E', () => { 29 | let controller: MCPController; 30 | let testManager: TestProjectManager; 31 | let testDeviceId: string; 32 | let testAppPath: string; 33 | 34 | beforeAll(async () => { 35 | // Set up test project with built app 36 | testManager = new TestProjectManager(); 37 | await testManager.setup(); 38 | 39 | // Build the test app using TestProjectManager 40 | testAppPath = await testManager.buildApp('xcodeProject'); 41 | 42 | // Get the latest iOS runtime 43 | const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); 44 | const runtimes = JSON.parse(runtimesResult.stdout); 45 | const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); 46 | 47 | if (!iosRuntime) { 48 | throw new Error('No iOS runtime found. Please install an iOS simulator runtime.'); 49 | } 50 | 51 | // Create and boot a test simulator 52 | const createResult = await execAsync( 53 | `xcrun simctl create "TestSimulator-InstallApp" "iPhone 15" "${iosRuntime.identifier}"` 54 | ); 55 | testDeviceId = createResult.stdout.trim(); 56 | 57 | // Boot the simulator and wait for it to be ready 58 | await bootAndWaitForSimulator(testDeviceId, 30); 59 | }); 60 | 61 | afterAll(async () => { 62 | // Clean up simulator 63 | if (testDeviceId) { 64 | try { 65 | await execAsync(`xcrun simctl shutdown "${testDeviceId}"`); 66 | await execAsync(`xcrun simctl delete "${testDeviceId}"`); 67 | } catch (error) { 68 | // Ignore cleanup errors 69 | } 70 | } 71 | 72 | // Clean up test project 73 | await testManager.cleanup(); 74 | }); 75 | 76 | beforeEach(() => { 77 | // Create controller with all real dependencies 78 | controller = InstallAppControllerFactory.create(); 79 | }); 80 | 81 | describe('install real apps on simulators', () => { 82 | it('should successfully install app on booted simulator', async () => { 83 | // Arrange - simulator is already booted from beforeAll 84 | 85 | // Act 86 | const result = await controller.execute({ 87 | appPath: testAppPath, 88 | simulatorId: testDeviceId 89 | }); 90 | 91 | // Assert 92 | expect(result).toMatchObject({ 93 | content: expect.arrayContaining([ 94 | expect.objectContaining({ 95 | type: 'text', 96 | text: expect.stringContaining('Successfully installed') 97 | }) 98 | ]) 99 | }); 100 | 101 | // Verify app is actually installed 102 | const listAppsResult = await execAsync( 103 | `xcrun simctl listapps "${testDeviceId}" | grep -i test || true` 104 | ); 105 | expect(listAppsResult.stdout).toBeTruthy(); 106 | }); 107 | 108 | it('should install app on booted simulator when no ID specified', async () => { 109 | // Arrange - ensure our test simulator is the only booted one 110 | const devicesResult = await execAsync('xcrun simctl list devices --json'); 111 | const devices = JSON.parse(devicesResult.stdout); 112 | 113 | // Shutdown all other booted simulators 114 | interface Device { 115 | state: string; 116 | udid: string; 117 | } 118 | for (const runtime of Object.values(devices.devices) as Device[][]) { 119 | for (const device of runtime) { 120 | if (device.state === SimulatorState.Booted && device.udid !== testDeviceId) { 121 | await execAsync(`xcrun simctl shutdown "${device.udid}"`); 122 | } 123 | } 124 | } 125 | 126 | // Act - install without specifying simulator ID 127 | const result = await controller.execute({ 128 | appPath: testAppPath 129 | }); 130 | 131 | // Assert 132 | expect(result).toMatchObject({ 133 | content: expect.arrayContaining([ 134 | expect.objectContaining({ 135 | type: 'text', 136 | text: expect.stringContaining('Successfully installed') 137 | }) 138 | ]) 139 | }); 140 | }); 141 | 142 | it('should boot and install when simulator is shutdown', async () => { 143 | // Arrange - get iOS runtime for creating simulator 144 | const runtimesResult = await execAsync('xcrun simctl list runtimes --json'); 145 | const runtimes = JSON.parse(runtimesResult.stdout); 146 | const iosRuntime = runtimes.runtimes.find((r: { platform: string }) => r.platform === 'iOS'); 147 | 148 | // Create a new shutdown simulator 149 | const createResult = await execAsync( 150 | `xcrun simctl create "TestSimulator-Shutdown" "iPhone 14" "${iosRuntime.identifier}"` 151 | ); 152 | const shutdownSimId = createResult.stdout.trim(); 153 | 154 | try { 155 | // Act 156 | const result = await controller.execute({ 157 | appPath: testAppPath, 158 | simulatorId: shutdownSimId 159 | }); 160 | 161 | // Assert 162 | expect(result).toMatchObject({ 163 | content: expect.arrayContaining([ 164 | expect.objectContaining({ 165 | type: 'text', 166 | text: expect.stringContaining('Successfully installed') 167 | }) 168 | ]) 169 | }); 170 | 171 | // Verify simulator was booted 172 | const stateResult = await execAsync( 173 | `xcrun simctl list devices --json | jq -r '.devices[][] | select(.udid=="${shutdownSimId}") | .state'` 174 | ); 175 | expect(stateResult.stdout.trim()).toBe(SimulatorState.Booted); 176 | } finally { 177 | // Clean up 178 | await execAsync(`xcrun simctl shutdown "${shutdownSimId}" || true`); 179 | await execAsync(`xcrun simctl delete "${shutdownSimId}"`); 180 | } 181 | }, 300000); 182 | }); 183 | 184 | describe('error handling with real simulators', () => { 185 | it('should fail when app path does not exist', async () => { 186 | // Arrange 187 | const nonExistentPath = '/path/that/does/not/exist.app'; 188 | 189 | // Act 190 | const result = await controller.execute({ 191 | appPath: nonExistentPath, 192 | simulatorId: testDeviceId 193 | }); 194 | 195 | // Assert - error message from xcrun simctl install (multi-line in real E2E) 196 | expect(result.content[0].text).toContain('❌'); 197 | expect(result.content[0].text).toContain('No such file or directory'); 198 | }); 199 | 200 | it('should fail when app path is not an app bundle', async () => { 201 | // Arrange - use a regular file instead of .app 202 | const invalidAppPath = testManager.paths.xcodeProjectXCTestPath; 203 | 204 | // Act 205 | const result = await controller.execute({ 206 | appPath: invalidAppPath, 207 | simulatorId: testDeviceId 208 | }); 209 | 210 | // Assert - validation error formatted with ❌ 211 | expect(result.content[0].text).toBe('❌ App path must end with .app'); 212 | }); 213 | 214 | it('should fail when simulator does not exist', async () => { 215 | // Arrange 216 | const nonExistentSimulator = 'non-existent-simulator-id'; 217 | 218 | // Act 219 | const result = await controller.execute({ 220 | appPath: testAppPath, 221 | simulatorId: nonExistentSimulator 222 | }); 223 | 224 | // Assert 225 | expect(result.content[0].text).toBe('❌ Simulator not found: non-existent-simulator-id'); 226 | }); 227 | 228 | it('should fail when no booted simulator and no ID specified', async () => { 229 | // Arrange - shutdown all simulators 230 | await execAsync('xcrun simctl shutdown all'); 231 | 232 | try { 233 | // Act 234 | const result = await controller.execute({ 235 | appPath: testAppPath 236 | }); 237 | 238 | // Assert 239 | expect(result.content[0].text).toBe('❌ No booted simulator found. Please boot a simulator first or specify a simulator ID.'); 240 | } finally { 241 | // Re-boot our test simulator for other tests 242 | await execAsync(`xcrun simctl boot "${testDeviceId}"`); 243 | await new Promise(resolve => setTimeout(resolve, 3000)); 244 | } 245 | }); 246 | }); 247 | 248 | describe('simulator name handling', () => { 249 | it('should handle simulator specified by name', async () => { 250 | // Act - use simulator name instead of UUID 251 | const result = await controller.execute({ 252 | appPath: testAppPath, 253 | simulatorId: 'TestSimulator-InstallApp' 254 | }); 255 | 256 | // Assert 257 | expect(result).toMatchObject({ 258 | content: expect.arrayContaining([ 259 | expect.objectContaining({ 260 | type: 'text', 261 | text: expect.stringContaining('Successfully installed') 262 | }) 263 | ]) 264 | }); 265 | }); 266 | }); 267 | }); ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/mockHelpers.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Mock helpers for unit testing 3 | * Provides utilities to mock subprocess execution, filesystem operations, and MCP interactions 4 | */ 5 | 6 | import { jest } from '@jest/globals'; 7 | import type { ExecSyncOptions } from 'child_process'; 8 | 9 | /** 10 | * Mock response builder for subprocess commands 11 | */ 12 | export class SubprocessMock { 13 | private responses = new Map<string, { stdout?: string; stderr?: string; error?: Error }>(); 14 | 15 | /** 16 | * Register a mock response for a command pattern 17 | */ 18 | mockCommand(pattern: string | RegExp, response: { stdout?: string; stderr?: string; error?: Error }) { 19 | const key = pattern instanceof RegExp ? pattern.source : pattern; 20 | this.responses.set(key, response); 21 | } 22 | 23 | /** 24 | * Get mock implementation for execSync 25 | */ 26 | getExecSyncMock() { 27 | return jest.fn((command: string, options?: ExecSyncOptions) => { 28 | // Find matching response 29 | for (const [pattern, response] of this.responses) { 30 | const regex = new RegExp(pattern); 31 | if (regex.test(command)) { 32 | if (response.error) { 33 | throw response.error; 34 | } 35 | return response.stdout || ''; 36 | } 37 | } 38 | throw new Error(`No mock defined for command: ${command}`); 39 | }); 40 | } 41 | 42 | /** 43 | * Get mock implementation for spawn 44 | */ 45 | getSpawnMock() { 46 | return jest.fn((command: string, args: string[], options?: any) => { 47 | const fullCommand = `${command} ${args.join(' ')}`; 48 | 49 | // Find matching response 50 | for (const [pattern, response] of this.responses) { 51 | const regex = new RegExp(pattern); 52 | if (regex.test(fullCommand)) { 53 | return { 54 | stdout: { 55 | on: jest.fn((event: string, cb: Function) => { 56 | if (event === 'data' && response.stdout) { 57 | cb(Buffer.from(response.stdout)); 58 | } 59 | }) 60 | }, 61 | stderr: { 62 | on: jest.fn((event: string, cb: Function) => { 63 | if (event === 'data' && response.stderr) { 64 | cb(Buffer.from(response.stderr)); 65 | } 66 | }) 67 | }, 68 | on: jest.fn((event: string, cb: Function) => { 69 | if (event === 'close') { 70 | cb(response.error ? 1 : 0); 71 | } 72 | if (event === 'error' && response.error) { 73 | cb(response.error); 74 | } 75 | }), 76 | kill: jest.fn() 77 | }; 78 | } 79 | } 80 | 81 | throw new Error(`No mock defined for command: ${fullCommand}`); 82 | }); 83 | } 84 | 85 | /** 86 | * Clear all mocked responses 87 | */ 88 | clear() { 89 | this.responses.clear(); 90 | } 91 | } 92 | 93 | /** 94 | * Mock filesystem operations 95 | */ 96 | export class FilesystemMock { 97 | private files = new Map<string, string | Buffer>(); 98 | private directories = new Set<string>(); 99 | 100 | /** 101 | * Mock a file with content 102 | */ 103 | mockFile(path: string, content: string | Buffer) { 104 | this.files.set(path, content); 105 | // Also add parent directories 106 | const parts = path.split('/'); 107 | for (let i = 1; i < parts.length; i++) { 108 | this.directories.add(parts.slice(0, i).join('/')); 109 | } 110 | } 111 | 112 | /** 113 | * Mock a directory 114 | */ 115 | mockDirectory(path: string) { 116 | this.directories.add(path); 117 | } 118 | 119 | /** 120 | * Get mock for existsSync 121 | */ 122 | getExistsSyncMock() { 123 | return jest.fn((path: string) => { 124 | return this.files.has(path) || this.directories.has(path); 125 | }); 126 | } 127 | 128 | /** 129 | * Get mock for readFileSync 130 | */ 131 | getReadFileSyncMock() { 132 | return jest.fn((path: string, encoding?: BufferEncoding) => { 133 | if (!this.files.has(path)) { 134 | const error: any = new Error(`ENOENT: no such file or directory, open '${path}'`); 135 | error.code = 'ENOENT'; 136 | throw error; 137 | } 138 | const content = this.files.get(path)!; 139 | return encoding && content instanceof Buffer ? content.toString(encoding) : content; 140 | }); 141 | } 142 | 143 | /** 144 | * Get mock for readdirSync 145 | */ 146 | getReaddirSyncMock() { 147 | return jest.fn((path: string) => { 148 | if (!this.directories.has(path)) { 149 | const error: any = new Error(`ENOENT: no such file or directory, scandir '${path}'`); 150 | error.code = 'ENOENT'; 151 | throw error; 152 | } 153 | 154 | // Return files and subdirectories in this directory 155 | const items = new Set<string>(); 156 | const pathWithSlash = path.endsWith('/') ? path : `${path}/`; 157 | 158 | for (const file of this.files.keys()) { 159 | if (file.startsWith(pathWithSlash)) { 160 | const relative = file.slice(pathWithSlash.length); 161 | const firstPart = relative.split('/')[0]; 162 | items.add(firstPart); 163 | } 164 | } 165 | 166 | for (const dir of this.directories) { 167 | if (dir.startsWith(pathWithSlash) && dir !== path) { 168 | const relative = dir.slice(pathWithSlash.length); 169 | const firstPart = relative.split('/')[0]; 170 | items.add(firstPart); 171 | } 172 | } 173 | 174 | return Array.from(items); 175 | }); 176 | } 177 | 178 | /** 179 | * Clear all mocked files and directories 180 | */ 181 | clear() { 182 | this.files.clear(); 183 | this.directories.clear(); 184 | } 185 | } 186 | 187 | /** 188 | * Common mock responses for Xcode/simulator commands 189 | */ 190 | export const commonMockResponses = { 191 | /** 192 | * Mock successful xcodebuild 193 | */ 194 | xcodebuildSuccess: (scheme: string = 'TestApp') => ({ 195 | stdout: `Build succeeded\nScheme: ${scheme}\n** BUILD SUCCEEDED **`, 196 | stderr: '' 197 | }), 198 | 199 | /** 200 | * Mock xcodebuild failure 201 | */ 202 | xcodebuildFailure: (error: string = 'Build failed') => ({ 203 | stdout: '', 204 | stderr: `error: ${error}\n** BUILD FAILED **`, 205 | error: new Error(`Command failed: xcodebuild\n${error}`) 206 | }), 207 | 208 | /** 209 | * Mock scheme not found error 210 | */ 211 | schemeNotFound: (scheme: string) => ({ 212 | stdout: '', 213 | stderr: `xcodebuild: error: The project does not contain a scheme named "${scheme}".`, 214 | error: new Error(`xcodebuild: error: The project does not contain a scheme named "${scheme}".`) 215 | }), 216 | 217 | /** 218 | * Mock simulator list 219 | */ 220 | simulatorList: (devices: Array<{ name: string; udid: string; state: string }> = []) => ({ 221 | stdout: JSON.stringify({ 222 | devices: { 223 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': devices.map(d => ({ 224 | ...d, 225 | isAvailable: true, 226 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 227 | })) 228 | } 229 | }), 230 | stderr: '' 231 | }), 232 | 233 | /** 234 | * Mock simulator boot success 235 | */ 236 | simulatorBootSuccess: (deviceId: string) => ({ 237 | stdout: `Device ${deviceId} booted successfully`, 238 | stderr: '' 239 | }), 240 | 241 | /** 242 | * Mock simulator already booted 243 | */ 244 | simulatorAlreadyBooted: (deviceId: string) => ({ 245 | stdout: '', 246 | stderr: `Device ${deviceId} is already booted`, 247 | error: new Error(`Device ${deviceId} is already booted`) 248 | }), 249 | 250 | /** 251 | * Mock app installation success 252 | */ 253 | appInstallSuccess: (appPath: string, deviceId: string) => ({ 254 | stdout: `Successfully installed ${appPath} on ${deviceId}`, 255 | stderr: '' 256 | }), 257 | 258 | /** 259 | * Mock list schemes 260 | */ 261 | schemesList: (schemes: string[] = ['TestApp', 'TestAppTests']) => ({ 262 | stdout: JSON.stringify({ project: { schemes: schemes } }), 263 | stderr: '' 264 | }), 265 | 266 | /** 267 | * Mock swift build success 268 | */ 269 | swiftBuildSuccess: () => ({ 270 | stdout: 'Building for debugging...\nBuild complete!', 271 | stderr: '' 272 | }), 273 | 274 | /** 275 | * Mock swift test success 276 | */ 277 | swiftTestSuccess: (passed: number = 10, failed: number = 0) => ({ 278 | stdout: `Test Suite 'All tests' passed at 2024-01-01\nExecuted ${passed + failed} tests, with ${failed} failures`, 279 | stderr: '' 280 | }) 281 | }; 282 | 283 | /** 284 | * Create a mock MCP client for testing 285 | */ 286 | export function createMockMCPClient() { 287 | return { 288 | request: jest.fn(), 289 | notify: jest.fn(), 290 | close: jest.fn(), 291 | on: jest.fn(), 292 | off: jest.fn() 293 | }; 294 | } 295 | 296 | /** 297 | * Helper to setup common mocks for a test 298 | */ 299 | export function setupCommonMocks() { 300 | const subprocess = new SubprocessMock(); 301 | const filesystem = new FilesystemMock(); 302 | 303 | // Mock child_process 304 | jest.mock('child_process', () => ({ 305 | execSync: subprocess.getExecSyncMock(), 306 | spawn: subprocess.getSpawnMock() 307 | })); 308 | 309 | // Mock fs 310 | jest.mock('fs', () => ({ 311 | existsSync: filesystem.getExistsSyncMock(), 312 | readFileSync: filesystem.getReadFileSyncMock(), 313 | readdirSync: filesystem.getReaddirSyncMock() 314 | })); 315 | 316 | return { subprocess, filesystem }; 317 | } 318 | 319 | /** 320 | * Helper to create a mock Xcode instance 321 | */ 322 | export function createMockXcode() { 323 | return { 324 | open: jest.fn().mockReturnValue({ 325 | buildWithConfiguration: jest.fn<() => Promise<any>>().mockResolvedValue({ 326 | success: true, 327 | stdout: 'Build succeeded', 328 | stderr: '' 329 | }), 330 | test: jest.fn<() => Promise<any>>().mockResolvedValue({ 331 | success: true, 332 | stdout: 'Test succeeded', 333 | stderr: '' 334 | }), 335 | run: jest.fn<() => Promise<any>>().mockResolvedValue({ 336 | success: true, 337 | stdout: 'Run succeeded', 338 | stderr: '' 339 | }), 340 | clean: jest.fn<() => Promise<any>>().mockResolvedValue({ 341 | success: true, 342 | stdout: 'Clean succeeded', 343 | stderr: '' 344 | }), 345 | archive: jest.fn<() => Promise<any>>().mockResolvedValue({ 346 | success: true, 347 | stdout: 'Archive succeeded', 348 | stderr: '' 349 | }) 350 | }) 351 | }; 352 | } ``` -------------------------------------------------------------------------------- /src/shared/tests/utils/TestProjectManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { existsSync, rmSync, readdirSync, statSync } from 'fs'; 2 | import { join, resolve } from 'path'; 3 | import { exec } from 'child_process'; 4 | import { promisify } from 'util'; 5 | import { createModuleLogger } from '../../../logger'; 6 | import { config } from '../../../config'; 7 | import { TestEnvironmentCleaner } from './TestEnvironmentCleaner'; 8 | import { gitResetTestArtifacts } from './gitResetTestArtifacts'; 9 | 10 | const execAsync = promisify(exec); 11 | 12 | const logger = createModuleLogger('TestProjectManager'); 13 | 14 | export class TestProjectManager { 15 | private testArtifactsDir: string; 16 | private xcodeProjectPath: string; 17 | private xcodeProjectSwiftTestingPath: string; 18 | private swiftPackageXCTestPath: string; 19 | private swiftPackageSwiftTestingPath: string; 20 | private workspacePath: string; 21 | private watchOSProjectPath: string; 22 | 23 | constructor() { 24 | // Use the actual test artifacts directory 25 | this.testArtifactsDir = resolve(process.cwd(), 'test_artifacts'); 26 | 27 | // Set up paths to real test projects 28 | // Xcode projects 29 | this.xcodeProjectPath = join(this.testArtifactsDir, 'TestProjectXCTest', 'TestProjectXCTest.xcodeproj'); 30 | this.xcodeProjectSwiftTestingPath = join(this.testArtifactsDir, 'TestProjectSwiftTesting', 'TestProjectSwiftTesting.xcodeproj'); 31 | 32 | // Swift packages 33 | this.swiftPackageXCTestPath = join(this.testArtifactsDir, 'TestSwiftPackageXCTest'); 34 | this.swiftPackageSwiftTestingPath = join(this.testArtifactsDir, 'TestSwiftPackageSwiftTesting'); 35 | 36 | // Workspace and other projects 37 | this.workspacePath = join(this.testArtifactsDir, 'Test.xcworkspace'); 38 | this.watchOSProjectPath = join(this.testArtifactsDir, 'TestProjectWatchOS', 'TestProjectWatchOS.xcodeproj'); 39 | } 40 | 41 | get paths() { 42 | return { 43 | testProjectDir: this.testArtifactsDir, 44 | // Xcode projects 45 | xcodeProjectXCTestDir: join(this.testArtifactsDir, 'TestProjectXCTest'), 46 | xcodeProjectXCTestPath: this.xcodeProjectPath, 47 | xcodeProjectSwiftTestingDir: join(this.testArtifactsDir, 'TestProjectSwiftTesting'), 48 | xcodeProjectSwiftTestingPath: this.xcodeProjectSwiftTestingPath, 49 | // Swift packages 50 | swiftPackageXCTestDir: this.swiftPackageXCTestPath, // Default to XCTest for backward compat 51 | swiftPackageSwiftTestingDir: this.swiftPackageSwiftTestingPath, 52 | // Other 53 | workspaceDir: this.testArtifactsDir, 54 | derivedDataPath: join(this.testArtifactsDir, 'DerivedData'), 55 | workspacePath: this.workspacePath, 56 | watchOSProjectPath: this.watchOSProjectPath, 57 | watchOSProjectDir: join(this.testArtifactsDir, 'TestProjectWatchOS') 58 | }; 59 | } 60 | 61 | get schemes() { 62 | return { 63 | xcodeProject: 'TestProjectXCTest', 64 | xcodeProjectSwiftTesting: 'TestProjectSwiftTesting', 65 | workspace: 'TestProjectXCTest', // The workspace uses the same scheme 66 | swiftPackageXCTest: 'TestSwiftPackageXCTest', 67 | swiftPackageSwiftTesting: 'TestSwiftPackageSwiftTesting', 68 | watchOSProject: 'TestProjectWatchOS Watch App' // The watchOS app scheme 69 | }; 70 | } 71 | 72 | get targets() { 73 | return { 74 | xcodeProject: { 75 | app: 'TestProjectXCTest', 76 | unitTests: 'TestProjectXCTestTests', 77 | uiTests: 'TestProjectXCTestUITests' 78 | }, 79 | xcodeProjectSwiftTesting: { 80 | app: 'TestProjectSwiftTesting', 81 | unitTests: 'TestProjectSwiftTestingTests', 82 | uiTests: 'TestProjectSwiftTestingUITests' 83 | }, 84 | watchOSProject: { 85 | app: 'TestProjectWatchOS Watch App', 86 | tests: 'TestProjectWatchOS Watch AppTests' 87 | } 88 | }; 89 | } 90 | 91 | async setup() { 92 | // Clean up any leftover build artifacts before starting 93 | this.cleanBuildArtifacts(); 94 | } 95 | 96 | /** 97 | * Build a test app for simulator testing 98 | * Uses optimized settings to avoid hanging on code signing or large output 99 | * @param projectType Which test project to build (defaults to 'xcodeProject') 100 | * @returns Path to the built .app bundle 101 | */ 102 | async buildApp(projectType: 'xcodeProject' | 'xcodeProjectSwiftTesting' | 'watchOSProject' = 'xcodeProject'): Promise<string> { 103 | let projectPath: string; 104 | let scheme: string; 105 | 106 | switch (projectType) { 107 | case 'xcodeProject': 108 | projectPath = this.xcodeProjectPath; 109 | scheme = this.schemes.xcodeProject; 110 | break; 111 | case 'xcodeProjectSwiftTesting': 112 | projectPath = this.xcodeProjectSwiftTestingPath; 113 | scheme = this.schemes.xcodeProjectSwiftTesting; 114 | break; 115 | case 'watchOSProject': 116 | projectPath = this.watchOSProjectPath; 117 | scheme = this.schemes.watchOSProject; 118 | break; 119 | } 120 | 121 | // Build with optimized settings for testing 122 | // Use generic/platform but with ONLY_ACTIVE_ARCH to build for current architecture only 123 | await execAsync( 124 | `xcodebuild -project "${projectPath}" ` + 125 | `-scheme "${scheme}" ` + 126 | `-configuration Debug ` + 127 | `-destination 'generic/platform=iOS Simulator' ` + 128 | `-derivedDataPath "${this.paths.derivedDataPath}" ` + 129 | `ONLY_ACTIVE_ARCH=YES ` + 130 | `CODE_SIGNING_ALLOWED=NO ` + 131 | `CODE_SIGNING_REQUIRED=NO ` + 132 | `build`, 133 | { maxBuffer: 50 * 1024 * 1024 } 134 | ); 135 | 136 | // Find the built app 137 | const findResult = await execAsync( 138 | `find "${this.paths.derivedDataPath}" -name "*.app" -type d | head -1` 139 | ); 140 | const appPath = findResult.stdout.trim(); 141 | 142 | if (!appPath || !existsSync(appPath)) { 143 | throw new Error('Failed to find built app'); 144 | } 145 | 146 | return appPath; 147 | } 148 | 149 | private cleanBuildArtifacts() { 150 | // Clean DerivedData 151 | TestEnvironmentCleaner.cleanupTestEnvironment() 152 | 153 | // Clean .build directories (for SPM) 154 | const buildDirs = [ 155 | join(this.swiftPackageXCTestPath, '.build'), 156 | join(this.swiftPackageSwiftTestingPath, '.build'), 157 | join(this.testArtifactsDir, '.build') 158 | ]; 159 | 160 | buildDirs.forEach(dir => { 161 | if (existsSync(dir)) { 162 | rmSync(dir, { recursive: true, force: true }); 163 | } 164 | }); 165 | 166 | // Clean xcresult bundles (test results) 167 | this.cleanTestResults(); 168 | 169 | // Clean any .swiftpm directories 170 | const swiftpmDirs = this.findDirectories(this.testArtifactsDir, '.swiftpm'); 171 | swiftpmDirs.forEach(dir => { 172 | rmSync(dir, { recursive: true, force: true }); 173 | }); 174 | 175 | // Clean build folders in Xcode projects 176 | const xcodeProjects = [ 177 | join(this.testArtifactsDir, 'TestProjectXCTest'), 178 | join(this.testArtifactsDir, 'TestProjectSwiftTesting'), 179 | join(this.testArtifactsDir, 'TestProjectWatchOS') 180 | ]; 181 | 182 | xcodeProjects.forEach(projectDir => { 183 | const buildDir = join(projectDir, 'build'); 184 | if (existsSync(buildDir)) { 185 | rmSync(buildDir, { recursive: true, force: true }); 186 | } 187 | }); 188 | } 189 | 190 | cleanTestResults() { 191 | // Find and remove all .xcresult bundles 192 | const xcresultFiles = this.findFiles(this.testArtifactsDir, '.xcresult'); 193 | xcresultFiles.forEach(file => { 194 | rmSync(file, { recursive: true, force: true }); 195 | }); 196 | 197 | // Clean test output files 198 | const testOutputFiles = [ 199 | join(this.swiftPackageXCTestPath, 'test-output.txt'), 200 | join(this.swiftPackageSwiftTestingPath, 'test-output.txt'), 201 | join(this.testArtifactsDir, 'test-results.json') 202 | ]; 203 | 204 | testOutputFiles.forEach(file => { 205 | if (existsSync(file)) { 206 | rmSync(file, { force: true }); 207 | } 208 | }); 209 | } 210 | 211 | cleanup() { 212 | // Use git to restore test_artifacts to pristine state 213 | gitResetTestArtifacts(); 214 | 215 | // ALWAYS clean build artifacts including MCP-Xcode DerivedData 216 | this.cleanBuildArtifacts(); 217 | 218 | // Also clean DerivedData in project root 219 | const projectDerivedData = join(process.cwd(), 'DerivedData'); 220 | if (existsSync(projectDerivedData)) { 221 | rmSync(projectDerivedData, { recursive: true, force: true }); 222 | } 223 | } 224 | 225 | private findFiles(dir: string, extension: string): string[] { 226 | const results: string[] = []; 227 | 228 | if (!existsSync(dir)) { 229 | return results; 230 | } 231 | 232 | try { 233 | const files = readdirSync(dir); 234 | 235 | for (const file of files) { 236 | const fullPath = join(dir, file); 237 | const stat = statSync(fullPath); 238 | 239 | if (stat.isDirectory()) { 240 | // Skip hidden directories and node_modules 241 | if (!file.startsWith('.') && file !== 'node_modules') { 242 | results.push(...this.findFiles(fullPath, extension)); 243 | } 244 | } else if (file.endsWith(extension)) { 245 | results.push(fullPath); 246 | } 247 | } 248 | } catch (error) { 249 | logger.error({ error, dir }, 'Error scanning directory'); 250 | } 251 | 252 | return results; 253 | } 254 | 255 | private findDirectories(dir: string, name: string): string[] { 256 | const results: string[] = []; 257 | 258 | if (!existsSync(dir)) { 259 | return results; 260 | } 261 | 262 | try { 263 | const files = readdirSync(dir); 264 | 265 | for (const file of files) { 266 | const fullPath = join(dir, file); 267 | const stat = statSync(fullPath); 268 | 269 | if (stat.isDirectory()) { 270 | if (file === name) { 271 | results.push(fullPath); 272 | } else if (!file.startsWith('.') && file !== 'node_modules') { 273 | // Recursively search subdirectories 274 | results.push(...this.findDirectories(fullPath, name)); 275 | } 276 | } 277 | } 278 | } catch (error) { 279 | logger.error({ error, dir }, 'Error scanning directory'); 280 | } 281 | 282 | return results; 283 | } 284 | } ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/SimulatorLocatorAdapter.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { SimulatorLocatorAdapter } from '../../infrastructure/SimulatorLocatorAdapter.js'; 3 | import { ICommandExecutor } from '../../../../application/ports/CommandPorts.js'; 4 | import { SimulatorState } from '../../domain/SimulatorState.js'; 5 | 6 | describe('SimulatorLocatorAdapter', () => { 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | function createSUT() { 12 | const mockExecute = jest.fn<ICommandExecutor['execute']>(); 13 | const mockExecutor: ICommandExecutor = { 14 | execute: mockExecute 15 | }; 16 | const sut = new SimulatorLocatorAdapter(mockExecutor); 17 | return { sut, mockExecute }; 18 | } 19 | 20 | function createDeviceListOutput(devices: any = {}) { 21 | return JSON.stringify({ devices }); 22 | } 23 | 24 | describe('findSimulator', () => { 25 | describe('finding by UUID', () => { 26 | it('should find simulator by exact UUID match', async () => { 27 | // Arrange 28 | const { sut, mockExecute } = createSUT(); 29 | const deviceList = { 30 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 31 | udid: 'ABC-123-EXACT', 32 | name: 'iPhone 15', 33 | state: 'Shutdown', 34 | isAvailable: true 35 | }] 36 | }; 37 | mockExecute.mockResolvedValue({ 38 | stdout: createDeviceListOutput(deviceList), 39 | stderr: '', 40 | exitCode: 0 41 | }); 42 | 43 | // Act 44 | const result = await sut.findSimulator('ABC-123-EXACT'); 45 | 46 | // Assert 47 | expect(result).toEqual({ 48 | id: 'ABC-123-EXACT', 49 | name: 'iPhone 15', 50 | state: SimulatorState.Shutdown, 51 | platform: 'iOS', 52 | runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-17-0' 53 | }); 54 | }); 55 | }); 56 | 57 | describe('finding by name with multiple matches', () => { 58 | it('should prefer booted device when multiple devices have same name', async () => { 59 | // Arrange 60 | const { sut, mockExecute } = createSUT(); 61 | const deviceList = { 62 | 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [{ 63 | udid: 'OLD-123', 64 | name: 'iPhone 15 Pro', 65 | state: 'Shutdown', 66 | isAvailable: true 67 | }], 68 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 69 | udid: 'NEW-456', 70 | name: 'iPhone 15 Pro', 71 | state: 'Booted', 72 | isAvailable: true 73 | }] 74 | }; 75 | mockExecute.mockResolvedValue({ 76 | stdout: createDeviceListOutput(deviceList), 77 | stderr: '', 78 | exitCode: 0 79 | }); 80 | 81 | // Act 82 | const result = await sut.findSimulator('iPhone 15 Pro'); 83 | 84 | // Assert 85 | expect(result?.id).toBe('NEW-456'); // Should pick booted one 86 | }); 87 | 88 | it('should prefer newer runtime when multiple shutdown devices have same name', async () => { 89 | // Arrange 90 | const { sut, mockExecute } = createSUT(); 91 | const deviceList = { 92 | 'com.apple.CoreSimulator.SimRuntime.iOS-16-4': [{ 93 | udid: 'OLD-123', 94 | name: 'iPhone 14', 95 | state: 'Shutdown', 96 | isAvailable: true 97 | }], 98 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [{ 99 | udid: 'NEW-456', 100 | name: 'iPhone 14', 101 | state: 'Shutdown', 102 | isAvailable: true 103 | }], 104 | 'com.apple.CoreSimulator.SimRuntime.iOS-15-0': [{ 105 | udid: 'OLDER-789', 106 | name: 'iPhone 14', 107 | state: 'Shutdown', 108 | isAvailable: true 109 | }] 110 | }; 111 | mockExecute.mockResolvedValue({ 112 | stdout: createDeviceListOutput(deviceList), 113 | stderr: '', 114 | exitCode: 0 115 | }); 116 | 117 | // Act 118 | const result = await sut.findSimulator('iPhone 14'); 119 | 120 | // Assert 121 | expect(result?.id).toBe('NEW-456'); // Should pick iOS 17.2 122 | expect(result?.runtime).toContain('iOS-17-2'); 123 | }); 124 | }); 125 | 126 | describe('availability handling', () => { 127 | it('should skip unavailable devices', async () => { 128 | // Arrange 129 | const { sut, mockExecute } = createSUT(); 130 | const deviceList = { 131 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 132 | udid: 'UNAVAIL-123', 133 | name: 'iPhone 15', 134 | state: 'Shutdown', 135 | isAvailable: false 136 | }] 137 | }; 138 | mockExecute.mockResolvedValue({ 139 | stdout: createDeviceListOutput(deviceList), 140 | stderr: '', 141 | exitCode: 0 142 | }); 143 | 144 | // Act 145 | const result = await sut.findSimulator('iPhone 15'); 146 | 147 | // Assert 148 | expect(result).toBeNull(); 149 | }); 150 | }); 151 | 152 | describe('platform extraction', () => { 153 | it('should correctly identify iOS platform', async () => { 154 | // Arrange 155 | const { sut, mockExecute } = createSUT(); 156 | const deviceList = { 157 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 158 | udid: 'IOS-123', 159 | name: 'iPhone 15', 160 | state: 'Shutdown', 161 | isAvailable: true 162 | }] 163 | }; 164 | mockExecute.mockResolvedValue({ 165 | stdout: createDeviceListOutput(deviceList), 166 | stderr: '', 167 | exitCode: 0 168 | }); 169 | 170 | // Act 171 | const result = await sut.findSimulator('IOS-123'); 172 | 173 | // Assert 174 | expect(result?.platform).toBe('iOS'); 175 | }); 176 | 177 | it('should correctly identify tvOS platform', async () => { 178 | // Arrange 179 | const { sut, mockExecute } = createSUT(); 180 | const deviceList = { 181 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [{ 182 | udid: 'TV-123', 183 | name: 'Apple TV', 184 | state: 'Shutdown', 185 | isAvailable: true 186 | }] 187 | }; 188 | mockExecute.mockResolvedValue({ 189 | stdout: createDeviceListOutput(deviceList), 190 | stderr: '', 191 | exitCode: 0 192 | }); 193 | 194 | // Act 195 | const result = await sut.findSimulator('TV-123'); 196 | 197 | // Assert 198 | expect(result?.platform).toBe('tvOS'); 199 | }); 200 | 201 | it('should correctly identify visionOS platform', async () => { 202 | // Arrange 203 | const { sut, mockExecute } = createSUT(); 204 | const deviceList = { 205 | 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0': [{ 206 | udid: 'VISION-123', 207 | name: 'Apple Vision Pro', 208 | state: 'Shutdown', 209 | isAvailable: true 210 | }] 211 | }; 212 | mockExecute.mockResolvedValue({ 213 | stdout: createDeviceListOutput(deviceList), 214 | stderr: '', 215 | exitCode: 0 216 | }); 217 | 218 | // Act 219 | const result = await sut.findSimulator('VISION-123'); 220 | 221 | // Assert 222 | expect(result?.platform).toBe('visionOS'); 223 | }); 224 | }); 225 | }); 226 | 227 | describe('findBootedSimulator', () => { 228 | describe('with single booted simulator', () => { 229 | it('should return the booted simulator', async () => { 230 | // Arrange 231 | const { sut, mockExecute } = createSUT(); 232 | const deviceList = { 233 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 234 | { 235 | udid: 'SHUT-123', 236 | name: 'iPhone 15', 237 | state: 'Shutdown', 238 | isAvailable: true 239 | }, 240 | { 241 | udid: 'BOOT-456', 242 | name: 'iPhone 14', 243 | state: 'Booted', 244 | isAvailable: true 245 | } 246 | ] 247 | }; 248 | mockExecute.mockResolvedValue({ 249 | stdout: createDeviceListOutput(deviceList), 250 | stderr: '', 251 | exitCode: 0 252 | }); 253 | 254 | // Act 255 | const result = await sut.findBootedSimulator(); 256 | 257 | // Assert 258 | expect(result).toEqual({ 259 | id: 'BOOT-456', 260 | name: 'iPhone 14', 261 | state: SimulatorState.Booted, 262 | platform: 'iOS', 263 | runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-17-0' 264 | }); 265 | }); 266 | }); 267 | 268 | describe('with multiple booted simulators', () => { 269 | it('should throw error indicating multiple booted simulators', async () => { 270 | // Arrange 271 | const { sut, mockExecute } = createSUT(); 272 | const deviceList = { 273 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 274 | { 275 | udid: 'BOOT-1', 276 | name: 'iPhone 15', 277 | state: 'Booted', 278 | isAvailable: true 279 | }, 280 | { 281 | udid: 'BOOT-2', 282 | name: 'iPhone 14', 283 | state: 'Booted', 284 | isAvailable: true 285 | } 286 | ] 287 | }; 288 | mockExecute.mockResolvedValue({ 289 | stdout: createDeviceListOutput(deviceList), 290 | stderr: '', 291 | exitCode: 0 292 | }); 293 | 294 | // Act & Assert 295 | await expect(sut.findBootedSimulator()) 296 | .rejects.toThrow('Multiple booted simulators found (2). Please specify a simulator ID.'); 297 | }); 298 | }); 299 | 300 | describe('with no booted simulators', () => { 301 | it('should return null', async () => { 302 | // Arrange 303 | const { sut, mockExecute } = createSUT(); 304 | const deviceList = { 305 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 306 | udid: 'SHUT-123', 307 | name: 'iPhone 15', 308 | state: 'Shutdown', 309 | isAvailable: true 310 | }] 311 | }; 312 | mockExecute.mockResolvedValue({ 313 | stdout: createDeviceListOutput(deviceList), 314 | stderr: '', 315 | exitCode: 0 316 | }); 317 | 318 | // Act 319 | const result = await sut.findBootedSimulator(); 320 | 321 | // Assert 322 | expect(result).toBeNull(); 323 | }); 324 | }); 325 | 326 | describe('with unavailable booted device', () => { 327 | it('should skip unavailable devices even if booted', async () => { 328 | // Arrange 329 | const { sut, mockExecute } = createSUT(); 330 | const deviceList = { 331 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 332 | udid: 'BOOT-123', 333 | name: 'iPhone 15', 334 | state: 'Booted', 335 | isAvailable: false 336 | }] 337 | }; 338 | mockExecute.mockResolvedValue({ 339 | stdout: createDeviceListOutput(deviceList), 340 | stderr: '', 341 | exitCode: 0 342 | }); 343 | 344 | // Act 345 | const result = await sut.findBootedSimulator(); 346 | 347 | // Assert 348 | expect(result).toBeNull(); 349 | }); 350 | }); 351 | }); 352 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/integration/BootSimulatorController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 3 | import { BootSimulatorControllerFactory } from '../../factories/BootSimulatorControllerFactory.js'; 4 | import { SimulatorState } from '../../domain/SimulatorState.js'; 5 | import { exec } from 'child_process'; 6 | import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; 7 | 8 | // Mock ONLY external boundaries 9 | jest.mock('child_process'); 10 | 11 | // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) 12 | jest.mock('util', () => { 13 | const actualUtil = jest.requireActual('util') as typeof import('util'); 14 | const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); 15 | 16 | return { 17 | ...actualUtil, 18 | promisify: (fn: Function) => 19 | fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) 20 | }; 21 | }); 22 | 23 | // Mock DependencyChecker to always report dependencies are available in tests 24 | jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ 25 | DependencyChecker: jest.fn().mockImplementation(() => ({ 26 | check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies 27 | })) 28 | })); 29 | 30 | const mockExec = exec as jest.MockedFunction<typeof exec>; 31 | 32 | /** 33 | * Integration tests for BootSimulatorController 34 | * 35 | * Tests the integration between: 36 | * - Controller → Use Case → Adapters 37 | * - Input validation → Domain logic → Output formatting 38 | * 39 | * Mocks only external boundaries (shell commands) 40 | * Tests behavior, not implementation details 41 | */ 42 | describe('BootSimulatorController Integration', () => { 43 | let controller: MCPController; 44 | let execCallIndex: number; 45 | let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; 46 | 47 | beforeEach(() => { 48 | jest.clearAllMocks(); 49 | execCallIndex = 0; 50 | execMockResponses = []; 51 | 52 | // Setup exec mock to return responses sequentially 53 | mockExec.mockImplementation((( 54 | _cmd: string, 55 | _options: any, 56 | callback: (error: Error | null, stdout: string, stderr: string) => void 57 | ) => { 58 | const response = execMockResponses[execCallIndex++] || { stdout: '', stderr: '' }; 59 | if (response.error) { 60 | callback(response.error, response.stdout, response.stderr); 61 | } else { 62 | callback(null, response.stdout, response.stderr); 63 | } 64 | }) as any); 65 | 66 | // Create controller with REAL components using factory 67 | controller = BootSimulatorControllerFactory.create(); 68 | }); 69 | 70 | describe('boot simulator workflow', () => { 71 | it('should boot a shutdown simulator', async () => { 72 | // Arrange 73 | const simulatorData = { 74 | devices: { 75 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 76 | udid: 'ABC123', 77 | name: 'iPhone 15', 78 | state: SimulatorState.Shutdown, 79 | isAvailable: true, 80 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 81 | }] 82 | } 83 | }; 84 | 85 | execMockResponses = [ 86 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 87 | { stdout: '', stderr: '' } // boot command succeeds 88 | ]; 89 | 90 | // Act 91 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 92 | 93 | // Assert - Test behavior: simulator was successfully booted 94 | expect(result.content[0].text).toBe('✅ Successfully booted simulator: iPhone 15 (ABC123)'); 95 | }); 96 | 97 | it('should handle already booted simulator', async () => { 98 | // Arrange 99 | const simulatorData = { 100 | devices: { 101 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 102 | udid: 'ABC123', 103 | name: 'iPhone 15', 104 | state: SimulatorState.Booted, 105 | isAvailable: true, 106 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 107 | }] 108 | } 109 | }; 110 | 111 | execMockResponses = [ 112 | { stdout: JSON.stringify(simulatorData), stderr: '' } // list devices - already booted 113 | ]; 114 | 115 | // Act 116 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 117 | 118 | // Assert - Test behavior: reports simulator is already running 119 | expect(result.content[0].text).toBe('✅ Simulator already booted: iPhone 15 (ABC123)'); 120 | }); 121 | 122 | it('should boot simulator by UUID', async () => { 123 | // Arrange 124 | const uuid = '550e8400-e29b-41d4-a716-446655440000'; 125 | const simulatorData = { 126 | devices: { 127 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 128 | udid: uuid, 129 | name: 'iPhone 15 Pro', 130 | state: SimulatorState.Shutdown, 131 | isAvailable: true, 132 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' 133 | }] 134 | } 135 | }; 136 | 137 | execMockResponses = [ 138 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 139 | { stdout: '', stderr: '' } // boot command succeeds 140 | ]; 141 | 142 | // Act 143 | const result = await controller.execute({ deviceId: uuid }); 144 | 145 | // Assert - Test behavior: simulator was booted using UUID 146 | expect(result.content[0].text).toBe(`✅ Successfully booted simulator: iPhone 15 Pro (${uuid})`); 147 | }); 148 | }); 149 | 150 | describe('error handling', () => { 151 | it('should handle simulator not found', async () => { 152 | // Arrange 153 | execMockResponses = [ 154 | { stdout: JSON.stringify({ devices: {} }), stderr: '' } // empty device list 155 | ]; 156 | 157 | // Act 158 | const result = await controller.execute({ deviceId: 'NonExistent' }); 159 | 160 | // Assert - Test behavior: appropriate error message shown 161 | expect(result.content[0].text).toBe('❌ Simulator not found: NonExistent'); 162 | }); 163 | 164 | it('should handle boot command failure', async () => { 165 | // Arrange 166 | const simulatorData = { 167 | devices: { 168 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 169 | udid: 'ABC123', 170 | name: 'iPhone 15', 171 | state: SimulatorState.Shutdown, 172 | isAvailable: true, 173 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 174 | }] 175 | } 176 | }; 177 | 178 | const bootError: NodeExecError = new Error('Command failed') as NodeExecError; 179 | bootError.code = 1; 180 | bootError.stdout = ''; 181 | bootError.stderr = 'Unable to boot device'; 182 | 183 | execMockResponses = [ 184 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 185 | { stdout: '', stderr: 'Unable to boot device', error: bootError } // boot fails 186 | ]; 187 | 188 | // Act 189 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 190 | 191 | // Assert - Test behavior: error message includes context for found simulator 192 | expect(result.content[0].text).toBe('❌ iPhone 15 (ABC123) - Unable to boot device'); 193 | }); 194 | }); 195 | 196 | describe('validation', () => { 197 | it('should validate required deviceId', async () => { 198 | // Act 199 | const result = await controller.execute({} as any); 200 | 201 | // Assert 202 | expect(result.content[0].text).toBe('❌ Device ID is required'); 203 | }); 204 | 205 | it('should validate empty deviceId', async () => { 206 | // Act 207 | const result = await controller.execute({ deviceId: '' }); 208 | 209 | // Assert 210 | expect(result.content[0].text).toBe('❌ Device ID cannot be empty'); 211 | }); 212 | 213 | it('should validate whitespace-only deviceId', async () => { 214 | // Act 215 | const result = await controller.execute({ deviceId: ' ' }); 216 | 217 | // Assert 218 | expect(result.content[0].text).toBe('❌ Device ID cannot be whitespace only'); 219 | }); 220 | }); 221 | 222 | describe('complex scenarios', () => { 223 | it('should boot specific simulator when multiple exist with similar names', async () => { 224 | // Arrange 225 | const simulatorData = { 226 | devices: { 227 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 228 | { 229 | udid: 'AAA111', 230 | name: 'iPhone 15', 231 | state: SimulatorState.Shutdown, 232 | isAvailable: true, 233 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 234 | }, 235 | { 236 | udid: 'BBB222', 237 | name: 'iPhone 15 Pro', 238 | state: SimulatorState.Shutdown, 239 | isAvailable: true, 240 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' 241 | }, 242 | { 243 | udid: 'CCC333', 244 | name: 'iPhone 15 Pro Max', 245 | state: SimulatorState.Shutdown, 246 | isAvailable: true, 247 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro-Max' 248 | } 249 | ] 250 | } 251 | }; 252 | 253 | execMockResponses = [ 254 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 255 | { stdout: '', stderr: '' } // boot command succeeds 256 | ]; 257 | 258 | // Act 259 | const result = await controller.execute({ deviceId: 'iPhone 15 Pro' }); 260 | 261 | // Assert - Test behavior: correct simulator was booted 262 | expect(result.content[0].text).toBe('✅ Successfully booted simulator: iPhone 15 Pro (BBB222)'); 263 | }); 264 | 265 | it('should handle mixed state simulators across runtimes', async () => { 266 | // Arrange 267 | const simulatorData = { 268 | devices: { 269 | 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [{ 270 | udid: 'OLD123', 271 | name: 'iPhone 14', 272 | state: SimulatorState.Booted, 273 | isAvailable: true, 274 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' 275 | }], 276 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 277 | udid: 'NEW456', 278 | name: 'iPhone 14', 279 | state: SimulatorState.Shutdown, 280 | isAvailable: true, 281 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' 282 | }] 283 | } 284 | }; 285 | 286 | execMockResponses = [ 287 | { stdout: JSON.stringify(simulatorData), stderr: '' } // list shows iOS 16 one is booted 288 | ]; 289 | 290 | // Act - should find the first matching by name regardless of runtime 291 | const result = await controller.execute({ deviceId: 'iPhone 14' }); 292 | 293 | // Assert - Test behavior: finds already booted simulator from any runtime 294 | expect(result.content[0].text).toBe('✅ Simulator already booted: iPhone 14 (OLD123)'); 295 | }); 296 | }); 297 | }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/unit/InstallAppUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { InstallResult, InstallOutcome, InstallCommandFailedError, SimulatorNotFoundError, NoBootedSimulatorError } from '../../domain/InstallResult.js'; 2 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 3 | import { InstallAppUseCase } from '../../use-cases/InstallAppUseCase.js'; 4 | import { InstallRequest } from '../../domain/InstallRequest.js'; 5 | import { SimulatorState } from '../../../simulator/domain/SimulatorState.js'; 6 | import { 7 | ISimulatorLocator, 8 | ISimulatorControl, 9 | IAppInstaller, 10 | SimulatorInfo 11 | } from '../../../../application/ports/SimulatorPorts.js'; 12 | import { ILogManager } from '../../../../application/ports/LoggingPorts.js'; 13 | 14 | describe('InstallAppUseCase', () => { 15 | beforeEach(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | function createSUT() { 20 | const mockFindSimulator = jest.fn<ISimulatorLocator['findSimulator']>(); 21 | const mockFindBootedSimulator = jest.fn<ISimulatorLocator['findBootedSimulator']>(); 22 | const mockSimulatorLocator: ISimulatorLocator = { 23 | findSimulator: mockFindSimulator, 24 | findBootedSimulator: mockFindBootedSimulator 25 | }; 26 | 27 | const mockBoot = jest.fn<ISimulatorControl['boot']>(); 28 | const mockShutdown = jest.fn<ISimulatorControl['shutdown']>(); 29 | const mockSimulatorControl: ISimulatorControl = { 30 | boot: mockBoot, 31 | shutdown: mockShutdown 32 | }; 33 | 34 | const mockInstallApp = jest.fn<IAppInstaller['installApp']>(); 35 | const mockAppInstaller: IAppInstaller = { 36 | installApp: mockInstallApp 37 | }; 38 | 39 | const mockSaveDebugData = jest.fn<ILogManager['saveDebugData']>(); 40 | const mockSaveLog = jest.fn<ILogManager['saveLog']>(); 41 | const mockLogManager: ILogManager = { 42 | saveDebugData: mockSaveDebugData, 43 | saveLog: mockSaveLog 44 | }; 45 | 46 | const sut = new InstallAppUseCase( 47 | mockSimulatorLocator, 48 | mockSimulatorControl, 49 | mockAppInstaller, 50 | mockLogManager 51 | ); 52 | 53 | return { 54 | sut, 55 | mockFindSimulator, 56 | mockFindBootedSimulator, 57 | mockBoot, 58 | mockInstallApp, 59 | mockSaveDebugData, 60 | mockSaveLog 61 | }; 62 | } 63 | 64 | function createTestSimulator(state: SimulatorState = SimulatorState.Booted): SimulatorInfo { 65 | return { 66 | id: 'test-simulator-id', 67 | name: 'iPhone 15', 68 | state, 69 | platform: 'iOS', 70 | runtime: 'iOS 17.0' 71 | }; 72 | } 73 | 74 | describe('when installing with specific simulator ID', () => { 75 | it('should install app on already booted simulator', async () => { 76 | // Arrange 77 | const { sut, mockFindSimulator, mockInstallApp } = createSUT(); 78 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 79 | const simulator = createTestSimulator(SimulatorState.Booted); 80 | 81 | mockFindSimulator.mockResolvedValue(simulator); 82 | mockInstallApp.mockResolvedValue(undefined); 83 | 84 | // Act 85 | const result = await sut.execute(request); 86 | 87 | // Assert 88 | expect(result.outcome).toBe(InstallOutcome.Succeeded); 89 | expect(result.diagnostics.bundleId).toBe('MyApp.app'); 90 | expect(result.diagnostics.simulatorId?.toString()).toBe('test-simulator-id'); 91 | expect(mockInstallApp).toHaveBeenCalledWith('/path/to/MyApp.app', 'test-simulator-id'); 92 | }); 93 | 94 | it('should auto-boot shutdown simulator before installing', async () => { 95 | // Arrange 96 | const { sut, mockFindSimulator, mockBoot, mockInstallApp } = createSUT(); 97 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 98 | const simulator = createTestSimulator(SimulatorState.Shutdown); 99 | 100 | mockFindSimulator.mockResolvedValue(simulator); 101 | mockBoot.mockResolvedValue(undefined); 102 | mockInstallApp.mockResolvedValue(undefined); 103 | 104 | // Act 105 | const result = await sut.execute(request); 106 | 107 | // Assert 108 | expect(mockBoot).toHaveBeenCalledWith('test-simulator-id'); 109 | expect(mockInstallApp).toHaveBeenCalledWith('/path/to/MyApp.app', 'test-simulator-id'); 110 | expect(result.outcome).toBe(InstallOutcome.Succeeded); 111 | }); 112 | 113 | it('should return failure when simulator not found', async () => { 114 | // Arrange 115 | const { sut, mockFindSimulator, mockSaveDebugData } = createSUT(); 116 | const request = InstallRequest.create('/path/to/MyApp.app', 'non-existent-id'); 117 | 118 | mockFindSimulator.mockResolvedValue(null); 119 | 120 | // Act 121 | const result = await sut.execute(request); 122 | 123 | // Assert 124 | expect(result.outcome).toBe(InstallOutcome.Failed); 125 | expect(result.diagnostics.error).toBeInstanceOf(SimulatorNotFoundError); 126 | expect((result.diagnostics.error as SimulatorNotFoundError).simulatorId.toString()).toBe('non-existent-id'); 127 | expect(mockSaveDebugData).toHaveBeenCalledWith( 128 | 'install-app-failed', 129 | expect.objectContaining({ reason: 'simulator_not_found' }), 130 | 'MyApp.app' 131 | ); 132 | }); 133 | 134 | it('should return failure when boot fails', async () => { 135 | // Arrange 136 | const { sut, mockFindSimulator, mockBoot, mockSaveDebugData } = createSUT(); 137 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 138 | const simulator = createTestSimulator(SimulatorState.Shutdown); 139 | 140 | mockFindSimulator.mockResolvedValue(simulator); 141 | mockBoot.mockRejectedValue(new Error('Boot failed')); 142 | 143 | // Act 144 | const result = await sut.execute(request); 145 | 146 | // Assert 147 | expect(result.outcome).toBe(InstallOutcome.Failed); 148 | expect(result.diagnostics.error).toBeInstanceOf(InstallCommandFailedError); 149 | expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('Boot failed'); 150 | expect(mockSaveDebugData).toHaveBeenCalledWith( 151 | 'simulator-boot-failed', 152 | expect.objectContaining({ error: 'Boot failed' }), 153 | 'MyApp.app' 154 | ); 155 | }); 156 | }); 157 | 158 | describe('when installing without simulator ID', () => { 159 | it('should use booted simulator', async () => { 160 | // Arrange 161 | const { sut, mockFindBootedSimulator, mockInstallApp } = createSUT(); 162 | const request = InstallRequest.create('/path/to/MyApp.app'); 163 | const simulator = createTestSimulator(SimulatorState.Booted); 164 | 165 | mockFindBootedSimulator.mockResolvedValue(simulator); 166 | mockInstallApp.mockResolvedValue(undefined); 167 | 168 | // Act 169 | const result = await sut.execute(request); 170 | 171 | // Assert 172 | expect(result.outcome).toBe(InstallOutcome.Succeeded); 173 | expect(result.diagnostics.simulatorId?.toString()).toBe('test-simulator-id'); 174 | expect(mockFindBootedSimulator).toHaveBeenCalled(); 175 | expect(mockInstallApp).toHaveBeenCalledWith('/path/to/MyApp.app', 'test-simulator-id'); 176 | }); 177 | 178 | it('should return failure when no booted simulator found', async () => { 179 | // Arrange 180 | const { sut, mockFindBootedSimulator, mockSaveDebugData } = createSUT(); 181 | const request = InstallRequest.create('/path/to/MyApp.app'); 182 | 183 | mockFindBootedSimulator.mockResolvedValue(null); 184 | 185 | // Act 186 | const result = await sut.execute(request); 187 | 188 | // Assert 189 | expect(result.outcome).toBe(InstallOutcome.Failed); 190 | expect(result.diagnostics.error).toBeInstanceOf(NoBootedSimulatorError); 191 | expect(mockSaveDebugData).toHaveBeenCalledWith( 192 | 'install-app-failed', 193 | expect.objectContaining({ reason: 'simulator_not_found' }), 194 | 'MyApp.app' 195 | ); 196 | }); 197 | }); 198 | 199 | describe('when installation fails', () => { 200 | it('should return failure with error message', async () => { 201 | // Arrange 202 | const { sut, mockFindSimulator, mockInstallApp, mockSaveDebugData } = createSUT(); 203 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 204 | const simulator = createTestSimulator(SimulatorState.Booted); 205 | 206 | mockFindSimulator.mockResolvedValue(simulator); 207 | mockInstallApp.mockRejectedValue(new Error('Code signing error')); 208 | 209 | // Act 210 | const result = await sut.execute(request); 211 | 212 | // Assert 213 | expect(result.outcome).toBe(InstallOutcome.Failed); 214 | expect(result.diagnostics.error).toBeInstanceOf(InstallCommandFailedError); 215 | expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('Code signing error'); 216 | expect(mockSaveDebugData).toHaveBeenCalledWith( 217 | 'install-app-error', 218 | expect.objectContaining({ error: 'Code signing error' }), 219 | 'MyApp.app' 220 | ); 221 | }); 222 | 223 | it('should handle generic error', async () => { 224 | // Arrange 225 | const { sut, mockFindSimulator, mockInstallApp } = createSUT(); 226 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 227 | const simulator = createTestSimulator(SimulatorState.Booted); 228 | 229 | mockFindSimulator.mockResolvedValue(simulator); 230 | mockInstallApp.mockRejectedValue('String error'); 231 | 232 | // Act 233 | const result = await sut.execute(request); 234 | 235 | // Assert 236 | expect(result.outcome).toBe(InstallOutcome.Failed); 237 | expect(result.diagnostics.error).toBeInstanceOf(InstallCommandFailedError); 238 | expect((result.diagnostics.error as InstallCommandFailedError).stderr).toBe('String error'); 239 | }); 240 | }); 241 | 242 | describe('debug data logging', () => { 243 | it('should log success with app name and simulator info', async () => { 244 | // Arrange 245 | const { sut, mockFindSimulator, mockInstallApp, mockSaveDebugData } = createSUT(); 246 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 247 | const simulator = createTestSimulator(SimulatorState.Booted); 248 | 249 | mockFindSimulator.mockResolvedValue(simulator); 250 | mockInstallApp.mockResolvedValue(undefined); 251 | 252 | // Act 253 | await sut.execute(request); 254 | 255 | // Assert 256 | expect(mockSaveDebugData).toHaveBeenCalledWith( 257 | 'install-app-success', 258 | expect.objectContaining({ 259 | simulator: 'iPhone 15', 260 | simulatorId: 'test-simulator-id', 261 | app: 'MyApp.app' 262 | }), 263 | 'MyApp.app' 264 | ); 265 | }); 266 | 267 | it('should log auto-boot event', async () => { 268 | // Arrange 269 | const { sut, mockFindSimulator, mockBoot, mockInstallApp, mockSaveDebugData } = createSUT(); 270 | const request = InstallRequest.create('/path/to/MyApp.app', 'test-simulator-id'); 271 | const simulator = createTestSimulator(SimulatorState.Shutdown); 272 | 273 | mockFindSimulator.mockResolvedValue(simulator); 274 | mockBoot.mockResolvedValue(undefined); 275 | mockInstallApp.mockResolvedValue(undefined); 276 | 277 | // Act 278 | await sut.execute(request); 279 | 280 | // Assert 281 | expect(mockSaveDebugData).toHaveBeenCalledWith( 282 | 'simulator-auto-booted', 283 | expect.objectContaining({ 284 | simulatorId: 'test-simulator-id', 285 | simulatorName: 'iPhone 15' 286 | }), 287 | 'MyApp.app' 288 | ); 289 | }); 290 | }); 291 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/integration/ShutdownSimulatorController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 3 | import { ShutdownSimulatorControllerFactory } from '../../factories/ShutdownSimulatorControllerFactory.js'; 4 | import { SimulatorState } from '../../domain/SimulatorState.js'; 5 | import { exec } from 'child_process'; 6 | import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; 7 | 8 | // Mock ONLY external boundaries 9 | jest.mock('child_process'); 10 | 11 | // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) 12 | jest.mock('util', () => { 13 | const actualUtil = jest.requireActual('util') as typeof import('util'); 14 | const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); 15 | 16 | return { 17 | ...actualUtil, 18 | promisify: (fn: Function) => 19 | fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) 20 | }; 21 | }); 22 | 23 | // Mock DependencyChecker to always report dependencies are available in tests 24 | jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ 25 | DependencyChecker: jest.fn().mockImplementation(() => ({ 26 | check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies 27 | })) 28 | })); 29 | 30 | const mockExec = exec as jest.MockedFunction<typeof exec>; 31 | 32 | /** 33 | * Integration tests for ShutdownSimulatorController 34 | * 35 | * Tests the integration between: 36 | * - Controller → Use Case → Adapters 37 | * - Input validation → Domain logic → Output formatting 38 | * 39 | * Mocks only external boundaries (shell commands) 40 | * Tests behavior, not implementation details 41 | */ 42 | describe('ShutdownSimulatorController Integration', () => { 43 | let controller: MCPController; 44 | let execCallIndex: number; 45 | let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; 46 | 47 | beforeEach(() => { 48 | jest.clearAllMocks(); 49 | execCallIndex = 0; 50 | execMockResponses = []; 51 | 52 | // Setup exec mock to return responses sequentially 53 | mockExec.mockImplementation((( 54 | _cmd: string, 55 | _options: any, 56 | callback: (error: Error | null, stdout: string, stderr: string) => void 57 | ) => { 58 | const response = execMockResponses[execCallIndex++] || { stdout: '', stderr: '' }; 59 | if (response.error) { 60 | callback(response.error, response.stdout, response.stderr); 61 | } else { 62 | callback(null, response.stdout, response.stderr); 63 | } 64 | }) as any); 65 | 66 | // Create controller with REAL components using factory 67 | controller = ShutdownSimulatorControllerFactory.create(); 68 | }); 69 | 70 | describe('shutdown simulator workflow', () => { 71 | it('should shutdown a booted simulator', async () => { 72 | // Arrange 73 | const simulatorData = { 74 | devices: { 75 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 76 | udid: 'ABC123', 77 | name: 'iPhone 15', 78 | state: SimulatorState.Booted, 79 | isAvailable: true, 80 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 81 | }] 82 | } 83 | }; 84 | 85 | execMockResponses = [ 86 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 87 | { stdout: '', stderr: '' } // shutdown command succeeds 88 | ]; 89 | 90 | // Act 91 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 92 | 93 | // Assert - Test behavior: simulator was successfully shutdown 94 | expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 15 (ABC123)'); 95 | }); 96 | 97 | it('should handle already shutdown simulator', async () => { 98 | // Arrange 99 | const simulatorData = { 100 | devices: { 101 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 102 | udid: 'ABC123', 103 | name: 'iPhone 15', 104 | state: SimulatorState.Shutdown, 105 | isAvailable: true, 106 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 107 | }] 108 | } 109 | }; 110 | 111 | execMockResponses = [ 112 | { stdout: JSON.stringify(simulatorData), stderr: '' } // list devices - already shutdown 113 | ]; 114 | 115 | // Act 116 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 117 | 118 | // Assert - Test behavior: reports simulator is already shutdown 119 | expect(result.content[0].text).toBe('✅ Simulator already shutdown: iPhone 15 (ABC123)'); 120 | }); 121 | 122 | it('should shutdown simulator by UUID', async () => { 123 | // Arrange 124 | const uuid = '550e8400-e29b-41d4-a716-446655440000'; 125 | const simulatorData = { 126 | devices: { 127 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 128 | udid: uuid, 129 | name: 'iPhone 15 Pro', 130 | state: SimulatorState.Booted, 131 | isAvailable: true, 132 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' 133 | }] 134 | } 135 | }; 136 | 137 | execMockResponses = [ 138 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 139 | { stdout: '', stderr: '' } // shutdown command succeeds 140 | ]; 141 | 142 | // Act 143 | const result = await controller.execute({ deviceId: uuid }); 144 | 145 | // Assert - Test behavior: simulator was shutdown using UUID 146 | expect(result.content[0].text).toBe(`✅ Successfully shutdown simulator: iPhone 15 Pro (${uuid})`); 147 | }); 148 | }); 149 | 150 | describe('error handling', () => { 151 | it('should handle simulator not found', async () => { 152 | // Arrange 153 | execMockResponses = [ 154 | { stdout: JSON.stringify({ devices: {} }), stderr: '' } // empty device list 155 | ]; 156 | 157 | // Act 158 | const result = await controller.execute({ deviceId: 'NonExistent' }); 159 | 160 | // Assert - Test behavior: appropriate error message shown 161 | expect(result.content[0].text).toBe('❌ Simulator not found: NonExistent'); 162 | }); 163 | 164 | it('should handle shutdown command failure', async () => { 165 | // Arrange 166 | const simulatorData = { 167 | devices: { 168 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 169 | udid: 'ABC123', 170 | name: 'iPhone 15', 171 | state: SimulatorState.Booted, 172 | isAvailable: true, 173 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 174 | }] 175 | } 176 | }; 177 | 178 | const shutdownError: NodeExecError = new Error('Command failed') as NodeExecError; 179 | shutdownError.code = 1; 180 | shutdownError.stdout = ''; 181 | shutdownError.stderr = 'Unable to shutdown device'; 182 | 183 | execMockResponses = [ 184 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 185 | { stdout: '', stderr: 'Unable to shutdown device', error: shutdownError } // shutdown fails 186 | ]; 187 | 188 | // Act 189 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 190 | 191 | // Assert - Test behavior: error message includes context for found simulator 192 | expect(result.content[0].text).toBe('❌ iPhone 15 (ABC123) - Unable to shutdown device'); 193 | }); 194 | }); 195 | 196 | describe('validation', () => { 197 | it('should validate required deviceId', async () => { 198 | // Act 199 | const result = await controller.execute({} as any); 200 | 201 | // Assert 202 | expect(result.content[0].text).toBe('❌ Device ID is required'); 203 | }); 204 | 205 | it('should validate empty deviceId', async () => { 206 | // Act 207 | const result = await controller.execute({ deviceId: '' }); 208 | 209 | // Assert 210 | expect(result.content[0].text).toBe('❌ Device ID cannot be empty'); 211 | }); 212 | 213 | it('should validate whitespace-only deviceId', async () => { 214 | // Act 215 | const result = await controller.execute({ deviceId: ' ' }); 216 | 217 | // Assert 218 | expect(result.content[0].text).toBe('❌ Device ID cannot be whitespace only'); 219 | }); 220 | }); 221 | 222 | describe('complex scenarios', () => { 223 | it('should shutdown specific simulator when multiple exist with similar names', async () => { 224 | // Arrange 225 | const simulatorData = { 226 | devices: { 227 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 228 | { 229 | udid: 'AAA111', 230 | name: 'iPhone 15', 231 | state: SimulatorState.Booted, 232 | isAvailable: true, 233 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 234 | }, 235 | { 236 | udid: 'BBB222', 237 | name: 'iPhone 15 Pro', 238 | state: SimulatorState.Booted, 239 | isAvailable: true, 240 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro' 241 | }, 242 | { 243 | udid: 'CCC333', 244 | name: 'iPhone 15 Pro Max', 245 | state: SimulatorState.Shutdown, 246 | isAvailable: true, 247 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro-Max' 248 | } 249 | ] 250 | } 251 | }; 252 | 253 | execMockResponses = [ 254 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices 255 | { stdout: '', stderr: '' } // shutdown command succeeds 256 | ]; 257 | 258 | // Act 259 | const result = await controller.execute({ deviceId: 'iPhone 15 Pro' }); 260 | 261 | // Assert - Test behavior: correct simulator was shutdown 262 | expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 15 Pro (BBB222)'); 263 | }); 264 | 265 | it('should handle mixed state simulators across runtimes', async () => { 266 | // Arrange 267 | const simulatorData = { 268 | devices: { 269 | 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [{ 270 | udid: 'OLD123', 271 | name: 'iPhone 14', 272 | state: SimulatorState.Shutdown, 273 | isAvailable: true, 274 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' 275 | }], 276 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 277 | udid: 'NEW456', 278 | name: 'iPhone 14', 279 | state: SimulatorState.Booted, 280 | isAvailable: true, 281 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-14' 282 | }] 283 | } 284 | }; 285 | 286 | execMockResponses = [ 287 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list shows iOS 17 one is booted 288 | { stdout: '', stderr: '' } // shutdown succeeds 289 | ]; 290 | 291 | // Act - should find the first matching by name (prioritizes newer runtime) 292 | const result = await controller.execute({ deviceId: 'iPhone 14' }); 293 | 294 | // Assert - Test behavior: finds and shuts down the iOS 17 device (newer runtime) 295 | expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 14 (NEW456)'); 296 | }); 297 | 298 | it('should shutdown simulator in Booting state', async () => { 299 | // Arrange 300 | const simulatorData = { 301 | devices: { 302 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [{ 303 | udid: 'BOOT123', 304 | name: 'iPhone 15', 305 | state: SimulatorState.Booting, 306 | isAvailable: true, 307 | deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15' 308 | }] 309 | } 310 | }; 311 | 312 | execMockResponses = [ 313 | { stdout: JSON.stringify(simulatorData), stderr: '' }, // list devices - in Booting state 314 | { stdout: '', stderr: '' } // shutdown command succeeds 315 | ]; 316 | 317 | // Act 318 | const result = await controller.execute({ deviceId: 'iPhone 15' }); 319 | 320 | // Assert - Test behavior: can shutdown a simulator that's booting 321 | expect(result.content[0].text).toBe('✅ Successfully shutdown simulator: iPhone 15 (BOOT123)'); 322 | }); 323 | }); 324 | }); ``` -------------------------------------------------------------------------------- /src/features/app-management/tests/integration/InstallAppController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration Test for InstallAppController 3 | * 4 | * Tests the controller with REAL use case, presenter, and adapters 5 | * but MOCKS external boundaries (filesystem, subprocess). 6 | * 7 | * Following testing philosophy: 8 | * - Integration tests (60% of suite) test component interactions 9 | * - Mock only external boundaries 10 | * - Test behavior, not implementation 11 | */ 12 | 13 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 14 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 15 | import { InstallAppControllerFactory } from '../../factories/InstallAppControllerFactory.js'; 16 | import { exec } from 'child_process'; 17 | import { existsSync, statSync } from 'fs'; 18 | import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; 19 | 20 | // Mock ONLY external boundaries 21 | jest.mock('child_process'); 22 | jest.mock('fs'); 23 | 24 | // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) 25 | jest.mock('util', () => { 26 | const actualUtil = jest.requireActual('util') as typeof import('util'); 27 | const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); 28 | 29 | return { 30 | ...actualUtil, 31 | promisify: (fn: Function) => 32 | fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) 33 | }; 34 | }); 35 | 36 | // Mock DependencyChecker to always report dependencies are available in tests 37 | jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ 38 | DependencyChecker: jest.fn().mockImplementation(() => ({ 39 | check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies 40 | })) 41 | })); 42 | 43 | const mockExec = exec as jest.MockedFunction<typeof exec>; 44 | const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>; 45 | const mockStatSync = statSync as jest.MockedFunction<typeof statSync>; 46 | 47 | describe('InstallAppController Integration', () => { 48 | let controller: MCPController; 49 | let execCallIndex: number; 50 | let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; 51 | 52 | // Helper to create device list JSON response 53 | const createDeviceListResponse = (devices: Array<{udid: string, name: string, state: string}>) => ({ 54 | stdout: JSON.stringify({ 55 | devices: { 56 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': devices.map(d => ({ 57 | ...d, 58 | isAvailable: true 59 | })) 60 | } 61 | }), 62 | stderr: '' 63 | }); 64 | 65 | beforeEach(() => { 66 | jest.clearAllMocks(); 67 | execCallIndex = 0; 68 | execMockResponses = []; 69 | 70 | // Setup selective exec mock for xcrun simctl commands 71 | const actualExec = (jest.requireActual('child_process') as typeof import('child_process')).exec; 72 | const { createSelectiveExecMock } = require('../../../../shared/tests/mocks/selectiveExecMock'); 73 | 74 | const isSimctlCommand = (cmd: string) => 75 | cmd.includes('xcrun simctl'); 76 | 77 | mockExec.mockImplementation( 78 | createSelectiveExecMock( 79 | isSimctlCommand, 80 | () => execMockResponses[execCallIndex++], 81 | actualExec 82 | ) 83 | ); 84 | 85 | // Default filesystem mocks 86 | mockExistsSync.mockImplementation((path) => { 87 | const pathStr = String(path); 88 | return pathStr.endsWith('.app'); 89 | }); 90 | 91 | mockStatSync.mockImplementation((path) => ({ 92 | isDirectory: () => String(path).endsWith('.app'), 93 | isFile: () => false, 94 | // Add other stat properties as needed 95 | } as any)); 96 | 97 | // Create controller with REAL components using factory 98 | controller = InstallAppControllerFactory.create(); 99 | }); 100 | 101 | describe('successful app installation', () => { 102 | it('should install app on booted simulator', async () => { 103 | // Arrange 104 | const appPath = '/Users/dev/MyApp.app'; 105 | const simulatorId = 'test-simulator-id'; 106 | 107 | execMockResponses = [ 108 | // Find simulator 109 | createDeviceListResponse([ 110 | { udid: simulatorId, name: 'iPhone 15', state: 'Booted' } 111 | ]), 112 | // Install app 113 | { stdout: '', stderr: '' } 114 | ]; 115 | 116 | // Act 117 | const result = await controller.execute({ 118 | appPath, 119 | simulatorId 120 | }); 121 | 122 | // Assert 123 | expect(result).toMatchObject({ 124 | content: expect.arrayContaining([ 125 | expect.objectContaining({ 126 | type: 'text', 127 | text: expect.stringContaining('Successfully installed') 128 | }) 129 | ]) 130 | }); 131 | 132 | }); 133 | 134 | it('should find and use booted simulator when no ID specified', async () => { 135 | // Arrange 136 | const appPath = '/Users/dev/MyApp.app'; 137 | 138 | execMockResponses = [ 139 | // xcrun simctl list devices --json (to find booted simulator) 140 | { 141 | stdout: JSON.stringify({ 142 | devices: { 143 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 144 | { 145 | udid: 'booted-sim-id', 146 | name: 'iPhone 15', 147 | state: 'Booted', 148 | isAvailable: true 149 | }, 150 | { 151 | udid: 'shutdown-sim-id', 152 | name: 'iPhone 14', 153 | state: 'Shutdown', 154 | isAvailable: true 155 | } 156 | ] 157 | } 158 | }), 159 | stderr: '' 160 | }, 161 | // xcrun simctl install command 162 | { stdout: '', stderr: '' } 163 | ]; 164 | 165 | // Act 166 | const result = await controller.execute({ 167 | appPath 168 | }); 169 | 170 | // Assert 171 | expect(result).toMatchObject({ 172 | content: expect.arrayContaining([ 173 | expect.objectContaining({ 174 | type: 'text', 175 | text: expect.stringContaining('Successfully installed') 176 | }) 177 | ]) 178 | }); 179 | 180 | }); 181 | 182 | it('should boot simulator if shutdown', async () => { 183 | // Arrange 184 | const appPath = '/Users/dev/MyApp.app'; 185 | const simulatorId = 'shutdown-sim-id'; 186 | 187 | execMockResponses = [ 188 | // Find simulator 189 | { 190 | stdout: JSON.stringify({ 191 | devices: { 192 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 193 | { 194 | udid: simulatorId, 195 | name: 'iPhone 15', 196 | state: 'Shutdown', 197 | isAvailable: true 198 | } 199 | ] 200 | } 201 | }), 202 | stderr: '' 203 | }, 204 | // Boot simulator 205 | { stdout: '', stderr: '' }, 206 | // Install app 207 | { stdout: '', stderr: '' } 208 | ]; 209 | 210 | // Act 211 | const result = await controller.execute({ 212 | appPath, 213 | simulatorId 214 | }); 215 | 216 | // Assert 217 | expect(result).toMatchObject({ 218 | content: expect.arrayContaining([ 219 | expect.objectContaining({ 220 | type: 'text', 221 | text: expect.stringContaining('Successfully installed') 222 | }) 223 | ]) 224 | }); 225 | 226 | }); 227 | }); 228 | 229 | describe('error handling', () => { 230 | it('should fail when app path does not exist', async () => { 231 | // Arrange 232 | const nonExistentPath = '/path/that/does/not/exist.app'; 233 | mockExistsSync.mockReturnValue(false); 234 | 235 | execMockResponses = [ 236 | // Find simulator 237 | createDeviceListResponse([ 238 | { udid: 'test-sim', name: 'iPhone 15', state: 'Booted' } 239 | ]), 240 | // Install command would fail with file not found 241 | { 242 | error: Object.assign(new Error('Failed to install app'), { 243 | code: 1, 244 | stdout: '', 245 | stderr: 'xcrun simctl install: No such file or directory' 246 | }), 247 | stdout: '', 248 | stderr: 'xcrun simctl install: No such file or directory' 249 | } 250 | ]; 251 | 252 | // Act 253 | const result = await controller.execute({ 254 | appPath: nonExistentPath, 255 | simulatorId: 'test-sim' 256 | }); 257 | 258 | // Assert 259 | expect(result.content[0].text).toBe('❌ iPhone 15 (test-sim) - xcrun simctl install: No such file or directory'); 260 | }); 261 | 262 | it('should fail when app path is not an app bundle', async () => { 263 | // Arrange 264 | const invalidPath = '/Users/dev/file.txt'; 265 | mockExistsSync.mockReturnValue(true); 266 | mockStatSync.mockReturnValue({ 267 | isDirectory: () => false, 268 | isFile: () => true 269 | } as any); 270 | 271 | // Act 272 | const result = await controller.execute({ 273 | appPath: invalidPath, 274 | simulatorId: 'test-sim' 275 | }); 276 | 277 | // Assert 278 | expect(result.content[0].text).toBe('❌ App path must end with .app'); 279 | }); 280 | 281 | it('should fail when simulator does not exist', async () => { 282 | // Arrange 283 | const appPath = '/Users/dev/MyApp.app'; 284 | const nonExistentSim = 'non-existent-id'; 285 | 286 | execMockResponses = [ 287 | // List devices - simulator not found 288 | { 289 | stdout: JSON.stringify({ 290 | devices: { 291 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [] 292 | } 293 | }), 294 | stderr: '' 295 | } 296 | ]; 297 | 298 | // Act 299 | const result = await controller.execute({ 300 | appPath, 301 | simulatorId: nonExistentSim 302 | }); 303 | 304 | // Assert 305 | expect(result.content[0].text).toBe(`❌ Simulator not found: ${nonExistentSim}`); 306 | }); 307 | 308 | it('should fail when no booted simulator and no ID specified', async () => { 309 | // Arrange 310 | const appPath = '/Users/dev/MyApp.app'; 311 | 312 | execMockResponses = [ 313 | // List devices - no booted simulators 314 | { 315 | stdout: JSON.stringify({ 316 | devices: { 317 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 318 | { 319 | udid: 'shutdown-sim', 320 | name: 'iPhone 15', 321 | state: 'Shutdown', 322 | isAvailable: true 323 | } 324 | ] 325 | } 326 | }), 327 | stderr: '' 328 | } 329 | ]; 330 | 331 | // Act 332 | const result = await controller.execute({ 333 | appPath 334 | }); 335 | 336 | // Assert 337 | expect(result.content[0].text).toBe('❌ No booted simulator found. Please boot a simulator first or specify a simulator ID.'); 338 | }); 339 | 340 | it('should handle installation failure gracefully', async () => { 341 | // Arrange 342 | const appPath = '/Users/dev/MyApp.app'; 343 | const simulatorId = 'test-sim'; 344 | 345 | const error = new Error('Failed to install app: incompatible architecture') as NodeExecError; 346 | error.code = 1; 347 | error.stdout = ''; 348 | error.stderr = 'Error: incompatible architecture'; 349 | 350 | execMockResponses = [ 351 | // Find simulator 352 | createDeviceListResponse([ 353 | { udid: simulatorId, name: 'iPhone 15', state: 'Booted' } 354 | ]), 355 | // Install fails 356 | { error, stdout: '', stderr: error.stderr } 357 | ]; 358 | 359 | // Act 360 | const result = await controller.execute({ 361 | appPath, 362 | simulatorId 363 | }); 364 | 365 | // Assert 366 | expect(result.content[0].text).toBe('❌ iPhone 15 (test-sim) - incompatible architecture'); 367 | }); 368 | }); 369 | 370 | describe('input validation', () => { 371 | it('should accept simulator name instead of UUID', async () => { 372 | // Arrange 373 | const appPath = '/Users/dev/MyApp.app'; 374 | const simulatorName = 'iPhone 15 Pro'; 375 | 376 | execMockResponses = [ 377 | // Find simulator by name 378 | createDeviceListResponse([ 379 | { udid: 'sim-id-123', name: simulatorName, state: 'Booted' } 380 | ]), 381 | // Install succeeds 382 | { stdout: '', stderr: '' } 383 | ]; 384 | 385 | // Act 386 | const result = await controller.execute({ 387 | appPath, 388 | simulatorId: simulatorName 389 | }); 390 | 391 | // Assert 392 | expect(result).toMatchObject({ 393 | content: expect.arrayContaining([ 394 | expect.objectContaining({ 395 | type: 'text', 396 | text: expect.stringContaining('Successfully installed') 397 | }) 398 | ]) 399 | }); 400 | // Should show both simulator name and ID in format: "name (id)" 401 | expect(result.content[0].text).toContain(`${simulatorName} (sim-id-123)`); 402 | }); 403 | 404 | it('should handle paths with spaces', async () => { 405 | // Arrange 406 | const appPath = '/Users/dev/My iOS App/MyApp.app'; 407 | const simulatorId = 'test-sim'; 408 | 409 | execMockResponses = [ 410 | // Find simulator 411 | createDeviceListResponse([ 412 | { udid: simulatorId, name: 'iPhone 15', state: 'Booted' } 413 | ]), 414 | // Install app 415 | { stdout: '', stderr: '' } 416 | ]; 417 | 418 | // Act 419 | const result = await controller.execute({ 420 | appPath, 421 | simulatorId 422 | }); 423 | 424 | // Assert 425 | expect(result).toMatchObject({ 426 | content: expect.arrayContaining([ 427 | expect.objectContaining({ 428 | type: 'text', 429 | text: expect.stringContaining('Successfully installed') 430 | }) 431 | ]) 432 | }); 433 | // Path with spaces should be handled correctly 434 | expect(result.content[0].text).toContain('iPhone 15 (test-sim)'); 435 | 436 | }); 437 | }); 438 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/integration/ListSimulatorsController.integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { MCPController } from '../../../../presentation/interfaces/MCPController.js'; 3 | import { ListSimulatorsControllerFactory } from '../../factories/ListSimulatorsControllerFactory.js'; 4 | import { SimulatorState } from '../../domain/SimulatorState.js'; 5 | import { exec } from 'child_process'; 6 | import type { NodeExecError } from '../../../../shared/tests/types/execTypes.js'; 7 | 8 | // Mock ONLY external boundaries 9 | jest.mock('child_process'); 10 | 11 | // Mock promisify to return {stdout, stderr} for exec (as node's promisify does) 12 | jest.mock('util', () => { 13 | const actualUtil = jest.requireActual('util') as typeof import('util'); 14 | const { createPromisifiedExec } = require('../../../../shared/tests/mocks/promisifyExec'); 15 | 16 | return { 17 | ...actualUtil, 18 | promisify: (fn: Function) => 19 | fn?.name === 'exec' ? createPromisifiedExec(fn) : actualUtil.promisify(fn) 20 | }; 21 | }); 22 | 23 | // Mock DependencyChecker to always report dependencies are available in tests 24 | jest.mock('../../../../infrastructure/services/DependencyChecker', () => ({ 25 | DependencyChecker: jest.fn().mockImplementation(() => ({ 26 | check: jest.fn<() => Promise<[]>>().mockResolvedValue([]) // No missing dependencies 27 | })) 28 | })); 29 | 30 | const mockExec = exec as jest.MockedFunction<typeof exec>; 31 | 32 | describe('ListSimulatorsController Integration', () => { 33 | let controller: MCPController; 34 | let execCallIndex: number; 35 | let execMockResponses: Array<{ stdout: string; stderr: string; error?: NodeExecError }>; 36 | 37 | beforeEach(() => { 38 | jest.clearAllMocks(); 39 | execCallIndex = 0; 40 | execMockResponses = []; 41 | 42 | // Setup exec mock to return responses sequentially 43 | mockExec.mockImplementation((( 44 | _cmd: string, 45 | _options: any, 46 | callback: (error: Error | null, stdout: string, stderr: string) => void 47 | ) => { 48 | const response = execMockResponses[execCallIndex++] || { stdout: '', stderr: '' }; 49 | if (response.error) { 50 | callback(response.error, response.stdout, response.stderr); 51 | } else { 52 | callback(null, response.stdout, response.stderr); 53 | } 54 | }) as any); 55 | 56 | // Create controller with REAL components using factory 57 | controller = ListSimulatorsControllerFactory.create(); 58 | }); 59 | 60 | describe('with mocked shell commands', () => { 61 | it('should list all simulators', async () => { 62 | // Arrange 63 | const mockDeviceList = { 64 | devices: { 65 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 66 | { 67 | dataPath: '/path/to/data', 68 | dataPathSize: 1000000, 69 | logPath: '/path/to/logs', 70 | udid: 'ABC123', 71 | isAvailable: true, 72 | deviceTypeIdentifier: 'com.apple.iPhone15', 73 | state: 'Booted', 74 | name: 'iPhone 15' 75 | }, 76 | { 77 | dataPath: '/path/to/data2', 78 | dataPathSize: 2000000, 79 | logPath: '/path/to/logs2', 80 | udid: 'DEF456', 81 | isAvailable: true, 82 | deviceTypeIdentifier: 'com.apple.iPadPro', 83 | state: 'Shutdown', 84 | name: 'iPad Pro' 85 | } 86 | ], 87 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ 88 | { 89 | dataPath: '/path/to/data3', 90 | dataPathSize: 3000000, 91 | logPath: '/path/to/logs3', 92 | udid: 'GHI789', 93 | isAvailable: true, 94 | deviceTypeIdentifier: 'com.apple.AppleTV', 95 | state: 'Shutdown', 96 | name: 'Apple TV' 97 | } 98 | ] 99 | } 100 | }; 101 | 102 | execMockResponses = [ 103 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 104 | ]; 105 | 106 | // Act 107 | const result = await controller.execute({}); 108 | 109 | // Assert - Test behavior: lists all simulators 110 | expect(result.content[0].text).toContain('Found 3 simulators'); 111 | expect(result.content[0].text).toContain('iPhone 15'); 112 | expect(result.content[0].text).toContain('iPad Pro'); 113 | expect(result.content[0].text).toContain('Apple TV'); 114 | }); 115 | 116 | it('should filter by iOS platform', async () => { 117 | // Arrange 118 | const mockDeviceList = { 119 | devices: { 120 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 121 | { 122 | udid: 'ABC123', 123 | isAvailable: true, 124 | state: 'Booted', 125 | name: 'iPhone 15' 126 | } 127 | ], 128 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ 129 | { 130 | udid: 'GHI789', 131 | isAvailable: true, 132 | state: 'Shutdown', 133 | name: 'Apple TV' 134 | } 135 | ] 136 | } 137 | }; 138 | 139 | execMockResponses = [ 140 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 141 | ]; 142 | 143 | // Act 144 | const result = await controller.execute({ platform: 'iOS' }); 145 | 146 | // Assert 147 | expect(result.content[0].text).toContain('Found 1 simulator'); 148 | expect(result.content[0].text).toContain('iPhone 15'); 149 | expect(result.content[0].text).not.toContain('Apple TV'); 150 | }); 151 | 152 | it('should filter by booted state', async () => { 153 | // Arrange 154 | const mockDeviceList = { 155 | devices: { 156 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 157 | { 158 | udid: 'ABC123', 159 | isAvailable: true, 160 | state: 'Booted', 161 | name: 'iPhone 15' 162 | }, 163 | { 164 | udid: 'DEF456', 165 | isAvailable: true, 166 | state: 'Shutdown', 167 | name: 'iPad Pro' 168 | } 169 | ] 170 | } 171 | }; 172 | 173 | execMockResponses = [ 174 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 175 | ]; 176 | 177 | // Act 178 | const result = await controller.execute({ state: 'Booted' }); 179 | 180 | // Assert 181 | expect(result.content[0].text).toContain('Found 1 simulator'); 182 | expect(result.content[0].text).toContain('iPhone 15'); 183 | expect(result.content[0].text).not.toContain('iPad Pro'); 184 | }); 185 | 186 | it('should return error when command execution fails', async () => { 187 | // Arrange 188 | const error = new Error('xcrun not found') as NodeExecError; 189 | error.code = 1; 190 | execMockResponses = [ 191 | { stdout: '', stderr: 'xcrun not found', error } 192 | ]; 193 | 194 | // Act 195 | const result = await controller.execute({}); 196 | 197 | // Assert 198 | expect(result.content[0].type).toBe('text'); 199 | expect(result.content[0].text).toMatch(/^❌.*JSON/); // Error about JSON parsing 200 | }); 201 | 202 | it('should show warning when no simulators exist', async () => { 203 | // Arrange 204 | execMockResponses = [ 205 | { stdout: JSON.stringify({ devices: {} }), stderr: '' } 206 | ]; 207 | 208 | // Act 209 | const result = await controller.execute({}); 210 | 211 | // Assert 212 | expect(result.content[0].text).toBe('🔍 No simulators found'); 213 | }); 214 | 215 | it('should filter by multiple criteria', async () => { 216 | // Arrange 217 | const mockDeviceList = { 218 | devices: { 219 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 220 | { 221 | udid: 'ABC123', 222 | isAvailable: true, 223 | state: 'Booted', 224 | name: 'iPhone 15' 225 | }, 226 | { 227 | udid: 'DEF456', 228 | isAvailable: true, 229 | state: 'Shutdown', 230 | name: 'iPad Pro' 231 | } 232 | ], 233 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ 234 | { 235 | udid: 'GHI789', 236 | isAvailable: true, 237 | state: 'Booted', 238 | name: 'Apple TV' 239 | } 240 | ] 241 | } 242 | }; 243 | 244 | execMockResponses = [ 245 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 246 | ]; 247 | 248 | // Act 249 | const result = await controller.execute({ 250 | platform: 'iOS', 251 | state: 'Booted' 252 | }); 253 | 254 | // Assert 255 | expect(result.content[0].text).toContain('Found 1 simulator'); 256 | expect(result.content[0].text).toContain('iPhone 15'); 257 | expect(result.content[0].text).not.toContain('iPad Pro'); 258 | expect(result.content[0].text).not.toContain('Apple TV'); 259 | }); 260 | 261 | it('should return JSON parse error for malformed response', async () => { 262 | // Arrange 263 | execMockResponses = [ 264 | { stdout: 'not valid json', stderr: '' } 265 | ]; 266 | 267 | // Act 268 | const result = await controller.execute({}); 269 | 270 | // Assert 271 | expect(result.content[0].type).toBe('text'); 272 | expect(result.content[0].text).toMatch(/^❌.*not valid JSON/); 273 | }); 274 | 275 | it('should return error for invalid platform', async () => { 276 | // Arrange, Act, Assert 277 | const result = await controller.execute({ 278 | platform: 'Android' 279 | }); 280 | 281 | expect(result.content[0].text).toBe('❌ Invalid platform: Android. Valid values are: iOS, macOS, tvOS, watchOS, visionOS'); 282 | }); 283 | 284 | it('should return error for invalid state', async () => { 285 | // Arrange, Act, Assert 286 | const result = await controller.execute({ 287 | state: 'Running' 288 | }); 289 | 290 | expect(result.content[0].text).toBe('❌ Invalid simulator state: Running. Valid values are: Booted, Booting, Shutdown, Shutting Down'); 291 | }); 292 | 293 | it('should filter by device name with partial match', async () => { 294 | // Arrange 295 | const mockDeviceList = { 296 | devices: { 297 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 298 | { 299 | udid: 'ABC123', 300 | isAvailable: true, 301 | state: 'Booted', 302 | name: 'iPhone 15 Pro' 303 | }, 304 | { 305 | udid: 'DEF456', 306 | isAvailable: true, 307 | state: 'Shutdown', 308 | name: 'iPhone 14' 309 | }, 310 | { 311 | udid: 'GHI789', 312 | isAvailable: true, 313 | state: 'Shutdown', 314 | name: 'iPad Pro' 315 | } 316 | ] 317 | } 318 | }; 319 | 320 | execMockResponses = [ 321 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 322 | ]; 323 | 324 | // Act 325 | const result = await controller.execute({ name: '15' }); 326 | 327 | // Assert - Tests actual behavior: only devices with "15" in name 328 | expect(result.content[0].text).toContain('Found 1 simulator'); 329 | expect(result.content[0].text).toContain('iPhone 15 Pro'); 330 | expect(result.content[0].text).not.toContain('iPhone 14'); 331 | expect(result.content[0].text).not.toContain('iPad Pro'); 332 | }); 333 | 334 | it('should filter by device name case-insensitive', async () => { 335 | // Arrange 336 | const mockDeviceList = { 337 | devices: { 338 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 339 | { 340 | udid: 'ABC123', 341 | isAvailable: true, 342 | state: 'Booted', 343 | name: 'iPhone 15 Pro' 344 | }, 345 | { 346 | udid: 'DEF456', 347 | isAvailable: true, 348 | state: 'Shutdown', 349 | name: 'iPad Air' 350 | } 351 | ] 352 | } 353 | }; 354 | 355 | execMockResponses = [ 356 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 357 | ]; 358 | 359 | // Act 360 | const result = await controller.execute({ name: 'iphone' }); 361 | 362 | // Assert - Case-insensitive matching 363 | expect(result.content[0].text).toContain('Found 1 simulator'); 364 | expect(result.content[0].text).toContain('iPhone 15 Pro'); 365 | expect(result.content[0].text).not.toContain('iPad Air'); 366 | }); 367 | 368 | it('should combine all filters (platform, state, and name)', async () => { 369 | // Arrange 370 | const mockDeviceList = { 371 | devices: { 372 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 373 | { 374 | udid: 'ABC123', 375 | isAvailable: true, 376 | state: 'Booted', 377 | name: 'iPhone 15 Pro' 378 | }, 379 | { 380 | udid: 'DEF456', 381 | isAvailable: true, 382 | state: 'Shutdown', 383 | name: 'iPhone 15' 384 | }, 385 | { 386 | udid: 'GHI789', 387 | isAvailable: true, 388 | state: 'Booted', 389 | name: 'iPhone 14' 390 | } 391 | ], 392 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ 393 | { 394 | udid: 'TV123', 395 | isAvailable: true, 396 | state: 'Booted', 397 | name: 'Apple TV 15' 398 | } 399 | ] 400 | } 401 | }; 402 | 403 | execMockResponses = [ 404 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 405 | ]; 406 | 407 | // Act 408 | const result = await controller.execute({ 409 | platform: 'iOS', 410 | state: 'Booted', 411 | name: '15' 412 | }); 413 | 414 | // Assert - All filters applied together 415 | expect(result.content[0].text).toContain('Found 1 simulator'); 416 | expect(result.content[0].text).toContain('iPhone 15 Pro'); 417 | expect(result.content[0].text).not.toContain('iPhone 14'); // Wrong name 418 | expect(result.content[0].text).not.toContain('Apple TV'); // Wrong platform 419 | }); 420 | 421 | it('should show no simulators when name filter matches nothing', async () => { 422 | // Arrange 423 | const mockDeviceList = { 424 | devices: { 425 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 426 | { 427 | udid: 'ABC123', 428 | isAvailable: true, 429 | state: 'Booted', 430 | name: 'iPhone 15 Pro' 431 | } 432 | ] 433 | } 434 | }; 435 | 436 | execMockResponses = [ 437 | { stdout: JSON.stringify(mockDeviceList), stderr: '' } 438 | ]; 439 | 440 | // Act 441 | const result = await controller.execute({ name: 'Galaxy' }); 442 | 443 | // Assert 444 | expect(result.content[0].text).toBe('🔍 No simulators found'); 445 | }); 446 | }); 447 | }); ``` -------------------------------------------------------------------------------- /src/features/simulator/tests/unit/ListSimulatorsUseCase.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 2 | import { ListSimulatorsUseCase } from '../../use-cases/ListSimulatorsUseCase.js'; 3 | import { DeviceRepository } from '../../../../infrastructure/repositories/DeviceRepository.js'; 4 | import { ListSimulatorsRequest } from '../../domain/ListSimulatorsRequest.js'; 5 | import { SimulatorListParseError } from '../../domain/ListSimulatorsResult.js'; 6 | import { Platform } from '../../../../shared/domain/Platform.js'; 7 | import { SimulatorState } from '../../domain/SimulatorState.js'; 8 | 9 | describe('ListSimulatorsUseCase', () => { 10 | let mockDeviceRepository: jest.Mocked<DeviceRepository>; 11 | let sut: ListSimulatorsUseCase; 12 | 13 | beforeEach(() => { 14 | mockDeviceRepository = { 15 | getAllDevices: jest.fn() 16 | } as any; 17 | 18 | sut = new ListSimulatorsUseCase(mockDeviceRepository); 19 | }); 20 | 21 | describe('execute', () => { 22 | it('should return all available simulators when no filters', async () => { 23 | // Arrange 24 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 25 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 26 | { 27 | udid: 'ABC123', 28 | name: 'iPhone 15', 29 | state: 'Booted', 30 | isAvailable: true 31 | }, 32 | { 33 | udid: 'DEF456', 34 | name: 'iPad Pro', 35 | state: 'Shutdown', 36 | isAvailable: true 37 | }, 38 | { 39 | udid: 'NOTAVAIL', 40 | name: 'Old iPhone', 41 | state: 'Shutdown', 42 | isAvailable: false 43 | } 44 | ] 45 | }); 46 | 47 | const request = ListSimulatorsRequest.create(); 48 | 49 | // Act 50 | const result = await sut.execute(request); 51 | 52 | // Assert 53 | expect(result.isSuccess).toBe(true); 54 | expect(result.count).toBe(2); // Only available devices 55 | expect(result.simulators).toHaveLength(2); 56 | expect(result.simulators[0]).toMatchObject({ 57 | udid: 'ABC123', 58 | name: 'iPhone 15', 59 | state: SimulatorState.Booted, 60 | platform: 'iOS', 61 | runtime: 'iOS 17.0' 62 | }); 63 | }); 64 | 65 | it('should filter by platform', async () => { 66 | // Arrange 67 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 68 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 69 | { 70 | udid: 'IOS1', 71 | name: 'iPhone 15', 72 | state: 'Booted', 73 | isAvailable: true 74 | } 75 | ], 76 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ 77 | { 78 | udid: 'TV1', 79 | name: 'Apple TV', 80 | state: 'Shutdown', 81 | isAvailable: true 82 | } 83 | ] 84 | }); 85 | 86 | const request = ListSimulatorsRequest.create(Platform.iOS); 87 | 88 | // Act 89 | const result = await sut.execute(request); 90 | 91 | // Assert 92 | expect(result.isSuccess).toBe(true); 93 | expect(result.count).toBe(1); 94 | expect(result.simulators[0].udid).toBe('IOS1'); 95 | }); 96 | 97 | it('should filter by state', async () => { 98 | // Arrange 99 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 100 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 101 | { 102 | udid: 'BOOTED1', 103 | name: 'iPhone 15', 104 | state: 'Booted', 105 | isAvailable: true 106 | }, 107 | { 108 | udid: 'SHUTDOWN1', 109 | name: 'iPad Pro', 110 | state: 'Shutdown', 111 | isAvailable: true 112 | } 113 | ] 114 | }); 115 | 116 | const request = ListSimulatorsRequest.create(undefined, SimulatorState.Booted); 117 | 118 | // Act 119 | const result = await sut.execute(request); 120 | 121 | // Assert 122 | expect(result.isSuccess).toBe(true); 123 | expect(result.count).toBe(1); 124 | expect(result.simulators[0].udid).toBe('BOOTED1'); 125 | }); 126 | 127 | it('should handle watchOS platform detection', async () => { 128 | // Arrange 129 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 130 | 'com.apple.CoreSimulator.SimRuntime.watchOS-10-0': [ 131 | { 132 | udid: 'WATCH1', 133 | name: 'Apple Watch Series 9', 134 | state: 'Shutdown', 135 | isAvailable: true 136 | } 137 | ] 138 | }); 139 | 140 | const request = ListSimulatorsRequest.create(); 141 | 142 | // Act 143 | const result = await sut.execute(request); 144 | 145 | // Assert 146 | expect(result.isSuccess).toBe(true); 147 | expect(result.simulators[0].platform).toBe('watchOS'); 148 | expect(result.simulators[0].runtime).toBe('watchOS 10.0'); 149 | }); 150 | 151 | it('should handle visionOS platform detection', async () => { 152 | // Arrange 153 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 154 | 'com.apple.CoreSimulator.SimRuntime.visionOS-1-0': [ 155 | { 156 | udid: 'VISION1', 157 | name: 'Apple Vision Pro', 158 | state: 'Shutdown', 159 | isAvailable: true 160 | } 161 | ] 162 | }); 163 | 164 | const request = ListSimulatorsRequest.create(); 165 | 166 | // Act 167 | const result = await sut.execute(request); 168 | 169 | // Assert 170 | expect(result.isSuccess).toBe(true); 171 | expect(result.simulators[0].platform).toBe('visionOS'); 172 | expect(result.simulators[0].runtime).toBe('visionOS 1.0'); 173 | }); 174 | 175 | it('should handle xrOS platform detection (legacy name for visionOS)', async () => { 176 | // Arrange 177 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 178 | 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0': [ 179 | { 180 | udid: 'XR1', 181 | name: 'Apple Vision Pro', 182 | state: 'Shutdown', 183 | isAvailable: true 184 | } 185 | ] 186 | }); 187 | 188 | const request = ListSimulatorsRequest.create(); 189 | 190 | // Act 191 | const result = await sut.execute(request); 192 | 193 | // Assert 194 | expect(result.isSuccess).toBe(true); 195 | expect(result.simulators[0].platform).toBe('visionOS'); 196 | expect(result.simulators[0].runtime).toBe('visionOS 1.0'); 197 | }); 198 | 199 | it('should handle macOS platform detection', async () => { 200 | // Arrange 201 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 202 | 'com.apple.CoreSimulator.SimRuntime.macOS-14-0': [ 203 | { 204 | udid: 'MAC1', 205 | name: 'Mac', 206 | state: 'Shutdown', 207 | isAvailable: true 208 | } 209 | ] 210 | }); 211 | 212 | const request = ListSimulatorsRequest.create(); 213 | 214 | // Act 215 | const result = await sut.execute(request); 216 | 217 | // Assert 218 | expect(result.isSuccess).toBe(true); 219 | expect(result.simulators[0].platform).toBe('macOS'); 220 | expect(result.simulators[0].runtime).toBe('macOS 14.0'); 221 | }); 222 | 223 | it('should handle unknown platform', async () => { 224 | // Arrange 225 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 226 | 'com.apple.CoreSimulator.SimRuntime.unknown-1-0': [ 227 | { 228 | udid: 'UNKNOWN1', 229 | name: 'Unknown Device', 230 | state: 'Shutdown', 231 | isAvailable: true 232 | } 233 | ] 234 | }); 235 | 236 | const request = ListSimulatorsRequest.create(); 237 | 238 | // Act 239 | const result = await sut.execute(request); 240 | 241 | // Assert 242 | expect(result.isSuccess).toBe(true); 243 | expect(result.simulators[0].platform).toBe('Unknown'); 244 | expect(result.simulators[0].runtime).toBe('Unknown 1.0'); 245 | }); 246 | 247 | it('should handle Booting state', async () => { 248 | // Arrange 249 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 250 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 251 | { 252 | udid: 'BOOT1', 253 | name: 'iPhone 15', 254 | state: 'Booting', 255 | isAvailable: true 256 | } 257 | ] 258 | }); 259 | 260 | const request = ListSimulatorsRequest.create(); 261 | 262 | // Act 263 | const result = await sut.execute(request); 264 | 265 | // Assert 266 | expect(result.isSuccess).toBe(true); 267 | expect(result.simulators[0].state).toBe(SimulatorState.Booting); 268 | }); 269 | 270 | it('should handle Shutting Down state', async () => { 271 | // Arrange 272 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 273 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 274 | { 275 | udid: 'SHUTTING1', 276 | name: 'iPhone 15', 277 | state: 'Shutting Down', 278 | isAvailable: true 279 | } 280 | ] 281 | }); 282 | 283 | const request = ListSimulatorsRequest.create(); 284 | 285 | // Act 286 | const result = await sut.execute(request); 287 | 288 | // Assert 289 | expect(result.isSuccess).toBe(true); 290 | expect(result.simulators[0].state).toBe(SimulatorState.ShuttingDown); 291 | }); 292 | 293 | it('should handle unknown device state by throwing error', async () => { 294 | // Arrange 295 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 296 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 297 | { 298 | udid: 'WEIRD1', 299 | name: 'iPhone 15', 300 | state: 'WeirdState', 301 | isAvailable: true 302 | } 303 | ] 304 | }); 305 | 306 | const request = ListSimulatorsRequest.create(); 307 | 308 | // Act 309 | const result = await sut.execute(request); 310 | 311 | // Assert - should fail with error about unrecognized state 312 | expect(result.isSuccess).toBe(false); 313 | expect(result.error?.message).toContain('Invalid simulator state: WeirdState'); 314 | }); 315 | 316 | it('should handle runtime without version number', async () => { 317 | // Arrange 318 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 319 | 'com.apple.CoreSimulator.SimRuntime.iOS': [ 320 | { 321 | udid: 'NOVERSION1', 322 | name: 'iPhone', 323 | state: 'Shutdown', 324 | isAvailable: true 325 | } 326 | ] 327 | }); 328 | 329 | const request = ListSimulatorsRequest.create(); 330 | 331 | // Act 332 | const result = await sut.execute(request); 333 | 334 | // Assert 335 | expect(result.isSuccess).toBe(true); 336 | expect(result.simulators[0].runtime).toBe('iOS Unknown'); 337 | }); 338 | 339 | it('should handle repository errors', async () => { 340 | // Arrange 341 | const error = new Error('Repository failed'); 342 | mockDeviceRepository.getAllDevices.mockRejectedValue(error); 343 | 344 | const request = ListSimulatorsRequest.create(); 345 | 346 | // Act 347 | const result = await sut.execute(request); 348 | 349 | // Assert 350 | expect(result.isSuccess).toBe(false); 351 | expect(result.error).toBeInstanceOf(SimulatorListParseError); 352 | expect(result.error?.message).toBe('Failed to parse simulator list: not valid JSON'); 353 | }); 354 | 355 | it('should return empty list when no simulators available', async () => { 356 | // Arrange 357 | mockDeviceRepository.getAllDevices.mockResolvedValue({}); 358 | 359 | const request = ListSimulatorsRequest.create(); 360 | 361 | // Act 362 | const result = await sut.execute(request); 363 | 364 | // Assert 365 | expect(result.isSuccess).toBe(true); 366 | expect(result.count).toBe(0); 367 | expect(result.simulators).toHaveLength(0); 368 | }); 369 | 370 | it('should filter by device name with partial match', async () => { 371 | // Arrange 372 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 373 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 374 | { 375 | udid: 'ABC123', 376 | name: 'iPhone 15 Pro', 377 | state: 'Booted', 378 | isAvailable: true 379 | }, 380 | { 381 | udid: 'DEF456', 382 | name: 'iPhone 14', 383 | state: 'Shutdown', 384 | isAvailable: true 385 | }, 386 | { 387 | udid: 'GHI789', 388 | name: 'iPad Pro', 389 | state: 'Shutdown', 390 | isAvailable: true 391 | } 392 | ] 393 | }); 394 | 395 | const request = ListSimulatorsRequest.create(undefined, undefined, '15'); 396 | 397 | // Act 398 | const result = await sut.execute(request); 399 | 400 | // Assert 401 | expect(result.isSuccess).toBe(true); 402 | expect(result.count).toBe(1); 403 | expect(result.simulators[0].name).toBe('iPhone 15 Pro'); 404 | }); 405 | 406 | it('should filter by device name case-insensitive', async () => { 407 | // Arrange 408 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 409 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 410 | { 411 | udid: 'ABC123', 412 | name: 'iPhone 15 Pro', 413 | state: 'Booted', 414 | isAvailable: true 415 | }, 416 | { 417 | udid: 'DEF456', 418 | name: 'iPad Air', 419 | state: 'Shutdown', 420 | isAvailable: true 421 | } 422 | ] 423 | }); 424 | 425 | const request = ListSimulatorsRequest.create(undefined, undefined, 'iphone'); 426 | 427 | // Act 428 | const result = await sut.execute(request); 429 | 430 | // Assert 431 | expect(result.isSuccess).toBe(true); 432 | expect(result.count).toBe(1); 433 | expect(result.simulators[0].name).toBe('iPhone 15 Pro'); 434 | }); 435 | 436 | it('should combine all filters (platform, state, and name)', async () => { 437 | // Arrange 438 | mockDeviceRepository.getAllDevices.mockResolvedValue({ 439 | 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ 440 | { 441 | udid: 'ABC123', 442 | name: 'iPhone 15 Pro', 443 | state: 'Booted', 444 | isAvailable: true 445 | }, 446 | { 447 | udid: 'DEF456', 448 | name: 'iPhone 15', 449 | state: 'Shutdown', 450 | isAvailable: true 451 | } 452 | ], 453 | 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0': [ 454 | { 455 | udid: 'GHI789', 456 | name: 'Apple TV 15', 457 | state: 'Booted', 458 | isAvailable: true 459 | } 460 | ] 461 | }); 462 | 463 | const request = ListSimulatorsRequest.create(Platform.iOS, SimulatorState.Booted, '15'); 464 | 465 | // Act 466 | const result = await sut.execute(request); 467 | 468 | // Assert 469 | expect(result.isSuccess).toBe(true); 470 | expect(result.count).toBe(1); 471 | expect(result.simulators[0].name).toBe('iPhone 15 Pro'); 472 | expect(result.simulators[0].platform).toBe('iOS'); 473 | expect(result.simulators[0].state).toBe(SimulatorState.Booted); 474 | }); 475 | }); 476 | }); ``` -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { program } from 'commander'; 4 | import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; 5 | import { join, resolve, dirname } from 'path'; 6 | import { fileURLToPath } from 'url'; 7 | import { homedir } from 'os'; 8 | import { execSync } from 'child_process'; 9 | import * as readline from 'readline/promises'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = dirname(__filename); 13 | const PACKAGE_ROOT = resolve(__dirname, '..'); 14 | 15 | interface ClaudeConfig { 16 | mcpServers?: Record<string, any>; 17 | [key: string]: any; 18 | } 19 | 20 | interface ClaudeSettings { 21 | hooks?: any; 22 | model?: string; 23 | [key: string]: any; 24 | } 25 | 26 | class MCPXcodeSetup { 27 | private rl = readline.createInterface({ 28 | input: process.stdin, 29 | output: process.stdout 30 | }); 31 | 32 | async setup() { 33 | console.log('🔧 MCP Xcode Setup\n'); 34 | 35 | // 1. Ask about MCP server 36 | console.log('📡 MCP Server Configuration'); 37 | const setupMCP = await this.askYesNo('Would you like to install the MCP Xcode server?'); 38 | let mcpScope: 'global' | 'project' | null = null; 39 | if (setupMCP) { 40 | mcpScope = await this.askMCPScope(); 41 | await this.setupMCPServer(mcpScope); 42 | } 43 | 44 | // 2. Ask about hooks (independent of MCP choice) 45 | console.log('\n📝 Xcode Sync Hook Configuration'); 46 | console.log('The hook will automatically sync file operations with Xcode projects.'); 47 | console.log('It syncs when:'); 48 | console.log(' - Files are created, modified, deleted, or moved'); 49 | console.log(' - An .xcodeproj file exists in the parent directories'); 50 | console.log(' - The project hasn\'t opted out (via .no-xcode-sync or .no-xcode-autoadd file)'); 51 | 52 | const setupHooks = await this.askYesNo('\nWould you like to enable Xcode file sync?'); 53 | let hookScope: 'global' | 'project' | null = null; 54 | if (setupHooks) { 55 | hookScope = await this.askHookScope(); 56 | await this.setupHooks(hookScope); 57 | } 58 | 59 | // 3. Build helper tools if anything was installed 60 | if (setupMCP || setupHooks) { 61 | console.log('\n📦 Building helper tools...'); 62 | await this.buildHelperTools(); 63 | } 64 | 65 | // 4. Show completion message 66 | console.log('\n✅ Setup complete!'); 67 | console.log('\nNext steps:'); 68 | console.log('1. Restart Claude Code for changes to take effect'); 69 | 70 | const hasProjectConfig = (mcpScope === 'project' || hookScope === 'project'); 71 | if (hasProjectConfig) { 72 | console.log('2. Commit .claude/settings.json to share with your team'); 73 | } 74 | 75 | this.rl.close(); 76 | } 77 | 78 | private async askMCPScope(): Promise<'global' | 'project'> { 79 | const answer = await this.rl.question( 80 | 'Where should the MCP server be installed?\n' + 81 | '1) Global (~/.claude.json)\n' + 82 | '2) Project (.claude/settings.json)\n' + 83 | 'Choice (1 or 2): ' 84 | ); 85 | 86 | return answer === '2' ? 'project' : 'global'; 87 | } 88 | 89 | private async askHookScope(): Promise<'global' | 'project'> { 90 | const answer = await this.rl.question( 91 | 'Where should the Xcode sync hook be installed?\n' + 92 | '1) Global (~/.claude/settings.json)\n' + 93 | '2) Project (.claude/settings.json)\n' + 94 | 'Choice (1 or 2): ' 95 | ); 96 | 97 | return answer === '2' ? 'project' : 'global'; 98 | } 99 | 100 | private async askYesNo(question: string): Promise<boolean> { 101 | const answer = await this.rl.question(`${question} (y/n): `); 102 | return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; 103 | } 104 | 105 | private getMCPConfigPath(scope: 'global' | 'project'): string { 106 | if (scope === 'global') { 107 | // MCP servers go in ~/.claude.json for global 108 | return join(homedir(), '.claude.json'); 109 | } else { 110 | // Project scope - everything in .claude/settings.json 111 | return join(process.cwd(), '.claude', 'settings.json'); 112 | } 113 | } 114 | 115 | private getHooksConfigPath(scope: 'global' | 'project'): string { 116 | if (scope === 'global') { 117 | // Hooks go in ~/.claude/settings.json for global 118 | return join(homedir(), '.claude', 'settings.json'); 119 | } else { 120 | // Project scope - everything in .claude/settings.json 121 | return join(process.cwd(), '.claude', 'settings.json'); 122 | } 123 | } 124 | 125 | private loadConfig(path: string): any { 126 | if (existsSync(path)) { 127 | try { 128 | return JSON.parse(readFileSync(path, 'utf8')); 129 | } catch (error) { 130 | console.warn(`⚠️ Warning: Could not parse existing config at ${path}`); 131 | return {}; 132 | } 133 | } 134 | return {}; 135 | } 136 | 137 | private saveConfig(path: string, config: any) { 138 | const dir = dirname(path); 139 | if (!existsSync(dir)) { 140 | mkdirSync(dir, { recursive: true }); 141 | } 142 | writeFileSync(path, JSON.stringify(config, null, 2), 'utf8'); 143 | } 144 | 145 | private async setupMCPServer(scope: 'global' | 'project') { 146 | const configPath = this.getMCPConfigPath(scope); 147 | const config = this.loadConfig(configPath); 148 | 149 | // Determine the command based on installation type 150 | const isGlobalInstall = await this.checkGlobalInstall(); 151 | const serverPath = isGlobalInstall 152 | ? 'mcp-xcode-server' 153 | : resolve(PACKAGE_ROOT, 'dist', 'index.js'); 154 | 155 | const serverConfig = { 156 | type: 'stdio', 157 | command: isGlobalInstall ? 'mcp-xcode-server' : 'node', 158 | args: isGlobalInstall ? ['serve'] : [serverPath], 159 | env: {} 160 | }; 161 | 162 | // Add to mcpServers 163 | if (!config.mcpServers) { 164 | config.mcpServers = {}; 165 | } 166 | 167 | if (config.mcpServers['mcp-xcode-server']) { 168 | const overwrite = await this.askYesNo('MCP Xcode server already configured. Overwrite?'); 169 | if (!overwrite) { 170 | console.log('Skipping MCP server configuration.'); 171 | return; 172 | } 173 | } 174 | 175 | config.mcpServers['mcp-xcode-server'] = serverConfig; 176 | 177 | this.saveConfig(configPath, config); 178 | console.log(`✅ MCP server configured in ${configPath}`); 179 | } 180 | 181 | private async setupHooks(scope: 'global' | 'project') { 182 | const configPath = this.getHooksConfigPath(scope); 183 | const config = this.loadConfig(configPath) as ClaudeSettings; 184 | 185 | const hookScriptPath = resolve(PACKAGE_ROOT, 'scripts', 'xcode-sync.swift'); 186 | 187 | // Set up hooks using the correct Claude settings format 188 | if (!config.hooks) { 189 | config.hooks = {}; 190 | } 191 | 192 | if (!config.hooks.PostToolUse) { 193 | config.hooks.PostToolUse = []; 194 | } 195 | 196 | // Check if hook already exists 197 | const existingHookIndex = config.hooks.PostToolUse.findIndex((hook: any) => 198 | hook.matcher === 'Write|Edit|MultiEdit|Bash' && 199 | (hook.hooks?.[0]?.command?.includes('xcode-sync.swift') || hook.hooks?.[0]?.command?.includes('xcode-sync.js')) 200 | ); 201 | 202 | if (existingHookIndex >= 0) { 203 | const overwrite = await this.askYesNo('PostToolUse hook for Xcode sync already exists. Overwrite?'); 204 | if (!overwrite) { 205 | console.log('Skipping hook configuration.'); 206 | return; 207 | } 208 | // Remove existing hook 209 | config.hooks.PostToolUse.splice(existingHookIndex, 1); 210 | } 211 | 212 | // Add the new hook in Claude's expected format 213 | config.hooks.PostToolUse.push({ 214 | matcher: 'Write|Edit|MultiEdit|Bash', 215 | hooks: [{ 216 | type: 'command', 217 | command: hookScriptPath 218 | }] 219 | }); 220 | 221 | this.saveConfig(configPath, config); 222 | console.log(`✅ Xcode sync hook configured in ${configPath}`); 223 | } 224 | 225 | private async checkGlobalInstall(): Promise<boolean> { 226 | try { 227 | execSync('which mcp-xcode-server', { stdio: 'ignore' }); 228 | return true; 229 | } catch { 230 | return false; 231 | } 232 | } 233 | 234 | private async buildHelperTools() { 235 | try { 236 | // Build TypeScript 237 | console.log(' Building TypeScript...'); 238 | execSync('npm run build', { 239 | cwd: PACKAGE_ROOT, 240 | stdio: 'inherit' 241 | }); 242 | 243 | // Build XcodeProjectModifier for the sync hook 244 | console.log(' Building XcodeProjectModifier for sync hook...'); 245 | await this.buildXcodeProjectModifier(); 246 | 247 | } catch (error) { 248 | console.error('❌ Failed to build:', error); 249 | process.exit(1); 250 | } 251 | } 252 | 253 | private async buildXcodeProjectModifier() { 254 | const modifierDir = '/tmp/XcodeProjectModifier'; 255 | const modifierBinary = join(modifierDir, '.build', 'release', 'XcodeProjectModifier'); 256 | 257 | // Check if already built 258 | if (existsSync(modifierBinary)) { 259 | // Check if it's the real modifier or just a mock 260 | try { 261 | const output = execSync(`"${modifierBinary}" --help 2>&1 || true`, { encoding: 'utf8' }); 262 | if (output.includes('Mock XcodeProjectModifier')) { 263 | console.log(' Detected mock modifier, rebuilding with real implementation...'); 264 | // Remove the mock 265 | execSync(`rm -rf "${modifierDir}"`, { stdio: 'ignore' }); 266 | } else { 267 | console.log(' XcodeProjectModifier already built'); 268 | return; 269 | } 270 | } catch { 271 | // If --help fails, rebuild 272 | execSync(`rm -rf "${modifierDir}"`, { stdio: 'ignore' }); 273 | } 274 | } 275 | 276 | console.log(' Creating XcodeProjectModifier...'); 277 | 278 | // Create directory structure 279 | mkdirSync(join(modifierDir, 'Sources', 'XcodeProjectModifier'), { recursive: true }); 280 | 281 | // Create Package.swift 282 | const packageSwift = `// swift-tools-version: 5.9 283 | import PackageDescription 284 | 285 | let package = Package( 286 | name: "XcodeProjectModifier", 287 | platforms: [.macOS(.v10_15)], 288 | dependencies: [ 289 | .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.0.0"), 290 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") 291 | ], 292 | targets: [ 293 | .executableTarget( 294 | name: "XcodeProjectModifier", 295 | dependencies: [ 296 | "XcodeProj", 297 | .product(name: "ArgumentParser", package: "swift-argument-parser") 298 | ] 299 | ) 300 | ] 301 | )`; 302 | 303 | writeFileSync(join(modifierDir, 'Package.swift'), packageSwift); 304 | 305 | // Create main.swift (simplified version for the hook) 306 | const mainSwift = `import Foundation 307 | import XcodeProj 308 | import ArgumentParser 309 | 310 | struct XcodeProjectModifier: ParsableCommand { 311 | @Argument(help: "Path to the .xcodeproj file") 312 | var projectPath: String 313 | 314 | @Argument(help: "Action to perform: add or remove") 315 | var action: String 316 | 317 | @Argument(help: "Path to the file to add/remove") 318 | var filePath: String 319 | 320 | @Argument(help: "Target name") 321 | var targetName: String 322 | 323 | @Option(name: .long, help: "Group path for the file") 324 | var groupPath: String = "" 325 | 326 | func run() throws { 327 | let project = try XcodeProj(pathString: projectPath) 328 | let pbxproj = project.pbxproj 329 | 330 | guard let target = pbxproj.nativeTargets.first(where: { $0.name == targetName }) else { 331 | print("Error: Target '\\(targetName)' not found") 332 | throw ExitCode.failure 333 | } 334 | 335 | let fileName = URL(fileURLWithPath: filePath).lastPathComponent 336 | 337 | if action == "remove" { 338 | // Remove file reference 339 | if let fileRef = pbxproj.fileReferences.first(where: { $0.path == fileName || $0.path == filePath }) { 340 | pbxproj.delete(object: fileRef) 341 | print("Removed \\(fileName) from project") 342 | } 343 | } else if action == "add" { 344 | // Remove existing reference if it exists 345 | if let existingRef = pbxproj.fileReferences.first(where: { $0.path == fileName || $0.path == filePath }) { 346 | pbxproj.delete(object: existingRef) 347 | } 348 | 349 | // Add new file reference 350 | let fileRef = PBXFileReference( 351 | sourceTree: .group, 352 | name: fileName, 353 | path: filePath 354 | ) 355 | pbxproj.add(object: fileRef) 356 | 357 | // Add to appropriate build phase based on file type 358 | let fileExtension = URL(fileURLWithPath: filePath).pathExtension.lowercased() 359 | 360 | if ["swift", "m", "mm", "c", "cpp", "cc", "cxx"].contains(fileExtension) { 361 | // Add to sources build phase 362 | if let sourcesBuildPhase = target.buildPhases.compactMap({ $0 as? PBXSourcesBuildPhase }).first { 363 | let buildFile = PBXBuildFile(file: fileRef) 364 | pbxproj.add(object: buildFile) 365 | sourcesBuildPhase.files?.append(buildFile) 366 | } 367 | } else if ["png", "jpg", "jpeg", "gif", "pdf", "json", "plist", "xib", "storyboard", "xcassets"].contains(fileExtension) { 368 | // Add to resources build phase 369 | if let resourcesBuildPhase = target.buildPhases.compactMap({ $0 as? PBXResourcesBuildPhase }).first { 370 | let buildFile = PBXBuildFile(file: fileRef) 371 | pbxproj.add(object: buildFile) 372 | resourcesBuildPhase.files?.append(buildFile) 373 | } 374 | } 375 | 376 | // Add to group 377 | if let mainGroup = try? pbxproj.rootProject()?.mainGroup { 378 | mainGroup.children.append(fileRef) 379 | } 380 | 381 | print("Added \\(fileName) to project") 382 | } 383 | 384 | try project.write(path: Path(projectPath)) 385 | } 386 | } 387 | 388 | XcodeProjectModifier.main()`; 389 | 390 | writeFileSync(join(modifierDir, 'Sources', 'XcodeProjectModifier', 'main.swift'), mainSwift); 391 | 392 | // Build the tool 393 | console.log(' Building with Swift Package Manager...'); 394 | try { 395 | execSync('swift build -c release', { 396 | cwd: modifierDir, 397 | stdio: 'pipe' 398 | }); 399 | console.log(' ✅ XcodeProjectModifier built successfully'); 400 | } catch (error) { 401 | console.warn(' ⚠️ Warning: Could not build XcodeProjectModifier. Sync hook may not work until first MCP server use.'); 402 | } 403 | } 404 | } 405 | 406 | // CLI Commands 407 | program 408 | .name('mcp-xcode-server') 409 | .description('MCP Xcode Server - Setup and management') 410 | .version('2.4.0'); 411 | 412 | program 413 | .command('setup') 414 | .description('Interactive setup for MCP Xcode server and hooks') 415 | .action(async () => { 416 | const setup = new MCPXcodeSetup(); 417 | await setup.setup(); 418 | }); 419 | 420 | program 421 | .command('serve') 422 | .description('Start the MCP server') 423 | .action(async () => { 424 | // Simply run the server 425 | await import('./index.js'); 426 | }); 427 | 428 | // Parse command line arguments 429 | program.parse(); 430 | 431 | // If no command specified, show help 432 | if (!process.argv.slice(2).length) { 433 | program.outputHelp(); 434 | } ```