| #!/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 uuid |
| import dataclasses |
| import glob |
| import json |
| import os |
| import re |
| import subprocess |
| import threading |
| from typing import Any, Dict, List, Optional, Tuple |
| import shlex |
| |
| |
| def _load_env() -> None: |
| """Loads environment variables from a .env file if it exists.""" |
| tools_dir = os.path.dirname(os.path.abspath(__file__)) |
| repo_root = os.path.abspath(os.path.join(tools_dir, '..', '..')) |
| |
| paths_to_try = [ |
| os.path.join(os.getcwd(), '.env'), |
| os.path.join(repo_root, '.env'), |
| ] |
| |
| loaded = set() |
| for path in paths_to_try: |
| path = os.path.abspath(path) |
| if path in loaded or not os.path.exists(path): |
| continue |
| loaded.add(path) |
| try: |
| with open(path, 'r', encoding='utf-8') as f: |
| for line in f: |
| line = line.strip() |
| if not line or line.startswith('#'): |
| continue |
| if '=' not in line: |
| continue |
| key, val = line.split('=', 1) |
| key = key.strip() |
| val = val.strip() |
| if ((val.startswith('"') and val.endswith('"')) or |
| (val.startswith("'") and val.endswith("'"))): |
| val = val[1:-1] |
| if key not in os.environ: |
| os.environ[key] = val |
| except Exception as e: |
| print(f"Warning: Failed to read .env file at {path}: {e}") |
| |
| |
| # Automatically load environment variables from .env files on import. |
| _load_env() |
| |
| |
| @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 ---") |
| |
| env_device = os.environ.get('IOS_SIMULATOR_DEFAULT_DEVICE') |
| env_os = os.environ.get('IOS_SIMULATOR_DEFAULT_OS') |
| |
| if not device_type and env_device: |
| device_type = env_device |
| print(f"Using default simulator device from environment: {device_type}") |
| if not os_version and env_os: |
| os_version = env_os |
| print(f"Using default simulator OS from environment: {os_version}") |
| 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: |
| is_udid = False |
| try: |
| uuid.UUID(device_type) |
| is_udid = True |
| except ValueError: |
| pass |
| |
| if is_udid: |
| for sim in sim_manager.simulators: |
| if sim.udid.lower() == device_type.lower(): |
| simulator_to_use = sim |
| break |
| if not simulator_to_use: |
| print(f"Could not find a simulator with UDID '{device_type}'.") |
| return None |
| else: |
| 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 |
| |
| |
| class SimulatorLogStreamer: |
| """Streams logs from the simulator in the background.""" |
| |
| def __init__(self, |
| simulator_udid: str, |
| out_dir: str, |
| scheme: str, |
| predicate: str, |
| show_in_stdout: bool = False): |
| self.simulator_udid = simulator_udid |
| self.out_dir = out_dir |
| self.scheme = scheme |
| self.predicate = predicate |
| self.show_in_stdout = show_in_stdout |
| self.proc = None |
| self.safe_scheme = re.sub(r'[^a-zA-Z0-9_]', '_', scheme) |
| self.log_file_path = os.path.join( |
| out_dir, f'app_logs_{self.safe_scheme}_{os.getpid()}.log') |
| self.thread = None |
| self.log_file = None |
| |
| def start(self): |
| os.makedirs(self.out_dir, exist_ok=True) |
| |
| # Keep only the 5 most recent log files for this scheme to limit |
| # disk usage. |
| try: |
| pattern = os.path.join(self.out_dir, |
| f'app_logs_{self.safe_scheme}_*.log') |
| existing_logs = glob.glob(pattern) |
| if len(existing_logs) >= 5: |
| existing_logs.sort(key=os.path.getmtime) |
| for old_log in existing_logs[:-4]: |
| os.remove(old_log) |
| except Exception: |
| pass |
| |
| cmd = [ |
| 'xcrun', 'simctl', 'spawn', self.simulator_udid, 'log', 'stream', |
| '--level', 'debug', '--style', 'syslog', '--predicate', |
| self.predicate |
| ] |
| |
| self.log_file = open(self.log_file_path, |
| 'w', |
| encoding='utf-8', |
| errors='replace') |
| |
| self.proc = subprocess.Popen(cmd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| encoding='utf-8', |
| errors='replace') |
| |
| def read_loop(): |
| for line in self.proc.stdout: |
| self.log_file.write(line) |
| self.log_file.flush() |
| if self.show_in_stdout: |
| print(f"{Colors.CYAN}[AppLog]{Colors.RESET} {line.strip()}") |
| |
| self.thread = threading.Thread(target=read_loop, daemon=True) |
| self.thread.start() |
| print( |
| f"{Colors.BLUE}Streaming app logs to: " |
| f"{self.log_file_path}{Colors.RESET}" |
| ) |
| |
| def stop(self): |
| if self.proc: |
| self.proc.terminate() |
| try: |
| self.proc.wait(timeout=2) |
| except subprocess.TimeoutExpired: |
| self.proc.kill() |
| self.proc.wait() |
| |
| if self.thread: |
| self.thread.join() |
| |
| if self.log_file: |
| self.log_file.close() |