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