# frozen_string_literal: true require 'open3' require 'json' module UITest # Manages iOS Simulator lifecycle class Simulator class SimulatorError < StandardError; end class << self # Boot a simulator by device name and return its UDID # @param device_name [String] Name of the device (e.g., "iPhone 17 Pro") # @return [String] UDID of the booted simulator def boot(device_name) udid = find_udid(device_name) raise SimulatorError, "Simulator '#{device_name}' not found" unless udid status = device_status(udid) if status == 'Shutdown' puts "Booting simulator '#{device_name}'..." run_simctl('boot', udid) wait_until_booted(udid) elsif status == 'Booted' puts "Simulator '#{device_name}' is already booted" else puts "Simulator '#{device_name}' is in state '#{status}', waiting..." wait_until_booted(udid) end # Set consistent status bar for reproducible screenshots set_status_bar_overrides(udid) udid end # Open a URL in the simulator (triggers deep link handling) # @param udid [String] UDID of the simulator # @param url [String] URL to open def open_url(udid, url) output, status = Open3.capture2e('xcrun', 'simctl', 'openurl', udid, url) raise SimulatorError, "openurl failed: #{output}" unless status.success? end # Shutdown a simulator by UDID # @param udid [String] UDID of the simulator def shutdown(udid) return unless udid status = device_status(udid) return if status == 'Shutdown' clear_status_bar_overrides(udid) puts 'Shutting down simulator...' run_simctl('shutdown', udid) end # Set status bar overrides for consistent screenshots # Uses Apple's iconic 9:41 time and full signal/battery # @param udid [String] UDID of the simulator def set_status_bar_overrides(udid) puts 'Setting status bar overrides for consistent screenshots...' run_simctl( 'status_bar', udid, 'override', '--time', '9:41', '--batteryState', 'charged', '--batteryLevel', '100', '--wifiBars', '3', '--cellularBars', '4' ) end # Clear status bar overrides # @param udid [String] UDID of the simulator def clear_status_bar_overrides(udid) run_simctl('status_bar', udid, 'clear') rescue SimulatorError # Ignore errors when clearing (simulator may already be shut down) nil end # Find UDID for a device by name # @param device_name [String] Name of the device # @return [String, nil] UDID or nil if not found def find_udid(device_name) output, status = Open3.capture2('xcrun', 'simctl', 'list', 'devices', 'available', '-j') raise SimulatorError, 'Failed to list simulators' unless status.success? data = JSON.parse(output) devices = data['devices'].values.flatten # Find exact match first device = devices.find { |d| d['name'] == device_name } device&.fetch('udid', nil) end # Get device status by UDID # @param udid [String] UDID of the simulator # @return [String] Status (e.g., "Booted", "Shutdown") def device_status(udid) output, status = Open3.capture2('xcrun', 'simctl', 'list', 'devices', '-j') raise SimulatorError, 'Failed to get device status' unless status.success? data = JSON.parse(output) devices = data['devices'].values.flatten device = devices.find { |d| d['udid'] == udid } device&.fetch('state', 'Unknown') || 'Unknown' end # Wait until simulator is fully booted # @param udid [String] UDID of the simulator # @param timeout [Integer] Timeout in seconds def wait_until_booted(udid, timeout: 60) start_time = Time.now loop do status = device_status(udid) return if status == 'Booted' if Time.now - start_time > timeout raise SimulatorError, "Timeout waiting for simulator to boot (status: #{status})" end sleep 1 end end private def run_simctl(*args) output, status = Open3.capture2e('xcrun', 'simctl', *args) raise SimulatorError, "simctl #{args.first} failed: #{output}" unless status.success? output end end end end