diff --git a/src/mcp/resources/__tests__/simulators.test.ts b/src/mcp/resources/__tests__/simulators.test.ts index 22aaf34f..31e8ce9f 100644 --- a/src/mcp/resources/__tests__/simulators.test.ts +++ b/src/mcp/resources/__tests__/simulators.test.ts @@ -65,16 +65,36 @@ describe('simulators resource', () => { expect(result.contents[0].text).toContain('Command failed'); }); - it('should handle JSON parsing errors', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'invalid json', - }); + it('should handle JSON parsing errors and fall back to text parsing', async () => { + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-123) (Shutdown)`; + + const mockExecutor = async (command: string[]) => { + // JSON command returns invalid JSON + if (command.includes('--json')) { + return { + success: true, + output: 'invalid json', + error: undefined, + process: { pid: 12345 }, + }; + } + + // Text command returns valid text output + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toBe('invalid json'); + expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)'); + expect(result.contents[0].text).toContain('iOS 17.0'); }); it('should handle spawn errors', async () => { diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index 00dfb84d..ccd1b3cd 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -49,7 +49,7 @@ describe('list_sims tool', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should handle successful simulator listing', async () => { - const mockOutput = JSON.stringify({ + const mockJsonOutput = JSON.stringify({ devices: { 'iOS 17.0': [ { @@ -62,31 +62,51 @@ describe('list_sims tool', () => { }, }); - const mockExecutor = createMockExecutor({ - success: true, - output: mockOutput, - error: undefined, - process: { pid: 12345 }, - }); + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-123) (Shutdown)`; - // Track calls manually - const wrappedExecutor = async ( + // Create a mock executor that returns different outputs based on command + const mockExecutor = async ( command: string[], logPrefix?: string, useShell?: boolean, env?: Record, ) => { callHistory.push({ command, logPrefix, useShell, env }); - return mockExecutor(command, logPrefix, useShell, env); + + // Return JSON output for JSON command + if (command.includes('--json')) { + return { + success: true, + output: mockJsonOutput, + error: undefined, + process: { pid: 12345 }, + }; + } + + // Return text output for text command + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; }; - const result = await list_simsLogic({ enabled: true }, wrappedExecutor); + const result = await list_simsLogic({ enabled: true }, mockExecutor); - // Verify command was called correctly - expect(callHistory).toHaveLength(1); + // Verify both commands were called + expect(callHistory).toHaveLength(2); expect(callHistory[0]).toEqual({ - command: ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], - logPrefix: 'List Simulators', + command: ['xcrun', 'simctl', 'list', 'devices', '--json'], + logPrefix: 'List Simulators (JSON)', + useShell: true, + env: undefined, + }); + expect(callHistory[1]).toEqual({ + command: ['xcrun', 'simctl', 'list', 'devices'], + logPrefix: 'List Simulators (Text)', useShell: true, env: undefined, }); @@ -111,7 +131,7 @@ Next Steps: }); it('should handle successful listing with booted simulator', async () => { - const mockOutput = JSON.stringify({ + const mockJsonOutput = JSON.stringify({ devices: { 'iOS 17.0': [ { @@ -124,12 +144,26 @@ Next Steps: }, }); - const mockExecutor = createMockExecutor({ - success: true, - output: mockOutput, - error: undefined, - process: { pid: 12345 }, - }); + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-123) (Booted)`; + + const mockExecutor = async (command: string[]) => { + if (command.includes('--json')) { + return { + success: true, + output: mockJsonOutput, + error: undefined, + process: { pid: 12345 }, + }; + } + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; const result = await list_simsLogic({ enabled: true }, mockExecutor); @@ -142,6 +176,68 @@ Next Steps: iOS 17.0: - iPhone 15 (test-uuid-123) [Booted] +Next Steps: +1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' }) +2. Open the simulator UI: open_sim({}) +3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, + }, + ], + }); + }); + + it('should merge devices from text that are missing from JSON', async () => { + const mockJsonOutput = JSON.stringify({ + devices: { + 'iOS 18.6': [ + { + name: 'iPhone 15', + udid: 'json-uuid-123', + isAvailable: true, + state: 'Shutdown', + }, + ], + }, + }); + + const mockTextOutput = `== Devices == +-- iOS 18.6 -- + iPhone 15 (json-uuid-123) (Shutdown) +-- iOS 26.0 -- + iPhone 17 Pro (text-uuid-456) (Shutdown)`; + + const mockExecutor = async (command: string[]) => { + if (command.includes('--json')) { + return { + success: true, + output: mockJsonOutput, + error: undefined, + process: { pid: 12345 }, + }; + } + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + // Should contain both iOS 18.6 from JSON and iOS 26.0 from text + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Available iOS Simulators: + +iOS 18.6: +- iPhone 15 (json-uuid-123) + +iOS 26.0: +- iPhone 17 Pro (text-uuid-456) + Next Steps: 1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' }) 2. Open the simulator UI: open_sim({}) @@ -172,21 +268,48 @@ Next Steps: }); }); - it('should handle JSON parse failure', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'invalid json', - error: undefined, - process: { pid: 12345 }, - }); + it('should handle JSON parse failure and fall back to text parsing', async () => { + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-456) (Shutdown)`; + + const mockExecutor = async (command: string[]) => { + // JSON command returns invalid JSON + if (command.includes('--json')) { + return { + success: true, + output: 'invalid json', + error: undefined, + process: { pid: 12345 }, + }; + } + + // Text command returns valid text output + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; const result = await list_simsLogic({ enabled: true }, mockExecutor); + // Should fall back to text parsing and extract devices expect(result).toEqual({ content: [ { type: 'text', - text: 'invalid json', + text: `Available iOS Simulators: + +iOS 17.0: +- iPhone 15 (test-uuid-456) + +Next Steps: +1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' }) +2. Open the simulator UI: open_sim({}) +3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, }, ], }); diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 6dfcd6e4..da74aea1 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -18,12 +18,50 @@ interface SimulatorDevice { udid: string; state: string; isAvailable: boolean; + runtime?: string; } interface SimulatorData { devices: Record; } +// Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs) +function parseTextOutput(textOutput: string): SimulatorDevice[] { + const devices: SimulatorDevice[] = []; + const lines = textOutput.split('\n'); + let currentRuntime = ''; + + for (const line of lines) { + // Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --" + const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/); + if (runtimeMatch) { + currentRuntime = runtimeMatch[1]; + continue; + } + + // Match device lines like " iPhone 17 Pro (UUID) (Booted)" + // UUID pattern is flexible to handle test UUIDs like "test-uuid-123" + const deviceMatch = line.match( + /^\s+(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(\s+\(unavailable.*\))?$/i, + ); + if (deviceMatch && currentRuntime) { + const [, name, udid, state, unavailableSuffix] = deviceMatch; + const isUnavailable = Boolean(unavailableSuffix); + if (!isUnavailable) { + devices.push({ + name: name.trim(), + udid, + state, + isAvailable: true, + runtime: currentRuntime, + }); + } + } + } + + return devices; +} + function isSimulatorData(value: unknown): value is SimulatorData { if (!value || typeof value !== 'object') { return false; @@ -68,79 +106,99 @@ export async function list_simsLogic( log('info', 'Starting xcrun simctl list devices request'); try { - const command = ['xcrun', 'simctl', 'list', 'devices', 'available', '--json']; - const result = await executor(command, 'List Simulators', true); + // Try JSON first for structured data + const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json']; + const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', true); - if (!result.success) { + if (!jsonResult.success) { return { content: [ { type: 'text', - text: `Failed to list simulators: ${result.error}`, + text: `Failed to list simulators: ${jsonResult.error}`, }, ], }; } + // Parse JSON output + let jsonDevices: Record = {}; try { - const parsedData: unknown = JSON.parse(result.output); - - if (!isSimulatorData(parsedData)) { - return { - content: [ - { - type: 'text', - text: 'Failed to parse simulator data: Invalid format', - }, - ], - }; + const parsedData: unknown = JSON.parse(jsonResult.output); + if (isSimulatorData(parsedData)) { + jsonDevices = parsedData.devices; } + } catch { + log('warn', 'Failed to parse JSON output, falling back to text parsing'); + } - const simulatorsData: SimulatorData = parsedData; - let responseText = 'Available iOS Simulators:\n\n'; - - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; + // Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta) + const textCommand = ['xcrun', 'simctl', 'list', 'devices']; + const textResult = await executor(textCommand, 'List Simulators (Text)', true); - if (devices.length === 0) continue; + const textDevices = textResult.success ? parseTextOutput(textResult.output) : []; - responseText += `${runtime}:\n`; + // Merge JSON and text devices, preferring JSON but adding any missing from text + const allDevices: Record = { ...jsonDevices }; + const jsonUUIDs = new Set(); - for (const device of devices) { - if (device.isAvailable) { - responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; - } + // Collect all UUIDs from JSON + for (const runtime in jsonDevices) { + for (const device of jsonDevices[runtime]) { + if (device.isAvailable) { + jsonUUIDs.add(device.udid); } + } + } - responseText += '\n'; + // Add devices from text that aren't in JSON (handles Apple's duplicate runtime ID bug) + for (const textDevice of textDevices) { + if (!jsonUUIDs.has(textDevice.udid)) { + const runtime = textDevice.runtime ?? 'Unknown Runtime'; + if (!allDevices[runtime]) { + allDevices[runtime] = []; + } + allDevices[runtime].push(textDevice); + log( + 'info', + `Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`, + ); } + } - responseText += 'Next Steps:\n'; - responseText += "1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })\n"; - responseText += '2. Open the simulator UI: open_sim({})\n'; - responseText += - "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n"; - responseText += - "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })"; + // Format output + let responseText = 'Available iOS Simulators:\n\n'; - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - }; - } catch { - return { - content: [ - { - type: 'text', - text: result.output, - }, - ], - }; + for (const runtime in allDevices) { + const devices = allDevices[runtime].filter((d) => d.isAvailable); + + if (devices.length === 0) continue; + + responseText += `${runtime}:\n`; + + for (const device of devices) { + responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; + } + + responseText += '\n'; } + + responseText += 'Next Steps:\n'; + responseText += "1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })\n"; + responseText += '2. Open the simulator UI: open_sim({})\n'; + responseText += + "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n"; + responseText += + "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })"; + + return { + content: [ + { + type: 'text', + text: responseText, + }, + ], + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error listing simulators: ${errorMessage}`);