1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import argparse 8import dataclasses 9import logging 10import os 11import re 12from typing import Any, Dict, Optional, TextIO, Tuple, cast 13 14import hjson 15 16import crossbench.browsers.all as browsers 17from crossbench import exception 18from crossbench import path as pth 19from crossbench import plt 20from crossbench.browsers.chrome.downloader import ChromeDownloader 21from crossbench.browsers.firefox.downloader import FirefoxDownloader 22from crossbench.cli.config.driver import BrowserDriverType, DriverConfig 23from crossbench.cli.config.network import NetworkConfig, NetworkSpeedPreset 24from crossbench.config import ConfigObject, ConfigParser 25from crossbench.parse import NumberParser, PathParser 26 27SUPPORTED_BROWSER = ("chromium", "chrome", "safari", "edge", "firefox") 28 29# Split inputs like: 30# - "/out/x64.release/chrome" 31# - "/out/x64.release/chrome:4G" 32# - "C:\out\x64.release\chrome" 33# - "C:\out\x64.release\chrome:4G" 34# - "applescript:/out/x64.release/chrome" 35# - "applescript:/out/x64.release/chrome:4G" 36# - "selenium:C:\out\x64.release\chrome" 37# - "selenium:C:\out\x64.release\chrome:4G" 38NETWORK_PRESETS: str = "|".join( 39 re.escape(preset.value) for preset in NetworkSpeedPreset) # pytype: disable=missing-parameter 40SHORT_FORM_RE = re.compile(r"((?P<driver>\w{3,}):)??" 41 r"(?P<path>([A-Z]:[/\\])?[^:]+)" 42 f"(:(?P<network>{NETWORK_PRESETS}))?") 43ANDROID_PACKAGE_RE = re.compile(r"[a-z]+(\.[a-z]+){2,}") 44VERSION_FOR_RANGE_RE = re.compile(r"(?P<prefix>[^\d]*)(?P<milestone>\d+)") 45 46 47@dataclasses.dataclass(frozen=True) 48class BrowserConfig(ConfigObject): 49 browser: pth.AnyPathLike 50 driver: DriverConfig = DriverConfig.default() 51 # Make network optional since --network provides a global default and we do 52 # want to have the option to explicitly specify the default network in a 53 # browser config. 54 network: Optional[NetworkConfig] = None 55 56 def __post_init__(self) -> None: 57 if not self.browser: 58 raise ValueError(f"{type(self).__name__}.browser cannot be None.") 59 if not self.driver: 60 raise ValueError(f"{type(self).__name__}.driver cannot be None.") 61 62 @classmethod 63 def default(cls) -> BrowserConfig: 64 return cls( 65 browsers.Chrome.stable_path(plt.PLATFORM), DriverConfig.default()) 66 67 @classmethod 68 def parse_str(cls, value: str) -> BrowserConfig: 69 if not value: 70 raise argparse.ArgumentTypeError("Cannot parse empty string") 71 network: Optional[NetworkConfig] = None 72 driver = DriverConfig.default() 73 path: Optional[pth.AnyPathLike] = None 74 if ":" not in value or cls.value_has_path_prefix(value): 75 # Variant 1: $PATH_OR_IDENTIFIER 76 path = cls._parse_path_or_identifier(value) 77 elif value[0] != "{": 78 # Variant 2: ${DRIVER_TYPE}:${PATH_OR_IDENTIFIER}:${NETWORK} 79 driver, path, network = cls._parse_inline_short_form(value) 80 else: 81 # Variant 3: Full inline hjson 82 return cls.parse_inline_hjson(value) 83 assert path, "Invalid path" 84 return cls(path, driver, network) 85 86 @classmethod 87 def parse_with_range(cls, value: Any) -> Tuple[BrowserConfig, ...]: 88 if isinstance(value, str): 89 return cls._parse_with_range(value) 90 return (cls.parse(value),) 91 92 @classmethod 93 def _parse_with_range(cls, value: str) -> Tuple[BrowserConfig, ...]: 94 if not value: 95 raise argparse.ArgumentTypeError("Cannot parse empty string") 96 parts = value.split("...", maxsplit=1) 97 start_version: str = parts.pop(0) 98 if not parts: 99 return (cls.parse(start_version),) 100 limit_version = parts[0] 101 102 start_match = VERSION_FOR_RANGE_RE.fullmatch(start_version) 103 if not start_match: 104 raise argparse.ArgumentTypeError( 105 f"Start of a browser range {repr(value)} must end in digits, " 106 f"but got {repr(start_version)}") 107 limit_match = VERSION_FOR_RANGE_RE.fullmatch(limit_version) 108 if not limit_match: 109 raise argparse.ArgumentTypeError( 110 f"Upper limit of a browser range {repr(value)} must end in digits, " 111 f"but got {repr(limit_version)}") 112 113 start_prefix = start_match["prefix"] 114 limit_prefix = limit_match["prefix"] 115 if limit_prefix and not start_prefix.endswith(limit_prefix): 116 raise argparse.ArgumentTypeError( 117 f"Browser version range start prefix {repr(start_prefix)} must match " 118 f"limit prefix {repr(limit_prefix)}: {repr(value)}") 119 120 start_milestone: int = NumberParser.positive_int( 121 start_match["milestone"], "browser version range start milestone") 122 limit_milestone: int = NumberParser.positive_int( 123 limit_match["milestone"], "browser version range limit milestone") 124 if start_milestone > limit_milestone: 125 raise argparse.ArgumentTypeError( 126 f"Browser version limit must be larger than start: {repr(value)}") 127 128 count = limit_milestone - start_milestone 129 logging.info("Creating %d intermediate browser versions from %s", count, 130 value) 131 versions = [] 132 for milestone in range(start_milestone, limit_milestone + 1): 133 version_str = f"{start_prefix}{milestone}" 134 versions.append(cls.parse(version_str)) 135 return tuple(versions) 136 137 @classmethod 138 def _parse_path_or_identifier( 139 cls, 140 maybe_path_or_identifier: str, 141 driver_type: Optional[BrowserDriverType] = None, 142 driver: Optional[DriverConfig] = None) -> pth.AnyPathLike: 143 if not maybe_path_or_identifier: 144 raise argparse.ArgumentTypeError("Got empty browser identifier.") 145 if not driver_type: 146 if driver: 147 driver_type = driver.type 148 else: 149 driver_type = BrowserDriverType.default() 150 identifier = maybe_path_or_identifier.lower() 151 path = None 152 if "/" in maybe_path_or_identifier or "\\" in maybe_path_or_identifier: 153 if cls._is_downloadable_identifier(maybe_path_or_identifier): 154 return maybe_path_or_identifier 155 # Assume a path since short-names never contain back-/slashes. 156 if driver_type.is_remote: 157 path = PathParser.path(maybe_path_or_identifier) 158 else: 159 path = PathParser.existing_path(maybe_path_or_identifier) 160 else: 161 if ":" in maybe_path_or_identifier: 162 raise argparse.ArgumentTypeError( 163 "Got unexpected short-form string " 164 f"{repr(maybe_path_or_identifier)}. \n" 165 " - Use a complex browser config with separate " 166 "'browser' and 'driver' attributes, or\n" 167 " - Use the short-form directly on the parent config attribute: \n" 168 f" {{my-browser: '{maybe_path_or_identifier}'}}") 169 if maybe_path := cls._try_parse_short_name(identifier, driver_type): 170 return maybe_path 171 if cls._is_downloadable_identifier(maybe_path_or_identifier): 172 return maybe_path_or_identifier 173 if driver_type == BrowserDriverType.ANDROID: 174 if ANDROID_PACKAGE_RE.fullmatch(maybe_path_or_identifier): 175 return pth.AnyPosixPath(maybe_path_or_identifier) 176 if not path: 177 path = pth.try_resolve_existing_path(maybe_path_or_identifier) 178 if not path: 179 raise argparse.ArgumentTypeError( 180 f"Unknown browser path or short name: '{maybe_path_or_identifier}'") 181 if cls.is_supported_browser_path(path): 182 return path 183 raise argparse.ArgumentTypeError(f"Unsupported browser path='{path}'") 184 185 @classmethod 186 def _is_downloadable_identifier(cls, maybe_path_or_identifier: str) -> bool: 187 # TODO: handle remote platforms. 188 platform = plt.PLATFORM 189 if ChromeDownloader.is_valid(maybe_path_or_identifier, platform): 190 return True 191 if FirefoxDownloader.is_valid(maybe_path_or_identifier, platform): 192 return True 193 return False 194 195 @classmethod 196 def _try_parse_short_name( 197 cls, identifier: str, 198 driver_type: BrowserDriverType) -> Optional[pth.AnyPath]: 199 # We're not using a dict-based lookup here, since not all browsers are 200 # available on all platforms 201 # TODO: handle remote platforms. 202 platform = plt.PLATFORM 203 if identifier in ("chrome", "chrome-stable", "chr-stable", "chr"): 204 if driver_type == BrowserDriverType.ANDROID: 205 return pth.AnyPosixPath("com.android.chrome") 206 return browsers.Chrome.stable_path(platform) 207 if identifier in ("chrome-app"): 208 if driver_type == BrowserDriverType.ANDROID: 209 return pth.AnyPosixPath("com.google.android.apps.chrome") 210 if identifier in ("chrome-beta", "chr-beta"): 211 if driver_type == BrowserDriverType.ANDROID: 212 return pth.AnyPosixPath("com.chrome.beta") 213 return browsers.Chrome.beta_path(platform) 214 if identifier in ("chrome-dev", "chr-dev"): 215 if driver_type == BrowserDriverType.ANDROID: 216 return pth.AnyPosixPath("com.chrome.dev") 217 return browsers.Chrome.dev_path(platform) 218 if identifier in ("chrome-canary", "chr-canary"): 219 if driver_type == BrowserDriverType.ANDROID: 220 return pth.AnyPosixPath("com.chrome.canary") 221 return browsers.Chrome.canary_path(platform) 222 if identifier == "chromium": 223 if driver_type == BrowserDriverType.ANDROID: 224 return pth.AnyPosixPath("org.chromium.chrome") 225 return browsers.Chromium.default_path(platform) 226 if identifier in ("edge", "edge-stable"): 227 return browsers.Edge.stable_path(platform) 228 if identifier == "edge-beta": 229 return browsers.Edge.beta_path(platform) 230 if identifier == "edge-dev": 231 return browsers.Edge.dev_path(platform) 232 if identifier == "edge-canary": 233 return browsers.Edge.canary_path(platform) 234 if identifier in ("safari", "sf", "safari-stable", "sf-stable"): 235 return browsers.Safari.default_path(platform) 236 if identifier in ("safari-technology-preview", "safari-tp", "sf-tp", "tp"): 237 return browsers.Safari.technology_preview_path(platform) 238 if identifier in ("firefox", "firefox-stable", "ff", "ff-stable"): 239 return browsers.Firefox.default_path(platform) 240 if identifier in ("firefox-dev", "firefox-developer-edition", "ff-dev"): 241 return browsers.Firefox.developer_edition_path(platform) 242 if identifier in ("firefox-nightly", "ff-nightly", "ff-trunk"): 243 return browsers.Firefox.nightly_path(platform) 244 return None 245 246 @classmethod 247 def is_supported_browser_path(cls, path: pth.AnyPath) -> bool: 248 path_str = os.fspath(path).lower() 249 for short_name in SUPPORTED_BROWSER: 250 if short_name in path_str: 251 return True 252 return False 253 254 @classmethod 255 def _parse_inline_short_form( 256 cls, value: str 257 ) -> Tuple[DriverConfig, pth.AnyPathLike, Optional[NetworkConfig]]: 258 assert ":" in value 259 match = SHORT_FORM_RE.fullmatch(value) 260 if not match: 261 raise argparse.ArgumentTypeError( 262 f"Invalid browser short form: '{value}' \n" 263 "A browser path/identifier and " 264 "at least a driver or network preset have to be present") 265 driver_identifier = match.group("driver") 266 path_or_identifier = match.group("path") 267 network_identifier = match.group("network") 268 if not path_or_identifier: 269 raise argparse.ArgumentTypeError( 270 "Browser short form: missing path or browser identifier.") 271 driver = DriverConfig.default() 272 if driver_identifier is not None: 273 driver = cast(DriverConfig, DriverConfig.parse(match.group("driver"))) 274 path: pth.AnyPathLike = cls._parse_path_or_identifier( 275 path_or_identifier, driver.type) 276 network = None 277 if network_identifier is not None: 278 network = NetworkConfig.parse_str(network_identifier) 279 return (driver, path, network) 280 281 @classmethod 282 def parse_text_io(cls, f: TextIO) -> BrowserConfig: 283 with exception.annotate(f"Loading browser config file: {f.name}"): 284 config = {} 285 with exception.annotate("Parsing hjson"): 286 config = hjson.load(f) 287 with exception.annotate(f"Parsing config file: {f.name}"): 288 return cls.parse_dict(config) 289 raise argparse.ArgumentTypeError(f"Could not parse : '{f.name}'") 290 291 @classmethod 292 def parse_dict(cls, config: Dict[str, Any]) -> BrowserConfig: 293 return cls.config_parser().parse(config) 294 295 @classmethod 296 def config_parser(cls) -> ConfigParser[BrowserConfig]: 297 parser = ConfigParser("BrowserConfig parser", cls) 298 parser.add_argument( 299 "browser", 300 aliases=("path",), 301 type=cls._parse_path_or_identifier, 302 required=True, 303 depends_on=("driver",)) 304 parser.add_argument( 305 "driver", type=DriverConfig, default=DriverConfig.default()) 306 parser.add_argument("network", required=False, type=NetworkConfig) 307 return parser 308 309 @property 310 def path(self) -> pth.AnyPath: 311 assert isinstance(self.browser, pth.AnyPath) 312 return self.browser 313 314 def get_platform(self) -> plt.Platform: 315 return self.driver.get_platform() 316