| #!/usr/bin/env python3 |
| # Copyright 2025 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Shared utility functions for iOS test runners.""" |
| |
| import dataclasses |
| import json |
| import re |
| import subprocess |
| from typing import Any, Dict, List, Optional, Tuple |
| import shlex |
| |
| |
| @dataclasses.dataclass |
| class Simulator: |
| """Dataclass for simulator information.""" |
| name: str |
| udid: str |
| os_version: str |
| state: str |
| is_available: bool |
| |
| @property |
| def booted(self) -> bool: |
| """Returns True if the simulator is in the 'Booted' state.""" |
| return self.state == 'Booted' |
| |
| @property |
| def parsed_os_version(self) -> Tuple[int, ...]: |
| """Returns the OS version string as a tuple of integers.""" |
| return tuple(map(int, self.os_version.split('.'))) |
| |
| @property |
| def display_string(self) -> str: |
| """Returns a formatted string for displaying simulator details.""" |
| booted_status = (f"({Colors.GREEN}Booted{Colors.RESET})" |
| if self.booted else "") |
| return (f"{self.name} (OS: {self.os_version}, UDID: {self.udid}) " |
| f"{booted_status}") |
| |
| |
| class Colors: |
| """ANSI color codes for terminal output.""" |
| HEADER = '\033[35m' # Magenta |
| BLUE = '\033[34m' |
| CYAN = '\033[36m' |
| GREEN = '\033[32m' |
| WARNING = '\033[33m' # Yellow |
| FAIL = '\033[31m' # Red |
| BOLD = '\033[1m' |
| RESET = '\033[0m' |
| |
| |
| def print_command(command_list: List[str]) -> None: |
| """Prints a shell command, quoting arguments for safe copy-pasting.""" |
| quoted_command = ' '.join(shlex.quote(arg) for arg in command_list) |
| print(f'{quoted_command}\n') |
| |
| |
| def print_header(header_text: str) -> None: |
| """Prints a formatted header string with special colors.""" |
| print(f'\n{Colors.HEADER}{Colors.BOLD}{header_text}{Colors.RESET}') |
| |
| |
| class SimulatorManager: |
| """Manages and queries the list of available simulators.""" |
| |
| def __init__(self): |
| """Initializes the manager, fetching and parsing the simulator list.""" |
| self.simulators: List[Simulator] = [] |
| try: |
| output = subprocess.check_output( |
| ['xcrun', 'simctl', 'list', 'devices', '--json'], |
| encoding='utf-8') |
| all_devices = json.loads(output).get('devices', {}) |
| for runtime, devices in all_devices.items(): |
| # Only process iOS runtimes. |
| if 'iOS' not in runtime: |
| continue |
| os_version_full = runtime.split('iOS-')[-1].replace('-', '.') |
| # Use only major.minor version for xcodebuild compatibility. |
| os_version = '.'.join(os_version_full.split('.')[:2]) |
| for device in devices: |
| self.simulators.append( |
| Simulator(name=device.get('name', ''), |
| udid=device.get('udid', ''), |
| os_version=os_version, |
| state=device.get('state', ''), |
| is_available=device.get( |
| 'isAvailable', False))) |
| except (subprocess.CalledProcessError, json.JSONDecodeError) as e: |
| print(f"Error fetching simulator list: {e}") |
| |
| def list_available_simulators(self): |
| """Prints a formatted list of available simulators.""" |
| print("\nAvailable Simulators:") |
| for sim in self.simulators: |
| if sim.is_available: |
| print(f" - {sim.display_string}") |
| |
| def find_specific_device(self, identifier: str) -> Optional[Simulator]: |
| """Finds a specific device by name or UDID.""" |
| for sim in self.simulators: |
| if sim.name.lower() == identifier.lower( |
| ) or sim.udid == identifier: |
| return sim |
| return None |
| |
| def find_best_available_device(self) -> Optional[Simulator]: |
| """Finds the best available iPhone simulator to use as a default.""" |
| best_candidate = None |
| best_sdk_version = (0, ) |
| best_iphone_version = 0 |
| |
| for sim in self.simulators: |
| if not sim.is_available: |
| continue |
| |
| current_sdk_version = sim.parsed_os_version |
| match = re.match(r'^iPhone (\d+)', sim.name) |
| if match: |
| iphone_version = int(match.group(1)) |
| if current_sdk_version > best_sdk_version: |
| best_sdk_version = current_sdk_version |
| best_iphone_version = iphone_version |
| best_candidate = sim |
| elif current_sdk_version == best_sdk_version: |
| if iphone_version > best_iphone_version: |
| best_iphone_version = iphone_version |
| best_candidate = sim |
| return best_candidate |
| |
| def find_device_by_type_and_version( |
| self, |
| device_type: str, |
| os_version: Optional[str] = None) -> Optional[Simulator]: |
| """Finds a device that matches a specific type and OS version.""" |
| best_candidate = None |
| best_sdk_version = (0, ) |
| |
| for sim in self.simulators: |
| if not sim.is_available or sim.name.lower() != device_type.lower(): |
| continue |
| |
| if os_version: |
| # Truncate input os_version to major.minor for comparison. |
| os_version_truncated = '.'.join(os_version.split('.')[:2]) |
| if sim.os_version == os_version_truncated: |
| return sim |
| else: |
| current_sdk_version = sim.parsed_os_version |
| if current_sdk_version > best_sdk_version: |
| best_sdk_version = current_sdk_version |
| best_candidate = sim |
| return best_candidate |
| |
| |
| def find_and_boot_simulator(device_type: Optional[str], |
| os_version: Optional[str]) -> Optional[Simulator]: |
| """Finds the requested simulator, booting it if necessary. |
| |
| Args: |
| device_type: The device type to look for (e.g., 'iPhone 15 Pro'). |
| os_version: The OS version to look for (e.g., '17.5'). |
| |
| Returns: |
| A Simulator object for the selected device, or None on failure. |
| """ |
| print_header("--- Selecting Simulator ---") |
| sim_manager = SimulatorManager() |
| if not sim_manager.simulators: |
| return None |
| |
| simulator_to_use = None |
| |
| # 1. If a specific device is requested, try to find it. |
| if device_type: |
| simulator_to_use = sim_manager.find_device_by_type_and_version( |
| device_type, os_version) |
| if not simulator_to_use: |
| print(f"Could not find a simulator for device '{device_type}' " |
| f"and OS '{os_version}'.") |
| sim_manager.list_available_simulators() |
| return None |
| |
| # 2. If no device was specified, look for an already booted one. |
| if not simulator_to_use: |
| for sim in sim_manager.simulators: |
| if sim.booted: |
| simulator_to_use = sim |
| break |
| |
| # 3. If still no device, find the best available default. |
| if not simulator_to_use: |
| print(f"{Colors.BLUE}No simulator booted. " |
| f"Finding the newest available iPhone...{Colors.RESET}") |
| simulator_to_use = sim_manager.find_best_available_device() |
| if not simulator_to_use: |
| print("Could not find a suitable default iPhone simulator.") |
| sim_manager.list_available_simulators() |
| return None |
| |
| print( |
| f"{Colors.BLUE}Device: {simulator_to_use.display_string}{Colors.RESET}" |
| ) |
| |
| # Boot the selected device if it's not already running. |
| if not simulator_to_use.booted: |
| print( |
| f"\n{Colors.CYAN}Simulator '{simulator_to_use.display_string}' is " |
| f"not booted. Booting...{Colors.RESET}") |
| try: |
| subprocess.check_call( |
| ['xcrun', 'simctl', 'boot', simulator_to_use.udid]) |
| except subprocess.CalledProcessError as e: |
| print(f"Error booting simulator: {e}") |
| return None |
| return simulator_to_use |