diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index bcce8b30..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-07T22:29:44.282Z 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 d0599376..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-07T22:29:44.282Z 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/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..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 @@ -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,43 @@ 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 was set; 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 was set; background resolution will repopulate it.', + ); }); - it('should return error when simulatorName cannot be resolved', async () => { + 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[]) => { if (command.includes('simctl') && command.includes('list')) { @@ -159,8 +174,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 +237,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 +254,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..c55a43ba 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,48 @@ 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; + 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( + '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 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'); + 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 = simulatorIdChanged || simulatorNameChanged; + if (selectorChanged) { + toClear.add('simulatorPlatform'); + notices.push('Cleared simulatorPlatform because simulator selector changed.'); } } @@ -129,6 +138,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 827f17a9..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,7 +436,37 @@ 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 }> = []; + + await build_run_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyTVScheme', + simulatorName: 'Apple TV 4K', + }, + createTrackingExecutor(callHistory), + ); + + expect(callHistory).toHaveLength(2); + expect(callHistory[0].command).toEqual(SIMCTL_LIST_COMMAND); + expect(callHistory[1].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[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 895152bc..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,119 +281,123 @@ 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 }> = []; + + await build_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyWatchScheme', + simulatorName: 'Apple Watch Ultra 2', + }, + createTrackingExecutor(callHistory), + ); + + 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/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 5a61a234..ed4126b9 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/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/runtime/__tests__/bootstrap-runtime.test.ts b/src/runtime/__tests__/bootstrap-runtime.test.ts new file mode 100644 index 00000000..e12a147d --- /dev/null +++ b/src/runtime/__tests__/bootstrap-runtime.test.ts @@ -0,0 +1,91 @@ +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; + }, + }); +} + +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(); + 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('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) => { + 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..cb6040ef 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,36 @@ export interface BootstrapRuntimeResult { notices: string[]; } +interface MCPSessionHydrationResult { + hydrated: boolean; + refreshScheduled: boolean; +} + +/** + * Hydrates MCP session defaults and reports whether a background simulator refresh was scheduled. + */ +function hydrateSessionDefaultsForMcp( + defaults: Partial | undefined, +): MCPSessionHydrationResult { + const hydratedDefaults = { ...(defaults ?? {}) }; + if (Object.keys(hydratedDefaults).length === 0) { + return { hydrated: false, refreshScheduled: false }; + } + + sessionStore.setDefaults(hydratedDefaults); + const revision = sessionStore.getRevision(); + const refreshScheduled = scheduleSimulatorDefaultsRefresh({ + expectedRevision: revision, + reason: 'startup-hydration', + persist: true, + simulatorId: hydratedDefaults.simulatorId, + simulatorName: hydratedDefaults.simulatorName, + recomputePlatform: true, + }); + + return { hydrated: true, refreshScheduled }; +} + export async function bootstrapRuntime( opts: BootstrapRuntimeOptions, ): Promise { @@ -55,26 +84,16 @@ 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}`, - ); - } + 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.', + ); } - sessionStore.setDefaults(defaults); } return { 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__/infer-platform.test.ts b/src/utils/__tests__/infer-platform.test.ts new file mode 100644 index 00000000..fa8c635f --- /dev/null +++ b/src/utils/__tests__/infer-platform.test.ts @@ -0,0 +1,264 @@ +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('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({ 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-runtime'); + }); + + it('reads simulatorName from session defaults and prefers runtime metadata', 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, + }, + ], + }, + }), + }); + + const result = await inferPlatform({}, mockExecutor); + + expect(result.platform).toBe(XcodePlatform.watchOSSimulator); + 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 () => { + 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('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') { + 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__/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__/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/__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/__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/__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/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) { diff --git a/src/utils/infer-platform.ts b/src/utils/infer-platform.ts new file mode 100644 index 00000000..c3d3cba5 --- /dev/null +++ b/src/utils/infer-platform.ts @@ -0,0 +1,276 @@ +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-platform-cache' + | '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 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 { + 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 isSimulatorPlatform(value: unknown): value is SimulatorPlatform { + return SIMULATOR_PLATFORMS.includes(value as SimulatorPlatform); +} + +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 resolveCachedPlatform(params: InferPlatformParams): SimulatorPlatform | null { + const defaults = params.sessionDefaults ?? sessionStore.getAll(); + 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): { + projectPath?: string; + workspacePath?: string; + 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, + 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; + }; + + 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); + } + } + + return null; +} + +export async function inferPlatform( + params: InferPlatformParams, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + const cachedPlatform = resolveCachedPlatform(params); + if (cachedPlatform) { + return { platform: cachedPlatform, source: 'simulator-platform-cache' }; + } + + const { simulatorId, simulatorName } = inferSimulatorSelectorForTool({ + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + sessionDefaults: params.sessionDefaults, + }); + + 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' }; + } + + 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); + 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, + }; + } +} 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..e3d60b54 --- /dev/null +++ b/src/utils/simulator-defaults-refresh.ts @@ -0,0 +1,112 @@ +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, +): boolean { + const hasSelector = options.simulatorId != null || options.simulatorName != null; + if (!hasSelector) { + return false; + } + + if (shouldSkipBackgroundRefresh()) { + return false; + } + + setTimeout(() => { + void refreshSimulatorDefaults(options); + }, 0); + + return true; +} + +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. 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 }); } /**