• 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 datetime as dt
8import logging
9import os
10from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Type
11
12from selenium import webdriver
13from selenium.webdriver.safari.options import Options as SafariOptions
14from selenium.webdriver.safari.service import Service as SafariService
15
16from crossbench import exception, helper
17from crossbench.browsers.attributes import BrowserAttributes
18from crossbench.browsers.safari.safari import Safari, find_safaridriver
19from crossbench.browsers.webdriver import DriverException, WebDriverBrowser
20
21if TYPE_CHECKING:
22  from crossbench.browsers.settings import Settings
23  from crossbench.path import AnyPath
24  from crossbench.runner.groups.session import BrowserSessionRunGroup
25
26
27class SafariWebDriver(WebDriverBrowser, Safari):
28
29  MAX_STARTUP_TIMEOUT = dt.timedelta(seconds=10)
30
31  def __init__(self,
32               label: str,
33               path: AnyPath,
34               settings: Optional[Settings] = None):
35    super().__init__(label, path, settings)
36    assert self.platform.is_macos
37
38  @property
39  def attributes(self) -> BrowserAttributes:
40    return BrowserAttributes.SAFARI | BrowserAttributes.WEBDRIVER
41
42  def clear_cache(self) -> None:
43    # skip the default caching, and only do it after launching the browser
44    # via selenium.
45    pass
46
47  def _find_driver(self) -> AnyPath:
48    # TODO: support remote platform
49    assert self.platform.is_local, "Remote platform is not supported yet"
50    return self.host_platform.local_path(
51        find_safaridriver(self.path, self.platform))
52
53  def _start_driver(self, session: BrowserSessionRunGroup,
54                    driver_path: AnyPath) -> webdriver.Remote:
55    return self._start_safari_driver(session, driver_path)
56
57  def _start_safari_driver(self, session: BrowserSessionRunGroup,
58                           driver_path: AnyPath) -> webdriver.Safari:
59    assert not self._is_running
60    logging.info("STARTING BROWSER: browser: %s driver: %s", self.path,
61                 driver_path)
62
63    options: SafariOptions = self._get_driver_options(session)
64    session.setup_selenium_options(options)
65    self._force_clear_cache(session)
66
67    service = SafariService(executable_path=os.fspath(driver_path))
68    driver_kwargs = {"service": service, "options": options}
69
70    if webdriver.__version__ == "4.1.0":
71      # Manually inject desired options for older selenium versions
72      # (currently fixed version from vpython3).
73      self._legacy_settings(options, driver_kwargs)
74
75    with helper.Spinner():
76      driver = self._start_driver_with_retries(driver_kwargs)
77
78    assert driver.session_id, "Could not start webdriver"
79    logs: AnyPath = (
80        self.platform.home() / "Library/Logs/com.apple.WebDriver" /
81        driver.session_id)
82    all_logs = list(self.platform.glob(logs, "safaridriver*"))
83    if all_logs:
84      self.log_file = all_logs[0]
85      assert self.platform.is_file(self.log_file)
86    return driver
87
88  # TODO(cbruni): implement iOS platform
89  def _start_driver_with_retries(
90      self, driver_kwargs: Dict[str, Any]) -> webdriver.Safari:
91    # safaridriver for iOS / technology preview seems to be brittle.
92    # Let's give it several chances to start up.
93    seen_exceptions: Set[Type[Exception]] = set()
94    retries = 0
95    for _ in helper.WaitRange(
96        min=2, timeout=self.MAX_STARTUP_TIMEOUT).wait_with_backoff():
97      try:
98        return webdriver.Safari(**driver_kwargs)
99      except Exception as e:  # pylint: disable=broad-except
100        retries += 1
101        exception_type = type(e)
102        logging.warning("SafariWebDriver: startup failed (%s), retrying...",
103                        exception_type)
104        logging.debug("SafariWebDriver: startup error %s", e)
105        # After 2 retries we don't accept the same error twice.
106        if retries >= 2 and exception_type in seen_exceptions:
107          raise DriverException("Could not start SafariWebDriver") from e
108        seen_exceptions.add(type(e))
109    raise DriverException("Could not start SafariWebDriver")
110
111  def _legacy_settings(self, options, driver_kwargs) -> None:
112    logging.debug("SafariDriver: using legacy capabilities")
113    options.binary_location = str(self.path)
114    driver_kwargs["desired_capabilities"] = options.to_capabilities()
115
116  def _force_clear_cache(self, session: BrowserSessionRunGroup) -> None:
117    del session
118    with exception.annotate("Clearing Browser Cache"):
119      self._clear_cache()
120      self.platform.exec_apple_script(f"""
121        tell application "{self.app_path}" to quit """)
122
123  def _get_driver_options(self,
124                          session: BrowserSessionRunGroup) -> SafariOptions:
125    options = SafariOptions()
126    # Don't wait for document-ready.
127    options.set_capability("pageLoadStrategy", "eager")
128
129    args = self._get_browser_flags_for_session(session)
130    for arg in args:
131      options.add_argument(arg)
132
133    if self._settings.driver_logging:
134      options.set_capability("safari:diagnose", "true")
135    if "Technology Preview" in self.app_name:
136      options.set_capability("browserName", "Safari Technology Preview")
137      options.use_technology_preview = True
138    return options
139
140  def _validate_driver_version(self) -> None:
141    # The bundled driver is always ok
142    assert self._driver_path
143    for parent in self._driver_path.parents:
144      if parent == self.path.parent:
145        return
146    version = self.platform.sh_stdout(self._driver_path, "--version")
147    assert str(self.major_version) in version, (
148        f"safaridriver={self._driver_path} version='{version}' "
149        f" doesn't match safari version={self.major_version}")
150
151  def _setup_window(self) -> None:
152    super()._setup_window()
153    self.platform.exec_apple_script(f"""
154        tell application "{self.app_name}"
155          activate
156        end tell""")
157
158  def quit(self) -> None:
159    super().quit()
160    # Safari needs some additional push to quit properly
161    self.platform.exec_apple_script(f"""
162        tell application "{self.app_name}"
163          quit
164        end tell""")
165
166
167class SafariWebdriverIOS(SafariWebDriver):
168  MAX_STARTUP_TIMEOUT = dt.timedelta(seconds=15)
169
170  def _get_driver_options(self,
171                          session: BrowserSessionRunGroup) -> SafariOptions:
172    options = super()._get_driver_options(session)
173    desired_cap = {
174        # "browserName": "Safari",
175        # "browserVersion": "17.0.3", # iOS version
176        # "safari:deviceType": "iPhone",
177        # "safari:deviceName": "XXX's iPhone",
178        # "safari:deviceUDID": "...",
179        "platformName": "iOS",
180        "safari:initialUrl": "about:blank",
181        "safari:openLinksInBackground": True,
182        "safari:allowPopups": True,
183    }
184    for key, value in desired_cap.items():
185      options.set_capability(key, value)
186    return options
187
188  def _setup_window(self) -> None:
189    pass
190
191  def _force_clear_cache(self, session: BrowserSessionRunGroup) -> None:
192    pass
193
194  def quit(self) -> None:
195    self._private_driver.close()
196    self.platform.sleep(1.0)
197    self._private_driver.quit()
198    self.force_quit()
199