1# Copyright 2022 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 abc 8import contextlib 9import copy 10import dataclasses 11import pathlib 12from typing import (TYPE_CHECKING, Any, Iterator, List, Optional, Tuple, Type, 13 Union, cast) 14 15from crossbench import plt 16from crossbench.browsers.all import Chrome, Chromium, Edge, Firefox, Safari 17from crossbench.browsers.attributes import BrowserAttributes 18from crossbench.browsers.browser import Browser 19from crossbench.browsers.settings import Settings 20from crossbench.flags.chrome import ChromeFeatures, ChromeFlags 21from crossbench.flags.js_flags import JSFlags 22from crossbench.network.base import Network 23from crossbench.plt.android_adb import AndroidAdbPlatform 24 25if TYPE_CHECKING: 26 import datetime as dt 27 import re 28 29 from crossbench.cli.config.secrets import Secret 30 from crossbench.flags.base import FlagsData 31 from crossbench.runner.groups.session import BrowserSessionRunGroup 32 33 34@dataclasses.dataclass(frozen=True) 35class JsInvocation: 36 result: Any 37 script: Optional[Union[str, re.Pattern]] = None 38 arguments: Optional[List[Any]] = None 39 timeout: Optional[dt.timedelta] = None 40 41 42class MockNetwork(Network): 43 44 @contextlib.contextmanager 45 def open(self, session: BrowserSessionRunGroup) -> Iterator[Network]: 46 with super().open(session): 47 assert session.browser.network is self 48 yield self 49 assert self.is_running 50 51 52class MockBrowser(Browser, metaclass=abc.ABCMeta): 53 MACOS_BIN_NAME: str = "" 54 VERSION: str = "100.22.33.44" 55 56 @classmethod 57 @abc.abstractmethod 58 def mock_app_path(cls, platform: plt.Platform) -> pathlib.Path: 59 pass 60 61 @classmethod 62 def setup_fs(cls, fs, platform: plt.Platform = plt.PLATFORM) -> None: 63 app_path = cls.mock_app_path(platform) 64 macos_bin_name = app_path.stem 65 if cls.MACOS_BIN_NAME: 66 macos_bin_name = cls.MACOS_BIN_NAME 67 cls.setup_bin(fs, app_path, macos_bin_name, platform) 68 69 @classmethod 70 def setup_bin(cls, 71 fs, 72 bin_path: pathlib.Path, 73 macos_bin_name: str, 74 platform: plt.Platform = plt.PLATFORM) -> None: 75 if platform.is_macos: 76 assert bin_path.suffix == ".app" 77 bin_path = bin_path / "Contents" / "MacOS" / macos_bin_name 78 elif platform.is_win: 79 assert bin_path.suffix == ".exe" 80 if not bin_path.exists(): 81 fs.create_file(bin_path) 82 83 @classmethod 84 def default_flags(cls, initial_data: FlagsData = None) -> ChromeFlags: 85 return ChromeFlags(initial_data) 86 87 def __init__(self, 88 label: str, 89 path: Optional[pathlib.Path] = None, 90 settings: Optional[Settings] = None): 91 settings = settings or Settings() 92 platform = settings.platform 93 path = path or self.mock_app_path(platform) 94 self.app_path = path 95 if maybe_driver := settings.driver_path: 96 assert isinstance(maybe_driver, pathlib.Path) and maybe_driver.exists() 97 super().__init__(label, path, settings=settings) 98 self.url_list: List[str] = [] 99 self.expected_js: List[JsInvocation] = [] 100 self.expected_is_logged_in: List[Secret] = [] 101 self.invoked_js: List[JsInvocation] = [] 102 self.did_run: bool = False 103 self.clear_cache_dir: bool = False 104 self.tab_handler_generator = self._tab_handler_generator() 105 self.tab_list: List[int] = [next(self.tab_handler_generator)] 106 107 def expect_js( 108 self, 109 expected_js: Optional[JsInvocation] = None, 110 result: Any = None, 111 ) -> None: 112 if not expected_js: 113 self.expected_js.append(JsInvocation(result=result)) 114 return 115 self.expected_js.append(expected_js) 116 return 117 118 def was_js_invoked(self, script: str) -> bool: 119 return any(script is invoked_js.script for invoked_js in self.invoked_js) 120 121 def expect_is_logged_in(self, secret: Secret) -> None: 122 self.expected_is_logged_in.append(secret) 123 124 def clear_cache(self) -> None: 125 pass 126 127 def start(self, session: BrowserSessionRunGroup) -> None: 128 assert not self._is_running 129 self._is_running = True 130 self.did_run = True 131 132 def force_quit(self) -> None: 133 if not self._is_running: 134 return 135 self._is_running = False 136 137 def _extract_version(self) -> str: 138 return self.VERSION 139 140 def user_agent(self) -> str: 141 return f"Mock Browser {self.type_name}, {self.VERSION}" 142 143 def show_url(self, url, target: Optional[str] = None) -> None: 144 self.url_list.append(url) 145 146 def current_window_id(self) -> str: 147 return str(self.tab_list[-1]) 148 149 def _tab_handler_generator(self): 150 tab_handler = 0 151 while True: 152 yield tab_handler 153 tab_handler += 1 154 155 def switch_to_new_tab(self) -> None: 156 self.tab_list.append(next(self.tab_handler_generator)) 157 158 def js(self, script, timeout: Optional[dt.timedelta] = None, arguments=()): 159 self.invoked_js.append( 160 JsInvocation( 161 result=None, script=script, arguments=arguments, timeout=timeout)) 162 163 if self.expected_js is None: 164 return None 165 166 assert self.expected_js, ("Not enough expected_js available. " 167 "Please add another expected_js entry for " 168 f"arguments={arguments} \n" 169 f"Script: {script}") 170 expectation = self.expected_js.pop(0) 171 172 if expectation.timeout: 173 assert expectation.timeout == timeout, ( 174 f"JS timeout does not match. " 175 f"Expected: {expectation.timeout} Got: {timeout}") 176 177 if expected_script := expectation.script: 178 if isinstance(expected_script, str): 179 result = expected_script == script 180 else: 181 result = expected_script.fullmatch(script) 182 assert result, (f"JS script does not match expectation. " 183 f"Expected: {expected_script} Got: {script}") 184 185 if expectation.arguments: 186 assert len(expectation.arguments) == len(arguments), ( 187 f"Number of JS arguments does not match. " 188 f"Expected: {len(expectation.arguments)} Got: {len(arguments)}") 189 190 for expected_argument, argument in zip(expectation.arguments, arguments): 191 assert expected_argument == argument, ( 192 f"Arguments do not match. " 193 f"Expected: {expected_argument} Got: {argument}") 194 195 # Return copies to avoid leaking data between repetitions. 196 return copy.deepcopy(expectation.result) 197 198 def is_logged_in(self, secret: Secret, strict: bool = False) -> bool: 199 for login in self.expected_is_logged_in: 200 if login.type == secret.type: 201 if login.username == secret.username: 202 return True 203 if strict: 204 raise RuntimeError("Secret mismatch") 205 return False 206 207def app_root(platform: plt.Platform) -> pathlib.Path: 208 if platform.is_macos: 209 return pathlib.Path("/Applications") 210 if platform.is_win: 211 return pathlib.Path("C:/Program Files") 212 return pathlib.Path("/usr/bin") 213 214 215class MockChromiumBrowser(MockBrowser, metaclass=abc.ABCMeta): 216 217 def _setup_flags(self, settings: Settings) -> ChromeFlags: 218 flags = ChromeFlags(settings.flags) 219 flags.js_flags.update(settings.js_flags) 220 return flags 221 222 @property 223 def chrome_flags(self) -> ChromeFlags: 224 chrome_flags = cast(ChromeFlags, self.flags) 225 assert isinstance(chrome_flags, ChromeFlags) 226 return chrome_flags 227 228 @property 229 def js_flags(self) -> JSFlags: 230 return self.chrome_flags.js_flags 231 232 @property 233 def features(self) -> ChromeFeatures: 234 return self.chrome_flags.features 235 236 @property 237 def attributes(self) -> BrowserAttributes: 238 return BrowserAttributes.CHROMIUM | BrowserAttributes.CHROMIUM_BASED 239 240 241# Inject MockBrowser into the browser hierarchy for easier testing. 242Chromium.register(MockChromiumBrowser) 243 244 245class MockChromeBrowser(MockChromiumBrowser, metaclass=abc.ABCMeta): 246 247 @property 248 def type_name(self) -> str: 249 return "chrome" 250 251 @property 252 def attributes(self) -> BrowserAttributes: 253 return BrowserAttributes.CHROME | BrowserAttributes.CHROMIUM_BASED 254 255 256Chrome.register(MockChromeBrowser) 257if not TYPE_CHECKING: 258 assert issubclass(MockChromeBrowser, Chrome) 259 260 261class MockChromeStable(MockChromeBrowser): 262 263 @classmethod 264 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 265 if platform.is_macos: 266 return app_root(platform) / "Google Chrome.app" 267 if platform.is_win: 268 return app_root(platform) / "Google/Chrome/Application/chrome.exe" 269 return app_root(platform) / "google-chrome" 270 271 272if not TYPE_CHECKING: 273 assert issubclass(MockChromeStable, Chromium) 274 assert issubclass(MockChromeStable, Chrome) 275 276 277class MockChromeAndroidStable(MockChromeStable): 278 279 @property 280 def platform(self) -> AndroidAdbPlatform: 281 assert isinstance( 282 self._platform, 283 AndroidAdbPlatform), (f"Invalid platform: {self._platform}") 284 return cast(AndroidAdbPlatform, self._platform) 285 286 def _resolve_binary(self, path: pathlib.Path) -> pathlib.Path: 287 return path 288 289 @property 290 def attributes(self) -> BrowserAttributes: 291 return (BrowserAttributes.CHROME | BrowserAttributes.CHROMIUM_BASED 292 | BrowserAttributes.MOBILE) 293 294 295class MockChromeBeta(MockChromeBrowser): 296 VERSION = "101.22.33.44" 297 298 @classmethod 299 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 300 if platform.is_macos: 301 return app_root(platform) / "Google Chrome Beta.app" 302 if platform.is_win: 303 return app_root(platform) / "Google/Chrome Beta/Application/chrome.exe" 304 return app_root(platform) / "google-chrome-beta" 305 306 307class MockChromeDev(MockChromeBrowser): 308 VERSION = "102.22.33.44" 309 310 @classmethod 311 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 312 if platform.is_macos: 313 return app_root(platform) / "Google Chrome Dev.app" 314 if platform.is_win: 315 return app_root(platform) / "Google/Chrome Dev/Application/chrome.exe" 316 return app_root(platform) / "google-chrome-unstable" 317 318 319class MockChromeCanary(MockChromeBrowser): 320 VERSION = "103.22.33.44" 321 322 @classmethod 323 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 324 if platform.is_macos: 325 return app_root(platform) / "Google Chrome Canary.app" 326 if platform.is_win: 327 return app_root(platform) / "Google/Chrome SxS/Application/chrome.exe" 328 return app_root(platform) / "google-chrome-canary" 329 330 331class MockEdgeBrowser(MockChromiumBrowser, metaclass=abc.ABCMeta): 332 333 @property 334 def type_name(self) -> str: 335 return "edge" 336 337 @property 338 def attributes(self) -> BrowserAttributes: 339 return BrowserAttributes.EDGE | BrowserAttributes.CHROMIUM_BASED 340 341 342Edge.register(MockEdgeBrowser) 343if not TYPE_CHECKING: 344 assert issubclass(MockEdgeBrowser, Chromium) 345 assert issubclass(MockEdgeBrowser, Edge) 346 347 348class MockEdgeStable(MockEdgeBrowser): 349 350 @classmethod 351 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 352 if platform.is_macos: 353 return app_root(platform) / "Microsoft Edge.app" 354 if platform.is_win: 355 return app_root(platform) / "Microsoft/Edge/Application/msedge.exe" 356 return app_root(platform) / "microsoft-edge" 357 358 359class MockEdgeBeta(MockEdgeBrowser): 360 VERSION = "101.22.33.44" 361 362 @classmethod 363 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 364 if platform.is_macos: 365 return app_root(platform) / "Microsoft Edge Beta.app" 366 if platform.is_win: 367 return app_root(platform) / "Microsoft/Edge Beta/Application/msedge.exe" 368 return app_root(platform) / "microsoft-edge-beta" 369 370 371class MockEdgeDev(MockEdgeBrowser): 372 VERSION = "102.22.33.44" 373 374 @classmethod 375 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 376 if platform.is_macos: 377 return app_root(platform) / "Microsoft Edge Dev.app" 378 if platform.is_win: 379 return app_root(platform) / "Microsoft/Edge Dev/Application/msedge.exe" 380 return app_root(platform) / "microsoft-edge-dev" 381 382 383class MockEdgeCanary(MockEdgeBrowser): 384 VERSION = "103.22.33.44" 385 386 @classmethod 387 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 388 if platform.is_macos: 389 return app_root(platform) / "Microsoft Edge Canary.app" 390 if platform.is_win: 391 return app_root(platform) / "Microsoft/Edge SxS/Application/msedge.exe" 392 return app_root(platform) / "unsupported/msedge-canary" 393 394 395class MockSafariBrowser(MockBrowser, metaclass=abc.ABCMeta): 396 397 @property 398 def type_name(self) -> str: 399 return "safari" 400 401 @property 402 def attributes(self) -> BrowserAttributes: 403 return BrowserAttributes.SAFARI 404 405 406Safari.register(MockSafariBrowser) 407if not TYPE_CHECKING: 408 assert issubclass(MockSafariBrowser, Safari) 409 410 411class MockSafari(MockSafariBrowser): 412 413 @classmethod 414 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 415 if platform.is_macos: 416 return app_root(platform) / "Safari.app" 417 if platform.is_win: 418 return app_root(platform) / "Unsupported/Safari.exe" 419 return pathlib.Path("/unsupported-platform/Safari") 420 421 422class MockSafariTechnologyPreview(MockSafariBrowser): 423 424 @classmethod 425 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 426 if platform.is_macos: 427 return app_root(platform) / "Safari Technology Preview.app" 428 if platform.is_win: 429 return app_root(platform) / "Unsupported/Safari Technology Preview.exe" 430 return pathlib.Path("/unsupported-platform/Safari Technology Preview") 431 432 433class MockFirefoxBrowser(MockBrowser, metaclass=abc.ABCMeta): 434 435 @property 436 def type_name(self) -> str: 437 return "firefox" 438 439 @property 440 def attributes(self) -> BrowserAttributes: 441 return BrowserAttributes.FIREFOX 442 443 444Firefox.register(MockFirefoxBrowser) 445if not TYPE_CHECKING: 446 assert issubclass(MockFirefoxBrowser, Firefox) 447 448 449class MockFirefox(MockFirefoxBrowser): 450 451 @classmethod 452 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 453 if platform.is_macos: 454 return app_root(platform) / "Firefox.app" 455 if platform.is_win: 456 return app_root(platform) / "Mozilla Firefox/firefox.exe" 457 return app_root(platform) / "firefox" 458 459 460class MockFirefoxDeveloperEdition(MockFirefoxBrowser): 461 462 @classmethod 463 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 464 if platform.is_macos: 465 return app_root(platform) / "Firefox Developer Edition.app" 466 if platform.is_win: 467 return app_root(platform) / "Firefox Developer Edition/firefox.exe" 468 return app_root(platform) / "firefox-developer-edition" 469 470 471class MockFirefoxNightly(MockFirefoxBrowser): 472 473 @classmethod 474 def mock_app_path(cls, platform: plt.Platform = plt.PLATFORM) -> pathlib.Path: 475 if platform.is_macos: 476 return app_root(platform) / "Firefox Nightly.app" 477 if platform.is_win: 478 return app_root(platform) / "Firefox Nightly/firefox.exe" 479 return app_root(platform) / "firefox-trunk" 480 481 482ALL: Tuple[Type[MockBrowser], ...] = ( 483 MockChromeCanary, 484 MockChromeDev, 485 MockChromeBeta, 486 MockChromeStable, 487 MockEdgeCanary, 488 MockEdgeDev, 489 MockEdgeBeta, 490 MockEdgeStable, 491 MockSafari, 492 MockSafariTechnologyPreview, 493 MockFirefox, 494 MockFirefoxDeveloperEdition, 495 MockFirefoxNightly, 496) 497