• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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