From 46d91b73827a99b85c317cf86b5e9fa1bffbb7bf Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 09:30:53 +0000 Subject: [PATCH 1/7] Infer simulator platform with fast path and fallback --- .../simulator/__tests__/build_run_sim.test.ts | 43 ++++ .../simulator/__tests__/build_sim.test.ts | 43 ++++ src/mcp/tools/simulator/build_run_sim.ts | 50 +++-- src/mcp/tools/simulator/build_sim.ts | 24 ++- src/mcp/tools/simulator/test_sim.ts | 19 +- src/utils/__tests__/infer-platform.test.ts | 122 +++++++++++ .../__tests__/platform-detection.test.ts | 116 +++++++++++ src/utils/infer-platform.ts | 195 ++++++++++++++++++ src/utils/platform-detection.ts | 129 ++++++++++++ 9 files changed, 717 insertions(+), 24 deletions(-) create mode 100644 src/utils/__tests__/infer-platform.test.ts create mode 100644 src/utils/__tests__/platform-detection.test.ts create mode 100644 src/utils/infer-platform.ts create mode 100644 src/utils/platform-detection.ts 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 827f17a9..eba7fc24 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -491,6 +491,49 @@ describe('build_run_sim tool', () => { ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); + + it('should infer tvOS platform from simulator name for build command', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + opts?: { env?: Record; cwd?: string }; + }> = []; + + const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { + callHistory.push({ command, logPrefix, useShell, opts }); + return createMockCommandResponse({ + success: false, + output: '', + error: 'Test error to stop execution early', + }); + }; + + await build_run_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyTVScheme', + simulatorName: 'Apple TV 4K', + }, + trackingExecutor, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyTVScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=tvOS Simulator,name=Apple TV 4K,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('tvOS Simulator Build'); + }); }); describe('XOR Validation', () => { diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 895152bc..38c00355 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -414,6 +414,49 @@ describe('build_sim tool', () => { ]); expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); }); + + it('should infer watchOS platform from simulator name', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + opts?: { env?: Record; cwd?: string }; + }> = []; + + const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { + callHistory.push({ command, logPrefix, useShell, opts }); + return createMockCommandResponse({ + success: false, + output: '', + error: 'Test error to stop execution early', + }); + }; + + await build_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyWatchScheme', + simulatorName: 'Apple Watch Ultra 2', + }, + trackingExecutor, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyWatchScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=watchOS Simulator,name=Apple Watch Ultra 2,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('watchOS Simulator Build'); + }); }); describe('Response Processing', () => { diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 5a61a234..1f4e0b69 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -20,6 +20,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 { inferPlatform } from '../../../utils/infer-platform.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { @@ -82,7 +83,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; @@ -94,10 +95,22 @@ async function _handleSimulatorBuildLogic( ); } - log( - 'info', - `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, + const inferred = await inferPlatform( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + }, + executor, ); + const detectedPlatform = inferred.platform; + const platformName = detectedPlatform.replace(' Simulator', ''); + const logPrefix = `${platformName} Simulator Build`; + + log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); + log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); // Create SharedBuildParams object with required configuration property const sharedBuildParams: SharedBuildParams = { @@ -109,19 +122,21 @@ 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. @@ -135,12 +150,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, @@ -150,6 +165,9 @@ export async function build_run_simLogic( return buildResult; // Return the build error } + 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']; @@ -168,12 +186,12 @@ export async function build_run_simLogic( // Handle destination for simulator 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); @@ -450,7 +468,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}'` @@ -462,7 +480,7 @@ 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}.\n\nThe app (${bundleId}) is now running in the iOS Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, + text: `✅ ${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the ${platformName} Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, }, ], nextSteps: [ @@ -489,8 +507,8 @@ export async function build_run_simLogic( }; } 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 a36303c3..3eef8b42 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -10,7 +10,6 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { @@ -18,6 +17,7 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { inferPlatform } from '../../../utils/infer-platform.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { @@ -91,10 +91,22 @@ async function _handleSimulatorBuildLogic( ); } - log( - 'info', - `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, + const inferred = await inferPlatform( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + }, + executor, ); + const detectedPlatform = inferred.platform; + const platformName = detectedPlatform.replace(' Simulator', ''); + const logPrefix = `${platformName} Simulator Build`; + + log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); + log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); // Ensure configuration has a default value for SharedBuildParams compatibility const sharedBuildParams = { @@ -106,11 +118,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 c6fc5e86..5f4bd2b3 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -10,7 +10,6 @@ import * as z from 'zod'; import { handleTestLogic } from '../../../utils/test/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; @@ -18,6 +17,7 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { inferPlatform } from '../../../utils/infer-platform.ts'; // Define base schema object with all fields const baseSchemaObject = z.object({ @@ -91,6 +91,21 @@ export async function test_simLogic( ); } + const inferred = await inferPlatform( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + }, + executor, + ); + log( + 'info', + `Inferred simulator platform for tests: ${inferred.platform} (source: ${inferred.source})`, + ); + return handleTestLogic( { projectPath: params.projectPath, @@ -103,7 +118,7 @@ export async function test_simLogic( extraArgs: params.extraArgs, useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), preferXcodebuild: params.preferXcodebuild ?? false, - platform: XcodePlatform.iOSSimulator, + platform: inferred.platform, testRunnerEnv: params.testRunnerEnv, }, executor, diff --git a/src/utils/__tests__/infer-platform.test.ts b/src/utils/__tests__/infer-platform.test.ts new file mode 100644 index 00000000..c7ffee6e --- /dev/null +++ b/src/utils/__tests__/infer-platform.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createMockCommandResponse, createMockExecutor } from '../../test-utils/mock-executors.ts'; +import type { CommandExecutor } from '../execution/index.ts'; +import { sessionStore } from '../session-store.ts'; +import { inferPlatform } from '../infer-platform.ts'; +import { XcodePlatform } from '../../types/common.ts'; + +describe('inferPlatform', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + it('infers iOS from simulator name without calling external commands', async () => { + const executor = createMockExecutor(new Error('Executor should not be called')); + const result = await inferPlatform({ simulatorName: 'iPhone 16 Pro' }, executor); + + expect(result.platform).toBe(XcodePlatform.iOSSimulator); + expect(result.source).toBe('simulator-name'); + }); + + it('reads simulatorName from session defaults', async () => { + sessionStore.setDefaults({ simulatorName: 'Apple Watch Ultra 2' }); + + const executor = createMockExecutor(new Error('Executor should not be called')); + const result = await inferPlatform({}, executor); + + expect(result.platform).toBe(XcodePlatform.watchOSSimulator); + expect(result.source).toBe('simulator-name'); + }); + + it('infers platform from simulator runtime when simulatorId is provided', async () => { + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + udid: 'SIM-UUID', + name: 'Apple TV', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await inferPlatform({ simulatorId: 'SIM-UUID' }, mockExecutor); + + expect(result.platform).toBe(XcodePlatform.tvOSSimulator); + expect(result.source).toBe('simulator-runtime'); + }); + + it('falls back to build settings when simulator runtime cannot be resolved', async () => { + const callHistory: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + callHistory.push(command); + + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ devices: {} }), + }); + } + + return createMockCommandResponse({ + success: true, + output: 'SDKROOT = watchsimulator\nSUPPORTED_PLATFORMS = watchsimulator watchos', + }); + }; + + const result = await inferPlatform( + { + simulatorId: 'SIM-UUID', + projectPath: '/tmp/Test.xcodeproj', + scheme: 'WatchScheme', + }, + mockExecutor, + ); + + expect(result.platform).toBe(XcodePlatform.watchOSSimulator); + expect(result.source).toBe('build-settings'); + expect(callHistory).toHaveLength(2); + expect(callHistory[0]).toEqual(['xcrun', 'simctl', 'list', 'devices', 'available', '--json']); + expect(callHistory[1]).toEqual([ + 'xcodebuild', + '-showBuildSettings', + '-scheme', + 'WatchScheme', + '-project', + '/tmp/Test.xcodeproj', + ]); + }); + + it('defaults to iOS when simulator and build-settings inference both fail', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: false, + error: 'simctl failed', + }); + } + + return createMockCommandResponse({ + success: false, + error: 'xcodebuild failed', + }); + }; + + const result = await inferPlatform( + { + simulatorId: 'SIM-UUID', + workspacePath: '/tmp/Test.xcworkspace', + scheme: 'UnknownScheme', + }, + mockExecutor, + ); + + expect(result.platform).toBe(XcodePlatform.iOSSimulator); + expect(result.source).toBe('default'); + }); +}); diff --git a/src/utils/__tests__/platform-detection.test.ts b/src/utils/__tests__/platform-detection.test.ts new file mode 100644 index 00000000..c170f518 --- /dev/null +++ b/src/utils/__tests__/platform-detection.test.ts @@ -0,0 +1,116 @@ +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'); + }); + + 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 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('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/infer-platform.ts b/src/utils/infer-platform.ts new file mode 100644 index 00000000..0f36502b --- /dev/null +++ b/src/utils/infer-platform.ts @@ -0,0 +1,195 @@ +import { XcodePlatform } from '../types/common.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { getDefaultCommandExecutor } from './execution/index.ts'; +import { log } from './logging/index.ts'; +import { detectPlatformFromScheme, type SimulatorPlatform } from './platform-detection.ts'; +import { sessionStore, type SessionDefaults } from './session-store.ts'; + +type PlatformInferenceSource = + | 'simulator-name' + | 'simulator-runtime' + | 'build-settings' + | 'default'; + +export interface InferPlatformParams { + projectPath?: string; + workspacePath?: string; + scheme?: string; + simulatorId?: string; + simulatorName?: string; + sessionDefaults?: Partial; +} + +export interface InferPlatformResult { + platform: SimulatorPlatform; + source: PlatformInferenceSource; +} + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function inferPlatformFromSimulatorName(simulatorName: string): SimulatorPlatform | null { + const name = simulatorName.toLowerCase(); + + if (name.includes('watch')) return XcodePlatform.watchOSSimulator; + if (name.includes('apple tv') || name.includes('tvos')) return XcodePlatform.tvOSSimulator; + if ( + name.includes('apple vision') || + name.includes('vision pro') || + name.includes('visionos') || + name.includes('xros') + ) { + return XcodePlatform.visionOSSimulator; + } + if (name.includes('iphone') || name.includes('ipad') || name.includes('ipod')) { + return XcodePlatform.iOSSimulator; + } + + return null; +} + +function inferPlatformFromRuntime(runtime: string): SimulatorPlatform | null { + const value = runtime.toLowerCase(); + + if (value.includes('simruntime.watchos') || value.startsWith('watchos')) { + return XcodePlatform.watchOSSimulator; + } + if ( + value.includes('simruntime.tvos') || + value.includes('simruntime.appletv') || + value.startsWith('tvos') + ) { + return XcodePlatform.tvOSSimulator; + } + if ( + value.includes('simruntime.xros') || + value.includes('simruntime.visionos') || + value.startsWith('xros') || + value.startsWith('visionos') + ) { + return XcodePlatform.visionOSSimulator; + } + if (value.includes('simruntime.ios') || value.startsWith('ios')) { + return XcodePlatform.iOSSimulator; + } + + return null; +} + +function resolveSimulatorsFromSession(params: InferPlatformParams): { + simulatorId?: string; + simulatorName?: string; +} { + const defaults = params.sessionDefaults ?? sessionStore.getAll(); + const simulatorId = params.simulatorId ?? defaults.simulatorId; + const simulatorName = params.simulatorName ?? defaults.simulatorName; + return { simulatorId, simulatorName }; +} + +function resolveProjectFromSession(params: InferPlatformParams): { + projectPath?: string; + workspacePath?: string; + scheme?: string; +} { + const defaults = params.sessionDefaults ?? sessionStore.getAll(); + return { + projectPath: params.projectPath ?? defaults.projectPath, + workspacePath: params.workspacePath ?? defaults.workspacePath, + scheme: params.scheme ?? defaults.scheme, + }; +} + +async function inferPlatformFromSimctl( + simulatorId: string | undefined, + simulatorName: string | undefined, + executor: CommandExecutor, +): Promise { + if (!simulatorId && !simulatorName) return null; + + const result = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'Infer Simulator Platform', + true, + ); + + if (!result.success) { + log('warning', `[Platform Inference] simctl failed: ${result.error ?? 'Unknown error'}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(result.output); + } catch { + log('warning', `[Platform Inference] Failed to parse simctl JSON output`); + return null; + } + + if (!parsed || typeof parsed !== 'object' || !('devices' in parsed)) { + log('warning', `[Platform Inference] simctl JSON missing devices`); + return null; + } + + const devices = (parsed as { devices: Record }).devices; + for (const runtime of Object.keys(devices)) { + const list = devices[runtime]; + if (!Array.isArray(list)) continue; + + for (const device of list) { + if (!device || typeof device !== 'object') continue; + const current = device as { + udid?: unknown; + name?: unknown; + isAvailable?: unknown; + }; + + const matchesId = typeof current.udid === 'string' && current.udid === simulatorId; + const matchesName = typeof current.name === 'string' && current.name === simulatorName; + if (!matchesId && !matchesName) continue; + if (typeof current.isAvailable === 'boolean' && !current.isAvailable) continue; + + return inferPlatformFromRuntime(runtime); + } + } + + return null; +} + +export async function inferPlatform( + params: InferPlatformParams, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + const { simulatorId, simulatorName } = resolveSimulatorsFromSession(params); + + if (simulatorName) { + const inferredFromName = inferPlatformFromSimulatorName(simulatorName); + if (inferredFromName) { + return { platform: inferredFromName, source: 'simulator-name' }; + } + } + + let simulatorIdForLookup = simulatorId; + let simulatorNameForLookup = simulatorName; + if (!simulatorIdForLookup && simulatorName && UUID_REGEX.test(simulatorName)) { + simulatorIdForLookup = simulatorName; + simulatorNameForLookup = undefined; + } + + const inferredFromRuntime = await inferPlatformFromSimctl( + simulatorIdForLookup, + simulatorNameForLookup, + executor, + ); + if (inferredFromRuntime) { + return { platform: inferredFromRuntime, source: 'simulator-runtime' }; + } + + const { projectPath, workspacePath, scheme } = resolveProjectFromSession(params); + if (scheme && (projectPath || workspacePath)) { + const detection = await detectPlatformFromScheme(projectPath, workspacePath, scheme, executor); + if (detection.platform) { + return { platform: detection.platform, source: 'build-settings' }; + } + } + + return { platform: XcodePlatform.iOSSimulator, source: 'default' }; +} diff --git a/src/utils/platform-detection.ts b/src/utils/platform-detection.ts new file mode 100644 index 00000000..61a4918f --- /dev/null +++ b/src/utils/platform-detection.ts @@ -0,0 +1,129 @@ +import { XcodePlatform } from '../types/common.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { getDefaultCommandExecutor } from './execution/index.ts'; +import { log } from './logging/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; +} + +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; +} + +function supportedPlatformsToSimulatorPlatform(platforms: string[]): SimulatorPlatform | null { + const normalized = new Set(platforms.map((platform) => platform.toLowerCase())); + + if (normalized.has('watchsimulator')) return XcodePlatform.watchOSSimulator; + if (normalized.has('appletvsimulator')) return XcodePlatform.tvOSSimulator; + if (normalized.has('xrsimulator')) return XcodePlatform.visionOSSimulator; + if (normalized.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; +} + +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 { + const result = await executor(command, 'Platform Detection', true); + if (!result.success) { + return { + platform: null, + sdkroot: null, + supportedPlatforms: [], + error: result.error ?? 'xcodebuild -showBuildSettings failed', + }; + } + + const output = result.output ?? ''; + const sdkroots = extractBuildSettingValues(output, 'SDKROOT'); + const supportedPlatforms = extractBuildSettingValues(output, 'SUPPORTED_PLATFORMS').flatMap( + (value) => value.split(/\s+/), + ); + + let sdkroot: string | null = null; + let platform: SimulatorPlatform | null = null; + + for (const sdkrootValue of sdkroots) { + const detected = sdkrootToSimulatorPlatform(sdkrootValue); + if (detected) { + platform = detected; + sdkroot = sdkrootValue; + break; + } + } + + if (!sdkroot && sdkroots.length > 0) sdkroot = sdkroots[0]; + + if (!platform && supportedPlatforms.length > 0) { + platform = supportedPlatformsToSimulatorPlatform(supportedPlatforms); + } + + return { platform, sdkroot, supportedPlatforms }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('warning', `[Platform Detection] ${errorMessage}`); + return { + platform: null, + sdkroot: null, + supportedPlatforms: [], + error: errorMessage, + }; + } +} From a92b31dca5b22a2451eeea0599463d047afcc8f0 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 12:05:57 +0000 Subject: [PATCH 2/7] fix(session): Refresh simulator defaults in background Run simulatorId/simulatorName resolution and platform inference asynchronously during MCP startup and session-set-defaults so tool execution is not blocked. Cache simulatorPlatform, clear stale selector fields when the selector changes, and apply async refresh results only when the session revision still matches. Persist startup hydration refresh results to self-heal stale config defaults. Co-Authored-By: Claude --- docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- ...ference-staleness-and-xor-normalization.md | 243 +++++++++++++ .../__tests__/session_set_defaults.test.ts | 42 ++- .../session_set_defaults.ts | 79 ++-- .../simulator/__tests__/build_run_sim.test.ts | 222 ++++-------- .../simulator/__tests__/build_sim.test.ts | 340 ++++++++---------- .../__tests__/bootstrap-runtime.test.ts | 62 ++++ src/runtime/bootstrap-runtime.ts | 46 +-- src/utils/__tests__/infer-platform.test.ts | 117 +++++- src/utils/__tests__/project-config.test.ts | 2 +- src/utils/infer-platform.ts | 108 +++++- src/utils/project-config.ts | 5 +- src/utils/session-defaults-schema.ts | 5 + src/utils/session-store.ts | 21 ++ src/utils/simulator-defaults-refresh.ts | 110 ++++++ src/utils/simulator-resolver.ts | 55 +++ 17 files changed, 1018 insertions(+), 443 deletions(-) create mode 100644 docs/investigations/platform-inference-staleness-and-xor-normalization.md create mode 100644 src/runtime/__tests__/bootstrap-runtime.test.ts create mode 100644 src/utils/simulator-defaults-refresh.ts diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index bcce8b30..43e479b4 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -187,4 +187,4 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-07T22:29:44.282Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T11:11:49.328Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index d0599376..bb297815 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,4 +202,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-07T22:29:44.282Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T11:11:49.328Z UTC* diff --git a/docs/investigations/platform-inference-staleness-and-xor-normalization.md b/docs/investigations/platform-inference-staleness-and-xor-normalization.md new file mode 100644 index 00000000..d6195033 --- /dev/null +++ b/docs/investigations/platform-inference-staleness-and-xor-normalization.md @@ -0,0 +1,243 @@ +# Investigation + Plan: Simulator Selector Normalization, CLI Determinism, and Platform Inference + +## Purpose + +This document separates three related but distinct problem domains and defines a concrete implementation plan for each. + +## Problem Domains + +1. Inconsistent handling of `simulatorId` and `simulatorName` across logic paths. +2. CLI hydrates session defaults, which makes CLI behavior non-deterministic. +3. Non-iOS simulator targets (watchOS/tvOS/visionOS) can use incorrect platform names and fail builds. + +## Scope + +1. Keep existing CLI argument UX and command surface. +2. Keep existing runtime validation behavior (`oneOf`/`allOf`/XOR checks). +3. Change only session-default hydration behavior, selector normalization behavior, and platform inference behavior. +4. Stateful CLI behavior (daemon, logs, debug sessions) remains by design and is out of scope. + +## Decision Snapshot + +1. Platform inference must support non-iOS simulators using simulator runtime metadata, with build-settings only as fallback. +2. Session defaults must be MCP-only runtime behavior (no CLI/daemon hydration into `sessionStore`). +3. Store both `simulatorId` and `simulatorName` in session defaults/config and disambiguate at tool boundary via shared helper logic. +4. Persist only `simulatorPlatform` as platform cache; do not persist `simulatorRuntime` or timestamp fields. + +--- + +## Domain 1: `simulatorId` / `simulatorName` Normalization + +## Current State (verified) + +1. `session_set_defaults` can keep both `simulatorId` and `simulatorName`. +2. Config normalization drops `simulatorName` when both are present. +3. Session-aware factory prunes exclusive pairs at merge-time and prefers first key when both come from defaults. +4. Some tools still enforce schema-level XOR, creating layered/duplicated enforcement. + +Net effect: behavior is usually correct, but inconsistent and hard to reason about. + +## Decision + +Use a single normalized model everywhere: + +1. Store both values in session defaults/config. +2. `simulatorId` is authoritative for tools that require UUID. +3. `simulatorName` is preserved for portability and tools that can use name. +4. Explicit user args that provide both remain invalid. +5. Each tool receives exactly one effective selector value at execution boundary. + +## Why this model + +1. `simulatorName` survives simulator resets better than UUIDs. +2. Some operations fundamentally require UUID; others can run with name. +3. Keeping both in storage avoids repeated lossy conversion and supports both execution modes. + +## Required Invariants + +1. Storage layer may contain both selector fields. +2. Explicit invocation args may not contain both selector fields. +3. Tool execution input must be disambiguated to one selector by a shared helper. +4. Disambiguation precedence must be deterministic and test-covered. + +## Implementation Plan + +1. Add `inferSimulatorSelectorForTool(...)` helper (or equivalent) used by simulator tools. +2. Normalize config/session behavior to stop dropping `simulatorName` when both exist. +3. Keep factory-level explicit XOR validation. +4. Keep per-tool requirement checks (`oneOf`/`allOf`) unchanged. +5. Reduce duplicated tool-local selector branching by centralizing selector choice. + +## Validation Plan + +1. Unit tests for helper precedence across explicit args, stored defaults, and missing values. +2. Regression tests for config-load behavior preserving both fields. +3. Integration tests for tools that require UUID vs tools that accept name. +4. Real-world MCP run validating both selector paths. + +--- + +## Domain 2: CLI Determinism and Session Defaults + +## Current State (verified) + +1. `session-management` workflow is not exposed in CLI. +2. CLI runtime still bootstraps config and hydrates `config.sessionDefaults` into `sessionStore`. +3. Result: CLI can pick up hidden persisted defaults that the user cannot inspect/mutate via CLI commands. + +Net effect: CLI behavior can vary based on hidden config state. + +## Decision + +Make session-default hydration MCP-only. + +1. MCP runtime hydrates `config.sessionDefaults` into `sessionStore`. +2. CLI and daemon runtimes do not hydrate `sessionDefaults` into `sessionStore`. +3. CLI/daemon still read non-session config (workflow filters, debug flags, timeouts, etc.). +4. No CLI command/flag redesign is required. + +## Required Invariants + +1. CLI tool behavior must not depend on persisted `sessionDefaults`. +2. CLI behavior remains explicit-argument driven. +3. Existing runtime validation for required parameters remains intact. +4. `disableSessionDefaults=true` behavior for MCP tools remains consistent with current expectations. + +## Implementation Plan + +1. Gate session-default hydration by runtime in `bootstrapRuntime`. +2. Ensure daemon startup path also does not hydrate selector defaults. +3. Add tests that verify: + - MCP hydrates session defaults. + - CLI and daemon do not. +4. Keep all existing CLI validation paths and error messages unless a bug is found. + +## Validation Plan + +1. Unit/integration tests for runtime hydration boundaries. +2. CLI real-world test with persisted `sessionDefaults` present in config: + - missing required args should still fail. + - explicit args should succeed. +3. MCP real-world test confirming session-default convenience still works. + +--- + +## Domain 3: Non-iOS Simulator Platform Inference + +## Current State (verified) + +1. iOS paths are generally reliable. +2. Non-iOS simulator targets can infer wrong platform and fail destination matching. +3. Build settings lookups are slower and should not be the first-line source for simulator platform inference. + +## Decision + +Platform inference for simulator tools must use simulator metadata first, then fallback. + +1. Primary source: simulator runtime metadata (via `simctl` resolution). +2. Derived output: correct simulator platform string (`iOS/watchOS/tvOS/visionOS Simulator`). +3. Secondary source: build settings only when simulator metadata cannot resolve. +4. Final fallback: explicit warning + `iOS Simulator` only when no better signal exists. + +Cache policy: + +1. Persist `simulatorPlatform` as the cached output. +2. Recompute `simulatorPlatform` during MCP startup hydration. +3. Recompute `simulatorPlatform` whenever simulator selector (`simulatorId`/`simulatorName`) changes. +4. Do not persist `simulatorRuntime` or timestamp fields. + +## Required Invariants + +1. If runtime indicates non-iOS simulator, platform must not default to iOS. +2. Platform inference source should be logged for observability. +3. Selector normalization and platform inference should be reusable utilities, not tool-local variants. + +## Implementation Plan + +1. Introduce/standardize `inferPlatform(...)` utility contract around selector + runtime metadata. +2. Ensure simulator-name and simulator-id paths both resolve runtime/platform deterministically. +3. Add normalized mapping from CoreSimulator runtime to xcodebuild destination platform. +4. Use build-settings only as fallback path. + +## Validation Plan + +1. Unit tests for runtime-to-platform mapping. +2. Integration tests for iOS + non-iOS simulator selectors. +3. Real-world CLI/MCP checks for watchOS/tvOS/visionOS flows where available. + +--- + +## Cross-Cutting Architecture + +## Shared Helpers + +1. `inferSimulatorSelectorForTool(...)` + - Input: explicit params + stored defaults + tool capability (`requiresId` vs `acceptsNameOrId`). + - Output: exactly one effective selector (or deterministic validation error). +2. `inferPlatform(...)` + - Input: resolved selector + scheme/path context. + - Output: `{ platform, source, runtime? }`. + +## Data Model + +Keep/add simulator metadata fields in session/config: + +1. `simulatorId` +2. `simulatorName` +3. `simulatorPlatform` (optional cache) + +`simulatorRuntime` can still be used transiently inside resolver/helper logic, but is not persisted. + +## Runtime Boundary Rules + +1. MCP: may hydrate/use session defaults. +2. CLI: no session-default hydration; explicit invocation only. +3. Daemon (CLI backend): same as CLI for hydration semantics. + +--- + +## Delivery Plan + +## Phase 0: Lock Decisions + +1. Approve this document’s three-domain decisions. +2. Confirm no CLI UX changes are in scope. + +## Phase 1: Runtime Boundary Fix (Domain 2) + +1. Implement MCP-only session-default hydration. +2. Add runtime boundary tests. + +## Phase 2: Selector Normalization (Domain 1) + +1. Implement shared selector helper. +2. Align config/session behavior to preserve both selector fields. +3. Remove path-specific inconsistencies. + +## Phase 3: Platform Inference Hardening (Domain 3) + +1. Consolidate non-iOS platform inference on simulator metadata. +2. Add mapping tests and fallback tests. + +## Phase 4: Regression + Real-World Validation + +1. Run project test/lint/typecheck/build pipeline. +2. Run real-world MCP and CLI smoke tests for selector + platform behavior. +3. Document outcomes in PR notes. + +--- + +## Risks and Mitigations + +1. Risk: duplicate validation behavior drifts across tools. + - Mitigation: central helper + contract tests. +2. Risk: config backward-compat surprises. + - Mitigation: additive fields and migration-safe parsing. +3. Risk: non-iOS paths regress silently. + - Mitigation: explicit non-iOS test coverage and runtime-source logging. + +## Out of Scope + +1. Redesigning CLI command structure or argument names. +2. Changing general daemon stateful behavior. +3. Introducing auto-retry heuristics on command failure. diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 24b70374..16cb70e1 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -71,8 +71,8 @@ describe('session-set-defaults tool', () => { const current = sessionStore.getAll(); expect(current.scheme).toBe('MyScheme'); expect(current.simulatorName).toBe('iPhone 16'); - // simulatorId should be auto-resolved from simulatorName - expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); + // simulatorId resolution happens in background; immediate update keeps explicit inputs only + expect(current.simulatorId).toBeUndefined(); expect(current.useLatestOS).toBe(true); expect(current.arch).toBe('arm64'); }); @@ -115,28 +115,33 @@ describe('session-set-defaults tool', () => { ); }); - it('should clear simulatorName when simulatorId is explicitly set', async () => { - sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); - const result = await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }, createContext()); + it('should clear stale simulatorName when simulatorId is explicitly set', async () => { + sessionStore.setDefaults({ simulatorName: 'Old Name' }); + const result = await sessionSetDefaultsLogic( + { simulatorId: 'RESOLVED-SIM-UUID' }, + createContext(), + ); const current = sessionStore.getAll(); - expect(current.simulatorId).toBe('SIM-UUID'); + expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(current.simulatorName).toBeUndefined(); expect(result.content[0].text).toContain( - 'Cleared simulatorName because simulatorId was explicitly set.', + 'Cleared simulatorName because simulatorId changed; background resolution will repopulate it.', ); }); - it('should auto-resolve simulatorName to simulatorId when only simulatorName is set', async () => { + it('should clear stale simulatorId when only simulatorName is set', async () => { sessionStore.setDefaults({ simulatorId: 'OLD-SIM-UUID' }); const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }, createContext()); const current = sessionStore.getAll(); - // Both should be set now - name provided, id resolved + // simulatorId resolution happens in background; stale id is cleared immediately expect(current.simulatorName).toBe('iPhone 16'); - expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); - expect(result.content[0].text).toContain('Resolved simulatorName'); + expect(current.simulatorId).toBeUndefined(); + expect(result.content[0].text).toContain( + 'Cleared simulatorId because simulatorName changed; background resolution will repopulate it.', + ); }); - it('should return error when simulatorName cannot be resolved', async () => { + it('should not fail when simulatorName cannot be resolved immediately', async () => { const contextWithFailingExecutor = { executor: vi.fn().mockImplementation(async (command: string[]) => { if (command.includes('simctl') && command.includes('list')) { @@ -159,8 +164,8 @@ describe('session-set-defaults tool', () => { { simulatorName: 'NonExistentSimulator' }, contextWithFailingExecutor, ); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to resolve simulator name'); + expect(result.isError).toBe(false); + expect(sessionStore.getAll().simulatorName).toBe('NonExistentSimulator'); }); it('should prefer workspacePath when both projectPath and workspacePath are provided', async () => { @@ -222,7 +227,11 @@ describe('session-set-defaults tool', () => { await initConfigStore({ cwd, fs }); const result = await sessionSetDefaultsLogic( - { workspacePath: '/new/App.xcworkspace', simulatorId: 'SIM-1', persist: true }, + { + workspacePath: '/new/App.xcworkspace', + simulatorId: 'RESOLVED-SIM-UUID', + persist: true, + }, createContext(), ); @@ -235,8 +244,7 @@ describe('session-set-defaults tool', () => { }; expect(parsed.sessionDefaults?.workspacePath).toBe('/new/App.xcworkspace'); expect(parsed.sessionDefaults?.projectPath).toBeUndefined(); - expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); - // simulatorName is cleared because simulatorId was explicitly set + expect(parsed.sessionDefaults?.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index 771f676e..c70b979d 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -1,13 +1,13 @@ import * as z from 'zod'; import { persistSessionDefaultsPatch } from '../../../utils/config-store.ts'; import { removeUndefined } from '../../../utils/remove-undefined.ts'; +import { scheduleSimulatorDefaultsRefresh } from '../../../utils/simulator-defaults-refresh.ts'; import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; import { sessionDefaultsSchema } from '../../../utils/session-defaults-schema.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { resolveSimulatorNameToId } from '../../../utils/simulator-resolver.ts'; const schemaObj = sessionDefaultsSchema.extend({ persist: z @@ -53,32 +53,6 @@ export async function sessionSetDefaultsLogic( ); } - if (hasSimulatorId && hasSimulatorName) { - // Both provided - keep both, simulatorId takes precedence for tools - notices.push( - 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', - ); - } else if (hasSimulatorName && !hasSimulatorId) { - // Only simulatorName provided - resolve to simulatorId - const resolution = await resolveSimulatorNameToId(context.executor, nextParams.simulatorName!); - if (resolution.success) { - nextParams.simulatorId = resolution.simulatorId; - notices.push( - `Resolved simulatorName "${nextParams.simulatorName}" to simulatorId: ${resolution.simulatorId}`, - ); - } else { - return { - content: [ - { - type: 'text', - text: `Failed to resolve simulator name: ${resolution.error}`, - }, - ], - isError: true, - }; - } - } - // Clear mutually exclusive counterparts before merging new defaults const toClear = new Set(); if ( @@ -99,13 +73,38 @@ export async function sessionSetDefaultsLogic( notices.push('Cleared projectPath because workspacePath was set.'); } } - // Note: simulatorId/simulatorName are no longer mutually exclusive. - // When simulatorName is provided, we auto-resolve to simulatorId and keep both. - // Only clear simulatorName if simulatorId was explicitly provided without simulatorName. - if (hasSimulatorId && !hasSimulatorName) { + + const selectorProvided = hasSimulatorId || hasSimulatorName; + if (hasSimulatorId && hasSimulatorName) { + // Both provided - keep both, simulatorId takes precedence for tools + notices.push( + 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', + ); + } else if (hasSimulatorId && !hasSimulatorName) { toClear.add('simulatorName'); - if (current.simulatorName !== undefined) { - notices.push('Cleared simulatorName because simulatorId was explicitly set.'); + notices.push( + 'Cleared simulatorName because simulatorId changed; background resolution will repopulate it.', + ); + notices.push( + `Set simulatorId to "${nextParams.simulatorId}". Simulator name and platform refresh scheduled in background.`, + ); + } else if (hasSimulatorName && !hasSimulatorId) { + toClear.add('simulatorId'); + notices.push( + 'Cleared simulatorId because simulatorName changed; background resolution will repopulate it.', + ); + notices.push( + `Set simulatorName to "${nextParams.simulatorName}". Simulator ID and platform refresh scheduled in background.`, + ); + } + + if (selectorProvided) { + const selectorChanged = + (hasSimulatorId && nextParams.simulatorId !== current.simulatorId) || + (hasSimulatorName && nextParams.simulatorName !== current.simulatorName); + if (selectorChanged) { + toClear.add('simulatorPlatform'); + notices.push('Cleared simulatorPlatform because simulator selector changed.'); } } @@ -129,6 +128,20 @@ export async function sessionSetDefaultsLogic( } } + const revision = sessionStore.getRevision(); + if (selectorProvided) { + const defaultsForRefresh = sessionStore.getAll(); + scheduleSimulatorDefaultsRefresh({ + executor: context.executor, + expectedRevision: revision, + reason: 'session-set-defaults', + persist: Boolean(persist), + simulatorId: defaultsForRefresh.simulatorId, + simulatorName: defaultsForRefresh.simulatorName, + recomputePlatform: true, + }); + } + const updated = sessionStore.getAll(); const noticeText = notices.length > 0 ? `\nNotices:\n- ${notices.join('\n- ')}` : ''; return { 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 eba7fc24..4772a13c 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -55,13 +55,25 @@ describe('build_run_sim tool', () => { const mockExecutor: CommandExecutor = async (command) => { callCount++; if (callCount === 1) { - // First call: build succeeds + // First call: runtime lookup succeeds return createMockCommandResponse({ success: true, - output: 'BUILD SUCCEEDED', + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'SIM-UUID', name: 'iPhone 16', isAvailable: true }, + ], + }, + }), }); } 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', @@ -223,36 +235,34 @@ describe('build_run_sim tool', () => { }); describe('Command Generation', () => { - it('should generate correct simctl list command with minimal parameters', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); + const SIMCTL_LIST_COMMAND = ['xcrun', 'simctl', 'list', 'devices', 'available', '--json']; + + function createTrackingExecutor(callHistory: Array<{ command: string[]; logPrefix?: string }>) { + return async (command: string[], logPrefix?: string) => { + callHistory.push({ command, logPrefix }); return createMockCommandResponse({ success: false, output: '', error: 'Test error to stop execution early', }); }; + } - const result = await build_run_simLogic( + it('should generate correct simctl list command with minimal parameters', async () => { + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; + + await build_run_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate the initial build command - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + expect(callHistory).toHaveLength(2); + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -265,51 +275,38 @@ 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 () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; let callCount = 0; - // Create tracking executor that succeeds on first call (list) and fails on second - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); + const trackingExecutor: CommandExecutor = async (command, logPrefix) => { + callHistory.push({ command, logPrefix }); callCount++; if (callCount === 1) { - // First call: simulator list succeeds return createMockCommandResponse({ success: true, output: JSON.stringify({ devices: { - 'iOS 16.0': [ - { - udid: 'test-uuid-123', - name: 'iPhone 16', - state: 'Booted', - }, + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'test-uuid-123', name: 'iPhone 16', isAvailable: true }, ], }, }), - error: undefined, - }); - } else { - // Second call: build command fails to stop execution - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution', }); } + + return createMockCommandResponse({ + success: false, + output: '', + error: 'Test error to stop execution', + }); }; - const result = await build_run_simLogic( + await build_run_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -318,11 +315,9 @@ describe('build_run_sim tool', () => { trackingExecutor, ); - // Should generate build command and then build settings command expect(callHistory).toHaveLength(2); - - // First call: build command - expect(callHistory[0].command).toEqual([ + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -335,73 +330,44 @@ describe('build_run_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); - - // Second call: build settings command to get app path - expect(callHistory[1].command).toEqual([ - 'xcodebuild', - '-showBuildSettings', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-destination', - 'platform=iOS Simulator,name=iPhone 16,OS=latest', - ]); - 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 () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; let callCount = 0; - // Create tracking executor that succeeds on first two calls and fails on third - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); + const trackingExecutor: CommandExecutor = async (command, logPrefix) => { + callHistory.push({ command, logPrefix }); callCount++; if (callCount === 1) { - // First call: simulator list succeeds return createMockCommandResponse({ success: true, output: JSON.stringify({ devices: { - 'iOS 16.0': [ - { - udid: 'test-uuid-123', - name: 'iPhone 16', - state: 'Booted', - }, + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'test-uuid-123', name: 'iPhone 16', isAvailable: true }, ], }, }), - error: undefined, }); - } else if (callCount === 2) { - // Second call: build command succeeds + } + if (callCount === 2) { return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED', - error: undefined, - }); - } else { - // Third call: build settings command fails to stop execution - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution', }); } + + return createMockCommandResponse({ + success: false, + output: '', + error: 'Test error to stop execution', + }); }; - const result = await build_run_simLogic( + await build_run_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -412,11 +378,9 @@ describe('build_run_sim tool', () => { trackingExecutor, ); - // Should generate build command and build settings command - expect(callHistory).toHaveLength(2); - - // First call: build command - expect(callHistory[0].command).toEqual([ + expect(callHistory).toHaveLength(3); + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -429,10 +393,8 @@ describe('build_run_sim tool', () => { 'platform=iOS Simulator,name=iPhone 16', 'build', ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); - - // Second call: build settings command - expect(callHistory[1].command).toEqual([ + expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); + expect(callHistory[2].command).toEqual([ 'xcodebuild', '-showBuildSettings', '-workspace', @@ -444,39 +406,24 @@ 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 () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - const result = await build_run_simLogic( + await build_run_simLogic( { workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', scheme: 'My Scheme', simulatorName: 'iPhone 16 Pro', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate build command first - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + expect(callHistory).toHaveLength(2); + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/Users/dev/My Project/MyProject.xcworkspace', @@ -489,25 +436,11 @@ 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'); }); it('should infer tvOS platform from simulator name for build command', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; await build_run_simLogic( { @@ -515,11 +448,12 @@ describe('build_run_sim tool', () => { scheme: 'MyTVScheme', simulatorName: 'Apple TV 4K', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ + expect(callHistory).toHaveLength(2); + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].command).toEqual([ 'xcodebuild', '-workspace', '/path/to/MyProject.xcworkspace', @@ -532,7 +466,7 @@ describe('build_run_sim tool', () => { 'platform=tvOS Simulator,name=Apple TV 4K,OS=latest', 'build', ]); - expect(callHistory[0].logPrefix).toBe('tvOS Simulator Build'); + expect(callHistory[1].logPrefix).toBe('tvOS 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 38c00355..ad9b05d0 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -182,115 +182,96 @@ describe('build_sim tool', () => { }); describe('Command Generation', () => { - it('should generate correct build command with minimal parameters (workspace)', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); + const SIMCTL_LIST_COMMAND = ['xcrun', 'simctl', 'list', 'devices', 'available', '--json']; + + function createTrackingExecutor(callHistory: Array<{ command: string[]; logPrefix?: string }>) { + return async (command: string[], logPrefix?: string) => { + callHistory.push({ command, logPrefix }); return createMockCommandResponse({ success: false, output: '', error: 'Test error to stop execution early', }); }; + } + + function expectRuntimeLookupThenBuild( + callHistory: Array<{ command: string[]; logPrefix?: string }>, + expectedBuildCommand: string[], + expectedLogPrefix: string, + ) { + expect(callHistory).toHaveLength(2); + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].command).toEqual(expectedBuildCommand); + expect(callHistory[1].logPrefix).toBe(expectedLogPrefix); + } - const result = await build_simLogic( + it('should generate correct build command with minimal parameters (workspace)', async () => { + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; + + await build_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate one build command - expect(callHistory).toHaveLength(1); - 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'); + expectRuntimeLookupThenBuild( + callHistory, + [ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ], + 'iOS Simulator Build', + ); }); it('should generate correct build command with minimal parameters (project)', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - const result = await build_simLogic( + await build_simLogic( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate one build command - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ - 'xcodebuild', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=iOS Simulator,name=iPhone 16,OS=latest', - 'build', - ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expectRuntimeLookupThenBuild( + callHistory, + [ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ], + 'iOS Simulator Build', + ); }); it('should generate correct build command with all optional parameters', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - const result = await build_simLogic( + await build_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -300,137 +281,96 @@ describe('build_sim tool', () => { extraArgs: ['--verbose'], useLatestOS: false, }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate one build command with all parameters - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Release', - '-skipMacroValidation', - '-destination', - 'platform=iOS Simulator,name=iPhone 16', - '-derivedDataPath', - '/custom/derived/path', - '--verbose', - 'build', - ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expectRuntimeLookupThenBuild( + callHistory, + [ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16', + '-derivedDataPath', + '/custom/derived/path', + '--verbose', + 'build', + ], + 'iOS Simulator Build', + ); }); it('should handle paths with spaces in command generation', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - const result = await build_simLogic( + await build_simLogic( { workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', scheme: 'My Scheme', simulatorName: 'iPhone 16 Pro', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate one build command with paths containing spaces - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ - 'xcodebuild', - '-workspace', - '/Users/dev/My Project/MyProject.xcworkspace', - '-scheme', - 'My Scheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', - 'build', - ]); - expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + expectRuntimeLookupThenBuild( + callHistory, + [ + 'xcodebuild', + '-workspace', + '/Users/dev/My Project/MyProject.xcworkspace', + '-scheme', + 'My Scheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', + 'build', + ], + 'iOS Simulator Build', + ); }); it('should generate correct build command with useLatestOS set to true', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - // Create tracking executor - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - const result = await build_simLogic( + await build_simLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16', useLatestOS: true, }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - // Should generate one build command with OS=latest - expect(callHistory).toHaveLength(1); - 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'); + expectRuntimeLookupThenBuild( + callHistory, + [ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ], + 'iOS Simulator Build', + ); }); it('should infer watchOS platform from simulator name', async () => { - const callHistory: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - - const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => { - callHistory.push({ command, logPrefix, useShell, opts }); - return createMockCommandResponse({ - success: false, - output: '', - error: 'Test error to stop execution early', - }); - }; + const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; await build_simLogic( { @@ -438,24 +378,26 @@ describe('build_sim tool', () => { scheme: 'MyWatchScheme', simulatorName: 'Apple Watch Ultra 2', }, - trackingExecutor, + createTrackingExecutor(callHistory), ); - expect(callHistory).toHaveLength(1); - expect(callHistory[0].command).toEqual([ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyWatchScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=watchOS Simulator,name=Apple Watch Ultra 2,OS=latest', - 'build', - ]); - expect(callHistory[0].logPrefix).toBe('watchOS Simulator Build'); + expectRuntimeLookupThenBuild( + callHistory, + [ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyWatchScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=watchOS Simulator,name=Apple Watch Ultra 2,OS=latest', + 'build', + ], + 'watchOS Simulator Build', + ); }); }); diff --git a/src/runtime/__tests__/bootstrap-runtime.test.ts b/src/runtime/__tests__/bootstrap-runtime.test.ts new file mode 100644 index 00000000..b235a41d --- /dev/null +++ b/src/runtime/__tests__/bootstrap-runtime.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { bootstrapRuntime, type RuntimeKind } from '../bootstrap-runtime.ts'; +import { __resetConfigStoreForTests } from '../../utils/config-store.ts'; +import { sessionStore } from '../../utils/session-store.ts'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; + +const cwd = '/repo'; +const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); + +function createFsWithSessionDefaults() { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' scheme: "AppScheme"', + ' simulatorId: "SIM-UUID"', + ' simulatorName: "iPhone 16"', + '', + ].join('\n'); + + return createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + }); +} + +describe('bootstrapRuntime', () => { + beforeEach(() => { + __resetConfigStoreForTests(); + sessionStore.clear(); + }); + + it('hydrates session defaults for mcp runtime', async () => { + const result = await bootstrapRuntime({ + runtime: 'mcp', + cwd, + fs: createFsWithSessionDefaults(), + }); + + expect(result.runtime.config.sessionDefaults?.scheme).toBe('AppScheme'); + expect(sessionStore.getAll()).toMatchObject({ + scheme: 'AppScheme', + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + }); + }); + + it.each(['cli', 'daemon'] as const)( + 'does not hydrate session defaults for %s runtime', + async (runtime: RuntimeKind) => { + const result = await bootstrapRuntime({ runtime, cwd, fs: createFsWithSessionDefaults() }); + + expect(result.runtime.config.sessionDefaults?.scheme).toBe('AppScheme'); + expect(sessionStore.getAll()).toEqual({}); + }, + ); +}); diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 23417f1c..092a4be3 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -5,12 +5,11 @@ import { type RuntimeConfigOverrides, type ResolvedRuntimeConfig, } from '../utils/config-store.ts'; -import { sessionStore } from '../utils/session-store.ts'; +import { sessionStore, type SessionDefaults } from '../utils/session-store.ts'; import { getDefaultFileSystemExecutor } from '../utils/command.ts'; -import { getDefaultCommandExecutor } from '../utils/execution/index.ts'; import { log } from '../utils/logger.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; -import { resolveSimulatorNameToId } from '../utils/simulator-resolver.ts'; +import { scheduleSimulatorDefaultsRefresh } from '../utils/simulator-defaults-refresh.ts'; export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; @@ -34,6 +33,24 @@ export interface BootstrapRuntimeResult { notices: string[]; } +function hydrateSessionDefaultsForMcp(defaults: Partial | undefined): void { + const hydratedDefaults = { ...(defaults ?? {}) }; + if (Object.keys(hydratedDefaults).length === 0) { + return; + } + + sessionStore.setDefaults(hydratedDefaults); + const revision = sessionStore.getRevision(); + scheduleSimulatorDefaultsRefresh({ + expectedRevision: revision, + reason: 'startup-hydration', + persist: true, + simulatorId: hydratedDefaults.simulatorId, + simulatorName: hydratedDefaults.simulatorName, + recomputePlatform: true, + }); +} + export async function bootstrapRuntime( opts: BootstrapRuntimeOptions, ): Promise { @@ -55,26 +72,9 @@ export async function bootstrapRuntime( const config = getConfig(); - const defaults = { ...(config.sessionDefaults ?? {}) }; - if (Object.keys(defaults).length > 0) { - // Auto-resolve simulatorName to simulatorId if only name is provided - if (defaults.simulatorName && !defaults.simulatorId) { - const executor = getDefaultCommandExecutor(); - const resolution = await resolveSimulatorNameToId(executor, defaults.simulatorName); - if (resolution.success) { - defaults.simulatorId = resolution.simulatorId; - log( - 'info', - `Resolved simulatorName "${defaults.simulatorName}" to simulatorId: ${resolution.simulatorId}`, - ); - } else { - log( - 'warning', - `Failed to resolve simulatorName "${defaults.simulatorName}": ${resolution.error}`, - ); - } - } - sessionStore.setDefaults(defaults); + if (opts.runtime === 'mcp') { + hydrateSessionDefaultsForMcp(config.sessionDefaults); + log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); } return { diff --git a/src/utils/__tests__/infer-platform.test.ts b/src/utils/__tests__/infer-platform.test.ts index c7ffee6e..83d7784d 100644 --- a/src/utils/__tests__/infer-platform.test.ts +++ b/src/utils/__tests__/infer-platform.test.ts @@ -10,22 +10,125 @@ describe('inferPlatform', () => { sessionStore.clear(); }); - it('infers iOS from simulator name without calling external commands', async () => { + it('uses cached simulatorPlatform when selector matches session defaults', async () => { + sessionStore.setDefaults({ + simulatorId: 'SIM-UUID', + simulatorPlatform: XcodePlatform.tvOSSimulator, + }); + const executor = createMockExecutor(new Error('Executor should not be called')); - const result = await inferPlatform({ simulatorName: 'iPhone 16 Pro' }, executor); + const result = await inferPlatform({ simulatorId: 'SIM-UUID' }, executor); + + expect(result.platform).toBe(XcodePlatform.tvOSSimulator); + expect(result.source).toBe('simulator-platform-cache'); + }); + + it('ignores cached simulatorPlatform when explicit selector differs', async () => { + sessionStore.setDefaults({ + simulatorId: 'OLD-SIM-UUID', + simulatorPlatform: XcodePlatform.watchOSSimulator, + }); + + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + udid: 'SIM-UUID', + name: 'Apple TV', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await inferPlatform({ simulatorId: 'SIM-UUID' }, mockExecutor); + + expect(result.platform).toBe(XcodePlatform.tvOSSimulator); + expect(result.source).toBe('simulator-runtime'); + }); + + it('prefers simulator runtime metadata when simulatorName is provided', async () => { + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: 'SIM-UUID', + name: 'iPhone 16 Pro', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await inferPlatform({ simulatorName: 'iPhone 16 Pro' }, mockExecutor); expect(result.platform).toBe(XcodePlatform.iOSSimulator); - expect(result.source).toBe('simulator-name'); + expect(result.source).toBe('simulator-runtime'); }); - it('reads simulatorName from session defaults', async () => { + it('reads simulatorName from session defaults and prefers runtime metadata', async () => { sessionStore.setDefaults({ simulatorName: 'Apple Watch Ultra 2' }); - const executor = createMockExecutor(new Error('Executor should not be called')); - const result = await inferPlatform({}, executor); + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [ + { + udid: 'WATCH-UUID', + name: 'Apple Watch Ultra 2', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await inferPlatform({}, mockExecutor); expect(result.platform).toBe(XcodePlatform.watchOSSimulator); - expect(result.source).toBe('simulator-name'); + expect(result.source).toBe('simulator-runtime'); + }); + + it('does not let session simulatorName override an explicit simulatorId', async () => { + sessionStore.setDefaults({ simulatorName: 'Apple Watch Ultra 2' }); + + const mockExecutor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [ + { + udid: 'WATCH-UUID', + name: 'Apple Watch Ultra 2', + isAvailable: true, + }, + ], + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + udid: 'SIM-UUID', + name: 'Apple TV', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await inferPlatform({ simulatorId: 'SIM-UUID' }, mockExecutor); + + expect(result.platform).toBe(XcodePlatform.tvOSSimulator); + expect(result.source).toBe('simulator-runtime'); }); it('infers platform from simulator runtime when simulatorId is provided', async () => { diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index c0b7029f..df36e6a6 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -79,7 +79,7 @@ describe('project-config', () => { expect(defaults.workspacePath).toBe(path.join(cwd, 'App.xcworkspace')); expect(defaults.projectPath).toBeUndefined(); expect(defaults.simulatorId).toBe('SIM-1'); - expect(defaults.simulatorName).toBeUndefined(); + expect(defaults.simulatorName).toBe('iPhone 16'); expect(defaults.derivedDataPath).toBe(path.join(cwd, '.derivedData')); expect(result.notices.length).toBeGreaterThan(0); }); diff --git a/src/utils/infer-platform.ts b/src/utils/infer-platform.ts index 0f36502b..64a82844 100644 --- a/src/utils/infer-platform.ts +++ b/src/utils/infer-platform.ts @@ -6,6 +6,7 @@ import { detectPlatformFromScheme, type SimulatorPlatform } from './platform-det import { sessionStore, type SessionDefaults } from './session-store.ts'; type PlatformInferenceSource = + | 'simulator-platform-cache' | 'simulator-name' | 'simulator-runtime' | 'build-settings' @@ -25,6 +26,13 @@ export interface InferPlatformResult { source: PlatformInferenceSource; } +const SIMULATOR_PLATFORMS: readonly SimulatorPlatform[] = [ + XcodePlatform.iOSSimulator, + XcodePlatform.watchOSSimulator, + XcodePlatform.tvOSSimulator, + XcodePlatform.visionOSSimulator, +] as const; + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function inferPlatformFromSimulatorName(simulatorName: string): SimulatorPlatform | null { @@ -75,14 +83,70 @@ function inferPlatformFromRuntime(runtime: string): SimulatorPlatform | null { return null; } +function isSimulatorPlatform(value: unknown): value is SimulatorPlatform { + return SIMULATOR_PLATFORMS.includes(value as SimulatorPlatform); +} + +export function inferSimulatorSelectorForTool(params: { + simulatorId?: string; + simulatorName?: string; + sessionDefaults?: Partial; +}): { simulatorId?: string; simulatorName?: string } { + const defaults = params.sessionDefaults ?? sessionStore.getAll(); + + if (params.simulatorId) { + return { simulatorId: params.simulatorId }; + } + if (params.simulatorName) { + return { simulatorName: params.simulatorName }; + } + if (defaults.simulatorId) { + return { simulatorId: defaults.simulatorId }; + } + if (defaults.simulatorName) { + return { simulatorName: defaults.simulatorName }; + } + + return {}; +} + function resolveSimulatorsFromSession(params: InferPlatformParams): { simulatorId?: string; simulatorName?: string; } { + return inferSimulatorSelectorForTool({ + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + sessionDefaults: params.sessionDefaults, + }); +} + +function resolveCachedPlatform(params: InferPlatformParams): SimulatorPlatform | null { const defaults = params.sessionDefaults ?? sessionStore.getAll(); - const simulatorId = params.simulatorId ?? defaults.simulatorId; - const simulatorName = params.simulatorName ?? defaults.simulatorName; - return { simulatorId, simulatorName }; + if (!isSimulatorPlatform(defaults.simulatorPlatform)) { + return null; + } + + const hasExplicitId = Boolean(params.simulatorId); + const hasExplicitName = Boolean(params.simulatorName); + + if (!hasExplicitId && !hasExplicitName) { + return defaults.simulatorPlatform; + } + + if (hasExplicitId && defaults.simulatorId && params.simulatorId === defaults.simulatorId) { + return defaults.simulatorPlatform; + } + + if ( + hasExplicitName && + defaults.simulatorName && + params.simulatorName === defaults.simulatorName + ) { + return defaults.simulatorPlatform; + } + + return null; } function resolveProjectFromSession(params: InferPlatformParams): { @@ -91,9 +155,14 @@ function resolveProjectFromSession(params: InferPlatformParams): { scheme?: string; } { const defaults = params.sessionDefaults ?? sessionStore.getAll(); + const projectPath = + params.projectPath ?? (params.workspacePath ? undefined : defaults.projectPath); + const workspacePath = + params.workspacePath ?? (params.projectPath ? undefined : defaults.workspacePath); + return { - projectPath: params.projectPath ?? defaults.projectPath, - workspacePath: params.workspacePath ?? defaults.workspacePath, + projectPath, + workspacePath, scheme: params.scheme ?? defaults.scheme, }; } @@ -142,9 +211,13 @@ async function inferPlatformFromSimctl( isAvailable?: unknown; }; - const matchesId = typeof current.udid === 'string' && current.udid === simulatorId; - const matchesName = typeof current.name === 'string' && current.name === simulatorName; - if (!matchesId && !matchesName) continue; + if (simulatorId) { + const matchesId = typeof current.udid === 'string' && current.udid === simulatorId; + if (!matchesId) continue; + } else { + const matchesName = typeof current.name === 'string' && current.name === simulatorName; + if (!matchesName) continue; + } if (typeof current.isAvailable === 'boolean' && !current.isAvailable) continue; return inferPlatformFromRuntime(runtime); @@ -158,15 +231,13 @@ export async function inferPlatform( params: InferPlatformParams, executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise { - const { simulatorId, simulatorName } = resolveSimulatorsFromSession(params); - - if (simulatorName) { - const inferredFromName = inferPlatformFromSimulatorName(simulatorName); - if (inferredFromName) { - return { platform: inferredFromName, source: 'simulator-name' }; - } + const cachedPlatform = resolveCachedPlatform(params); + if (cachedPlatform) { + return { platform: cachedPlatform, source: 'simulator-platform-cache' }; } + const { simulatorId, simulatorName } = resolveSimulatorsFromSession(params); + let simulatorIdForLookup = simulatorId; let simulatorNameForLookup = simulatorName; if (!simulatorIdForLookup && simulatorName && UUID_REGEX.test(simulatorName)) { @@ -183,6 +254,13 @@ export async function inferPlatform( return { platform: inferredFromRuntime, source: 'simulator-runtime' }; } + if (simulatorNameForLookup) { + const inferredFromName = inferPlatformFromSimulatorName(simulatorNameForLookup); + if (inferredFromName) { + return { platform: inferredFromName, source: 'simulator-name' }; + } + } + const { projectPath, workspacePath, scheme } = resolveProjectFromSession(params); if (scheme && (projectPath || workspacePath)) { const detection = await detectPlatformFromScheme(projectPath, workspacePath, scheme, executor); diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index c45d6dbb..dd919ad5 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -68,8 +68,9 @@ function normalizeMutualExclusivity(defaults: Partial): { } if (hasValue(normalized, 'simulatorId') && hasValue(normalized, 'simulatorName')) { - delete normalized.simulatorName; - notices.push('Both simulatorId and simulatorName were provided; keeping simulatorId.'); + notices.push( + 'Both simulatorId and simulatorName were provided; storing both and preferring simulatorId when disambiguating.', + ); } return { normalized, notices }; diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts index e4819d9f..0383e86e 100644 --- a/src/utils/session-defaults-schema.ts +++ b/src/utils/session-defaults-schema.ts @@ -7,6 +7,7 @@ export const sessionDefaultKeys = [ 'configuration', 'simulatorName', 'simulatorId', + 'simulatorPlatform', 'deviceId', 'useLatestOS', 'arch', @@ -29,6 +30,10 @@ export const sessionDefaultsSchema = z.object({ .describe("Build configuration for Xcode and SwiftPM tools (e.g. 'Debug' or 'Release')."), simulatorName: z.string().optional(), simulatorId: z.string().optional(), + simulatorPlatform: z + .enum(['iOS Simulator', 'watchOS Simulator', 'tvOS Simulator', 'visionOS Simulator']) + .optional() + .describe('Cached inferred simulator platform.'), deviceId: z.string().optional(), useLatestOS: z.boolean().optional(), arch: z.enum(['arm64', 'x86_64']).optional(), diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 520e97e6..85ddf05c 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -7,6 +7,11 @@ export type SessionDefaults = { configuration?: string; simulatorName?: string; simulatorId?: string; + simulatorPlatform?: + | 'iOS Simulator' + | 'watchOS Simulator' + | 'tvOS Simulator' + | 'visionOS Simulator'; deviceId?: string; useLatestOS?: boolean; arch?: 'arm64' | 'x86_64'; @@ -19,15 +24,18 @@ export type SessionDefaults = { class SessionStore { private defaults: SessionDefaults = {}; + private revision = 0; setDefaults(partial: Partial): void { this.defaults = { ...this.defaults, ...partial }; + this.revision += 1; log('info', `[Session] Defaults updated: ${Object.keys(partial).join(', ')}`); } clear(keys?: (keyof SessionDefaults)[]): void { if (keys == null) { this.defaults = {}; + this.revision += 1; log('info', '[Session] All defaults cleared'); return; } @@ -37,6 +45,7 @@ class SessionStore { return; } for (const k of keys) delete this.defaults[k]; + this.revision += 1; log('info', `[Session] Defaults cleared: ${keys.join(', ')}`); } @@ -47,6 +56,18 @@ class SessionStore { getAll(): SessionDefaults { return { ...this.defaults }; } + + getRevision(): number { + return this.revision; + } + + setDefaultsIfRevision(partial: Partial, expectedRevision: number): boolean { + if (this.revision !== expectedRevision) { + return false; + } + this.setDefaults(partial); + return true; + } } export const sessionStore = new SessionStore(); diff --git a/src/utils/simulator-defaults-refresh.ts b/src/utils/simulator-defaults-refresh.ts new file mode 100644 index 00000000..c2b8c9e1 --- /dev/null +++ b/src/utils/simulator-defaults-refresh.ts @@ -0,0 +1,110 @@ +import { persistSessionDefaultsPatch } from './config-store.ts'; +import { getDefaultCommandExecutor, type CommandExecutor } from './execution/index.ts'; +import { inferPlatform } from './infer-platform.ts'; +import { log } from './logger.ts'; +import { resolveSimulatorIdToName, resolveSimulatorNameToId } from './simulator-resolver.ts'; +import { sessionStore, type SessionDefaults } from './session-store.ts'; + +type RefreshReason = 'startup-hydration' | 'session-set-defaults'; + +export interface ScheduleSimulatorDefaultsRefreshOptions { + executor?: CommandExecutor; + expectedRevision: number; + reason: RefreshReason; + persist?: boolean; + simulatorId?: string; + simulatorName?: string; + recomputePlatform?: boolean; +} + +function shouldSkipBackgroundRefresh(): boolean { + return process.env.NODE_ENV === 'test' || process.env.VITEST === 'true'; +} + +export function scheduleSimulatorDefaultsRefresh( + options: ScheduleSimulatorDefaultsRefreshOptions, +): void { + const hasSelector = options.simulatorId != null || options.simulatorName != null; + if (!hasSelector) { + return; + } + + if (shouldSkipBackgroundRefresh()) { + return; + } + + setTimeout(() => { + void refreshSimulatorDefaults(options); + }, 0); +} + +async function refreshSimulatorDefaults( + options: ScheduleSimulatorDefaultsRefreshOptions, +): Promise { + let simulatorId = options.simulatorId; + let simulatorName = options.simulatorName; + const patch: Partial = {}; + const executor = options.executor ?? getDefaultCommandExecutor(); + + try { + if (!simulatorId && simulatorName) { + const resolution = await resolveSimulatorNameToId(executor, simulatorName); + if (resolution.success) { + simulatorId = resolution.simulatorId; + patch.simulatorId = resolution.simulatorId; + } + } + + if (!simulatorName && simulatorId) { + const resolution = await resolveSimulatorIdToName(executor, simulatorId); + if (resolution.success) { + simulatorName = resolution.simulatorName; + patch.simulatorName = resolution.simulatorName; + } + } + + const shouldRecomputePlatform = options.recomputePlatform ?? true; + if (shouldRecomputePlatform && (simulatorId || simulatorName)) { + const inferred = await inferPlatform( + { + simulatorId, + simulatorName, + sessionDefaults: { + ...sessionStore.getAll(), + ...patch, + simulatorId, + simulatorName, + simulatorPlatform: undefined, + }, + }, + executor, + ); + + if (inferred.source !== 'default') { + patch.simulatorPlatform = inferred.platform; + } + } + + if (Object.keys(patch).length === 0) { + return; + } + + const applied = sessionStore.setDefaultsIfRevision(patch, options.expectedRevision); + if (!applied) { + log( + 'info', + `[Session] Skipped background simulator defaults refresh (${options.reason}) because defaults changed during refresh.`, + ); + return; + } + + if (options.persist) { + await persistSessionDefaultsPatch({ patch }); + } + } catch (error) { + log( + 'warning', + `[Session] Background simulator defaults refresh failed (${options.reason}): ${String(error)}`, + ); + } +} diff --git a/src/utils/simulator-resolver.ts b/src/utils/simulator-resolver.ts index e24db724..e0e87f57 100644 --- a/src/utils/simulator-resolver.ts +++ b/src/utils/simulator-resolver.ts @@ -65,6 +65,61 @@ export async function resolveSimulatorNameToId( }; } +/** + * Resolves a simulator UUID to its name by querying simctl. + * + * @param executor - Command executor for running simctl + * @param simulatorId - The simulator UUID + * @returns Resolution result with simulatorName on success, or error message on failure + */ +export async function resolveSimulatorIdToName( + executor: CommandExecutor, + simulatorId: string, +): Promise { + log('info', `Looking up simulator by UUID: ${simulatorId}`); + + const result = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List Simulators', + false, + ); + + if (!result.success) { + return { + success: false, + error: `Failed to list simulators: ${result.error}`, + }; + } + + let simulatorsData: { devices: Record> }; + try { + simulatorsData = JSON.parse(result.output) as typeof simulatorsData; + } catch (parseError) { + return { + success: false, + error: `Failed to parse simulator list: ${parseError}`, + }; + } + + for (const runtime in simulatorsData.devices) { + const devices = simulatorsData.devices[runtime]; + const simulator = devices.find((device) => device.udid === simulatorId); + if (simulator) { + log('info', `Resolved simulator UUID "${simulatorId}" to name: ${simulator.name}`); + return { + success: true, + simulatorId: simulator.udid, + simulatorName: simulator.name, + }; + } + } + + return { + success: false, + error: `Simulator UUID "${simulatorId}" not found. Use list_sims to see available simulators.`, + }; +} + /** * Helper to resolve simulatorId from either simulatorId or simulatorName. * If simulatorId is provided, returns it directly. From a32ed1a853bdccd5f0830c90f4df241127ee364e Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 12:09:40 +0000 Subject: [PATCH 3/7] docs(tools): Regenerate tool reference docs --- docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 43e479b4..a1eb08c8 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -187,4 +187,4 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T11:11:49.328Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T12:09:33.648Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index bb297815..71950ad7 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,4 +202,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T11:11:49.328Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T12:09:33.648Z UTC* From ea47350e42327ce5f7bacf73979ec9e9b4f72053 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 17:27:40 +0000 Subject: [PATCH 4/7] fix(build): normalize relative Xcode paths before execution Resolve project, workspace, and derived data paths to absolute paths before assembling xcodebuild commands so relative CLI inputs keep working when cwd changes. Refresh MCPTest integration fixture expectations to the current simulator UUID/name so parser and Xcode defaults sync tests remain deterministic. --- .../__tests__/sync_xcode_defaults.test.ts | 20 ++++----- src/utils/__tests__/build-utils.test.ts | 43 +++++++++++++++++++ .../__tests__/nskeyedarchiver-parser.test.ts | 2 +- .../__tests__/xcode-state-reader.test.ts | 14 +++--- src/utils/build-utils.ts | 30 +++++++++---- 5 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts index 117a101a..6d8d1e20 100644 --- a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts @@ -60,7 +60,7 @@ describe('sync_xcode_defaults tool', () => { const simctlOutput = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', name: 'iPhone 16 Pro' }, + { udid: 'CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', name: 'Apple Vision Pro' }, ], }, }); @@ -82,15 +82,15 @@ describe('sync_xcode_defaults tool', () => { expect(result.content[0].text).toContain('Synced session defaults from Xcode IDE'); expect(result.content[0].text).toContain('Scheme: MCPTest'); expect(result.content[0].text).toContain( - 'Simulator ID: E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', + 'Simulator ID: CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', ); - expect(result.content[0].text).toContain('Simulator Name: iPhone 16 Pro'); + expect(result.content[0].text).toContain('Simulator Name: Apple Vision Pro'); expect(result.content[0].text).toContain('Bundle ID: com.example.MCPTest'); const defaults = sessionStore.getAll(); expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); - expect(defaults.simulatorName).toBe('iPhone 16 Pro'); + expect(defaults.simulatorId).toBe('CE3C0D03-8F60-497A-A3B9-6A80BA997FC2'); + expect(defaults.simulatorName).toBe('Apple Vision Pro'); expect(defaults.bundleId).toBe('com.example.MCPTest'); }, ); @@ -99,7 +99,7 @@ describe('sync_xcode_defaults tool', () => { const simctlOutput = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', name: 'iPhone 16 Pro' }, + { udid: 'CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', name: 'Apple Vision Pro' }, ], }, }); @@ -125,7 +125,7 @@ describe('sync_xcode_defaults tool', () => { const defaults = sessionStore.getAll(); expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + expect(defaults.simulatorId).toBe('CE3C0D03-8F60-497A-A3B9-6A80BA997FC2'); expect(defaults.bundleId).toBe('com.example.MCPTest'); }); @@ -140,7 +140,7 @@ describe('sync_xcode_defaults tool', () => { const simctlOutput = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', name: 'iPhone 16 Pro' }, + { udid: 'CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', name: 'Apple Vision Pro' }, ], }, }); @@ -162,8 +162,8 @@ describe('sync_xcode_defaults tool', () => { const defaults = sessionStore.getAll(); expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); - expect(defaults.simulatorName).toBe('iPhone 16 Pro'); + expect(defaults.simulatorId).toBe('CE3C0D03-8F60-497A-A3B9-6A80BA997FC2'); + expect(defaults.simulatorName).toBe('Apple Vision Pro'); expect(defaults.bundleId).toBe('com.example.MCPTest'); // Original projectPath should be preserved expect(defaults.projectPath).toBe('/some/project.xcodeproj'); diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index 838576e8..a7fd73e9 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect } from 'vitest'; +import path from 'node:path'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; import { executeXcodeBuildCommand } from '../build-utils.ts'; import { XcodePlatform } from '../xcode.ts'; @@ -344,5 +345,47 @@ describe('build-utils Sentry Classification', () => { expect(capturedOptions.cwd).toBe('/path/to/project'); expect(capturedOptions.env).toEqual({ CUSTOM_VAR: 'value' }); }); + + it('should resolve relative project and derived data paths before execution', async () => { + let capturedOptions: unknown; + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + exitCode: 0, + onExecute: (command, _logPrefix, _useShell, opts) => { + capturedCommand = command; + capturedOptions = opts; + }, + }); + + const relativeProjectPath = 'example_projects/iOS/MCPTest.xcodeproj'; + const relativeDerivedDataPath = '.derivedData/e2e'; + const expectedProjectPath = path.resolve(relativeProjectPath); + const expectedDerivedDataPath = path.resolve(relativeDerivedDataPath); + + await executeXcodeBuildCommand( + { + scheme: 'TestScheme', + configuration: 'Debug', + projectPath: relativeProjectPath, + derivedDataPath: relativeDerivedDataPath, + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorName: 'iPhone 17 Pro', + useLatestOS: true, + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + mockExecutor, + ); + + expect(capturedCommand).toBeDefined(); + expect(capturedCommand).toContain(expectedProjectPath); + expect(capturedCommand).toContain(expectedDerivedDataPath); + expect(capturedOptions).toEqual({ cwd: path.dirname(expectedProjectPath) }); + }); }); }); diff --git a/src/utils/__tests__/nskeyedarchiver-parser.test.ts b/src/utils/__tests__/nskeyedarchiver-parser.test.ts index 13a62c11..6b04ef1b 100644 --- a/src/utils/__tests__/nskeyedarchiver-parser.test.ts +++ b/src/utils/__tests__/nskeyedarchiver-parser.test.ts @@ -18,7 +18,7 @@ const EXAMPLE_PROJECT_XCUSERSTATE = join( // Expected values for the MCPTest example project const EXPECTED_MCPTEST = { scheme: 'MCPTest', - simulatorId: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', + simulatorId: 'CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', simulatorPlatform: 'iphonesimulator', }; diff --git a/src/utils/__tests__/xcode-state-reader.test.ts b/src/utils/__tests__/xcode-state-reader.test.ts index 7960b386..956b33c2 100644 --- a/src/utils/__tests__/xcode-state-reader.test.ts +++ b/src/utils/__tests__/xcode-state-reader.test.ts @@ -244,8 +244,8 @@ describe('readXcodeIdeState integration', () => { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ { - udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', - name: 'iPhone 16 Pro', + udid: 'CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', + name: 'Apple Vision Pro', }, ], }, @@ -260,8 +260,8 @@ describe('readXcodeIdeState integration', () => { expect(result.error).toBeUndefined(); expect(result.scheme).toBe('MCPTest'); - expect(result.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); - expect(result.simulatorName).toBe('iPhone 16 Pro'); + expect(result.simulatorId).toBe('CE3C0D03-8F60-497A-A3B9-6A80BA997FC2'); + expect(result.simulatorName).toBe('Apple Vision Pro'); }, ); @@ -276,8 +276,8 @@ describe('readXcodeIdeState integration', () => { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ { - udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', - name: 'iPhone 16 Pro', + udid: 'CE3C0D03-8F60-497A-A3B9-6A80BA997FC2', + name: 'Apple Vision Pro', }, ], }, @@ -293,7 +293,7 @@ describe('readXcodeIdeState integration', () => { expect(result.error).toBeUndefined(); expect(result.scheme).toBe('MCPTest'); - expect(result.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + expect(result.simulatorId).toBe('CE3C0D03-8F60-497A-A3B9-6A80BA997FC2'); }, ); }); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index b786d2aa..34f480ce 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -33,6 +33,13 @@ import { import { sessionStore } from './session-store.ts'; import path from 'path'; +function resolvePathFromCwd(pathValue: string): string { + if (path.isAbsolute(pathValue)) { + return pathValue; + } + return path.resolve(process.cwd(), pathValue); +} + /** * Common function to execute an Xcode build command across platforms * @param params Common build parameters @@ -98,14 +105,21 @@ export async function executeXcodeBuildCommand( try { const command = ['xcodebuild']; + const workspacePath = params.workspacePath + ? resolvePathFromCwd(params.workspacePath) + : undefined; + const projectPath = params.projectPath ? resolvePathFromCwd(params.projectPath) : undefined; + const derivedDataPath = params.derivedDataPath + ? resolvePathFromCwd(params.derivedDataPath) + : undefined; let projectDir = ''; - if (params.workspacePath) { - projectDir = path.dirname(params.workspacePath); - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - projectDir = path.dirname(params.projectPath); - command.push('-project', params.projectPath); + if (workspacePath) { + projectDir = path.dirname(workspacePath); + command.push('-workspace', workspacePath); + } else if (projectPath) { + projectDir = path.dirname(projectPath); + command.push('-project', projectPath); } command.push('-scheme', params.scheme); @@ -179,8 +193,8 @@ export async function executeXcodeBuildCommand( command.push('-destination', destinationString); - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); + if (derivedDataPath) { + command.push('-derivedDataPath', derivedDataPath); } if (params.extraArgs && params.extraArgs.length > 0) { From e0238094482b476a2546c1c8c7f688261825b1d5 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 18:06:05 +0000 Subject: [PATCH 5/7] fix(simulator): Correct refresh signaling and selector notices Return explicit scheduling status from simulator defaults refresh so MCP bootstrap logs only claim refresh scheduling when one is actually queued. Tighten session default notices so cleared selector messages only appear when a counterpart existed, and changed messages only appear on actual selector changes. Also prefer workspace defaults over project defaults when both are present, remove an unnecessary infer-platform export, and drop emoji from simulator build/run user-facing success strings. Co-Authored-By: Codex --- .../__tests__/session_set_defaults.test.ts | 14 ++++++- .../session_set_defaults.ts | 40 ++++++++++++------- src/mcp/tools/simulator/build_run_sim.ts | 4 +- src/runtime/bootstrap-runtime.ts | 12 +++--- src/utils/__tests__/infer-platform.test.ts | 39 ++++++++++++++++++ src/utils/infer-platform.ts | 12 +++++- src/utils/simulator-defaults-refresh.ts | 8 ++-- 7 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 16cb70e1..f1cb8c3b 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -125,7 +125,7 @@ describe('session-set-defaults tool', () => { expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(current.simulatorName).toBeUndefined(); expect(result.content[0].text).toContain( - 'Cleared simulatorName because simulatorId changed; background resolution will repopulate it.', + 'Cleared simulatorName because simulatorId was set; background resolution will repopulate it.', ); }); @@ -137,10 +137,20 @@ describe('session-set-defaults tool', () => { expect(current.simulatorName).toBe('iPhone 16'); expect(current.simulatorId).toBeUndefined(); expect(result.content[0].text).toContain( - 'Cleared simulatorId because simulatorName changed; background resolution will repopulate it.', + 'Cleared simulatorId because simulatorName was set; background resolution will repopulate it.', ); }); + it('does not claim simulatorName was cleared when none existed', async () => { + sessionStore.setDefaults({ simulatorId: 'RESOLVED-SIM-UUID' }); + const result = await sessionSetDefaultsLogic( + { simulatorId: 'RESOLVED-SIM-UUID' }, + createContext(), + ); + + expect(result.content[0].text).not.toContain('Cleared simulatorName'); + }); + it('should not fail when simulatorName cannot be resolved immediately', async () => { const contextWithFailingExecutor = { executor: vi.fn().mockImplementation(async (command: string[]) => { diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index c70b979d..c55a43ba 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -75,6 +75,10 @@ export async function sessionSetDefaultsLogic( } const selectorProvided = hasSimulatorId || hasSimulatorName; + const simulatorIdChanged = hasSimulatorId && nextParams.simulatorId !== current.simulatorId; + const simulatorNameChanged = + hasSimulatorName && nextParams.simulatorName !== current.simulatorName; + if (hasSimulatorId && hasSimulatorName) { // Both provided - keep both, simulatorId takes precedence for tools notices.push( @@ -82,26 +86,32 @@ export async function sessionSetDefaultsLogic( ); } else if (hasSimulatorId && !hasSimulatorName) { toClear.add('simulatorName'); - notices.push( - 'Cleared simulatorName because simulatorId changed; background resolution will repopulate it.', - ); - notices.push( - `Set simulatorId to "${nextParams.simulatorId}". Simulator name and platform refresh scheduled in background.`, - ); + if (current.simulatorName !== undefined) { + notices.push( + 'Cleared simulatorName because simulatorId was set; background resolution will repopulate it.', + ); + } + if (simulatorIdChanged) { + notices.push( + `Set simulatorId to "${nextParams.simulatorId}". Simulator name and platform refresh scheduled in background.`, + ); + } } else if (hasSimulatorName && !hasSimulatorId) { toClear.add('simulatorId'); - notices.push( - 'Cleared simulatorId because simulatorName changed; background resolution will repopulate it.', - ); - notices.push( - `Set simulatorName to "${nextParams.simulatorName}". Simulator ID and platform refresh scheduled in background.`, - ); + if (current.simulatorId !== undefined) { + notices.push( + 'Cleared simulatorId because simulatorName was set; background resolution will repopulate it.', + ); + } + if (simulatorNameChanged) { + notices.push( + `Set simulatorName to "${nextParams.simulatorName}". Simulator ID and platform refresh scheduled in background.`, + ); + } } if (selectorProvided) { - const selectorChanged = - (hasSimulatorId && nextParams.simulatorId !== current.simulatorId) || - (hasSimulatorName && nextParams.simulatorName !== current.simulatorName); + const selectorChanged = simulatorIdChanged || simulatorNameChanged; if (selectorChanged) { toClear.add('simulatorPlatform'); notices.push('Cleared simulatorPlatform because simulator selector changed.'); diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 1f4e0b69..ed4126b9 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -468,7 +468,7 @@ export async function build_run_simLogic( } // --- Success --- - log('info', `✅ ${platformName} simulator build & run succeeded.`); + log('info', `${platformName} simulator build & run succeeded.`); const target = params.simulatorId ? `simulator UUID '${params.simulatorId}'` @@ -480,7 +480,7 @@ export async function build_run_simLogic( content: [ { type: 'text', - text: `✅ ${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the ${platformName} Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, + text: `${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the ${platformName} Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, }, ], nextSteps: [ diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 092a4be3..40808541 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -33,15 +33,18 @@ export interface BootstrapRuntimeResult { notices: string[]; } -function hydrateSessionDefaultsForMcp(defaults: Partial | undefined): void { +/** + * Returns true when defaults were hydrated and a background simulator refresh was scheduled. + */ +function hydrateSessionDefaultsForMcp(defaults: Partial | undefined): boolean { const hydratedDefaults = { ...(defaults ?? {}) }; if (Object.keys(hydratedDefaults).length === 0) { - return; + return false; } sessionStore.setDefaults(hydratedDefaults); const revision = sessionStore.getRevision(); - scheduleSimulatorDefaultsRefresh({ + return scheduleSimulatorDefaultsRefresh({ expectedRevision: revision, reason: 'startup-hydration', persist: true, @@ -72,8 +75,7 @@ export async function bootstrapRuntime( const config = getConfig(); - if (opts.runtime === 'mcp') { - hydrateSessionDefaultsForMcp(config.sessionDefaults); + if (opts.runtime === 'mcp' && hydrateSessionDefaultsForMcp(config.sessionDefaults)) { log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); } diff --git a/src/utils/__tests__/infer-platform.test.ts b/src/utils/__tests__/infer-platform.test.ts index 83d7784d..fa8c635f 100644 --- a/src/utils/__tests__/infer-platform.test.ts +++ b/src/utils/__tests__/infer-platform.test.ts @@ -195,6 +195,45 @@ describe('inferPlatform', () => { ]); }); + it('prefers workspace defaults when both projectPath and workspacePath are present in session defaults', async () => { + sessionStore.setDefaults({ + projectPath: '/tmp/Test.xcodeproj', + workspacePath: '/tmp/Test.xcworkspace', + scheme: 'WatchScheme', + }); + + const callHistory: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + callHistory.push(command); + + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ devices: {} }), + }); + } + + return createMockCommandResponse({ + success: true, + output: 'SDKROOT = watchsimulator', + }); + }; + + const result = await inferPlatform({ simulatorId: 'SIM-UUID' }, mockExecutor); + + expect(result.platform).toBe(XcodePlatform.watchOSSimulator); + expect(result.source).toBe('build-settings'); + expect(callHistory).toHaveLength(2); + expect(callHistory[1]).toEqual([ + 'xcodebuild', + '-showBuildSettings', + '-scheme', + 'WatchScheme', + '-workspace', + '/tmp/Test.xcworkspace', + ]); + }); + it('defaults to iOS when simulator and build-settings inference both fail', async () => { const mockExecutor: CommandExecutor = async (command) => { if (command[0] === 'xcrun') { diff --git a/src/utils/infer-platform.ts b/src/utils/infer-platform.ts index 64a82844..c96f4e76 100644 --- a/src/utils/infer-platform.ts +++ b/src/utils/infer-platform.ts @@ -87,7 +87,7 @@ function isSimulatorPlatform(value: unknown): value is SimulatorPlatform { return SIMULATOR_PLATFORMS.includes(value as SimulatorPlatform); } -export function inferSimulatorSelectorForTool(params: { +function inferSimulatorSelectorForTool(params: { simulatorId?: string; simulatorName?: string; sessionDefaults?: Partial; @@ -155,11 +155,21 @@ function resolveProjectFromSession(params: InferPlatformParams): { scheme?: string; } { const defaults = params.sessionDefaults ?? sessionStore.getAll(); + const hasExplicitProjectPath = params.projectPath !== undefined; + const hasExplicitWorkspacePath = params.workspacePath !== undefined; const projectPath = params.projectPath ?? (params.workspacePath ? undefined : defaults.projectPath); const workspacePath = params.workspacePath ?? (params.projectPath ? undefined : defaults.workspacePath); + if (projectPath && workspacePath && !hasExplicitProjectPath && !hasExplicitWorkspacePath) { + return { + projectPath: undefined, + workspacePath, + scheme: params.scheme ?? defaults.scheme, + }; + } + return { projectPath, workspacePath, diff --git a/src/utils/simulator-defaults-refresh.ts b/src/utils/simulator-defaults-refresh.ts index c2b8c9e1..e3d60b54 100644 --- a/src/utils/simulator-defaults-refresh.ts +++ b/src/utils/simulator-defaults-refresh.ts @@ -23,19 +23,21 @@ function shouldSkipBackgroundRefresh(): boolean { export function scheduleSimulatorDefaultsRefresh( options: ScheduleSimulatorDefaultsRefreshOptions, -): void { +): boolean { const hasSelector = options.simulatorId != null || options.simulatorName != null; if (!hasSelector) { - return; + return false; } if (shouldSkipBackgroundRefresh()) { - return; + return false; } setTimeout(() => { void refreshSimulatorDefaults(options); }, 0); + + return true; } async function refreshSimulatorDefaults( From bac5b5ad0bcf28d8b62a813000c613b8287cb977 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 18:51:24 +0000 Subject: [PATCH 6/7] fix(runtime): Separate MCP hydration and refresh scheduling state Return explicit hydration status so bootstrap logging reflects what happened. Log hydration even when background simulator refresh is not scheduled. Also remove a trivial simulator selector pass-through in infer-platform to reduce indirection, and add coverage for scheme-only defaults. Refs #206 Co-Authored-By: Codex --- .../__tests__/bootstrap-runtime.test.ts | 29 +++++++++++++++++++ src/runtime/bootstrap-runtime.ts | 29 +++++++++++++++---- src/utils/infer-platform.ts | 17 ++++------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/runtime/__tests__/bootstrap-runtime.test.ts b/src/runtime/__tests__/bootstrap-runtime.test.ts index b235a41d..e12a147d 100644 --- a/src/runtime/__tests__/bootstrap-runtime.test.ts +++ b/src/runtime/__tests__/bootstrap-runtime.test.ts @@ -29,6 +29,20 @@ function createFsWithSessionDefaults() { }); } +function createFsWithSchemeOnlySessionDefaults() { + const yaml = ['schemaVersion: 1', 'sessionDefaults:', ' scheme: "AppScheme"', ''].join('\n'); + + return createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + }); +} + describe('bootstrapRuntime', () => { beforeEach(() => { __resetConfigStoreForTests(); @@ -50,6 +64,21 @@ describe('bootstrapRuntime', () => { }); }); + it('hydrates non-simulator session defaults for mcp runtime', async () => { + const result = await bootstrapRuntime({ + runtime: 'mcp', + cwd, + fs: createFsWithSchemeOnlySessionDefaults(), + }); + + expect(result.runtime.config.sessionDefaults?.scheme).toBe('AppScheme'); + expect(sessionStore.getAll()).toMatchObject({ + scheme: 'AppScheme', + }); + expect(sessionStore.getAll().simulatorId).toBeUndefined(); + expect(sessionStore.getAll().simulatorName).toBeUndefined(); + }); + it.each(['cli', 'daemon'] as const)( 'does not hydrate session defaults for %s runtime', async (runtime: RuntimeKind) => { diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 40808541..cb6040ef 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -33,18 +33,25 @@ export interface BootstrapRuntimeResult { notices: string[]; } +interface MCPSessionHydrationResult { + hydrated: boolean; + refreshScheduled: boolean; +} + /** - * Returns true when defaults were hydrated and a background simulator refresh was scheduled. + * Hydrates MCP session defaults and reports whether a background simulator refresh was scheduled. */ -function hydrateSessionDefaultsForMcp(defaults: Partial | undefined): boolean { +function hydrateSessionDefaultsForMcp( + defaults: Partial | undefined, +): MCPSessionHydrationResult { const hydratedDefaults = { ...(defaults ?? {}) }; if (Object.keys(hydratedDefaults).length === 0) { - return false; + return { hydrated: false, refreshScheduled: false }; } sessionStore.setDefaults(hydratedDefaults); const revision = sessionStore.getRevision(); - return scheduleSimulatorDefaultsRefresh({ + const refreshScheduled = scheduleSimulatorDefaultsRefresh({ expectedRevision: revision, reason: 'startup-hydration', persist: true, @@ -52,6 +59,8 @@ function hydrateSessionDefaultsForMcp(defaults: Partial | undef simulatorName: hydratedDefaults.simulatorName, recomputePlatform: true, }); + + return { hydrated: true, refreshScheduled }; } export async function bootstrapRuntime( @@ -75,8 +84,16 @@ export async function bootstrapRuntime( const config = getConfig(); - if (opts.runtime === 'mcp' && hydrateSessionDefaultsForMcp(config.sessionDefaults)) { - log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); + if (opts.runtime === 'mcp') { + const hydration = hydrateSessionDefaultsForMcp(config.sessionDefaults); + if (hydration.hydrated && hydration.refreshScheduled) { + log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); + } else if (hydration.hydrated) { + log( + 'info', + '[Session] Hydrated MCP session defaults; simulator metadata refresh not scheduled.', + ); + } } return { diff --git a/src/utils/infer-platform.ts b/src/utils/infer-platform.ts index c96f4e76..c3d3cba5 100644 --- a/src/utils/infer-platform.ts +++ b/src/utils/infer-platform.ts @@ -110,17 +110,6 @@ function inferSimulatorSelectorForTool(params: { return {}; } -function resolveSimulatorsFromSession(params: InferPlatformParams): { - simulatorId?: string; - simulatorName?: string; -} { - return inferSimulatorSelectorForTool({ - simulatorId: params.simulatorId, - simulatorName: params.simulatorName, - sessionDefaults: params.sessionDefaults, - }); -} - function resolveCachedPlatform(params: InferPlatformParams): SimulatorPlatform | null { const defaults = params.sessionDefaults ?? sessionStore.getAll(); if (!isSimulatorPlatform(defaults.simulatorPlatform)) { @@ -246,7 +235,11 @@ export async function inferPlatform( return { platform: cachedPlatform, source: 'simulator-platform-cache' }; } - const { simulatorId, simulatorName } = resolveSimulatorsFromSession(params); + const { simulatorId, simulatorName } = inferSimulatorSelectorForTool({ + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + sessionDefaults: params.sessionDefaults, + }); let simulatorIdForLookup = simulatorId; let simulatorNameForLookup = simulatorName; From a0c10d0b59265635e34f9e90f6930d5e234b902a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 8 Feb 2026 19:00:47 +0000 Subject: [PATCH 7/7] fix(xcodemake): Avoid global cwd mutation during build setup Run xcodemake with per-process cwd instead of process.chdir to prevent cross-request working-directory races in concurrent execution. Add regression tests to verify executeXcodemakeCommand leaves process cwd unchanged and forwards cwd to the command executor. Refs #206 --- src/utils/__tests__/xcodemake.test.ts | 49 +++++++++++++++++++++++++++ src/utils/xcodemake.ts | 5 +-- 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/xcodemake.test.ts diff --git a/src/utils/__tests__/xcodemake.test.ts b/src/utils/__tests__/xcodemake.test.ts new file mode 100644 index 00000000..9ed1bdda --- /dev/null +++ b/src/utils/__tests__/xcodemake.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { executorMock } = vi.hoisted(() => ({ + executorMock: vi.fn(), +})); + +vi.mock('../command.ts', () => ({ + getDefaultCommandExecutor: () => executorMock, +})); + +import { executeXcodemakeCommand } from '../xcodemake.ts'; + +describe('executeXcodemakeCommand', () => { + beforeEach(() => { + executorMock.mockReset(); + }); + + it('runs xcodemake using child-process cwd without mutating process cwd', async () => { + const projectDir = '/tmp/project'; + const originalCwd = process.cwd(); + executorMock.mockResolvedValue({ success: true, output: 'ok' }); + + await executeXcodemakeCommand( + projectDir, + ['-scheme', 'App', '-project', '/tmp/project/App.xcodeproj'], + 'Build', + ); + + expect(executorMock).toHaveBeenCalledWith( + ['xcodemake', '-scheme', 'App', '-project', 'App.xcodeproj'], + 'Build', + false, + { cwd: projectDir }, + ); + expect(process.cwd()).toBe(originalCwd); + }); + + it('does not mutate process cwd when command execution fails', async () => { + const projectDir = '/tmp/project'; + const originalCwd = process.cwd(); + executorMock.mockRejectedValue(new Error('xcodemake failed')); + + await expect(executeXcodemakeCommand(projectDir, ['-scheme', 'App'], 'Build')).rejects.toThrow( + 'xcodemake failed', + ); + + expect(process.cwd()).toBe(originalCwd); + }); +}); diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index c4d4336f..d3c03be7 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -206,9 +206,6 @@ export async function executeXcodemakeCommand( buildArgs: string[], logPrefix: string, ): Promise { - // Change directory to project directory, this is needed for xcodemake to work - process.chdir(projectDir); - const xcodemakeCommand = [getXcodemakeCommand(), ...buildArgs]; // Remove projectDir from arguments if present at the start @@ -220,7 +217,7 @@ export async function executeXcodemakeCommand( return arg; }); - return getDefaultCommandExecutor()(command, logPrefix); + return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); } /**