blob: d4d8b7bd6c99406732a161149fc80c206e84cc60 [file]
#!/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()