Skip to content

Commit 4843100

Browse files
committed
feat: changed simulators into an array, skip tests if xcode isn't already installed and add check if xcode is installed inside the create
1 parent bc202f1 commit 4843100

3 files changed

Lines changed: 198 additions & 128 deletions

File tree

docs/resources/(resources)/ios-simulator.mdx

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,34 @@ description: A reference page for the ios-simulator resource
44
---
55

66
The ios-simulator resource manages iOS (and iPadOS/watchOS/tvOS/visionOS) simulator instances on macOS
7-
using `xcrun simctl`. Each resource declaration represents one simulator. You can declare multiple
8-
`ios-simulator` resources to create a full testing matrix across device types and OS versions.
9-
10-
Simulators are created with the specified device type and runtime, and optionally booted. Removing a
11-
resource deletes the simulator from the system. Xcode Command Line Tools must be installed — add an
7+
using `xcrun simctl`. A single resource declaration manages a list of simulators, making it easy to
8+
define a full testing matrix across device types and OS versions in one place. Simulators are created
9+
with the specified device type and runtime, and optionally booted. Removing the resource deletes all
10+
declared simulators from the system. Xcode Command Line Tools must be installed — add an
1211
`xcode-tools` resource as a dependency if you are not sure they are present.
1312

1413
## Parameters:
1514

16-
- **simulatorName** *(string, required)* — Human-readable name for the simulator instance (e.g. `"iPhone 15 Dev"`). Must be unique across your declared simulators.
17-
18-
- **deviceType** *(string, required)* — CoreSimulator device type identifier. Use the format `com.apple.CoreSimulator.SimDeviceType.<Device>`. Run `xcrun simctl list devicetypes` to see identifiers available on your machine.
19-
20-
- **runtime** *(string, required)* — CoreSimulator runtime identifier. Use the format `com.apple.CoreSimulator.SimRuntime.<Platform>-<Version>`. Run `xcrun simctl list runtimes` to see installed runtimes.
21-
22-
- **state** *(string, optional)* — Desired runtime state of the simulator. One of `"Booted"` or `"Shutdown"`. Defaults to `"Shutdown"`. Can be modified after creation.
15+
- **simulators** *(object[], optional)* — List of simulators to create and manage. Each entry has:
16+
- **name** *(string, required)* — Human-readable name for the simulator instance (e.g. `"iPhone 15 Dev"`). Must be unique across your declared simulators.
17+
- **deviceType** *(string, required)* — CoreSimulator device type identifier. Use the format `com.apple.CoreSimulator.SimDeviceType.<Device>`. Run `xcrun simctl list devicetypes` to see identifiers available on your machine.
18+
- **runtime** *(string, required)* — CoreSimulator runtime identifier. Use the format `com.apple.CoreSimulator.SimRuntime.<Platform>-<Version>`. Run `xcrun simctl list runtimes` to see installed runtimes.
19+
- **state** *(string, optional)* — Desired runtime state of the simulator. One of `"Booted"` or `"Shutdown"`. Defaults to `"Shutdown"`. Can be modified after creation.
2320

2421
## Example usage:
2522

