diff --git a/docs/resources/(resources)/ios-simulator.mdx b/docs/resources/(resources)/ios-simulator.mdx new file mode 100644 index 00000000..2161805a --- /dev/null +++ b/docs/resources/(resources)/ios-simulator.mdx @@ -0,0 +1,62 @@ +--- +title: ios-simulator +description: A reference page for the ios-simulator resource +--- + +The ios-simulator resource manages iOS (and iPadOS/watchOS/tvOS/visionOS) simulator instances on macOS +using `xcrun simctl`. Each resource declaration represents one simulator. You can declare multiple +`ios-simulator` resources to create a full testing matrix across device types and OS versions. + +Simulators are created with the specified device type and runtime, and optionally booted. Removing a +resource deletes the simulator from the system. Xcode Command Line Tools must be installed — add an +`xcode-tools` resource as a dependency if you are not sure they are present. + +## Parameters: + +- **name** *(string, required)* — Human-readable name for the simulator instance (e.g. `"iPhone 15 Dev"`). Must be unique across your declared simulators. + +- **deviceType** *(string, required)* — CoreSimulator device type identifier. Use the format `com.apple.CoreSimulator.SimDeviceType.`. Run `xcrun simctl list devicetypes` to see identifiers available on your machine. + +- **runtime** *(string, required)* — CoreSimulator runtime identifier. Use the format `com.apple.CoreSimulator.SimRuntime.-`. Run `xcrun simctl list runtimes` to see installed runtimes. + +- **state** *(string, optional)* — Desired runtime state of the simulator. One of `"Booted"` or `"Shutdown"`. Defaults to `"Shutdown"`. Can be modified after creation. + +## Example usage: + +```json title="codify.jsonc" +[ + { + "type": "ios-simulator", + "name": "iPhone 15 Dev", + "deviceType": "com.apple.CoreSimulator.SimDeviceType.iPhone-15", + "runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0", + "state": "Shutdown", + "os": ["macOS"] + } +] +``` + +```json title="codify.jsonc" +[ + { + "type": "xcode-tools", + "os": ["macOS"] + }, + { + "type": "ios-simulator", + "name": "iPhone 15 Pro", + "deviceType": "com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro", + "runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0", + "state": "Shutdown", + "os": ["macOS"] + }, + { + "type": "ios-simulator", + "name": "iPad Pro 11-inch", + "deviceType": "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4", + "runtime": "com.apple.CoreSimulator.SimRuntime.iOS-18-0", + "state": "Shutdown", + "os": ["macOS"] + } +] +``` diff --git a/src/index.ts b/src/index.ts index 0cb3831b..5b860ea4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ import { TerraformResource } from './resources/terraform/terraform.js'; import { CursorResource } from './resources/cursor/cursor.js'; import { VscodeResource } from './resources/vscode/vscode.js'; import { WebStormResource } from './resources/webstorm/webstorm.js'; +import { IosSimulatorResource } from './resources/ios/ios-simulator/ios-simulator.js'; import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js'; import { YumResource } from './resources/yum/yum.js'; import { PyCharmResource } from './resources/jetbrains/pycharm/pycharm.js'; @@ -82,6 +83,7 @@ runPlugin(Plugin.create( [ new GitResource(), new XcodeToolsResource(), + new IosSimulatorResource(), new PathResource(), new AliasResource(), new AliasesResource(), diff --git a/src/resources/ios/ios-simulator/completions/ios-simulator.deviceType.ts b/src/resources/ios/ios-simulator/completions/ios-simulator.deviceType.ts new file mode 100644 index 00000000..8635f10c --- /dev/null +++ b/src/resources/ios/ios-simulator/completions/ios-simulator.deviceType.ts @@ -0,0 +1,55 @@ +// Known CoreSimulator device type identifiers shipped with Xcode. +// These identifiers are stable across Xcode versions for each device family. +export default async function loadIosSimulatorDeviceTypes(): Promise { + return [ + // iPhone SE + 'com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation', + + // iPhone 14 family + 'com.apple.CoreSimulator.SimDeviceType.iPhone-14', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-14-Pro', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-14-Pro-Max', + + // iPhone 15 family + 'com.apple.CoreSimulator.SimDeviceType.iPhone-15', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Plus', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro-Max', + + // iPhone 16 family + 'com.apple.CoreSimulator.SimDeviceType.iPhone-16', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-16-Plus', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro', + 'com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro-Max', + + // iPad mini + 'com.apple.CoreSimulator.SimDeviceType.iPad-mini-6th-generation', + 'com.apple.CoreSimulator.SimDeviceType.iPad-mini-A17-Pro', + + // iPad Air + 'com.apple.CoreSimulator.SimDeviceType.iPad-Air-5th-generation', + 'com.apple.CoreSimulator.SimDeviceType.iPad-Air-11-inch-M2', + 'com.apple.CoreSimulator.SimDeviceType.iPad-Air-13-inch-M2', + + // iPad Pro 11-inch + 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-4th-generation', + 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4', + + // iPad Pro 12.9 / 13-inch + 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-12-9-inch-6th-generation', + 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M4', + + // Apple Watch + 'com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-9-41mm', + 'com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-9-45mm', + 'com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Ultra-2-49mm', + + // Apple TV + 'com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K', + 'com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-1080p', + + // Apple Vision Pro + 'com.apple.CoreSimulator.SimDeviceType.Apple-Vision-Pro', + ]; +} diff --git a/src/resources/ios/ios-simulator/completions/ios-simulator.runtime.ts b/src/resources/ios/ios-simulator/completions/ios-simulator.runtime.ts new file mode 100644 index 00000000..c781be4c --- /dev/null +++ b/src/resources/ios/ios-simulator/completions/ios-simulator.runtime.ts @@ -0,0 +1,38 @@ +// Known CoreSimulator runtime identifiers. Each entry corresponds to an iOS +// (or watchOS/tvOS/visionOS) runtime that can be installed via Xcode. +export default async function loadIosSimulatorRuntimes(): Promise { + return [ + // iOS + 'com.apple.CoreSimulator.SimRuntime.iOS-16-0', + 'com.apple.CoreSimulator.SimRuntime.iOS-16-1', + 'com.apple.CoreSimulator.SimRuntime.iOS-16-2', + 'com.apple.CoreSimulator.SimRuntime.iOS-16-4', + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0', + 'com.apple.CoreSimulator.SimRuntime.iOS-17-2', + 'com.apple.CoreSimulator.SimRuntime.iOS-17-4', + 'com.apple.CoreSimulator.SimRuntime.iOS-17-5', + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + 'com.apple.CoreSimulator.SimRuntime.iOS-18-1', + 'com.apple.CoreSimulator.SimRuntime.iOS-18-2', + 'com.apple.CoreSimulator.SimRuntime.iOS-18-3', + 'com.apple.CoreSimulator.SimRuntime.iOS-18-4', + + // watchOS + 'com.apple.CoreSimulator.SimRuntime.watchOS-10-0', + 'com.apple.CoreSimulator.SimRuntime.watchOS-10-4', + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0', + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-2', + + // tvOS + 'com.apple.CoreSimulator.SimRuntime.tvOS-17-0', + 'com.apple.CoreSimulator.SimRuntime.tvOS-17-4', + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0', + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-2', + + // visionOS + 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0', + 'com.apple.CoreSimulator.SimRuntime.xrOS-1-2', + 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0', + 'com.apple.CoreSimulator.SimRuntime.xrOS-2-2', + ]; +} diff --git a/src/resources/ios/ios-simulator/ios-simulator.ts b/src/resources/ios/ios-simulator/ios-simulator.ts new file mode 100644 index 00000000..13533ad9 --- /dev/null +++ b/src/resources/ios/ios-simulator/ios-simulator.ts @@ -0,0 +1,196 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; + +const schema = z.object({ + name: z + .string() + .describe('Name for the iOS simulator instance (e.g. "iPhone 15 Dev")'), + deviceType: z + .string() + .describe('Device type identifier (e.g. "com.apple.CoreSimulator.SimDeviceType.iPhone-15")'), + runtime: z + .string() + .describe('Runtime identifier (e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-0")'), + state: z + .enum(['Booted', 'Shutdown']) + .optional() + .describe('Desired runtime state of the simulator. Defaults to Shutdown.'), +}); + +export type IosSimulatorConfig = z.infer; + +interface SimDevice { + udid: string; + name: string; + state: string; + deviceTypeIdentifier: string; +} + +interface SimctlDevicesOutput { + devices: Record; +} + +const defaultConfig: Partial & { os: any } = { + name: '', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + state: 'Shutdown', + os: ['macOS'], +}; + +const exampleBasic: ExampleConfig = { + title: 'iPhone 15 simulator for development', + description: 'Create an iPhone 15 simulator running iOS 18 for use in development and UI testing.', + configs: [{ + type: 'ios-simulator', + name: 'iPhone 15 Dev', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + state: 'Shutdown', + os: ['macOS'], + }], +}; + +const exampleMultiDevice: ExampleConfig = { + title: 'iPhone and iPad simulator setup', + description: 'Install Xcode Command Line Tools and create an iPhone and iPad simulator for cross-device testing.', + configs: [ + { type: 'xcode-tools', os: ['macOS'] }, + { + type: 'ios-simulator', + name: 'iPhone 15 Pro', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15-Pro', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + state: 'Shutdown', + os: ['macOS'], + }, + { + type: 'ios-simulator', + name: 'iPad Pro 11-inch', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + state: 'Shutdown', + os: ['macOS'], + }, + ], +}; + +export class IosSimulatorResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'ios-simulator', + defaultConfig, + exampleConfigs: { + example1: exampleBasic, + example2: exampleMultiDevice, + }, + operatingSystems: [OS.Darwin], + dependencies: ['xcode-tools'], + schema, + parameterSettings: { + state: { type: 'string', canModify: true }, + }, + allowMultiple: { + identifyingParameters: ['name'], + }, + }; + } + + async refresh(parameters: Partial): Promise | null> { + const $ = getPty(); + + const { status, data } = await $.spawnSafe('xcrun simctl list devices --json'); + if (status !== SpawnStatus.SUCCESS) { + return null; + } + + let parsed: SimctlDevicesOutput; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + + for (const [runtimeId, devices] of Object.entries(parsed.devices)) { + const match = devices.find((d) => d.name === parameters.name); + if (match) { + return { + name: match.name, + deviceType: match.deviceTypeIdentifier, + runtime: runtimeId, + state: match.state === 'Booted' ? 'Booted' : 'Shutdown', + }; + } + } + + return null; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + const { name, deviceType, runtime, state } = plan.desiredConfig; + + // xcrun simctl create prints the new simulator's UDID to stdout + const { data: udid } = await $.spawn( + `xcrun simctl create "${name}" "${deviceType}" "${runtime}"`, + { interactive: true } + ); + + if (state === 'Booted') { + await $.spawn(`xcrun simctl boot "${udid.trim()}"`, { interactive: true }); + } + } + + async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + if (pc.name !== 'state') return; + + const $ = getPty(); + const udid = await this.getUdidByName(plan.desiredConfig.name); + if (!udid) return; + + if (plan.desiredConfig.state === 'Booted') { + await $.spawn(`xcrun simctl boot "${udid}"`, { interactive: true }); + } else { + await $.spawn(`xcrun simctl shutdown "${udid}"`, { interactive: true }); + } + } + + async destroy(plan: DestroyPlan): Promise { + const $ = getPty(); + const udid = await this.getUdidByName(plan.currentConfig.name); + if (!udid) return; + + await $.spawn(`xcrun simctl delete "${udid}"`, { interactive: true }); + } + + private async getUdidByName(name: string | undefined): Promise { + if (!name) return null; + + const $ = getPty(); + const { status, data } = await $.spawnSafe('xcrun simctl list devices --json'); + if (status !== SpawnStatus.SUCCESS) return null; + + try { + const parsed: SimctlDevicesOutput = JSON.parse(data); + for (const devices of Object.values(parsed.devices)) { + const match = devices.find((d) => d.name === name); + if (match) return match.udid; + } + } catch { + // ignore parse errors + } + + return null; + } +} diff --git a/test/ios/ios-simulator.test.ts b/test/ios/ios-simulator.test.ts new file mode 100644 index 00000000..77d6e528 --- /dev/null +++ b/test/ios/ios-simulator.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import { SpawnStatus, Utils } from '@codifycli/plugin-core'; + +describe('iOS Simulator tests', { skip: !Utils.isMacOS() }, async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can create, modify state, and destroy an iOS simulator', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'ios-simulator', + name: 'codify-test-iphone', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + state: 'Shutdown', + }, + ], { + validateApply: async () => { + const { data, status } = await testSpawn('xcrun simctl list devices --json'); + expect(status).toBe(SpawnStatus.SUCCESS); + const parsed = JSON.parse(data); + const allDevices: any[] = Object.values(parsed.devices).flat(); + const sim = allDevices.find((d: any) => d.name === 'codify-test-iphone'); + expect(sim).toBeDefined(); + expect(sim.state).toBe('Shutdown'); + }, + testModify: { + modifiedConfigs: [{ + type: 'ios-simulator', + name: 'codify-test-iphone', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0', + state: 'Booted', + }], + validateModify: async () => { + const { data } = await testSpawn('xcrun simctl list devices --json'); + const parsed = JSON.parse(data); + const allDevices: any[] = Object.values(parsed.devices).flat(); + const sim = allDevices.find((d: any) => d.name === 'codify-test-iphone'); + expect(sim).toBeDefined(); + expect(sim.state).toBe('Booted'); + }, + }, + validateDestroy: async () => { + const { data } = await testSpawn('xcrun simctl list devices --json'); + const parsed = JSON.parse(data); + const allDevices: any[] = Object.values(parsed.devices).flat(); + const sim = allDevices.find((d: any) => d.name === 'codify-test-iphone'); + expect(sim).toBeUndefined(); + }, + }); + }); +});