Skip to content

Commit 666ba0c

Browse files
committed
fix: handle iOS 26.0 simulators missing from list_sims output
Work around Apple simctl JSON bug where duplicate runtime IDs (multiple iOS 26.0 betas with same com.apple.CoreSimulator.SimRuntime.iOS-26-0 identifier) cause JSON output collision, leaving one runtime's devices as empty array. Solution: Hybrid parsing approach that combines JSON (primary) and text (fallback) outputs, merging results to capture all available simulators across all runtimes.
1 parent 1beefab commit 666ba0c

2 files changed

Lines changed: 200 additions & 81 deletions

File tree

src/mcp/tools/simulator/__tests__/list_sims.test.ts

Lines changed: 91 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('list_sims tool', () => {
4949

5050
describe('Handler Behavior (Complete Literal Returns)', () => {
5151
it('should handle successful simulator listing', async () => {
52-
const mockOutput = JSON.stringify({
52+
const mockJsonOutput = JSON.stringify({
5353
devices: {
5454
'iOS 17.0': [
5555
{
@@ -62,31 +62,51 @@ describe('list_sims tool', () => {
6262
},
6363
});
6464

65-
const mockExecutor = createMockExecutor({
66-
success: true,
67-
output: mockOutput,
68-
error: undefined,
69-
process: { pid: 12345 },
70-
});
65+
const mockTextOutput = `== Devices ==
66+
-- iOS 17.0 --
67+
iPhone 15 (test-uuid-123) (Shutdown)`;
7168

72-
// Track calls manually
73-
const wrappedExecutor = async (
69+
// Create a mock executor that returns different outputs based on command
70+
const mockExecutor = async (
7471
command: string[],
7572
logPrefix?: string,
7673
useShell?: boolean,
7774
env?: Record<string, string>,
7875
) => {
7976
callHistory.push({ command, logPrefix, useShell, env });
80-
return mockExecutor(command, logPrefix, useShell, env);
77+
78+
// Return JSON output for JSON command
79+
if (command.includes('--json')) {
80+
return {
81+
success: true,
82+
output: mockJsonOutput,
83+
error: undefined,
84+
process: { pid: 12345 },
85+
};
86+
}
87+
88+
// Return text output for text command
89+
return {
90+
success: true,
91+
output: mockTextOutput,
92+
error: undefined,
93+
process: { pid: 12345 },
94+
};
8195
};
8296

83-
const result = await list_simsLogic({ enabled: true }, wrappedExecutor);
97+
const result = await list_simsLogic({ enabled: true }, mockExecutor);
8498

85-
// Verify command was called correctly
86-
expect(callHistory).toHaveLength(1);
99+
// Verify both commands were called
100+
expect(callHistory).toHaveLength(2);
87101
expect(callHistory[0]).toEqual({
88-
command: ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'],
89-
logPrefix: 'List Simulators',
102+
command: ['xcrun', 'simctl', 'list', 'devices', '--json'],
103+
logPrefix: 'List Simulators (JSON)',
104+
useShell: true,
105+
env: undefined,
106+
});
107+
expect(callHistory[1]).toEqual({
108+
command: ['xcrun', 'simctl', 'list', 'devices'],
109+
logPrefix: 'List Simulators (Text)',
90110
useShell: true,
91111
env: undefined,
92112
});
@@ -111,7 +131,7 @@ Next Steps:
111131
});
112132

113133
it('should handle successful listing with booted simulator', async () => {
114-
const mockOutput = JSON.stringify({
134+
const mockJsonOutput = JSON.stringify({
115135
devices: {
116136
'iOS 17.0': [
117137
{
@@ -124,12 +144,26 @@ Next Steps:
124144
},
125145
});
126146

127-
const mockExecutor = createMockExecutor({
128-
success: true,
129-
output: mockOutput,
130-
error: undefined,
131-
process: { pid: 12345 },
132-
});
147+
const mockTextOutput = `== Devices ==
148+
-- iOS 17.0 --
149+
iPhone 15 (test-uuid-123) (Booted)`;
150+
151+
const mockExecutor = async (command: string[]) => {
152+
if (command.includes('--json')) {
153+
return {
154+
success: true,
155+
output: mockJsonOutput,
156+
error: undefined,
157+
process: { pid: 12345 },
158+
};
159+
}
160+
return {
161+
success: true,
162+
output: mockTextOutput,
163+
error: undefined,
164+
process: { pid: 12345 },
165+
};
166+
};
133167

134168
const result = await list_simsLogic({ enabled: true }, mockExecutor);
135169

@@ -172,21 +206,48 @@ Next Steps:
172206
});
173207
});
174208

175-
it('should handle JSON parse failure', async () => {
176-
const mockExecutor = createMockExecutor({
177-
success: true,
178-
output: 'invalid json',
179-
error: undefined,
180-
process: { pid: 12345 },
181-
});
209+
it('should handle JSON parse failure and fall back to text parsing', async () => {
210+
const mockTextOutput = `== Devices ==
211+
-- iOS 17.0 --
212+
iPhone 15 (test-uuid-456) (Shutdown)`;
213+
214+
const mockExecutor = async (command: string[]) => {
215+
// JSON command returns invalid JSON
216+
if (command.includes('--json')) {
217+
return {
218+
success: true,
219+
output: 'invalid json',
220+
error: undefined,
221+
process: { pid: 12345 },
222+
};
223+
}
224+
225+
// Text command returns valid text output
226+
return {
227+
success: true,
228+
output: mockTextOutput,
229+
error: undefined,
230+
process: { pid: 12345 },
231+
};
232+
};
182233

183234
const result = await list_simsLogic({ enabled: true }, mockExecutor);
184235

236+
// Should fall back to text parsing and extract devices
185237
expect(result).toEqual({
186238
content: [
187239
{
188240
type: 'text',
189-
text: 'invalid json',
241+
text: `Available iOS Simulators:
242+
243+
iOS 17.0:
244+
- iPhone 15 (test-uuid-456)
245+
246+
Next Steps:
247+
1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
248+
2. Open the simulator UI: open_sim({})
249+
3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
250+
4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
190251
},
191252
],
192253
});

src/mcp/tools/simulator/list_sims.ts

Lines changed: 109 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,50 @@ interface SimulatorDevice {
1818
udid: string;
1919
state: string;
2020
isAvailable: boolean;
21+
runtime?: string;
2122
}
2223

2324
interface SimulatorData {
2425
devices: Record<string, SimulatorDevice[]>;
2526
}
2627

28+
// Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs)
29+
function parseTextOutput(textOutput: string): SimulatorDevice[] {
30+
const devices: SimulatorDevice[] = [];
31+
const lines = textOutput.split('\n');
32+
let currentRuntime = '';
33+
34+
for (const line of lines) {
35+
// Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --"
36+
const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/);
37+
if (runtimeMatch) {
38+
currentRuntime = runtimeMatch[1];
39+
continue;
40+
}
41+
42+
// Match device lines like " iPhone 17 Pro (UUID) (Booted)"
43+
// UUID pattern is flexible to handle test UUIDs like "test-uuid-123"
44+
const deviceMatch = line.match(
45+
/^\s{4}(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(?:\s+\(unavailable.*\))?$/i,
46+
);
47+
if (deviceMatch && currentRuntime) {
48+
const [, name, udid, state] = deviceMatch;
49+
const isUnavailable = line.includes('unavailable');
50+
if (!isUnavailable) {
51+
devices.push({
52+
name: name.trim(),
53+
udid,
54+
state,
55+
isAvailable: true,
56+
runtime: currentRuntime,
57+
});
58+
}
59+
}
60+
}
61+
62+
return devices;
63+
}
64+
2765
function isSimulatorData(value: unknown): value is SimulatorData {
2866
if (!value || typeof value !== 'object') {
2967
return false;
@@ -68,79 +106,99 @@ export async function list_simsLogic(
68106
log('info', 'Starting xcrun simctl list devices request');
69107

70108
try {
71-
const command = ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'];
72-
const result = await executor(command, 'List Simulators', true);
109+
// Try JSON first for structured data
110+
const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json'];
111+
const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', true);
73112

74-
if (!result.success) {
113+
if (!jsonResult.success) {
75114
return {
76115
content: [
77116
{
78117
type: 'text',
79-
text: `Failed to list simulators: ${result.error}`,
118+
text: `Failed to list simulators: ${jsonResult.error}`,
80119
},
81120
],
82121
};
83122
}
84123

124+
// Parse JSON output
125+
let jsonDevices: Record<string, SimulatorDevice[]> = {};
85126
try {
86-
const parsedData: unknown = JSON.parse(result.output);
87-
88-
if (!isSimulatorData(parsedData)) {
89-
return {
90-
content: [
91-
{
92-
type: 'text',
93-
text: 'Failed to parse simulator data: Invalid format',
94-
},
95-
],
96-
};
127+
const parsedData: unknown = JSON.parse(jsonResult.output);
128+
if (isSimulatorData(parsedData)) {
129+
jsonDevices = parsedData.devices;
97130
}
131+
} catch {
132+
log('warn', 'Failed to parse JSON output, falling back to text parsing');
133+
}
98134

99-
const simulatorsData: SimulatorData = parsedData;
100-
let responseText = 'Available iOS Simulators:\n\n';
101-
102-
for (const runtime in simulatorsData.devices) {
103-
const devices = simulatorsData.devices[runtime];
135+
// Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta)
136+
const textCommand = ['xcrun', 'simctl', 'list', 'devices'];
137+
const textResult = await executor(textCommand, 'List Simulators (Text)', true);
104138

105-
if (devices.length === 0) continue;
139+
const textDevices = textResult.success ? parseTextOutput(textResult.output) : [];
106140

107-
responseText += `${runtime}:\n`;
141+
// Merge JSON and text devices, preferring JSON but adding any missing from text
142+
const allDevices: Record<string, SimulatorDevice[]> = { ...jsonDevices };
143+
const jsonUUIDs = new Set<string>();
108144

109-
for (const device of devices) {
110-
if (device.isAvailable) {
111-
responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`;
112-
}
145+
// Collect all UUIDs from JSON
146+
for (const runtime in jsonDevices) {
147+
for (const device of jsonDevices[runtime]) {
148+
if (device.isAvailable) {
149+
jsonUUIDs.add(device.udid);
113150
}
151+
}
152+
}
114153

115-
responseText += '\n';
154+
// Add devices from text that aren't in JSON (handles Apple's duplicate runtime ID bug)
155+
for (const textDevice of textDevices) {
156+
if (!jsonUUIDs.has(textDevice.udid)) {
157+
const runtime = textDevice.runtime ?? 'Unknown Runtime';
158+
if (!allDevices[runtime]) {
159+
allDevices[runtime] = [];
160+
}
161+
allDevices[runtime].push(textDevice);
162+
log(
163+
'info',
164+
`Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`,
165+
);
116166
}
167+
}
117168

118-
responseText += 'Next Steps:\n';
119-
responseText += "1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })\n";
120-
responseText += '2. Open the simulator UI: open_sim({})\n';
121-
responseText +=
122-
"3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n";
123-
responseText +=
124-
"4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })";
169+
// Format output
170+
let responseText = 'Available iOS Simulators:\n\n';
125171

126-
return {
127-
content: [
128-
{
129-
type: 'text',
130-
text: responseText,
131-
},
132-
],
133-
};
134-
} catch {
135-
return {
136-
content: [
137-
{
138-
type: 'text',
139-
text: result.output,
140-
},
141-
],
142-
};
172+
for (const runtime in allDevices) {
173+
const devices = allDevices[runtime].filter((d) => d.isAvailable);
174+
175+
if (devices.length === 0) continue;
176+
177+
responseText += `${runtime}:\n`;
178+
179+
for (const device of devices) {
180+
responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`;
181+
}
182+
183+
responseText += '\n';
143184
}
185+
186+
responseText += 'Next Steps:\n';
187+
responseText += "1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })\n";
188+
responseText += '2. Open the simulator UI: open_sim({})\n';
189+
responseText +=
190+
"3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n";
191+
responseText +=
192+
"4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })";
193+
194+
return {
195+
content: [
196+
{
197+
type: 'text',
198+
text: responseText,
199+
},
200+
],
201+
};
144202
} catch (error) {
145203
const errorMessage = error instanceof Error ? error.message : String(error);
146204
log('error', `Error listing simulators: ${errorMessage}`);

0 commit comments

Comments
 (0)