"""Builds and runs selected EG tests."""
import argparse
import glob
import json
import os
import plistlib
import re
import subprocess
import sys
from typing import Any, Dict, List, Optional, Tuple
import shlex
import shared_test_utils
from shared_test_utils import Colors, Simulator, print_header, print_command
_EG2TESTS_MODULE_SUFFIX = '_eg2tests_module'
_TEST_CASE_SUFFIX = 'TestCase'
_NON_PREFERRED_MODULE_SUFFIXES = (
f'_flaky{_EG2TESTS_MODULE_SUFFIX}',
f'_multitasking{_EG2TESTS_MODULE_SUFFIX}',
)
def _camel_to_snake(name: str) -> str:
"""Converts a CamelCase string to snake_case."""
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
def _find_test_file(test_case: str) -> Optional[str]:
"""Finds the test file for a given test case.
Args:
test_case: The name of the test case (e.g., 'TestCase').
Returns:
The path to the test file, or None if not found or ambiguous.
"""
if test_case.endswith(_TEST_CASE_SUFFIX):
test_case = test_case[:-len(_TEST_CASE_SUFFIX)]
snake_case_name = _camel_to_snake(test_case)
search_pattern = f'ios/**/{snake_case_name}_egtest.mm'
test_files = glob.glob(search_pattern, recursive=True)
if not test_files:
search_pattern = f'ios/**/{snake_case_name}_eg.mm'
test_files = glob.glob(search_pattern, recursive=True)
if not test_files:
print(f"Could not find a test file for test case '{test_case}'.")
return None
if len(test_files) > 1:
print(f"Found multiple test files for '{test_case}': {test_files}")
print("Please specify the scheme manually using --scheme.")
return None
test_file = test_files[0]
print(f"{Colors.BLUE}Found test file: {test_file}{Colors.RESET}")
return test_file
def _find_gn_modules_for_file(test_file: str,
out_dir: str) -> Optional[List[str]]:
"""Finds all eg2tests_module GN targets that depend on a test file.
Args:
test_file: The path to the test file.
out_dir: The output directory for the build.
Returns:
A list of GN target labels, or None on error.
"""
try:
gn_refs_cmd = [
'gn', 'refs', out_dir, f'//{test_file}', '--all', '--testonly=true'
]
refs_output = subprocess.check_output(gn_refs_cmd,
encoding='utf-8').strip()
eg2tests_modules = []
for line in refs_output.splitlines():
if line.strip().endswith(_EG2TESTS_MODULE_SUFFIX):
eg2tests_modules.append(line.strip())
if not eg2tests_modules:
print(f"Could not find a suitable test scheme for '{test_file}'.")
return None
return eg2tests_modules
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"Error running gn command: {e}")
return None
def _select_best_target(eg2tests_modules: List[str]) -> str:
"""Selects the best target from a list of eg2tests_module targets.
Args:
eg2tests_modules: A list of eg2tests_module GN targets.
Returns:
The label of the best target.
"""
preferred_modules = []
for target in eg2tests_modules:
is_non_preferred = False
for suffix in _NON_PREFERRED_MODULE_SUFFIXES:
if target.endswith(suffix):
is_non_preferred = True
break
if not is_non_preferred:
preferred_modules.append(target)
if preferred_modules:
return preferred_modules[0]
return eg2tests_modules[0]
def _find_scheme_for_tests(test: str, out_dir: str) -> Optional[str]:
"""Finds the scheme that contains the given test.
Args:
test: The test specifier (e.g., 'TestCase/testMethod').
out_dir: The output directory for the build.
Returns:
The name of the scheme, or None if it could not be determined.
"""
test_case = test.split('/')[0]
test_file = _find_test_file(test_case)
if not test_file:
return None
eg2tests_modules = _find_gn_modules_for_file(test_file, out_dir)
if not eg2tests_modules:
return None
final_target = _select_best_target(eg2tests_modules)
scheme = final_target.split(':')[-1]
return scheme
def _build_tests(out_dir: str, scheme: str) -> bool:
"""Builds the EG test target.
Args:
out_dir: The output directory for the build.
scheme: The EG test scheme to build.
Returns:
True if the build was successful, False otherwise.
"""
build_command = ['autoninja', '-C', out_dir, scheme]
print_header("--- Building Tests ---")
print_command(build_command)
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_name: str, scheme: str,
test_filters: List[str]) -> int:
"""Runs the EG tests on the specified simulator.
Args:
out_dir: The output directory for the build.
simulator_name: The name of the simulator to use.
scheme: The EG test scheme to run.
test_filters: A list of test filters to apply.
Returns:
The exit code of the test runner.
"""
project_path = os.path.join(os.getcwd(), 'out', 'build', 'all.xcodeproj')
launch_command = [
'xcodebuild',
'test-without-building',
'-project',
project_path,
'-scheme',
scheme,
'-destination',
f'platform=iOS Simulator,name={simulator_name}',
]
if test_filters:
for test_filter in test_filters:
launch_command.append(f'-only-testing:{scheme}/{test_filter}')
print_header("--- Running Tests ---")
print_command(launch_command)
try:
subprocess.check_call(launch_command)
return 0
except subprocess.CalledProcessError as e:
return e.returncode
def _build_and_run_eg_tests(args: argparse.Namespace) -> int:
"""Runs the test logic based on the parsed arguments."""
if not args.tests:
print(f"{Colors.FAIL}Error: You must provide --tests.{Colors.RESET}")
return 1
tests_by_scheme = {}
test_filters = [f.strip() for f in args.tests.split(',')]
if args.scheme:
tests_by_scheme[args.scheme] = test_filters
else:
print_header("--- Selecting Scheme ---")
print(f"{Colors.CYAN}Scheme not provided. Inferring from test filter..."
f"{Colors.RESET}")
for test in test_filters:
scheme = _find_scheme_for_tests(test, args.out_dir)
if not scheme:
print(f"\n{Colors.FAIL}Could not determine the scheme for "
f"test filter '{test}'. Please specify it with --scheme."
f"{Colors.RESET}")
return 1
if scheme not in tests_by_scheme:
tests_by_scheme[scheme] = []
tests_by_scheme[scheme].append(test)
for scheme, tests in tests_by_scheme.items():
print(f"{Colors.GREEN}Found scheme '{scheme}' for tests: "
f"{', '.join(tests)}{Colors.RESET}")
simulator = shared_test_utils.find_and_boot_simulator(args.device, args.os)
if not simulator:
return 1
final_exit_code = 0
for scheme, tests in tests_by_scheme.items():
if not _build_tests(args.out_dir, scheme):
final_exit_code = 1
continue
exit_code = _run_tests(args.out_dir, simulator.name, scheme, tests)
if exit_code != 0:
final_exit_code = exit_code
return final_exit_code
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_header("=== Run EG Tests ===")
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('--tests',
help='Comma-separated list of tests to run (e.g., '
"'TestCase1/testMethodA,TestCase2/testMethodB'"
'). '
'Does not support wildcards.')
parser.add_argument(
'--scheme',
help='The EG test scheme to build and run. If not provided, it will be '
'inferred from --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()
return _build_and_run_eg_tests(args)
if __name__ == '__main__':
sys.exit(main())