Skip to content

Commit 059b91c

Browse files
authored
fix: handle iOS 26.0 simulators missing from list_sims output (#118)
* 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. * refactor: improve text parser robustness and test coverage - Replace fixed-width indentation (\s{4}) with flexible indentation (\s+) - Capture unavailable suffix in regex group instead of line.includes() - Add test for merge logic with text-only devices (core bug fix scenario) - Update resource test to handle new fallback behavior Addresses CodeRabbit review feedback on PR #118
1 parent 1beefab commit 059b91c

File tree

3 files changed

+288
-87
lines changed

3 files changed

+288
-87
lines changed

src/mcp/resources/__tests__/simulators.test.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,36 @@ describe('simulators resource', () => {
6565
expect(result.contents[0].text).toContain('Command failed');
6666
});
6767

68-
it('should handle JSON parsing errors', async () => {
69-
const mockExecutor = createMockExecutor({
70-
success: true,
71-
output: 'invalid json',
72-
});
68+
it('should handle JSON parsing errors and fall back to text parsing', async () => {
69+
const mockTextOutput = `== Devices ==
70+
-- iOS 17.0 --
71+
iPhone 15 (test-uuid-123) (Shutdown)`;
72+
73+
const mockExecutor = async (command: string[]) => {
74+
// JSON command returns invalid JSON
75+
if (command.includes('--json')) {
76+
return {
77+
success: true,
78+
output: 'invalid json',
79+
error: undefined,
80+
process: { pid: 12345 },
81+
};
82+
}
83+
84+
// Text command returns valid text output
85+
return {
86+
success: true,
87+
output: mockTextOutput,
88+
error: undefined,
89+
process: { pid: 12345 },
90+
};
91+
};
7392

7493
const result = await simulatorsResourceLogic(mockExecutor);
7594

7695
expect(result.contents).toHaveLength(1);
77-
expect(result.contents[0].text).toBe('invalid json');
96+
expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)');
97+
expect(result.contents[0].text).toContain('iOS 17.0');
7898
});
7999

80100
it('should handle spawn errors', async () => {

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

Lines changed: 153 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

@@ -142,6 +176,68 @@ Next Steps:
142176
iOS 17.0:
143177
- iPhone 15 (test-uuid-123) [Booted]
144178
179+
Next Steps:
180+
1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
181+
2. Open the simulator UI: open_sim({})
182+
3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
183+
4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
184+
},
185+
],
186+
});
187+
});
188+
189+
it('should merge devices from text that are missing from JSON', async () => {
190+
const mockJsonOutput = JSON.stringify({
191+
devices: {
192+
'iOS 18.6': [
193+
{
194+
name: 'iPhone 15',
195+
udid: 'json-uuid-123',
196+
isAvailable: true,
197+
state: 'Shutdown',
198+
},
199+
],
200+
},
201+
});
202+
203+
const mockTextOutput = `== Devices ==
204+
-- iOS 18.6 --
205+
iPhone 15 (json-uuid-123) (Shutdown)
206+
-- iOS 26.0 --
207+
iPhone 17 Pro (text-uuid-456) (Shutdown)`;
208+
209+
const mockExecutor = async (command: string[]) => {
210+
if (command.includes('--json')) {
211+
return {
212+
success: true,
213+
output: mockJsonOutput,
214+
error: undefined,
215+
process: { pid: 12345 },
216+
};
217+
}
218+
return {
219+
success: true,
220+
output: mockTextOutput,
221+
error: undefined,
222+
process: { pid: 12345 },
223+
};
224+
};
225+
226+
const result = await list_simsLogic({ enabled: true }, mockExecutor);
227+
228+
// Should contain both iOS 18.6 from JSON and iOS 26.0 from text
229+
expect(result).toEqual({
230+
content: [
231+
{
232+
type: 'text',
233+
text: `Available iOS Simulators:
234+
235+
iOS 18.6:
236+
- iPhone 15 (json-uuid-123)
237+
238+
iOS 26.0:
239+
- iPhone 17 Pro (text-uuid-456)
240+
145241
Next Steps:
146242
1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
147243
2. Open the simulator UI: open_sim({})
@@ -172,21 +268,48 @@ Next Steps:
172268
});
173269
});
174270

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-
});
271+
it('should handle JSON parse failure and fall back to text parsing', async () => {
272+
const mockTextOutput = `== Devices ==
273+
-- iOS 17.0 --
274+
iPhone 15 (test-uuid-456) (Shutdown)`;
275+
276+
const mockExecutor = async (command: string[]) => {
277+
// JSON command returns invalid JSON
278+
if (command.includes('--json')) {
279+
return {
280+
success: true,
281+
output: 'invalid json',
282+
error: undefined,
283+
process: { pid: 12345 },
284+
};
285+
}
286+
287+
// Text command returns valid text output
288+
return {
289+
success: true,
290+
output: mockTextOutput,
291+
error: undefined,
292+
process: { pid: 12345 },
293+
};
294+
};
182295

183296
const result = await list_simsLogic({ enabled: true }, mockExecutor);
184297

298+
// Should fall back to text parsing and extract devices
185299
expect(result).toEqual({
186300
content: [
187301
{
188302
type: 'text',
189-
text: 'invalid json',
303+
text: `Available iOS Simulators:
304+
305+
iOS 17.0:
306+
- iPhone 15 (test-uuid-456)
307+
308+
Next Steps:
309+
1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })
310+
2. Open the simulator UI: open_sim({})
311+
3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
312+
4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`,
190313
},
191314
],
192315
});

0 commit comments

Comments
 (0)