2623
```json title="codify.jsonc"
2724
[
2825
{
2926
"type": "ios-simulator",
30-
"simulatorName": "iPhone 15 Dev",
31-
"deviceType": "com.apple.CoreSimulator.SimDeviceType.iPhone-15",
32-
"runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
33-
"state": "Shutdown",
27+
"simulators": [
28+
{
29+
"name": "iPhone 15 Dev",
30+
"deviceType": "com.apple.CoreSimulator.SimDeviceType.iPhone-15",
31+
"runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
32+
"state": "Shutdown"
33+
}
34+
],
3435
"os": ["macOS"]
3536
}
3637
]
@@ -44,18 +45,20 @@ resource deletes the simulator from the system. Xcode Command Line Tools must be
4445
},
4546
{
4647
"type": "ios-simulator",
47-
"simulatorName": "iPhone 15 Pro",
48-
"deviceType": "com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro",
49-
"runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
50-
"state": "Shutdown",
51-
"os": ["macOS"]
52-
},
53-
{
54-
"type": "ios-simulator",
55-
"simulatorName": "iPad Pro 11-inch",
56-
"deviceType": "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4",
57-
"runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
58-
"state": "Shutdown",
48+
"simulators": [
49+
{
50+
"name": "iPhone 15 Pro",
51+
"deviceType": "com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro",
52+
"runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
53+
"state": "Shutdown"
54+
},
55+
{
56+
"name": "iPad Pro 11-inch",
57+
"deviceType": "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4",
58+
"runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0",
59+
"state": "Shutdown"
60+
}
61+
],
5962
"os": ["macOS"]
6063
}
6164
]

src/resources/ios/ios-simulator/ios-simulator.ts

Lines changed: 141 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,23 @@ import {
1212
} from '@codifycli/plugin-core';
1313
import { OS } from '@codifycli/schemas';
1414

15-
const schema = z.object({
16-
simulatorName: z
17-
.string()
18-
.describe('Name for the iOS simulator instance (e.g. "iPhone 15 Dev")'),
19-
deviceType: z
20-
.string()
21-
.describe('Device type identifier (e.g. "com.apple.CoreSimulator.SimDeviceType.iPhone-15")'),
22-
runtime: z
23-
.string()
24-
.describe('Runtime identifier (e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-0")'),
15+
const simulatorSchema = z.object({
16+
name: z.string().describe('Name for the simulator instance (e.g. "iPhone 15 Dev")'),
17+
deviceType: z.string().describe('Device type identifier (e.g. "com.apple.CoreSimulator.SimDeviceType.iPhone-15")'),
18+
runtime: z.string().describe('Runtime identifier (e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-0")'),
2519
state: z
2620
.enum(['Booted', 'Shutdown'])
2721
.optional()
28-
.describe('Desired runtime state of the simulator. Defaults to Shutdown.'),
22+
.describe('Desired runtime state. Defaults to Shutdown.'),
23+
});
24+
25+
export type SimulatorDeclaration = z.infer<typeof simulatorSchema>;
26+
27+
const schema = z.object({
28+
simulators: z
29+
.array(simulatorSchema)
30+
.optional()
31+
.describe('List of iOS simulators to create and manage.'),
2932
});
3033

3134
export type IosSimulatorConfig = z.infer<typeof schema>;
@@ -42,10 +45,7 @@ interface SimctlDevicesOutput {
4245
}
4346

4447
const defaultConfig: Partial<IosSimulatorConfig> & { os: any } = {
45-
simulatorName: '<Replace me here!>',
46-
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
47-
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
48-
state: 'Shutdown',
48+
simulators: [],
4949
os: ['macOS'],
5050
};
5151

@@ -54,10 +54,14 @@ const exampleBasic: ExampleConfig = {
5454
description: 'Create an iPhone 15 simulator running iOS 18 for use in development and UI testing.',
5555
configs: [{
5656
type: 'ios-simulator',
57-
simulatorName: 'iPhone 15 Dev',
58-
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
59-
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
60-
state: 'Shutdown',
57+
simulators: [
58+
{
59+
name: 'iPhone 15 Dev',
60+
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
61+
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
62+
state: 'Shutdown',
63+
},
64+
],
6165
os: ['macOS'],
6266
}],
6367
};
@@ -69,18 +73,20 @@ const exampleMultiDevice: ExampleConfig = {
6973
{ type: 'xcode-tools', os: ['macOS'] },
7074
{
7175
type: 'ios-simulator',
72-
simulatorName: 'iPhone 15 Pro',
73-
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro',
74-
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
75-
state: 'Shutdown',
76-
os: ['macOS'],
77-
},
78-
{
79-
type: 'ios-simulator',
80-
simulatorName: 'iPad Pro 11-inch',
81-
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4',
82-
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
83-
state: 'Shutdown',
76+
simulators: [
77+
{
78+
name: 'iPhone 15 Pro',
79+
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro',
80+
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
81+
state: 'Shutdown',
82+
},
83+
{
84+
name: 'iPad Pro 11-inch',
85+
deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4',
86+
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
87+
state: 'Shutdown',
88+
},
89+
],
8490
os: ['macOS'],
8591
},
8692
],
@@ -99,98 +105,143 @@ export class IosSimulatorResource extends Resource<IosSimulatorConfig> {
99105
dependencies: ['xcode-tools'],
100106
schema,
101107
parameterSettings: {
102-
state: { type: 'string', canModify: true },
103-
},
104-
allowMultiple: {
105-
identifyingParameters: ['simulatorName'],
108+
simulators: {
109+
type: 'array',
110+
itemType: 'object',
111+
isElementEqual: (a, b) =>
112+
a.name === b.name &&
113+
a.deviceType === b.deviceType &&
114+
a.runtime === b.runtime &&
115+
a.state === b.state,
116+
filterInStatelessMode: (desired, current) =>
117+
current.filter((c) => desired.some((d) => d.name === c.name)),
118+
canModify: true,
119+
},
106120
},
107121
};
108122
}
109123

110-
async refresh(parameters: Partial<IosSimulatorConfig>): Promise<Partial<IosSimulatorConfig> | null> {
111-
const $ = getPty();
112-
113-
const { status, data } = await $.spawnSafe('xcrun simctl list devices --json');
114-
if (status !== SpawnStatus.SUCCESS) {
115-
return null;
116-
}
124+
async refresh(): Promise<Partial<IosSimulatorConfig> | null> {
125+
const allDevices = await this.listAllDevices();
126+
if (!allDevices) return null;
117127

118-
let parsed: SimctlDevicesOutput;
119-
try {
120-
parsed = JSON.parse(data);
121-
} catch {
122-
return null;
123-
}
124-
125-
for (const [runtimeId, devices] of Object.entries(parsed.devices)) {
126-
const match = devices.find((d) => d.name === parameters.simulatorName);
127-
if (match) {
128-
return {
129-
simulatorName: match.name,
130-
deviceType: match.deviceTypeIdentifier,
128+
const simulators: SimulatorDeclaration[] = [];
129+
for (const [runtimeId, devices] of Object.entries(allDevices)) {
130+
for (const device of devices) {
131+
simulators.push({
132+
name: device.name,
133+
deviceType: device.deviceTypeIdentifier,
131134
runtime: runtimeId,
132-
state: match.state === 'Booted' ? 'Booted' : 'Shutdown',
133-
};
135+
state: device.state === 'Booted' ? 'Booted' : 'Shutdown',
136+
});
134137
}
135138
}
136139

137-
return null;
140+
return simulators.length > 0 ? { simulators } : null;
138141
}
139142

140143
async create(plan: CreatePlan<IosSimulatorConfig>): Promise<void> {
144+
await this.assertSimctlAvailable();
141145
const $ = getPty();
142-
const { simulatorName, deviceType, runtime, state } = plan.desiredConfig;
143-
144-
// xcrun simctl create prints the new simulator's UDID to stdout
145-
const { data: udid } = await $.spawn(
146-
`xcrun simctl create "${simulatorName}" "${deviceType}" "${runtime}"`,
147-
{ interactive: true }
148-
);
149-
150-
if (state === 'Booted') {
151-
await $.spawn(`xcrun simctl boot "${udid.trim()}"`, { interactive: true });
146+
for (const sim of plan.desiredConfig.simulators ?? []) {
147+
const { data: udid } = await $.spawn(
148+
`xcrun simctl create "${sim.name}" "${sim.deviceType}" "${sim.runtime}"`,
149+
{ interactive: true },
150+
);
151+
if (sim.state === 'Booted') {
152+
await $.spawn(`xcrun simctl boot "${udid.trim()}"`, { interactive: true });
153+
}
152154
}
153155
}
154156

155-
async modify(pc: ParameterChange<IosSimulatorConfig>, plan: ModifyPlan<IosSimulatorConfig>): Promise<void> {
156-
if (pc.name !== 'state') return;
157+
async modify(pc: ParameterChange<IosSimulatorConfig>, _plan: ModifyPlan<IosSimulatorConfig>): Promise<void> {
158+
if (pc.name !== 'simulators') return;
157159

158160
const $ = getPty();
159-
const udid = await this.getUdidByName(plan.desiredConfig.simulatorName);
160-
if (!udid) return;
161+
const allDevices = await this.listAllDevices();
162+
if (!allDevices) return;
161163

162-
if (plan.desiredConfig.state === 'Booted') {
163-
await $.spawn(`xcrun simctl boot "${udid}"`, { interactive: true });
164-
} else {
165-
await $.spawn(`xcrun simctl shutdown "${udid}"`, { interactive: true });
164+
const findUdid = (name: string): string | undefined => {
165+
for (const devices of Object.values(allDevices)) {
166+
const match = devices.find((d) => d.name === name);
167+
if (match) return match.udid;
168+
}
169+
};
170+
171+
const previous: SimulatorDeclaration[] = pc.previousValue ?? [];
172+
const desired: SimulatorDeclaration[] = pc.newValue ?? [];
173+
174+
// Remove simulators no longer declared
175+
const toRemove = previous.filter((p) => !desired.some((d) => d.name === p.name));
176+
for (const sim of toRemove) {
177+
const udid = findUdid(sim.name);
178+
if (udid) await $.spawn(`xcrun simctl delete "${udid}"`, { interactive: true });
179+
}
180+
181+
// Add newly declared simulators
182+
const toAdd = desired.filter((d) => !previous.some((p) => p.name === d.name));
183+
for (const sim of toAdd) {
184+
const { data: udid } = await $.spawn(
185+
`xcrun simctl create "${sim.name}" "${sim.deviceType}" "${sim.runtime}"`,
186+
{ interactive: true },
187+
);
188+
if (sim.state === 'Booted') {
189+
await $.spawn(`xcrun simctl boot "${udid.trim()}"`, { interactive: true });
190+
}
191+
}
192+
193+
// Update state for simulators that changed only their state
194+
const stateChanged = desired.filter((d) => {
195+
const prev = previous.find((p) => p.name === d.name);
196+
return prev && prev.state !== d.state;
197+
});
198+
for (const sim of stateChanged) {
199+
const udid = findUdid(sim.name);
200+
if (!udid) continue;
201+
if (sim.state === 'Booted') {
202+
await $.spawn(`xcrun simctl boot "${udid}"`, { interactive: true });
203+
} else {
204+
await $.spawn(`xcrun simctl shutdown "${udid}"`, { interactive: true });
205+
}
166206
}
167207
}
168208

169209
async destroy(plan: DestroyPlan<IosSimulatorConfig>): Promise<void> {
170210
const $ = getPty();
171-
const udid = await this.getUdidByName(plan.currentConfig.simulatorName);
172-
if (!udid) return;
173-
174-
await $.spawn(`xcrun simctl delete "${udid}"`, { interactive: true });
211+
const allDevices = await this.listAllDevices();
212+
if (!allDevices) return;
213+
214+
for (const sim of plan.currentConfig.simulators ?? []) {
215+
for (const devices of Object.values(allDevices)) {
216+
const match = devices.find((d) => d.name === sim.name);
217+
if (match) {
218+
await $.spawn(`xcrun simctl delete "${match.udid}"`, { interactive: true });
219+
break;
220+
}
221+
}
222+
}
175223
}
176224

177-
private async getUdidByName(name: string | undefined): Promise<string | null> {
178-
if (!name) return null;
225+
private async assertSimctlAvailable(): Promise<void> {
226+
const $ = getPty();
227+
const { status } = await $.spawnSafe('xcrun simctl help');
228+
if (status !== SpawnStatus.SUCCESS) {
229+
throw new Error(
230+
'xcrun simctl is not available. Xcode must be installed to manage iOS simulators. ' +
231+
'Install it manually from the Mac App Store or use the xcodes resource to manage Xcode versions.',
232+
);
233+
}
234+
}
179235

236+
private async listAllDevices(): Promise<Record<string, SimDevice[]> | null> {
180237
const $ = getPty();
181238
const { status, data } = await $.spawnSafe('xcrun simctl list devices --json');
182239
if (status !== SpawnStatus.SUCCESS) return null;
183-
184240
try {
185241
const parsed: SimctlDevicesOutput = JSON.parse(data);
186-
for (const devices of Object.values(parsed.devices)) {
187-
const match = devices.find((d) => d.name === name);
188-
if (match) return match.udid;
189-
}
242+
return parsed.devices;
190243
} catch {
191-
// ignore parse errors
244+
return null;
192245
}
193-
194-
return null;
195246
}
196247
}

0 commit comments

Comments
 (0)