Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 91 additions & 30 deletions src/mcp/tools/simulator/__tests__/list_sims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
{
Expand All @@ -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<string, string>,
) => {
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,
});
Expand All @@ -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': [
{
Expand All @@ -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[]) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Mock Executor Signature Mismatch

The mock executors in list_simsLogic and simulatorsResourceLogic tests have a signature mismatch. They're defined to only accept command: string[], but the functions under test call the executor with additional parameters like logPrefix, useShell, and env. This divergence from the CommandExecutor interface could lead to unexpected test behavior or runtime errors.

Additional Locations (2)

Fix in Cursor Fix in Web

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);

Expand Down Expand Up @@ -172,21 +206,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' })`,
},
],
});
Expand Down
160 changes: 109 additions & 51 deletions src/mcp/tools/simulator/list_sims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,50 @@ interface SimulatorDevice {
udid: string;
state: string;
isAvailable: boolean;
runtime?: string;
}

interface SimulatorData {
devices: Record<string, SimulatorDevice[]>;
}

// 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{4}(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(?:\s+\(unavailable.*\))?$/i,
);
if (deviceMatch && currentRuntime) {
const [, name, udid, state] = deviceMatch;
const isUnavailable = line.includes('unavailable');
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;
Expand Down Expand Up @@ -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<string, SimulatorDevice[]> = {};
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<string, SimulatorDevice[]> = { ...jsonDevices };
const jsonUUIDs = new Set<string>();

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}`);
Expand Down