blob: 75ed8141492a33420079a3b1dd7d17d84bea5167 [file] [log] [blame]
#!/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.
"""Builds and runs selected unit tests."""
import argparse
import json
import os
import plistlib
import re
import subprocess
import sys
from typing import Any, Dict, List, Optional, Tuple
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 _parse_version_string(v_str: str) -> Tuple[int, ...]:
"""Parses a version string into a tuple of integers."""
return tuple(map(int, v_str.split('.')))
def _find_specific_device(all_devices: Dict[str, List[Dict[str, Any]]],
identifier: str) -> Optional[Dict[str, Any]]:
"""Finds a specific device by name or UDID.
Args:
all_devices: A dictionary of devices from `simctl list`.
identifier: The name or UDID of the device to find.
Returns:
The device dictionary if found, otherwise None.
"""
for runtime, devices in all_devices.items():
for device in devices:
if (device['name'].lower() == identifier.lower() or
device['udid'] == identifier):
return device
return None
def _find_best_available_device(
all_devices: Dict[str, List[Dict[str, Any]]]) -> Optional[Dict[str, Any]]:
"""Finds the best available iPhone simulator to use as a default.
Args:
all_devices: A dictionary of devices from `simctl list`.
Returns:
The device dictionary for the best candidate, or None if none is found.
"""
best_candidate = None
best_sdk_version = (0,)
best_iphone_version = 0
for runtime, devices in all_devices.items():
if 'iOS' not in runtime:
continue
sdk_version_str = runtime.split('iOS-')[-1].replace('-', '.')
current_sdk_version = _parse_version_string(sdk_version_str)
for device in devices:
if not device['isAvailable']:
continue
match = re.match(r'^iPhone (\d+)', device['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 = device
elif current_sdk_version == best_sdk_version:
if iphone_version > best_iphone_version:
best_iphone_version = iphone_version
best_candidate = device
return best_candidate
def _find_device_by_type_and_version(
all_devices: Dict[str, List[Dict[str, Any]]],
device_type: str,
os_version: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Finds a device that matches a specific type and OS version.
Args:
all_devices: A dictionary of devices from `simctl list`.
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'). If None, the latest
available OS for the device will be used.
Returns:
The device dictionary if found, otherwise None.
"""
best_candidate = None
best_sdk_version = (0,)
for runtime, devices in all_devices.items():
if os_version:
if f"iOS-{os_version.replace('.', '-')}" not in runtime:
continue
elif 'iOS' not in runtime:
continue
sdk_version_str = runtime.split('iOS-')[-1].replace('-', '.')
current_sdk_version = _parse_version_string(sdk_version_str)
for device in devices:
if (device['name'].lower() == device_type.lower() and
device['isAvailable']):
if os_version:
return device
if current_sdk_version > best_sdk_version:
best_sdk_version = current_sdk_version
best_candidate = device
return best_candidate
def _find_and_boot_simulator(device_type: Optional[str],
os_version: Optional[str]) -> Optional[str]:
"""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:
The UDID of the simulator to use. Returns None on failure.
"""
try:
output = subprocess.check_output(
['xcrun', 'simctl', 'list', 'devices', '--json'], encoding='utf-8')
all_devices = json.loads(output)['devices']
device_to_use = None
if device_type:
device_to_use = _find_device_by_type_and_version(all_devices, device_type,
os_version)
if not device_to_use:
print(f"Could not find a simulator for device '{device_type}' and OS "
f"'{os_version}'.")
subprocess.call(['xcrun', 'simctl', 'list', 'devices', 'available'])
return None
else:
# No device specified, look for a booted one first.
for runtime, devices in all_devices.items():
for device in devices:
if device['state'] == 'Booted':
device_to_use = device
break
if device_to_use:
break
# No device is booted, find the best one.
if not device_to_use:
print(f"{Colors.BLUE}No simulator booted. "
f"Finding the newest available iPhone...{Colors.RESET}")
device_to_use = _find_best_available_device(all_devices)
if not device_to_use:
print("Could not find a suitable default iPhone simulator.")
subprocess.call(['xcrun', 'simctl', 'list', 'devices', 'available'])
return None
# Find the OS version for the selected device.
device_os_version = 'Unknown'
for runtime, devices in all_devices.items():
if any(d['udid'] == device_to_use['udid'] for d in devices):
device_os_version = runtime.split('iOS-')[-1].replace('-', '.')
break
print(f'\n{Colors.HEADER}{Colors.BOLD}--- Selecting Simulator ---'
f'{Colors.RESET}')
print(f"{Colors.BLUE}Device: {device_to_use['name']}{Colors.RESET}")
print(f"{Colors.BLUE}OS: {device_os_version}{Colors.RESET}")
print(f"{Colors.BLUE}UDID: {device_to_use['udid']}{Colors.RESET}")
# Boot the selected device if it's not already running.
if device_to_use['state'] != 'Booted':
print(f"\n{Colors.CYAN}Simulator '{device_to_use['name']}' is not "
f"booted. Booting...{Colors.RESET}")
subprocess.check_call(['xcrun', 'simctl', 'boot', device_to_use['udid']])
return device_to_use['udid']
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
print(f"Error managing simulators: {e}")
return None
def _build_tests(out_dir: str) -> bool:
"""Builds the unit test target.
Args:
out_dir: The output directory for the build.
Returns:
True if the build was successful, False otherwise.
"""
build_command = ['autoninja', '-C', out_dir, 'ios_chrome_unittests']
print(f'\n{Colors.HEADER}{Colors.BOLD}--- Building Tests ---{Colors.RESET}')
print(' '.join(build_command) + '\n')
try:
subprocess.check_call(build_command)
return True
except subprocess.CalledProcessError as e:
print(f"Build failed with exit code {e.returncode}")
return False
def _run_tests(out_dir: str, simulator_udid: str,
gtest_filter: Optional[str]) -> int:
"""Installs and runs the tests on the specified simulator.
Args:
out_dir: The output directory for the build.
simulator_udid: The UDID of the simulator to use.
gtest_filter: The gtest filter to apply.
Returns:
The exit code of the test runner.
"""
app_path = os.path.join(out_dir, 'ios_chrome_unittests.app')
info_plist_path = os.path.join(app_path, 'Info.plist')
with open(info_plist_path, 'rb') as f:
info_plist = plistlib.load(f)
bundle_id = info_plist['CFBundleIdentifier']
install_command = ['xcrun', 'simctl', 'install', simulator_udid, app_path]
print(f'\n{Colors.HEADER}{Colors.BOLD}--- Installing App ---{Colors.RESET}')
print(' '.join(install_command))
subprocess.check_call(install_command)
launch_command = [
'xcrun', 'simctl', 'launch', '--console-pty', simulator_udid, bundle_id
]
if gtest_filter:
launch_command.append('--gtest_filter=' + gtest_filter)
print(f'\n{Colors.HEADER}{Colors.BOLD}--- Running Tests ---{Colors.RESET}')
print(' '.join(launch_command) + '\n')
process = subprocess.Popen(launch_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True)
test_failed = False
test_completed = False
while True:
line = process.stdout.readline()
if not line:
break
sys.stdout.write(line)
if '[ PASSED ]' in line or '[ FAILED ]' in line:
test_completed = True
if '[ FAILED ]' in line:
test_failed = True
process.wait()
# If the test completed and there were failures, return 1.
if test_completed and test_failed:
return 1
# If the test did not complete (i.e. crashed), return 1.
if not test_completed:
print(f'\n{Colors.FAIL}{Colors.BOLD}'
'ERROR: Test runner did not complete. Assuming crash.'
f'{Colors.RESET}')
return 1
# If the test completed and there were no failures, return 0.
return 0
def main() -> int:
"""Main function for the script.
Parses arguments, finds a simulator, builds, and runs the tests.
Returns:
The exit code of the test run.
"""
print(f'{Colors.HEADER}{Colors.BOLD}=== Run Unit Tests ==={Colors.RESET}')
parser = argparse.ArgumentParser()
parser.add_argument(
'--out-dir',
default='out/Debug-iphonesimulator',
help='The output directory to use for the build (default: %(default)s).')
parser.add_argument('--gtest_filter',
help='The gtest_filter to use for running the tests.')
parser.add_argument('--device', help='The device type to use for the test.')
parser.add_argument('--os',
help='The OS version to use for the test (e.g., 17.5).')
args = parser.parse_args()
simulator_udid = _find_and_boot_simulator(args.device, args.os)
if not simulator_udid:
return 1
if not _build_tests(args.out_dir):
return 1
return _run_tests(args.out_dir, simulator_udid, args.gtest_filter)
if __name__ == '__main__':
sys.exit(main())