diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 5af66ccd..5424bd67 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -63,13 +63,19 @@ describe('build_run_sim tool', () => { const mockExecutor: CommandExecutor = async (command) => { callCount++; if (callCount === 1) { - // First call: build succeeds + // First call: platform detection succeeds return createMockCommandResponse({ success: true, - output: 'BUILD SUCCEEDED', + output: 'SDKROOT = iphonesimulator\nSUPPORTED_PLATFORMS = iphonesimulator iphoneos', }); } else if (callCount === 2) { - // Second call: showBuildSettings fails to get app path + // Second call: build succeeds + return createMockCommandResponse({ + success: true, + output: 'BUILD SUCCEEDED', + }); + } else if (callCount === 3) { + // Third call: showBuildSettings fails to get app path return createMockCommandResponse({ success: false, error: 'Could not get build settings', @@ -258,9 +264,13 @@ describe('build_run_sim tool', () => { trackingExecutor, ); - // Should generate the initial build command - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -273,7 +283,7 @@ describe('build_run_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command after finding simulator', async () => { @@ -285,26 +295,16 @@ describe('build_run_sim tool', () => { }> = []; let callCount = 0; - // Create tracking executor that succeeds on first call (list) and fails on second + // Create tracking executor: platform detection, then build (fails) const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { callHistory.push({ command, logPrefix, useShell, opts }); callCount++; if (callCount === 1) { - // First call: simulator list succeeds + // First call: platform detection succeeds return createMockCommandResponse({ success: true, - output: JSON.stringify({ - devices: { - 'iOS 16.0': [ - { - udid: 'test-uuid-123', - name: 'iPhone 16', - state: 'Booted', - }, - ], - }, - }), + output: 'SDKROOT = iphonesimulator\nSUPPORTED_PLATFORMS = iphonesimulator iphoneos', error: undefined, }); } else { @@ -326,39 +326,29 @@ describe('build_run_sim tool', () => { trackingExecutor, ); - // Should generate build command and then build settings command + // Should generate platform detection and then build command expect(callHistory).toHaveLength(2); - // First call: build command - expect(callHistory[0].command).toEqual([ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=iOS Simulator,name=iPhone 16,OS=latest', - 'build', - ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + // First call: platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + expect(callHistory[0].logPrefix).toBe('Platform Detection'); - // Second call: build settings command to get app path + // Second call: build command (which fails) expect(callHistory[1].command).toEqual([ 'xcodebuild', - '-showBuildSettings', '-workspace', '/path/to/MyProject.xcworkspace', '-scheme', 'MyScheme', '-configuration', 'Debug', + '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', ]); - expect(callHistory[1].logPrefix).toBe('Get App Path'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build settings command after successful build', async () => { @@ -370,26 +360,16 @@ describe('build_run_sim tool', () => { }> = []; let callCount = 0; - // Create tracking executor that succeeds on first two calls and fails on third + // Create tracking executor: platform detection, build, then build settings for app path const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { callHistory.push({ command, logPrefix, useShell, opts }); callCount++; if (callCount === 1) { - // First call: simulator list succeeds + // First call: platform detection succeeds return createMockCommandResponse({ success: true, - output: JSON.stringify({ - devices: { - 'iOS 16.0': [ - { - udid: 'test-uuid-123', - name: 'iPhone 16', - state: 'Booted', - }, - ], - }, - }), + output: 'SDKROOT = iphonesimulator\nSUPPORTED_PLATFORMS = iphonesimulator iphoneos', error: undefined, }); } else if (callCount === 2) { @@ -420,11 +400,16 @@ describe('build_run_sim tool', () => { trackingExecutor, ); - // Should generate build command and build settings command - expect(callHistory).toHaveLength(2); + // Should generate platform detection, build command, and build settings command + expect(callHistory).toHaveLength(3); + + // First call: platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + expect(callHistory[0].logPrefix).toBe('Platform Detection'); - // First call: build command - expect(callHistory[0].command).toEqual([ + // Second call: build command + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -437,10 +422,10 @@ describe('build_run_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); - // Second call: build settings command - expect(callHistory[1].command).toEqual([ + // Third call: build settings command for app path + expect(callHistory[2].command).toEqual([ 'xcodebuild', '-showBuildSettings', '-workspace', @@ -452,7 +437,7 @@ describe('build_run_sim tool', () => { '-destination', 'platform=iOS Simulator,name=iPhone 16', ]); - expect(callHistory[1].logPrefix).toBe('Get App Path'); + expect(callHistory[2].logPrefix).toBe('Get App Path'); }); it('should handle paths with spaces in command generation', async () => { @@ -482,9 +467,13 @@ describe('build_run_sim tool', () => { trackingExecutor, ); - // Should generate build command first - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build with paths containing spaces + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/Users/dev/My Project/MyProject.xcworkspace', @@ -497,7 +486,7 @@ describe('build_run_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); }); diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 4773bfde..2f783203 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -217,9 +217,13 @@ describe('build_sim tool', () => { trackingExecutor, ); - // Should generate one build command - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -232,7 +236,7 @@ describe('build_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command with minimal parameters (project)', async () => { @@ -262,9 +266,13 @@ describe('build_sim tool', () => { trackingExecutor, ); - // Should generate one build command - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-project', '/path/to/MyProject.xcodeproj', @@ -277,7 +285,7 @@ describe('build_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command with all optional parameters', async () => { @@ -311,9 +319,13 @@ describe('build_sim tool', () => { trackingExecutor, ); - // Should generate one build command with all parameters - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build with all parameters + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -329,7 +341,7 @@ describe('build_sim tool', () => { '--verbose', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); it('should handle paths with spaces in command generation', async () => { @@ -359,9 +371,13 @@ describe('build_sim tool', () => { trackingExecutor, ); - // Should generate one build command with paths containing spaces - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build with paths containing spaces + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/Users/dev/My Project/MyProject.xcworkspace', @@ -374,7 +390,7 @@ describe('build_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); it('should generate correct build command with useLatestOS set to true', async () => { @@ -405,9 +421,13 @@ describe('build_sim tool', () => { trackingExecutor, ); - // Should generate one build command with OS=latest - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + // Should generate two commands: platform detection + build + expect(callHistory).toHaveLength(2); + // First call is platform detection + expect(callHistory[0].command[0]).toBe('xcodebuild'); + expect(callHistory[0].command).toContain('-showBuildSettings'); + // Second call is the actual build with OS=latest + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -420,7 +440,7 @@ describe('build_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); }); }); diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 04ecf76b..78be15fb 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -4,6 +4,9 @@ * Builds and runs an app from a project or workspace on a specific simulator by UUID or name. * Accepts mutually exclusive `projectPath` or `workspacePath`. * Accepts mutually exclusive `simulatorId` or `simulatorName`. + * + * Automatically detects the target platform (iOS, watchOS, tvOS, visionOS) from the + * scheme's build settings. */ import * as z from 'zod'; @@ -19,6 +22,7 @@ import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { detectPlatformFromScheme } from '../../../utils/platform-detection.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { @@ -81,7 +85,7 @@ async function _handleSimulatorBuildLogic( params: BuildRunSimulatorParams, executor: CommandExecutor, executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, -): Promise { +): Promise<{ response: ToolResponse; detectedPlatform: XcodePlatform }> { const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; @@ -93,11 +97,29 @@ async function _handleSimulatorBuildLogic( ); } - log( - 'info', - `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, + // Auto-detect platform from scheme's build settings + const detectionResult = await detectPlatformFromScheme( + params.projectPath, + params.workspacePath, + params.scheme, + executor, ); + // Default to iOS Simulator if detection fails + const detectedPlatform = detectionResult.platform ?? XcodePlatform.iOSSimulator; + + // Generate appropriate log prefix based on detected platform + const platformName = detectedPlatform.replace(' Simulator', ''); + const logPrefix = `${platformName} Simulator Build`; + + log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); + + if (detectionResult.platform) { + log('info', `Auto-detected platform: ${detectedPlatform}`); + } else { + log('warning', `Could not detect platform from scheme, defaulting to iOS Simulator`); + } + // Create SharedBuildParams object with required configuration property const sharedBuildParams: SharedBuildParams = { workspacePath: params.workspacePath, @@ -108,22 +130,24 @@ async function _handleSimulatorBuildLogic( extraArgs: params.extraArgs, }; - return executeXcodeBuildCommandFn( + const response = await executeXcodeBuildCommandFn( sharedBuildParams, { - platform: XcodePlatform.iOSSimulator, + platform: detectedPlatform, simulatorId: params.simulatorId, simulatorName: params.simulatorName, useLatestOS: params.simulatorId ? false : params.useLatestOS, - logPrefix: 'iOS Simulator Build', + logPrefix, }, params.preferXcodebuild as boolean, 'build', executor, ); + + return { response, detectedPlatform }; } -// Exported business logic function for building and running iOS Simulator apps. +// Exported business logic function for building and running Simulator apps. export async function build_run_simLogic( params: BuildRunSimulatorParams, executor: CommandExecutor, @@ -134,12 +158,12 @@ export async function build_run_simLogic( log( 'info', - `Starting iOS Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, + `Starting Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, ); try { // --- Build Step --- - const buildResult = await _handleSimulatorBuildLogic( + const { response: buildResult, detectedPlatform } = await _handleSimulatorBuildLogic( params, executor, executeXcodeBuildCommandFn, @@ -149,6 +173,10 @@ export async function build_run_simLogic( return buildResult; // Return the build error } + // Get the platform string for destination (e.g., "iOS Simulator", "watchOS Simulator") + const platformDestination = detectedPlatform; + const platformName = detectedPlatform.replace(' Simulator', ''); + // --- Get App Path Step --- // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; @@ -164,15 +192,15 @@ export async function build_run_simLogic( command.push('-scheme', params.scheme); command.push('-configuration', params.configuration ?? 'Debug'); - // Handle destination for simulator + // Handle destination for simulator using detected platform let destinationString: string; if (params.simulatorId) { - destinationString = `platform=iOS Simulator,id=${params.simulatorId}`; + destinationString = `platform=${platformDestination},id=${params.simulatorId}`; } else if (params.simulatorName) { - destinationString = `platform=iOS Simulator,name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; + destinationString = `platform=${platformDestination},name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; } else { // This shouldn't happen due to validation, but handle it - destinationString = 'platform=iOS Simulator'; + destinationString = `platform=${platformDestination}`; } command.push('-destination', destinationString); @@ -449,7 +477,7 @@ export async function build_run_simLogic( } // --- Success --- - log('info', '✅ iOS simulator build & run succeeded.'); + log('info', `✅ ${platformName} simulator build & run succeeded.`); const target = params.simulatorId ? `simulator UUID '${params.simulatorId}'` @@ -461,9 +489,9 @@ export async function build_run_simLogic( content: [ { type: 'text', - text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}. - -The app (${bundleId}) is now running in the iOS Simulator. + text: `✅ ${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}. + +The app (${bundleId}) is now running in the ${platformName} Simulator. If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open. Next Steps: @@ -481,8 +509,8 @@ When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' }) }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in iOS Simulator build and run: ${errorMessage}`); - return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true); + log('error', `Error in Simulator build and run: ${errorMessage}`); + return createTextResponse(`Error in Simulator build and run: ${errorMessage}`, true); } } diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index 5a8d75e2..837c9256 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -4,6 +4,9 @@ * Builds an app from a project or workspace for a specific simulator by UUID or name. * Accepts mutually exclusive `projectPath` or `workspacePath`. * Accepts mutually exclusive `simulatorId` or `simulatorName`. + * + * Automatically detects the target platform (iOS, watchOS, tvOS, visionOS) from the + * scheme's build settings, so Watch apps use watchOS Simulator, iOS apps use iOS Simulator, etc. */ import * as z from 'zod'; @@ -17,6 +20,7 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { detectPlatformFromScheme } from '../../../utils/platform-detection.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { @@ -90,11 +94,29 @@ async function _handleSimulatorBuildLogic( ); } - log( - 'info', - `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, + // Auto-detect platform from scheme's build settings + const detectionResult = await detectPlatformFromScheme( + params.projectPath, + params.workspacePath, + params.scheme, + executor, ); + // Default to iOS Simulator if detection fails + const detectedPlatform = detectionResult.platform ?? XcodePlatform.iOSSimulator; + + // Generate appropriate log prefix based on detected platform + const platformName = detectedPlatform.replace(' Simulator', ''); + const logPrefix = `${platformName} Simulator Build`; + + log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); + + if (detectionResult.platform) { + log('info', `Auto-detected platform: ${detectedPlatform}`); + } else { + log('warning', `Could not detect platform from scheme, defaulting to iOS Simulator`); + } + // Ensure configuration has a default value for SharedBuildParams compatibility const sharedBuildParams = { ...params, @@ -105,11 +127,11 @@ async function _handleSimulatorBuildLogic( return executeXcodeBuildCommand( sharedBuildParams, { - platform: XcodePlatform.iOSSimulator, + platform: detectedPlatform, simulatorName: params.simulatorName, simulatorId: params.simulatorId, useLatestOS: params.simulatorId ? false : params.useLatestOS, // Ignore useLatestOS with ID - logPrefix: 'iOS Simulator Build', + logPrefix, }, params.preferXcodebuild ?? false, 'build', diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index c34bffef..5581ea1b 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -4,6 +4,9 @@ * Runs tests for a project or workspace on a simulator by UUID or name. * Accepts mutually exclusive `projectPath` or `workspacePath`. * Accepts mutually exclusive `simulatorId` or `simulatorName`. + * + * Automatically detects the target platform (iOS, watchOS, tvOS, visionOS) from the + * scheme's build settings. */ import * as z from 'zod'; @@ -18,6 +21,7 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { detectPlatformFromScheme } from '../../../utils/platform-detection.ts'; // Define base schema object with all fields const baseSchemaObject = z.object({ @@ -91,6 +95,23 @@ export async function test_simLogic( ); } + // Auto-detect platform from scheme's build settings + const detectionResult = await detectPlatformFromScheme( + params.projectPath, + params.workspacePath, + params.scheme, + executor, + ); + + // Default to iOS Simulator if detection fails + const detectedPlatform = detectionResult.platform ?? XcodePlatform.iOSSimulator; + + if (detectionResult.platform) { + log('info', `Auto-detected platform for tests: ${detectedPlatform}`); + } else { + log('warning', `Could not detect platform from scheme, defaulting to iOS Simulator`); + } + return handleTestLogic( { projectPath: params.projectPath, @@ -103,7 +124,7 @@ export async function test_simLogic( extraArgs: params.extraArgs, useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), preferXcodebuild: params.preferXcodebuild ?? false, - platform: XcodePlatform.iOSSimulator, + platform: detectedPlatform, testRunnerEnv: params.testRunnerEnv, }, executor, diff --git a/src/utils/__tests__/platform-detection.test.ts b/src/utils/__tests__/platform-detection.test.ts new file mode 100644 index 00000000..838f6a0f --- /dev/null +++ b/src/utils/__tests__/platform-detection.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { XcodePlatform } from '../../types/common.ts'; +import { detectPlatformFromScheme } from '../platform-detection.ts'; + +describe('detectPlatformFromScheme', () => { + it('detects simulator platform from SDKROOT', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SDKROOT = watchsimulator\nSUPPORTED_PLATFORMS = watchsimulator watchos', + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + undefined, + 'WatchScheme', + executor, + ); + + expect(result.platform).toBe(XcodePlatform.watchOSSimulator); + expect(result.sdkroot).toBe('watchsimulator'); + }); + + it('falls back to SUPPORTED_PLATFORMS when SDKROOT is missing', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SUPPORTED_PLATFORMS = appletvsimulator appletvos', + }); + + const result = await detectPlatformFromScheme( + undefined, + '/tmp/Test.xcworkspace', + 'TVScheme', + executor, + ); + + expect(result.platform).toBe(XcodePlatform.tvOSSimulator); + expect(result.sdkroot).toBeNull(); + }); + + it('returns null platform for non-simulator SDKROOT values', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SDKROOT = macosx\nSUPPORTED_PLATFORMS = macosx', + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + undefined, + 'MacScheme', + executor, + ); + + expect(result.platform).toBeNull(); + expect(result.sdkroot).toBe('macosx'); + expect(result.supportedPlatforms).toEqual(['macosx']); + }); + + it('returns null platform for device SDKROOT values', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SDKROOT = iphoneos\nSUPPORTED_PLATFORMS = iphoneos', + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + undefined, + 'DeviceScheme', + executor, + ); + + expect(result.platform).toBeNull(); + expect(result.sdkroot).toBe('iphoneos'); + expect(result.supportedPlatforms).toEqual(['iphoneos']); + }); + + it('prefers simulator SDKROOT when build settings contain multiple blocks', async () => { + const executor = createMockExecutor({ + success: true, + output: ` +Build settings for action build and target DeviceTarget: + SDKROOT = iphoneos + SUPPORTED_PLATFORMS = iphoneos + +Build settings for action build and target SimulatorTarget: + SDKROOT = iphonesimulator18.0 + SUPPORTED_PLATFORMS = iphonesimulator iphoneos +`, + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + undefined, + 'MixedScheme', + executor, + ); + + expect(result.platform).toBe(XcodePlatform.iOSSimulator); + expect(result.sdkroot).toBe('iphonesimulator18.0'); + }); + + it('returns null platform for device-only SUPPORTED_PLATFORMS', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SUPPORTED_PLATFORMS = iphoneos watchos appletvos', + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + undefined, + 'DeviceOnlyScheme', + executor, + ); + + expect(result.platform).toBeNull(); + expect(result.sdkroot).toBeNull(); + expect(result.supportedPlatforms).toEqual(['iphoneos', 'watchos', 'appletvos']); + }); + + it('returns error when both projectPath and workspacePath are provided', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SDKROOT = iphonesimulator', + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + '/tmp/Test.xcworkspace', + 'AmbiguousScheme', + executor, + ); + + expect(result.platform).toBeNull(); + expect(result.error).toContain('mutually exclusive'); + }); + + it('returns error when neither projectPath nor workspacePath is provided', async () => { + const executor = createMockExecutor({ + success: true, + output: 'SDKROOT = iphonesimulator', + }); + + const result = await detectPlatformFromScheme( + undefined, + undefined, + 'NoProjectScheme', + executor, + ); + + expect(result.platform).toBeNull(); + expect(result.error).toContain('required'); + }); + + it('surfaces command failure details', async () => { + const executor = createMockExecutor({ + success: false, + error: 'xcodebuild failed', + }); + + const result = await detectPlatformFromScheme( + '/tmp/Test.xcodeproj', + undefined, + 'BrokenScheme', + executor, + ); + + expect(result.platform).toBeNull(); + expect(result.error).toBe('xcodebuild failed'); + }); +}); diff --git a/src/utils/platform-detection.ts b/src/utils/platform-detection.ts new file mode 100644 index 00000000..e0ce0e84 --- /dev/null +++ b/src/utils/platform-detection.ts @@ -0,0 +1,199 @@ +/** + * Platform Detection Utility + * + * Detects the simulator platform for a scheme by querying xcodebuild build settings. + * This allows build tools to automatically select the correct simulator type + * (iOS vs watchOS vs tvOS vs visionOS) based on what the scheme actually targets. + */ + +import { XcodePlatform } from '../types/common.ts'; +import { log } from './logging/index.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { getDefaultCommandExecutor } from './execution/index.ts'; + +export type SimulatorPlatform = + | XcodePlatform.iOSSimulator + | XcodePlatform.watchOSSimulator + | XcodePlatform.tvOSSimulator + | XcodePlatform.visionOSSimulator; + +export interface PlatformDetectionResult { + platform: SimulatorPlatform | null; + sdkroot: string | null; + supportedPlatforms: string[]; + error?: string; +} + +/** + * Maps SDKROOT values to simulator platform enum values. + */ +function sdkrootToSimulatorPlatform(sdkroot: string): SimulatorPlatform | null { + const sdkLower = sdkroot.toLowerCase(); + + if (sdkLower.startsWith('watchsimulator')) { + return XcodePlatform.watchOSSimulator; + } + if (sdkLower.startsWith('appletvsimulator')) { + return XcodePlatform.tvOSSimulator; + } + if (sdkLower.startsWith('xrsimulator')) { + return XcodePlatform.visionOSSimulator; + } + if (sdkLower.startsWith('iphonesimulator')) { + return XcodePlatform.iOSSimulator; + } + + return null; +} + +/** + * Maps SUPPORTED_PLATFORMS values to determine the primary simulator platform. + */ +function supportedPlatformsToSimulatorPlatform(platforms: string[]): SimulatorPlatform | null { + const normalizedPlatforms = new Set(platforms.map((platform) => platform.toLowerCase())); + + // Check in order of specificity + if (normalizedPlatforms.has('watchsimulator')) { + return XcodePlatform.watchOSSimulator; + } + if (normalizedPlatforms.has('appletvsimulator')) { + return XcodePlatform.tvOSSimulator; + } + if (normalizedPlatforms.has('xrsimulator')) { + return XcodePlatform.visionOSSimulator; + } + + // Check for iOS after more specific platforms + if (normalizedPlatforms.has('iphonesimulator')) { + return XcodePlatform.iOSSimulator; + } + + return null; +} + +function extractBuildSettingValues(output: string, settingName: string): string[] { + const regex = new RegExp(`^\\s*${settingName}\\s*=\\s*(.+)$`, 'gm'); + const values: string[] = []; + + for (const match of output.matchAll(regex)) { + const value = match[1]?.trim(); + if (value) { + values.push(value); + } + } + + return values; +} + +/** + * Detects the simulator platform for a given scheme by querying xcodebuild. + * + * @param projectPath - Path to the .xcodeproj file (mutually exclusive with workspacePath) + * @param workspacePath - Path to the .xcworkspace file (mutually exclusive with projectPath) + * @param scheme - The scheme name to query + * @param executor - Command executor (defaults to standard executor) + * @returns PlatformDetectionResult with the detected platform or error + */ +export async function detectPlatformFromScheme( + projectPath: string | undefined, + workspacePath: string | undefined, + scheme: string, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + const command = ['xcodebuild', '-showBuildSettings', '-scheme', scheme]; + + if (projectPath && workspacePath) { + return { + platform: null, + sdkroot: null, + supportedPlatforms: [], + error: 'projectPath and workspacePath are mutually exclusive for platform detection', + }; + } + + if (projectPath) { + command.push('-project', projectPath); + } else if (workspacePath) { + command.push('-workspace', workspacePath); + } else { + return { + platform: null, + sdkroot: null, + supportedPlatforms: [], + error: 'Either projectPath or workspacePath is required for platform detection', + }; + } + + try { + log('info', `[Platform Detection] Querying build settings for scheme: ${scheme}`); + const result = await executor(command, 'Platform Detection', true); + + if (!result.success) { + const errorMessage = result.error ?? 'xcodebuild -showBuildSettings failed'; + log('warning', `[Platform Detection] Failed to query build settings: ${result.error}`); + return { + platform: null, + sdkroot: null, + supportedPlatforms: [], + error: errorMessage, + }; + } + + const output = result.output || ''; + + // Parse all SDKROOT values and prefer the first simulator-compatible one + const sdkroots = extractBuildSettingValues(output, 'SDKROOT'); + let sdkroot: string | null = null; + + // Parse all SUPPORTED_PLATFORMS values and flatten into one list + const supportedPlatforms = extractBuildSettingValues(output, 'SUPPORTED_PLATFORMS').flatMap( + (value) => value.split(/\s+/), + ); + + // Determine platform from SDKROOT first, then fall back to SUPPORTED_PLATFORMS + let platform: SimulatorPlatform | null = null; + + for (const sdkrootValue of sdkroots) { + const detectedPlatform = sdkrootToSimulatorPlatform(sdkrootValue); + if (detectedPlatform) { + platform = detectedPlatform; + sdkroot = sdkrootValue; + break; + } + } + + if (!sdkroot && sdkroots.length > 0) { + sdkroot = sdkroots[0]; + } + + if (platform) { + log('info', `[Platform Detection] Detected platform from SDKROOT: ${platform}`); + } + + if (!platform && supportedPlatforms.length > 0) { + platform = supportedPlatformsToSimulatorPlatform(supportedPlatforms); + if (platform) { + log('info', `[Platform Detection] Detected platform from SUPPORTED_PLATFORMS: ${platform}`); + } + } + + if (!platform) { + log('warning', `[Platform Detection] Could not determine platform from build settings`); + } + + return { + platform, + sdkroot, + supportedPlatforms, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `[Platform Detection] Error: ${errorMessage}`); + return { + platform: null, + sdkroot: null, + supportedPlatforms: [], + error: errorMessage, + }; + } +}