fixtures.py (17531B)
1 # This Source Code Form is subject to the terms of the Mozilla Public 2 # License, v. 2.0. If a copy of the MPL was not distributed with this 3 # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5 import asyncio 6 import json 7 import math 8 import os 9 import re 10 import subprocess 11 import sys 12 from datetime import datetime 13 14 import pytest 15 import webdriver 16 17 from client import Client 18 19 try: 20 import pathlib 21 except ImportError: 22 import pathlib2 as pathlib 23 24 CB_PBM_PREF = "network.cookie.cookieBehavior.pbmode" 25 CB_PREF = "network.cookie.cookieBehavior" 26 INTERVENTIONS_PREF = "extensions.webcompat.enable_interventions" 27 NOTIFICATIONS_PERMISSIONS_PREF = "permissions.default.desktop-notification" 28 PBM_PREF = "browser.privatebrowsing.autostart" 29 PIP_OVERRIDES_PREF = "extensions.webcompat.enable_picture_in_picture_overrides" 30 SHIMS_PREF = "extensions.webcompat.enable_shims" 31 STRICT_ETP_PREF = "privacy.trackingprotection.enabled" 32 SYSTEM_ADDON_UPDATES_PREF = "extensions.systemAddon.update.enabled" 33 DOWNLOAD_TO_TEMP_PREF = "browser.download.start_downloads_in_tmp_dir" 34 DELETE_DOWNLOADS_PREF = "browser.helperApps.deleteTempFileOnExit" 35 PLATFORM_OVERRIDE_PREF = "extensions.webcompat.platform_override" 36 37 38 class WebDriver: 39 def __init__(self, config): 40 self.browser_binary = config.getoption("browser_binary") 41 self.device_serial = config.getoption("device_serial") 42 self.package_name = config.getoption("package_name") 43 self.addon = config.getoption("addon") 44 self.webdriver_binary = config.getoption("webdriver_binary") 45 self.port = config.getoption("webdriver_port") 46 self.ws_port = config.getoption("webdriver_ws_port") 47 self.log_level = config.getoption("webdriver_log_level") 48 self.headless = config.getoption("headless") 49 self.debug = config.getoption("debug") 50 self.proc = None 51 52 def command_line_driver(self): 53 raise NotImplementedError 54 55 def capabilities(self, request, test_config): 56 raise NotImplementedError 57 58 def __enter__(self): 59 assert self.proc is None 60 self.proc = subprocess.Popen(self.command_line_driver()) 61 return self 62 63 def __exit__(self, *args, **kwargs): 64 self.proc.kill() 65 66 67 class FirefoxWebDriver(WebDriver): 68 def command_line_driver(self): 69 rv = [ 70 self.webdriver_binary, 71 "--port", 72 str(self.port), 73 "--websocket-port", 74 str(self.ws_port), 75 ] 76 if self.debug: 77 rv.append("-vv") 78 elif self.log_level == "DEBUG": 79 rv.append("-v") 80 return rv 81 82 def capabilities(self, request, test_config): 83 prefs = {} 84 85 override = request.config.getoption("platform_override") 86 if override: 87 prefs[PLATFORM_OVERRIDE_PREF] = override 88 89 if "use_interventions" in test_config: 90 value = test_config["use_interventions"] 91 prefs[INTERVENTIONS_PREF] = value 92 prefs[PIP_OVERRIDES_PREF] = value 93 94 if "use_pbm" in test_config: 95 prefs[PBM_PREF] = test_config["use_pbm"] 96 97 if "use_shims" in test_config: 98 prefs[SHIMS_PREF] = test_config["use_shims"] 99 100 if "use_strict_etp" in test_config: 101 prefs[STRICT_ETP_PREF] = test_config["use_strict_etp"] 102 103 if test_config.get("no_overlay_scrollbars"): 104 prefs["widget.gtk.overlay-scrollbars.enabled"] = False 105 prefs["widget.windows.overlay-scrollbars.enabled"] = False 106 107 if test_config.get("enable_webkit_fill_available"): 108 prefs["layout.css.webkit-fill-available.enabled"] = True 109 elif test_config.get("disable_webkit_fill_available"): 110 prefs["layout.css.webkit-fill-available.enabled"] = False 111 112 if test_config.get("enable_moztransform"): 113 prefs["layout.css.prefixes.transforms"] = True 114 elif test_config.get("disable_moztransform"): 115 prefs["layout.css.prefixes.transforms"] = False 116 117 # keep system addon updates off to prevent bug 1882562 118 prefs[SYSTEM_ADDON_UPDATES_PREF] = False 119 120 cookieBehavior = 4 if test_config.get("without_tcp") else 5 121 prefs[CB_PREF] = cookieBehavior 122 prefs[CB_PBM_PREF] = cookieBehavior 123 124 # prevent "allow notifications for?" popups by setting the 125 # default permission for notificaitons to PERM_DENY_ACTION. 126 prefs[NOTIFICATIONS_PERMISSIONS_PREF] = 2 127 128 # if any downloads happen, put them in a temporary folder. 129 prefs[DOWNLOAD_TO_TEMP_PREF] = True 130 # also delete those files afterward. 131 prefs[DELETE_DOWNLOADS_PREF] = True 132 133 fx_options = {"args": ["--remote-allow-system-access"], "prefs": prefs} 134 135 if self.browser_binary: 136 fx_options["binary"] = self.browser_binary 137 if self.headless: 138 fx_options["args"].append("--headless") 139 140 if self.device_serial: 141 fx_options["androidDeviceSerial"] = self.device_serial 142 fx_options["androidPackage"] = self.package_name 143 144 if self.addon: 145 prefs["xpinstall.signatures.required"] = False 146 prefs["extensions.experiments.enabled"] = True 147 148 return { 149 "pageLoadStrategy": "normal", 150 "moz:firefoxOptions": fx_options, 151 } 152 153 154 @pytest.fixture(scope="session") 155 def should_do_2fa(request): 156 return request.config.getoption("do2fa", False) 157 158 159 @pytest.fixture(scope="session") 160 def config_file(request): 161 path = request.config.getoption("config") 162 if not path: 163 return None 164 with open(path) as f: 165 return json.load(f) 166 167 168 @pytest.fixture 169 def bug_number(request): 170 return re.findall(r"\d+", str(request.fspath.basename))[0] 171 172 173 @pytest.fixture 174 def in_headless_mode(request, session): 175 # Android cannot be headless even if we request it on the commandline. 176 if session.capabilities["platformName"] == "android": 177 return False 178 179 180 @pytest.fixture 181 def credentials(bug_number, config_file): 182 if not config_file: 183 pytest.skip(f"login info required for bug #{bug_number}") 184 return None 185 186 try: 187 credentials = config_file[bug_number] 188 except KeyError: 189 pytest.skip(f"no login for bug #{bug_number} found") 190 return 191 192 return {"username": credentials["username"], "password": credentials["password"]} 193 194 195 @pytest.fixture(scope="session") 196 def driver(pytestconfig): 197 if pytestconfig.getoption("browser") == "firefox": 198 cls = FirefoxWebDriver 199 else: 200 assert False 201 202 with cls(pytestconfig) as driver_instance: 203 yield driver_instance 204 205 206 @pytest.hookimpl(tryfirst=True, hookwrapper=True) 207 def pytest_runtest_makereport(item, call): 208 outcome = yield 209 rep = outcome.get_result() 210 setattr(item, "rep_" + rep.when, rep) 211 212 213 @pytest.fixture(scope="function", autouse=True) 214 async def test_failed_check(request): 215 yield 216 if ( 217 not request.config.getoption("no_failure_screenshots") 218 and request.node.rep_setup.passed 219 and request.node.rep_call.failed 220 ): 221 session = request.node.funcargs["session"] 222 file_name = f"{request.node.nodeid}_failure_{datetime.today().strftime('%Y-%m-%d_%H:%M')}.png".replace( 223 "/", "_" 224 ).replace("::", "__") 225 dest_dir = request.config.getoption("failure_screenshots_dir") 226 try: 227 await take_screenshot(session, file_name, dest_dir=dest_dir) 228 print("Saved failure screenshot to: ", file_name) 229 except Exception as e: 230 print("Error saving screenshot: ", e) 231 232 233 async def take_screenshot(session, file_name, dest_dir=None): 234 if dest_dir: 235 cwd = pathlib.Path(dest_dir) 236 else: 237 cwd = pathlib.Path(os.getcwd()) 238 path = cwd / file_name 239 240 top = await session.bidi_session.browsing_context.get_tree() 241 screenshot = await session.bidi_session.browsing_context.capture_screenshot( 242 context=top[0]["context"] 243 ) 244 245 with path.open("wb") as strm: 246 strm.write(screenshot) 247 248 return file_name 249 250 251 @pytest.fixture(scope="session") 252 def event_loop(): 253 return asyncio.get_event_loop_policy().new_event_loop() 254 255 256 @pytest.fixture(scope="function") 257 async def client(request, session, event_loop): 258 client = Client(request, session, event_loop) 259 yield client 260 261 # force-cancel any active downloads to prevent dialogs on exit 262 with client.using_context("chrome"): 263 client.execute_async_script( 264 """ 265 const done = arguments[0]; 266 const { Downloads } = ChromeUtils.importESModule( 267 "resource://gre/modules/Downloads.sys.mjs" 268 ); 269 Downloads.getList(Downloads.ALL).then(list => { 270 list.getAll().then(downloads => { 271 Promise.allSettled(downloads.map(download => [ 272 list.remove(download), 273 download.finalize(true) 274 ]).flat()).then(done); 275 }); 276 }); 277 """ 278 ) 279 280 281 def install_addon(session, addon_file_path): 282 context = session.send_session_command("GET", "moz/context") 283 session.send_session_command("POST", "moz/context", {"context": "chrome"}) 284 session.execute_async_script( 285 """ 286 async function installAsBuiltinExtension(xpi) { 287 // The built-in location requires a resource: URL that maps to a 288 // jar: or file: URL. This would typically be something bundled 289 // into omni.ja but we use a temp file. 290 let base = Services.io.newURI(`jar:file:${xpi.path}!/`); 291 let resProto = Services.io 292 .getProtocolHandler("resource") 293 .QueryInterface(Ci.nsIResProtocolHandler); 294 resProto.setSubstitution("ext-test", base); 295 return AddonManager.installBuiltinAddon("resource://ext-test/"); 296 } 297 298 const addon_file_path = arguments[0]; 299 const cb = arguments[1]; 300 const { AddonManager } = ChromeUtils.importESModule( 301 "resource://gre/modules/AddonManager.sys.mjs" 302 ); 303 const { ExtensionPermissions } = ChromeUtils.importESModule( 304 "resource://gre/modules/ExtensionPermissions.sys.mjs" 305 ); 306 const { FileUtils } = ChromeUtils.importESModule( 307 "resource://gre/modules/FileUtils.sys.mjs" 308 ); 309 const file = new FileUtils.File(arguments[0]); 310 installAsBuiltinExtension(file).then(addon => { 311 // also make sure the addon works in private browsing mode 312 const incognitoPermission = { 313 permissions: ["internal:privateBrowsingAllowed"], 314 origins: [], 315 }; 316 ExtensionPermissions.add(addon.id, incognitoPermission).then(() => { 317 addon.reload().then(cb); 318 }); 319 }); 320 """, 321 [addon_file_path], 322 ) 323 session.send_session_command("POST", "moz/context", {"context": context}) 324 325 326 @pytest.fixture(scope="function") 327 async def session(driver, request, test_config): 328 caps = driver.capabilities(request, test_config) 329 caps.update({ 330 "acceptInsecureCerts": True, 331 "webSocketUrl": True, 332 "unhandledPromptBehavior": "dismiss", 333 }) 334 caps = {"alwaysMatch": caps} 335 print(caps) 336 337 session = None 338 for i in range(0, 15): 339 try: 340 if not session: 341 session = webdriver.Session( 342 "localhost", driver.port, capabilities=caps, enable_bidi=True 343 ) 344 session.test_config = test_config 345 session.start() 346 break 347 except (ConnectionRefusedError, webdriver.error.TimeoutException): 348 await asyncio.sleep(0.5) 349 350 try: 351 await session.bidi_session.start() 352 except AttributeError: 353 sys.exit("Could not start a WebDriver session; please try again") 354 355 if driver.addon: 356 install_addon(session, driver.addon) 357 358 yield session 359 360 await session.bidi_session.end() 361 try: 362 session.end() 363 except webdriver.error.UnknownErrorException: 364 pass 365 366 367 @pytest.fixture(autouse=True) 368 def firefox_version(session): 369 raw = session.capabilities["browserVersion"] 370 clean = re.findall(r"(\d+(\.\d+)?)", raw)[0][0] 371 return float(clean) 372 373 374 @pytest.fixture(autouse=True) 375 def platform(request, session, test_config): 376 return ( 377 request.config.getoption("platform_override") 378 or session.capabilities["platformName"] 379 ) 380 381 382 @pytest.fixture(autouse=True) 383 def channel(session): 384 ver = session.capabilities["browserVersion"] 385 if "a" in ver: 386 return "nightly" 387 elif "b" in ver: 388 return "beta" 389 elif "esr" in ver: 390 return "esr" 391 return "stable" 392 393 394 @pytest.fixture(autouse=True) 395 def check_visible_scrollbars(session): 396 plat = session.capabilities["platformName"] 397 if plat == "android": 398 return "Android does not have visible scrollbars" 399 elif plat == "mac": 400 cmd = ["defaults", "read", "-g", "AppleShowScrollBars"] 401 p = subprocess.Popen(cmd, stdout=subprocess.PIPE) 402 p.wait() 403 if "Always" in str(p.stdout.readline()): 404 return None 405 return "scrollbars are not set to always be visible in MacOS system preferences" 406 return None 407 408 409 @pytest.fixture(autouse=True) 410 def need_visible_scrollbars(bug_number, check_visible_scrollbars, request, session): 411 if request.node.get_closest_marker("need_visible_scrollbars"): 412 if ( 413 request.node.get_closest_marker("need_visible_scrollbars") 414 and check_visible_scrollbars 415 ): 416 pytest.skip(f"Bug #{bug_number} skipped: {check_visible_scrollbars}") 417 418 419 @pytest.fixture(autouse=True) 420 def only_firefox_versions(bug_number, firefox_version, request): 421 if request.node.get_closest_marker("only_firefox_versions"): 422 kwargs = request.node.get_closest_marker("only_firefox_versions").kwargs 423 424 min = float(kwargs["min"]) if "min" in kwargs else 0.0 425 if firefox_version < min: 426 pytest.skip( 427 f"Bug #{bug_number} skipped on this Firefox version ({firefox_version} < {min})" 428 ) @ pytest.fixture(autouse=True) 429 430 if "max" in kwargs: 431 max = kwargs["max"] 432 433 # if we don't care about the minor version, ignore it 434 bad = False 435 if isinstance(max, float): 436 bad = firefox_version > max 437 else: 438 bad = math.floor(firefox_version) > max 439 440 if bad: 441 pytest.skip( 442 f"Bug #{bug_number} skipped on this Firefox version ({firefox_version} > {max})" 443 ) @ pytest.fixture(autouse=True) 444 445 446 @pytest.fixture(autouse=True) 447 def only_platforms(bug_number, platform, request, session): 448 is_fenix = "org.mozilla.fenix" in session.capabilities.get("moz:profile", "") 449 is_gve = "org.mozilla.geckoview_example" in session.capabilities.get( 450 "moz:profile", "" 451 ) 452 actualPlatform = session.capabilities["platformName"] 453 actualPlatformRequired = request.node.get_closest_marker("actual_platform_required") 454 if actualPlatformRequired and request.config.getoption("platform_override"): 455 pytest.skip( 456 f"Bug #{bug_number} skipped; needs to be run on the actual platform, won't work while overriding" 457 ) 458 if request.node.get_closest_marker("only_platforms"): 459 plats = request.node.get_closest_marker("only_platforms").args 460 for only in plats: 461 if ( 462 only == platform 463 or (only == "fenix" and is_fenix) 464 or (only == "gve" and is_gve) 465 ): 466 if actualPlatform == platform or not actualPlatformRequired: 467 return 468 pytest.skip( 469 f"Bug #{bug_number} skipped on platform ({platform}, test only for {' or '.join(plats)})" 470 ) 471 472 473 @pytest.fixture(autouse=True) 474 def skip_platforms(bug_number, platform, request, session): 475 is_fenix = "org.mozilla.fenix" in session.capabilities.get("moz:profile", "") 476 is_gve = "org.mozilla.geckoview_example" in session.capabilities.get( 477 "moz:profile", "" 478 ) 479 if request.node.get_closest_marker("skip_platforms"): 480 plats = request.node.get_closest_marker("skip_platforms").args 481 for skipped in plats: 482 if ( 483 skipped == platform 484 or (skipped == "fenix" and is_fenix) 485 or (skipped == "gve" and is_gve) 486 ): 487 pytest.skip( 488 f"Bug #{bug_number} skipped on platform ({platform}, test skipped for {' and '.join(plats)})" 489 ) 490 491 492 @pytest.fixture(autouse=True) 493 def only_channels(bug_number, channel, request, session): 494 if request.node.get_closest_marker("only_channels"): 495 channels = request.node.get_closest_marker("only_channels").args 496 for only in channels: 497 if only == channel: 498 return 499 pytest.skip( 500 f"Bug #{bug_number} skipped on channel ({channel}, test only for {' or '.join(channels)})" 501 ) 502 503 504 @pytest.fixture(autouse=True) 505 def skip_channels(bug_number, channel, request, session): 506 if request.node.get_closest_marker("skip_channels"): 507 channels = request.node.get_closest_marker("skip_channels").args 508 for skipped in channels: 509 if skipped == channel: 510 pytest.skip( 511 f"Bug #{bug_number} skipped on channel ({channel}, test skipped for {' and '.join(channels)})" 512 )