client.py (64588B)
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 contextlib 7 import math 8 import time 9 import zipfile 10 from base64 import b64decode, b64encode 11 from io import BytesIO 12 from urllib.parse import quote 13 14 import pytest 15 import webdriver 16 from PIL import Image 17 from webdriver.bidi.error import InvalidArgumentException, NoSuchFrameException 18 from webdriver.bidi.modules.script import ContextTarget 19 20 21 class Client: 22 def __init__(self, request, session, event_loop): 23 self.request = request 24 self.session = session 25 self.event_loop = event_loop 26 self.subscriptions = {} 27 self.content_blocker_loaded = False 28 29 platform_override = request.config.getoption("platform_override") 30 if ( 31 platform_override 32 and platform_override != session.capabilities["platformName"] 33 ): 34 self.platform_override = platform_override 35 36 self._start_collecting_alerts() 37 38 async def set_page_zoom_level(self, level): 39 with self.using_context("chrome"): 40 self.execute_script( 41 r""" 42 const [ level ] = arguments; 43 const win = browser.ownerGlobal; 44 win.ZoomManager.setZoomForBrowser(win.gBrowser.selectedTab.linkedBrowser, level); 45 """, 46 level, 47 ) 48 49 async def maybe_enable_font_inflation(self): 50 # GVE does not enable font inflation by default. We want to match Fenix. 51 if self.session.capabilities["platformName"] != "android": 52 return 53 with self.using_context("chrome"): 54 self.execute_script( 55 r""" 56 const minTwips = "font.size.inflation.minTwips"; 57 if (!Services.prefs.getIntPref(minTwips)) { 58 Services.prefs.setIntPref(minTwips, 120); 59 } 60 """ 61 ) 62 63 async def maybe_override_platform(self): 64 if hasattr(self, "_platform_override_checked"): 65 return 66 self._platform_override_checked = True 67 68 if not hasattr(self, "platform_override"): 69 return False 70 71 target = self.platform_override 72 73 with self.using_context("chrome"): 74 self.execute_script( 75 r""" 76 const [ target ] = arguments; 77 78 // Start responsive design mode if emulating an Android device. 79 if (target === "android") { 80 Services.prefs.setBoolPref("devtools.responsive.touchSimulation.enabled", true); 81 Services.prefs.setIntPref("devtools.responsive.viewport.pixelRatio", 2); 82 Services.prefs.setIntPref("devtools.responsive.viewport.width", 400); 83 Services.prefs.setIntPref("devtools.responsive.viewport.height", 640); 84 ChromeUtils.defineESModuleGetters(this, { 85 loader: "resource://devtools/shared/loader/Loader.sys.mjs", 86 }); 87 loader.lazyRequireGetter( 88 this, 89 "ResponsiveUIManager", 90 "resource://devtools/client/responsive/manager.js" 91 ); 92 const tab = gBrowser.selectedTab; 93 ResponsiveUIManager.toggle(gBrowser.ownerDocument.defaultView, tab, { 94 trigger: "toolbox", 95 }); 96 } 97 98 const ver = navigator.userAgent.match(/Firefox\/([0-9.]+)/)[1]; 99 const overrides = { 100 android: { 101 appVersion: "5.0 (Android 11)", 102 oscpu: "Linux armv81", 103 platform: "Linux armv81", 104 userAgent: `Mozilla/5.0 (Android 15; Mobile; rv:${ver}) Gecko/${ver} Firefox/${ver}`, 105 }, 106 linux: { 107 appVersion: "5.0 (X11)", 108 oscpu: "Linux x86_64", 109 platform: "Linux x86_64", 110 userAgent: `Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:${ver}) Gecko/20100101 Firefox/${ver}`, 111 }, 112 mac: { 113 appVersion: "5.0 (Macintosh)", 114 oscpu: "Intel Mac OS X 10.15", 115 platform: "MacIntel", 116 userAgent: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${ver}) Gecko/20100101 Firefox/${ver}`, 117 }, 118 windows: { 119 appVersion: "5.0 (Windows)", 120 oscpu: "Windows NT 10.0; Win64; x64", 121 platform: "Win32", 122 userAgent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${ver}) Gecko/20100101 Firefox/${ver}`, 123 }, 124 }[target]; 125 if (overrides) { 126 const { appVersion, oscpu, platform, userAgent } = overrides; 127 Services.prefs.setCharPref("general.appversion.override", appVersion); 128 Services.prefs.setCharPref("general.oscpu.override", oscpu); 129 Services.prefs.setCharPref("general.platform.override", platform); 130 131 // We must override the userAgent as a header, like our addon does. 132 const defaultUA = navigator.userAgent; 133 Services.obs.addObserver(function (subject) { 134 const channel = subject.QueryInterface(Ci.nsIHttpChannel); 135 // If we raced with the webcompat addon, and it changed the UA already, leave it alone. 136 if (defaultUA === channel.getRequestHeader("user-agent")) { 137 channel.setRequestHeader("user-agent", userAgent, true); 138 } 139 }, "http-on-modify-request"); 140 } 141 """, 142 target, 143 ) 144 145 @property 146 def current_url(self): 147 return self.session.url 148 149 @property 150 def alert(self): 151 return self.session.alert 152 153 @property 154 def context(self): 155 return self.session.send_session_command("GET", "moz/context") 156 157 @context.setter 158 def context(self, context): 159 self.session.send_session_command("POST", "moz/context", {"context": context}) 160 161 @contextlib.contextmanager 162 def using_context(self, context): 163 orig_context = self.context 164 needs_change = context != orig_context 165 166 if needs_change: 167 self.context = context 168 169 try: 170 yield 171 finally: 172 if needs_change: 173 self.context = orig_context 174 175 def set_screen_size(self, width, height): 176 if self.request.config.getoption("platform_override") == "android": 177 return False 178 if self.session.capabilities.get("setWindowRect"): 179 self.session.window.size = (width, height) 180 return True 181 return False 182 183 def get_element_screen_position(self, element): 184 return self.execute_script( 185 """ 186 const e = arguments[0]; 187 const b = e.getClientRects()[0]; 188 const leftChrome = window.mozInnerScreenX; 189 const topChrome = window.mozInnerScreenY; 190 const x = window.scrollX + leftChrome + b.x; 191 const y = window.scrollY + topChrome + b.y; 192 return [x, y]; 193 """, 194 element, 195 ) 196 197 async def send_apz_scroll_gesture( 198 self, units, element=None, offset=None, coords=None 199 ): 200 if coords is None: 201 if element is None: 202 raise ValueError("require coords and/or element") 203 coords = self.get_element_screen_position(element) 204 if offset is not None: 205 coords[0] += offset[0] 206 coords[1] += offset[1] 207 with self.using_context("chrome"): 208 return self.execute_async_script( 209 """ 210 const [units, coords, done] = arguments; 211 const { devicePixelRatio, windowUtils } = window; 212 const resolution = windowUtils.getResolution(); 213 const toScreenCoords = x => x * devicePixelRatio * resolution; 214 215 // based on nativeVerticalWheelEventMsg() 216 let msg = 4; // linux default 217 switch (Services.appinfo.OS) { 218 case "WINNT": 219 msg = 0x0115; // WM_VSCROLL 220 break; 221 case "Darwin": 222 msg = 1; // use a gesture; don't synthesize a wheel scroll 223 break; 224 } 225 226 windowUtils.sendNativeMouseScrollEvent( 227 toScreenCoords(coords[0]), 228 toScreenCoords(coords[1]), 229 msg, 230 0, 231 units, 232 0, 233 0, 234 0, 235 document.documentElement, 236 () => { done(); }, 237 ); 238 """, 239 units, 240 coords, 241 ) 242 243 async def send_apz_mouse_event( 244 self, event_type, coords=None, element=None, offset=None, button=0 245 ): 246 # note: use button=2 for context menu/right click (0 is left button) 247 if event_type == "down": 248 message = "BUTTON_DOWN" 249 elif event_type == "up": 250 message = "BUTTON_UP" 251 elif event_type == "move": 252 message = "MOVE" 253 else: 254 raise ValueError("event_type must be 'down', 'up' or 'move'") 255 if coords is None: 256 if element is None: 257 raise ValueError("require coords and/or element") 258 coords = self.get_element_screen_position(element) 259 if offset: 260 coords[0] += offset[0] 261 coords[1] += offset[1] 262 with self.using_context("chrome"): 263 return self.execute_async_script( 264 """ 265 const [coords, message, button, done] = arguments; 266 const { devicePixelRatio, windowUtils } = window; 267 const resolution = windowUtils.getResolution(); 268 const toScreenCoords = x => x * devicePixelRatio * resolution; 269 windowUtils.sendNativeMouseEvent( 270 toScreenCoords(coords[0]), 271 toScreenCoords(coords[1]), 272 windowUtils[`NATIVE_MOUSE_MESSAGE_${message}`], 273 button, 274 0, 275 window.document.documentElement, 276 () => { done(coords); }, 277 ); 278 """, 279 coords, 280 message, 281 button, 282 ) 283 284 async def apz_down(self, **kwargs): 285 return await self.send_apz_mouse_event("down", **kwargs) 286 287 async def apz_up(self, **kwargs): 288 return await self.send_apz_mouse_event("up", **kwargs) 289 290 async def apz_move(self, **kwargs): 291 return await self.send_apz_mouse_event("move", **kwargs) 292 293 async def apz_click(self, **kwargs): 294 await self.apz_down(**kwargs) 295 return await self.apz_up(**kwargs) 296 297 def apz_scroll(self, element, dx=0, dy=0, dz=0): 298 pt = self.get_element_screen_position(element) 299 with self.using_context("chrome"): 300 return self.execute_script( 301 """ 302 const [pt, delta] = arguments; 303 const windowUtils = window.windowUtils; 304 const scale = window.devicePixelRatio; 305 const resolution = windowUtils.getResolution(); 306 const toScreenCoords = x => x * scale * resolution; 307 const coords = pt.map(toScreenCoords); 308 window.windowUtils.sendWheelEvent( 309 coords[0], 310 coords[1], 311 delta[0], 312 delta[1], 313 delta[2], 314 WheelEvent.DOM_DELTA_PIXEL, 315 0, 316 delta[0] > 0 ? 1 : -1, 317 delta[1] > 0 ? 1 : -1, 318 undefined, 319 ); 320 """, 321 pt, 322 [dx, dy, dz], 323 ) 324 325 def wait_for_content_blocker(self): 326 if not self.content_blocker_loaded: 327 with self.using_context("chrome"): 328 self.session.execute_async_script( 329 """ 330 const done = arguments[0], 331 signal = "safebrowsing-update-finished"; 332 function finish() { 333 Services.obs.removeObserver(finish, signal); 334 done(); 335 } 336 Services.obs.addObserver(finish, signal); 337 """ 338 ) 339 self.content_blocker_loaded = True 340 341 @property 342 def keyboard(self): 343 return self.session.actions.sequence("key", "keyboard_id") 344 345 @property 346 def mouse(self): 347 return self.session.actions.sequence( 348 "pointer", "pointer_id", {"pointerType": "mouse"} 349 ) 350 351 @property 352 def pen(self): 353 return self.session.actions.sequence( 354 "pointer", "pointer_id", {"pointerType": "pen"} 355 ) 356 357 @property 358 def touch(self): 359 return self.session.actions.sequence( 360 "pointer", "pointer_id", {"pointerType": "touch"} 361 ) 362 363 @property 364 def wheel(self): 365 return self.session.actions.sequence("wheel", "wheel_id") 366 367 @property 368 def modifier_key(self): 369 if self.session.capabilities["platformName"] == "mac": 370 return "\ue03d" # meta (command) 371 else: 372 return "\ue009" # control 373 374 def inline(self, doc): 375 return f"data:text/html;charset=utf-8,{quote(doc)}" 376 377 async def top_context(self): 378 contexts = await self.session.bidi_session.browsing_context.get_tree() 379 return contexts[0] 380 381 async def subscribe(self, events): 382 if type(events) is not list: 383 events = [events] 384 385 must_sub = [] 386 for event in events: 387 if not event in self.subscriptions: 388 must_sub.append(event) 389 self.subscriptions[event] = 1 390 else: 391 self.subscriptions[event] += 1 392 393 if must_sub: 394 await self.session.bidi_session.session.subscribe(events=must_sub) 395 396 async def unsubscribe(self, events): 397 if type(events) is not list: 398 events = [events] 399 400 must_unsub = [] 401 for event in events: 402 self.subscriptions[event] -= 1 403 if not self.subscriptions[event]: 404 must_unsub.append(event) 405 406 if must_unsub: 407 try: 408 await self.session.bidi_session.session.unsubscribe(events=must_unsub) 409 except (InvalidArgumentException, NoSuchFrameException): 410 pass 411 412 async def wait_for_events(self, events, checkFn=None, timeout=None): 413 if type(events) is not list: 414 events = [events] 415 416 if timeout is None: 417 timeout = 10 418 419 future = self.event_loop.create_future() 420 remove_listeners = [] 421 422 async def on_event(event, data): 423 val = data 424 if checkFn: 425 val = await checkFn(event, data) 426 if val is None: 427 return 428 for remover in remove_listeners: 429 remover() 430 await self.unsubscribe(events) 431 future.set_result(val) 432 433 for event in events: 434 remove_listeners.append( 435 self.session.bidi_session.add_event_listener(event, on_event) 436 ) 437 await self.subscribe(events) 438 return await asyncio.wait_for(future, timeout=timeout) 439 440 async def get_iframe_by_url(self, url): 441 def check_children(children): 442 for child in children: 443 if "url" in child and url in child["url"]: 444 return child 445 for child in children: 446 if "children" in child: 447 frame = check_children(child["children"]) 448 if frame: 449 return frame 450 return None 451 452 tree = await self.session.bidi_session.browsing_context.get_tree() 453 for top in tree: 454 frame = check_children(top["children"]) 455 if frame is not None: 456 return frame 457 458 return None 459 460 async def is_iframe(self, context): 461 def check_children(children): 462 for child in children: 463 if "context" in child and child["context"] == context: 464 return True 465 if "children" in child: 466 return check_children(child["children"]) 467 return False 468 469 for top in await self.session.bidi_session.browsing_context.get_tree(): 470 if check_children(top["children"]): 471 return True 472 return False 473 474 async def wait_for_iframe_loaded(self, url, timeout=None): 475 async def wait_for_url(_, data): 476 if url in data["url"] and await self.is_iframe(data["context"]): 477 return data["context"] 478 return None 479 480 return self.wait_for_events("browsingContext.load", wait_for_url) 481 482 async def run_script_in_context(self, script, context=None, sandbox=None): 483 if not context: 484 context = (await self.top_context())["context"] 485 target = ContextTarget(context, sandbox) 486 return await self.session.bidi_session.script.evaluate( 487 expression=script, 488 target=target, 489 await_promise=False, 490 ) 491 492 async def run_script( 493 self, script, *args, await_promise=False, context=None, sandbox=None 494 ): 495 if not context: 496 context = (await self.top_context())["context"] 497 target = ContextTarget(context, sandbox) 498 val = await self.session.bidi_session.script.call_function( 499 arguments=self.wrap_script_args(args), 500 await_promise=await_promise, 501 function_declaration=script, 502 target=target, 503 ) 504 if val and "value" in val: 505 return val["value"] 506 return val 507 508 def await_script(self, script, *args, **kwargs): 509 return self.run_script(script, *args, **kwargs, await_promise=True) 510 511 async def await_interventions_started(self): 512 interventionsOn = self.request.node.get_closest_marker("with_interventions") 513 shimsOn = self.request.node.get_closest_marker("with_shims") 514 515 if not interventionsOn and not shimsOn: 516 print("Not waiting for interventions/shims") 517 return 518 519 waitFor = "interventions" if interventionsOn else "shims" 520 521 print("Waiting for", waitFor, "to be ready") 522 context = await self.session.bidi_session.browsing_context.create( 523 type_hint="tab", background=True 524 ) 525 await self.session.bidi_session.browsing_context.navigate( 526 context=context["context"], 527 url="about:compat", 528 wait="interactive", 529 ) 530 await self.session.bidi_session.script.evaluate( 531 expression=f"window.browser.extension.getBackgroundPage().{waitFor}.ready()", 532 target=ContextTarget(context["context"]), 533 await_promise=True, 534 ) 535 await self.session.bidi_session.browsing_context.close( 536 context=context["context"] 537 ) 538 539 async def navigate(self, url, timeout=90, no_skip=False, **kwargs): 540 await self.await_interventions_started() 541 await self.maybe_override_platform() 542 await self.maybe_enable_font_inflation() 543 try: 544 return await asyncio.wait_for( 545 asyncio.ensure_future(self._navigate(url, **kwargs)), timeout=timeout 546 ) 547 except asyncio.exceptions.TimeoutError as t: 548 if no_skip: 549 raise t 550 return 551 pytest.skip( 552 f"{self.request.fspath.basename}: Timed out navigating to site after {timeout} seconds. Please try again later." 553 ) 554 except webdriver.bidi.error.UnknownErrorException as e: 555 if no_skip: 556 raise e 557 return 558 s = str(e) 559 if "Address rejected" in s or "NS_ERROR_NET_TIMEOUT" in s: 560 pytest.skip( 561 f"{self.request.fspath.basename}: Site not responding. Please try again later." 562 ) 563 return 564 elif "NS_ERROR_UNKNOWN_HOST" in s: 565 pytest.skip( 566 f"{self.request.fspath.basename}: Site appears to be down. Please try again later." 567 ) 568 return 569 elif "NS_ERROR_REDIRECT_LOOP" in s: 570 pytest.skip( 571 f"{self.request.fspath.basename}: Site is stuck in a redirect loop. Please try again later." 572 ) 573 return 574 elif "NS_ERROR_CONNECTION_REFUSED" in s: 575 raise ConnectionRefusedError("Connection refused") 576 raise e 577 578 async def _navigate(self, url, wait="complete", await_console_message=None): 579 if self.session.test_config.get("use_pbm") or self.session.test_config.get( 580 "use_strict_etp" 581 ): 582 print("waiting for content blocker...") 583 self.wait_for_content_blocker() 584 if await_console_message is not None: 585 console_message = await self.promise_console_message_listener( 586 await_console_message 587 ) 588 if wait == "load": 589 page_load = await self.promise_readystate_listener("load", url=url) 590 try: 591 await self.session.bidi_session.browsing_context.navigate( 592 context=(await self.top_context())["context"], 593 url=url, 594 wait=wait if wait != "load" else None, 595 ) 596 except webdriver.bidi.error.UnknownErrorException as u: 597 m = str(u) 598 if ( 599 "NS_BINDING_ABORTED" not in m 600 and "NS_ERROR_ABORT" not in m 601 and "NS_ERROR_WONT_HANDLE_CONTENT" not in m 602 ): 603 raise u 604 if wait == "load": 605 await page_load 606 if await_console_message is not None: 607 await console_message 608 609 async def promise_event_listener(self, events, check_fn=None, timeout=20): 610 if type(events) is not list: 611 events = [events] 612 613 await self.session.bidi_session.session.subscribe(events=events) 614 615 future = self.event_loop.create_future() 616 617 listener_removers = [] 618 619 def remove_listeners(): 620 for listener_remover in listener_removers: 621 try: 622 listener_remover() 623 except Exception: 624 pass 625 626 async def on_event(method, data): 627 print("on_event", method, data) 628 val = None 629 if check_fn is not None: 630 val = check_fn(method, data) 631 if val is None: 632 return 633 future.set_result(val) 634 635 for event in events: 636 r = self.session.bidi_session.add_event_listener(event, on_event) 637 listener_removers.append(r) 638 639 async def task(): 640 try: 641 return await asyncio.wait_for(future, timeout=timeout) 642 finally: 643 remove_listeners() 644 try: 645 await asyncio.wait_for( 646 self.session.bidi_session.session.unsubscribe(events=events), 647 timeout=4, 648 ) 649 except asyncio.exceptions.TimeoutError: 650 print("Unexpectedly timed out unsubscribing", events) 651 pass 652 653 return asyncio.create_task(task()) 654 655 async def promise_navigation_begins(self, url=None, **kwargs): 656 def check(method, data): 657 if url is None: 658 return data 659 if "url" in data and url in data["url"]: 660 return data 661 662 return await self.promise_event_listener( 663 "browsingContext.navigationStarted", check, **kwargs 664 ) 665 666 async def promise_console_message_listener(self, msg, **kwargs): 667 def check(method, data): 668 if "text" in data: 669 if msg in data["text"]: 670 return data 671 if "args" in data and len(data["args"]): 672 for arg in data["args"]: 673 if "value" in arg and msg in arg["value"]: 674 return data 675 676 return await self.promise_event_listener("log.entryAdded", check, **kwargs) 677 678 async def is_console_message(self, message): 679 try: 680 await (await self.promise_console_message_listener(message, timeout=2)) 681 return True 682 except asyncio.exceptions.TimeoutError: 683 return False 684 685 async def promise_readystate_listener(self, state, url=None, **kwargs): 686 event = f"browsingContext.{state}" 687 688 def check(method, data): 689 if url is None or url in data["url"]: 690 return data 691 692 return await self.promise_event_listener(event, check, **kwargs) 693 694 async def promise_frame_listener(self, url, state="domContentLoaded", **kwargs): 695 event = f"browsingContext.{state}" 696 697 def check(method, data): 698 if url is None or url in data["url"]: 699 return Client.Context(self, data["context"]) 700 701 return await self.promise_event_listener(event, check, **kwargs) 702 703 async def find_frame_context_by_url(self, url): 704 def find_in(arr, url): 705 for context in arr: 706 if url in context["url"]: 707 return context 708 for context in arr: 709 found = find_in(context["children"], url) 710 if found: 711 return found 712 713 return find_in([await self.top_context()], url) 714 715 def stall(self, delay): 716 return asyncio.sleep(delay) 717 718 class Context: 719 def __init__(self, client, id): 720 self.client = client 721 self.target = ContextTarget(id) 722 723 async def find_css(self, selector, all=False): 724 all = "All" if all else "" 725 return await self.client.session.bidi_session.script.evaluate( 726 expression=f"document.querySelector{all}('{selector}')", 727 target=self.target, 728 await_promise=False, 729 ) 730 731 def timed_js(self, timeout, poll, fn, is_displayed=False): 732 return f"""() => new Promise((_good, _bad) => {{ 733 {self.is_displayed_js()} 734 var _poll = {poll} * 1000; 735 var _time = {timeout} * 1000; 736 var _done = false; 737 var resolve = val => {{ 738 if ({is_displayed}) {{ 739 if (val.length) {{ 740 val = val.filter(v = is_displayed(v)); 741 }} else {{ 742 val = is_displayed(val) && val; 743 }} 744 if (!val.length && !val.matches) {{ 745 return; 746 }} 747 }} 748 _done = true; 749 clearInterval(_int); 750 _good(val); 751 }}; 752 var reject = str => {{ 753 _done = true; 754 clearInterval(_int); 755 _bad(val); 756 }}; 757 var _int = setInterval(() => {{ 758 {fn}; 759 if (!_done) {{ 760 _time -= _poll; 761 if (_time <= 0) {{ 762 reject(); 763 }} 764 }} 765 }}, poll); 766 }})""" 767 768 def is_displayed_js(self): 769 return """ 770 function is_displayed(e) { 771 const s = window.getComputedStyle(e), 772 v = s.visibility === "visible", 773 o = Math.abs(parseFloat(s.opacity)); 774 return e.getClientRects().length > 0 && v && (isNaN(o) || o === 1.0); 775 } 776 """ 777 778 async def await_css( 779 self, 780 selector, 781 all=False, 782 timeout=10, 783 poll=0.25, 784 condition=False, 785 is_displayed=False, 786 ): 787 all = "All" if all else "" 788 condition = ( 789 f"var elem=arguments[0]; if ({condition})" if condition else False 790 ) 791 return await self.client.session.bidi_session.script.evaluate( 792 expression=self.timed_js( 793 timeout, 794 poll, 795 f""" 796 var ele = document.querySelector{all}('{selector}')"; 797 if (ele && (!"length" in ele || ele.length > 0)) {{ 798 '{condition}' 799 resolve(ele); 800 }} 801 """, 802 ), 803 target=self.target, 804 await_promise=True, 805 ) 806 807 async def await_text(self, text, **kwargs): 808 xpath = f"//*[text()[contains(.,'{text}')]]" 809 return await self.await_xpath(self, xpath, **kwargs) 810 811 async def await_xpath( 812 self, xpath, all=False, timeout=10, poll=0.25, is_displayed=False 813 ): 814 all = "true" if all else "false" 815 return await self.client.session.bidi_session.script.evaluate( 816 expression=self.timed_js( 817 timeout, 818 poll, 819 """ 820 var ret = []; 821 var r, res = document.evaluate(`{xpath}`, document, null, 4); 822 while (r = res.iterateNext()) { 823 ret.push(r); 824 } 825 resolve({all} ? ret : ret[0]); 826 """, 827 ), 828 target=self.target, 829 await_promise=True, 830 ) 831 832 def wrap_script_args(self, args): 833 if args is None: 834 return args 835 out = [] 836 for arg in args: 837 if arg is None: 838 out.append({"type": "undefined"}) 839 continue 840 elif isinstance(arg, webdriver.client.WebElement): 841 out.append({"sharedId": arg.id}) 842 continue 843 t = type(arg) 844 if t is int or t is float: 845 out.append({"type": "number", "value": arg}) 846 elif t is bool: 847 out.append({"type": "boolean", "value": arg}) 848 elif t is str: 849 out.append({"type": "string", "value": arg}) 850 else: 851 if "type" in arg: 852 out.append(arg) 853 continue 854 raise ValueError(f"Unhandled argument type: {t}") 855 return out 856 857 class PreloadScript: 858 def __init__(self, client, script, target): 859 self.client = client 860 self.script = script 861 if type(target) is list: 862 self.target = target[0] 863 else: 864 self.target = target 865 866 def stop(self): 867 return self.client.session.bidi_session.script.remove_preload_script( 868 script=self.script 869 ) 870 871 async def run(self, fn, *args, await_promise=False): 872 val = await self.client.session.bidi_session.script.call_function( 873 arguments=self.client.wrap_script_args(args), 874 await_promise=await_promise, 875 function_declaration=fn, 876 target=self.target, 877 ) 878 if val and "value" in val: 879 return val["value"] 880 return val 881 882 async def make_preload_script(self, text, sandbox=None, args=None, context=None): 883 if not context: 884 context = (await self.top_context())["context"] 885 target = ContextTarget(context, sandbox) 886 if args is None: 887 text = f"() => {{ {text} }}" 888 script = await self.session.bidi_session.script.add_preload_script( 889 function_declaration=text, 890 arguments=self.wrap_script_args(args), 891 sandbox=sandbox, 892 ) 893 return Client.PreloadScript(self, script, target) 894 895 async def disable_window_alert(self): 896 return await self.make_preload_script("window.alert = () => {}") 897 898 async def set_prompt_responses(self, responses, timeout=10): 899 if type(responses) is not list: 900 responses = [responses] 901 if not hasattr(self, "prompts_preload_script"): 902 self.prompts_preload_script = await self.make_preload_script( 903 f""" 904 const responses = {responses}; 905 window.wrappedJSObject.prompt = function() {{ 906 return responses.shift(); 907 }} 908 """, 909 "prompt_detector", 910 ) 911 return self.prompts_preload_script 912 913 def _start_collecting_alerts(self): 914 # WebDriver doesn't make it easy to just wait for an alert, because while you can 915 # listen for the events, there is no guarantee that UnexpectedAlertExceptions won't 916 # be thrown while you're doing other things. So we just tell Gecko to collect the 917 # prompts as they come in, and immediately dismiss them to prevent the exceptions. 918 with self.using_context("chrome"): 919 self.execute_script( 920 """ 921 const lazy = {}; 922 923 ChromeUtils.defineESModuleGetters(lazy, { 924 EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", 925 modal: "chrome://remote/content/shared/Prompt.sys.mjs", 926 NavigableManager: "chrome://remote/content/shared/NavigableManager.sys.mjs", 927 PromptListener: "chrome://remote/content/shared/listeners/PromptListener.sys.mjs", 928 TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", 929 }); 930 931 async function tryClosePrompt(contextId) { 932 const context = lazy.NavigableManager.getBrowsingContextById(contextId); 933 if (!context) { 934 return; 935 } 936 937 const tab = lazy.TabManager.getTabForBrowsingContext(context); 938 const browser = lazy.TabManager.getBrowserForTab(tab); 939 const window = lazy.TabManager.getWindowForTab(tab); 940 const dialog = lazy.modal.findPrompt({ 941 window, 942 contentBrowser: browser, 943 }); 944 945 const closePrompt = async callback => { 946 const dialogClosed = new lazy.EventPromise( 947 window, 948 "DOMModalDialogClosed" 949 ); 950 callback(); 951 await dialogClosed; 952 }; 953 954 if (dialog && dialog.isOpen) { 955 switch (dialog.promptType) { 956 case "alert": 957 await closePrompt(() => dialog.accept()); 958 return; 959 960 case "beforeunload": 961 case "confirm": 962 await closePrompt(() => { 963 if (accept) { 964 dialog.accept(); 965 } else { 966 dialog.dismiss(); 967 } 968 }); 969 return; 970 971 case "prompt": 972 await closePrompt(() => { 973 if (accept) { 974 dialog.text = userText; 975 dialog.accept(); 976 } else { 977 dialog.dismiss(); 978 } 979 }); 980 return; 981 } 982 } 983 } 984 985 const alerts = []; 986 const promptListener = new lazy.PromptListener(); 987 promptListener.on("opened", async (eventName, data) => { 988 const { contentBrowser, prompt } = data; 989 const type = prompt.promptType; 990 const context = lazy.NavigableManager.getIdForBrowser(contentBrowser); 991 const message = await prompt.getText(); 992 alerts.push({type, context, message}); 993 tryClosePrompt(context); 994 Services.ppmm.sharedData.set("WebCompatTests:Prompts", alerts); 995 console.error(`**** Closed ${type} in context ${context} with message: ${message}`); 996 }); 997 promptListener.startListening(); 998 """ 999 ) 1000 1001 def _get_prompts(self): 1002 with self.using_context("chrome"): 1003 return self.execute_script( 1004 "return Services.cpmm.sharedData.get('WebCompatTests:Prompts')" 1005 ) 1006 1007 def _check_prompts(self, specific_messages, prompts): 1008 if not prompts: 1009 return 1010 for prompt in prompts: 1011 message = prompt["message"] 1012 if not specific_messages: 1013 return message 1014 else: 1015 for specific_message in specific_messages: 1016 if specific_message in prompt["message"]: 1017 return prompt["message"] 1018 1019 async def find_alert(self, specific_messages=None, delay=None): 1020 if delay: 1021 await asyncio.sleep(delay) 1022 found = self._check_prompts(specific_messages, self._get_prompts()) 1023 if found is not None: 1024 return found 1025 1026 async def await_alert( 1027 self, specific_messages=None, timeout=20, polling_interval=0.2 1028 ): 1029 with self.using_context("chrome"): 1030 print(math.ceil(timeout / polling_interval)) 1031 for _ in range(math.ceil(timeout / polling_interval)): 1032 found = self._check_prompts(specific_messages, self._get_prompts()) 1033 if found is not None: 1034 return found 1035 await asyncio.sleep(polling_interval) 1036 1037 async def await_popup(self, url=None): 1038 if not hasattr(self, "popup_preload_script"): 1039 self.popup_preload_script = await self.make_preload_script( 1040 """ 1041 window.__popups = []; 1042 window.wrappedJSObject.open = function(url) { 1043 window.__popups.push(url); 1044 } 1045 """, 1046 "popup_detector", 1047 ) 1048 return self.popup_preload_script.run( 1049 """(url) => new Promise(done => { 1050 const to = setInterval(() => { 1051 if (url === undefined && window.__popups.length) { 1052 clearInterval(to); 1053 return done(window.__popups[0]); 1054 } 1055 const found = window.__popups.find(u => u.includes(url)); 1056 if (found !== undefined) { 1057 clearInterval(to); 1058 done(found); 1059 } 1060 }, 1000); 1061 }) 1062 """, 1063 url, 1064 await_promise=True, 1065 ) 1066 1067 async def track_listener(self, type, selector): 1068 if not hasattr(self, "listener_preload_script"): 1069 self.listener_preload_script = await self.make_preload_script( 1070 """ 1071 window.__listeners = {}; 1072 var proto = EventTarget.wrappedJSObject.prototype; 1073 var def = Object.getOwnPropertyDescriptor(proto, "addEventListener"); 1074 var old = def.value; 1075 def.value = function(type, fn, opts) { 1076 if ("matches" in this) { 1077 if (!window.__listeners[type]) { 1078 window.__listeners[type] = new Set(); 1079 } 1080 window.__listeners[type].add(this); 1081 } 1082 return old.call(this, type, fn, opts) 1083 }; 1084 Object.defineProperty(proto, "addEventListener", def); 1085 """, 1086 "listener_detector", 1087 ) 1088 return Client.ListenerTracker(self.listener_preload_script, type, selector) 1089 1090 @contextlib.asynccontextmanager 1091 async def preload_script(self, text, *args): 1092 script = await self.make_preload_script(text, "preload", args=args) 1093 yield script 1094 await script.stop() 1095 1096 def back(self): 1097 self.session.back() 1098 1099 def switch_to_frame(self, frame=None): 1100 if not frame: 1101 return self.session.transport.send( 1102 "POST", "session/{session_id}/frame/parent".format(**vars(self.session)) 1103 ) 1104 1105 return self.session.transport.send( 1106 "POST", 1107 "session/{session_id}/frame".format(**vars(self.session)), 1108 {"id": frame}, 1109 encoder=webdriver.protocol.Encoder, 1110 decoder=webdriver.protocol.Decoder, 1111 session=self.session, 1112 ) 1113 1114 def switch_frame(self, frame): 1115 self.session.switch_frame(frame) 1116 1117 async def load_page_and_wait_for_iframe( 1118 self, url, finder, loads=1, timeout=None, **kwargs 1119 ): 1120 while loads > 0: 1121 await self.navigate(url, **kwargs) 1122 frame = self.await_element(finder, timeout=timeout) 1123 loads -= 1 1124 self.switch_frame(frame) 1125 return frame 1126 1127 def execute_script(self, script, *args): 1128 return self.session.execute_script(script, args=args) 1129 1130 def execute_async_script(self, script, *args, **kwargs): 1131 return self.session.execute_async_script(script, args, **kwargs) 1132 1133 def clear_all_cookies(self): 1134 self.session.transport.send( 1135 "DELETE", f"session/{self.session.session_id}/cookie" 1136 ) 1137 1138 def send_element_command(self, element, method, uri, body=None): 1139 url = f"element/{element.id}/{uri}" 1140 return self.session.send_session_command(method, url, body) 1141 1142 def get_element_attribute(self, element, name): 1143 return self.send_element_command(element, "GET", f"attribute/{name}") 1144 1145 def _do_is_displayed_check(self, ele, is_displayed): 1146 if ele is None: 1147 return None 1148 1149 if type(ele) in [list, tuple]: 1150 return [x for x in ele if self._do_is_displayed_check(x, is_displayed)] 1151 1152 if is_displayed is False and ele and self.is_displayed(ele): 1153 return None 1154 if is_displayed is True and ele and not self.is_displayed(ele): 1155 return None 1156 return ele 1157 1158 def find_css(self, *args, all=False, is_displayed=None, **kwargs): 1159 try: 1160 ele = self.session.find.css(*args, all=all, **kwargs) 1161 return self._do_is_displayed_check(ele, is_displayed) 1162 except webdriver.error.NoSuchElementException: 1163 return None 1164 1165 def find_xpath(self, xpath, all=False, is_displayed=None): 1166 route = "elements" if all else "element" 1167 body = {"using": "xpath", "value": xpath} 1168 try: 1169 ele = self.session.send_session_command("POST", route, body) 1170 return self._do_is_displayed_check(ele, is_displayed) 1171 except webdriver.error.NoSuchElementException: 1172 return None 1173 1174 def find_text(self, text, is_displayed=None, **kwargs): 1175 try: 1176 e = self.find_xpath(f"//*[text()[contains(.,'{text}')]]", **kwargs) 1177 return self._do_is_displayed_check(e, is_displayed) 1178 except webdriver.error.NoSuchElementException: 1179 return None 1180 1181 def find_element(self, finder, is_displayed=None, all=None, **kwargs): 1182 ele = finder.find(self, all=True, **kwargs) 1183 found = self._do_is_displayed_check(ele, is_displayed) 1184 if not all: 1185 return found[0] if len(found) else None 1186 return found 1187 1188 def await_css(self, selector, **kwargs): 1189 return self.await_element(self.css(selector), **kwargs) 1190 1191 def await_xpath(self, selector, **kwargs): 1192 return self.await_element(self.xpath(selector), **kwargs) 1193 1194 def await_text(self, selector, *args, **kwargs): 1195 return self.await_element(self.text(selector), **kwargs) 1196 1197 def await_element(self, finder, **kwargs): 1198 return self.await_first_element_of([finder], **kwargs)[0] 1199 1200 class css: 1201 def __init__(self, selector): 1202 self.selector = selector 1203 1204 def find(self, client, **kwargs): 1205 return client.find_css(self.selector, **kwargs) 1206 1207 class xpath: 1208 def __init__(self, selector): 1209 self.selector = selector 1210 1211 def find(self, client, **kwargs): 1212 return client.find_xpath(self.selector, **kwargs) 1213 1214 class text: 1215 def __init__(self, selector): 1216 self.selector = selector 1217 1218 def find(self, client, **kwargs): 1219 return client.find_text(self.selector, **kwargs) 1220 1221 def await_first_element_of( 1222 self, finders, timeout=None, delay=0.25, condition=False, all=False, **kwargs 1223 ): 1224 t0 = time.time() 1225 condition = ( 1226 f"return arguments[0].filter(elem => {condition})" if condition else False 1227 ) 1228 1229 if timeout is None: 1230 timeout = 10 1231 1232 found = [None for finder in finders] 1233 1234 exc = None 1235 while time.time() < t0 + timeout: 1236 for i, finder in enumerate(finders): 1237 try: 1238 result = finder.find(self, all=True, **kwargs) 1239 if len(result): 1240 if condition: 1241 result = self.session.execute_script(condition, [result]) 1242 if not len(result): 1243 continue 1244 found[i] = result[0] if not all else result 1245 return found 1246 except webdriver.error.NoSuchElementException as e: 1247 exc = e 1248 time.sleep(delay) 1249 raise exc if exc is not None else webdriver.error.NoSuchElementException 1250 return found 1251 1252 async def dom_ready(self, timeout=None): 1253 if timeout is None: 1254 timeout = 20 1255 1256 async def wait(): 1257 return self.session.execute_async_script( 1258 """ 1259 const cb = arguments[0]; 1260 setInterval(() => { 1261 if (document.readyState === "complete") { 1262 cb(); 1263 } 1264 }, 500); 1265 """ 1266 ) 1267 1268 task = asyncio.create_task(wait()) 1269 return await asyncio.wait_for(task, timeout) 1270 1271 def is_float_cleared(self, elem1, elem2): 1272 return self.session.execute_script( 1273 """return (function(a, b) { 1274 // Ensure that a is placed under b (and not to its right) 1275 return a?.offsetTop >= b?.offsetTop + b?.offsetHeight && 1276 a?.offsetLeft < b?.offsetLeft + b?.offsetWidth; 1277 }(arguments[0], arguments[1]));""", 1278 elem1, 1279 elem2, 1280 ) 1281 1282 def is_content_wider_than_screen(self): 1283 return self.execute_script("return window.innerWidth > window.outerWidth") 1284 1285 @contextlib.contextmanager 1286 def assert_getUserMedia_called(self): 1287 self.execute_script( 1288 """ 1289 navigator.mediaDevices.getUserMedia = 1290 navigator.mozGetUserMedia = 1291 navigator.getUserMedia = 1292 () => { window.__gumCalled = true; }; 1293 """ 1294 ) 1295 yield 1296 assert self.execute_script("return window.__gumCalled === true;") 1297 1298 def await_element_hidden(self, finder, timeout=None, delay=0.25): 1299 t0 = time.time() 1300 1301 if timeout is None: 1302 timeout = 20 1303 1304 elem = finder.find(self) 1305 while time.time() < t0 + timeout: 1306 try: 1307 if not self.is_displayed(elem): 1308 return 1309 time.sleep(delay) 1310 except webdriver.error.StaleElementReferenceException: 1311 return 1312 1313 def try_closing_popup(self, close_button_finder, timeout=None): 1314 try: 1315 self.soft_click( 1316 self.await_element( 1317 close_button_finder, is_displayed=True, timeout=timeout 1318 ) 1319 ) 1320 self.await_element_hidden(close_button_finder) 1321 return True 1322 except webdriver.error.NoSuchElementException: 1323 return False 1324 1325 def try_closing_popups(self, popup_close_button_finders, timeout=None): 1326 left_to_try = list(popup_close_button_finders) 1327 closed_one = False 1328 num_intercepted = 0 1329 while left_to_try: 1330 finder = left_to_try.pop(0) 1331 try: 1332 if self.try_closing_popup(finder, timeout=timeout): 1333 closed_one = True 1334 num_intercepted = 0 1335 except webdriver.error.ElementClickInterceptedException as e: 1336 # If more than one popup is visible at the same time, we will 1337 # get this exception for all but the topmost one. So we re-try 1338 # removing the others again after the topmost one is dismissed, 1339 # until we've removed them all. 1340 num_intercepted += 1 1341 if num_intercepted == len(left_to_try): 1342 raise e 1343 left_to_try.append(finder) 1344 return closed_one 1345 1346 def click( 1347 self, element, force=False, popups=None, popups_timeout=None, button=None 1348 ): 1349 tries = 0 1350 while True: 1351 self.scroll_into_view(element) 1352 try: 1353 if button: 1354 self.mouse.pointer_move(0, 0, origin=element).pointer_down( 1355 button 1356 ).pointer_up(button).perform() 1357 else: 1358 element.click() 1359 return 1360 except webdriver.error.ElementClickInterceptedException as c: 1361 if force: 1362 self.clear_covering_elements(element) 1363 elif not popups or not self.try_closing_popups( 1364 popups, timeout=popups_timeout 1365 ): 1366 raise c 1367 except webdriver.error.WebDriverException as e: 1368 if not "could not be scrolled into view" in str(e): 1369 raise e 1370 tries += 1 1371 if tries == 5: 1372 raise e 1373 time.sleep(0.5) 1374 1375 def soft_click(self, element, popups=None, popups_timeout=None): 1376 while True: 1377 try: 1378 self.execute_script("arguments[0].click()", element) 1379 return 1380 except webdriver.error.ElementClickInterceptedException as e: 1381 if not popups or not self.try_closing_popups( 1382 popups, timeout=popups_timeout 1383 ): 1384 raise e 1385 1386 def remove_element(self, element): 1387 self.execute_script("arguments[0].remove()", element) 1388 1389 def clear_covering_elements(self, element): 1390 self.execute_script( 1391 """ 1392 const getInViewCentrePoint = function(rect, win) { 1393 const { floor, max, min } = Math; 1394 let visible = { 1395 left: max(0, min(rect.x, rect.x + rect.width)), 1396 right: min(win.innerWidth, max(rect.x, rect.x + rect.width)), 1397 top: max(0, min(rect.y, rect.y + rect.height)), 1398 bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)), 1399 }; 1400 let x = (visible.left + visible.right) / 2.0; 1401 let y = (visible.top + visible.bottom) / 2.0; 1402 x = floor(x); 1403 y = floor(y); 1404 return { x, y }; 1405 }; 1406 1407 const el = arguments[0]; 1408 if (el.isConnected) { 1409 const rect = el.getClientRects()[0]; 1410 if (rect) { 1411 const c = getInViewCentrePoint(rect, window); 1412 const efp = el.getRootNode().elementsFromPoint(c.x, c.y); 1413 for (const cover of efp) { 1414 if (cover == el) { 1415 break; 1416 } 1417 cover.style.visibility = "hidden"; 1418 } 1419 } 1420 } 1421 """, 1422 element, 1423 ) 1424 1425 def scroll_into_view(self, element): 1426 self.execute_script( 1427 "arguments[0].scrollIntoView({block:'center', inline:'center', behavior: 'instant'})", 1428 element, 1429 ) 1430 1431 @contextlib.asynccontextmanager 1432 async def ensure_fastclick_activates(self): 1433 fastclick_preload_script = await self.make_preload_script( 1434 """ 1435 var _ = document.createElement("webcompat_test"); 1436 _.style = "position:absolute;right:-1px;width:1px;height:1px"; 1437 document.documentElement.appendChild(_); 1438 """, 1439 "fastclick_forcer", 1440 ) 1441 yield 1442 fastclick_preload_script.stop() 1443 1444 async def ensure_InstallTrigger_defined(self): 1445 return await self.make_preload_script("window.InstallTrigger = function() {}") 1446 1447 async def ensure_InstallTrigger_undefined(self): 1448 return await self.make_preload_script("delete InstallTrigger") 1449 1450 def test_future_plc_trending_scrollbar(self, shouldFail=False): 1451 trending_list = self.await_css(".trending__list") 1452 if not trending_list: 1453 raise ValueError("trending list is still where expected") 1454 1455 # First confirm that the scrollbar is the color the site specifies. 1456 css_var_colors = self.execute_script( 1457 """ 1458 // first, force a scrollbar, as the content on each site might 1459 // not always be wide enough to force a scrollbar to appear. 1460 const list = arguments[0]; 1461 list.style.overflow = "scroll hidden !important"; 1462 1463 const computedStyle = getComputedStyle(list); 1464 return [ 1465 computedStyle.getPropertyValue('--trending-scrollbar-color'), 1466 computedStyle.getPropertyValue('--trending-scrollbar-background-color'), 1467 ]; 1468 """, 1469 trending_list, 1470 ) 1471 if not css_var_colors[0] or not css_var_colors[1]: 1472 raise ValueError("expected CSS vars are still used for scrollbar-color") 1473 1474 [expected, actual] = self.execute_script( 1475 """ 1476 const [list, cssVarColors] = arguments; 1477 const sbColor = getComputedStyle(list).scrollbarColor; 1478 // scrollbar-color is a two-color value wth no easy way to separate 1479 // them and no way to be sure the value will remain consistent in 1480 // the format "rgb(x, y, z) rgb(x, y, z)". Likewise, the colors the 1481 // site specified in the CSS might be in hex format or any CSS color 1482 // value. So rather than trying to normalize the values ourselves, we 1483 // set the border-color of an element, which is also a two-color CSS 1484 // value, and then also read it back through the computed style, so 1485 // Firefox normalizes both colors the same way for us and lets us 1486 // compare their equivalence as simple strings. 1487 list.style.borderColor = sbColor; 1488 const actual = getComputedStyle(list).borderColor; 1489 list.style.borderColor = cssVarColors.join(" "); 1490 const expected = getComputedStyle(list).borderColor; 1491 return [expected, actual]; 1492 """, 1493 trending_list, 1494 css_var_colors, 1495 ) 1496 if shouldFail: 1497 assert expected != actual, "scrollbar is not the correct color" 1498 else: 1499 assert expected == actual, "scrollbar is the correct color" 1500 1501 # Also check that the scrollbar does not cover any text (it may not 1502 # actually cover any text even without the intervention, so we skip 1503 # checking that case). To find out, we color the scrollbar the same as 1504 # the trending list's background, and compare screenshots of the 1505 # list with and without the scrollbar. This way if no text is covered, 1506 # the screenshots will not differ. 1507 if not shouldFail: 1508 self.execute_script( 1509 """ 1510 const list = arguments[0]; 1511 const bgc = getComputedStyle(list).backgroundColor; 1512 list.style.scrollbarColor = `${bgc} ${bgc}`; 1513 """, 1514 trending_list, 1515 ) 1516 time.sleep(0.5) 1517 with_scrollbar = trending_list.screenshot() 1518 self.execute_script( 1519 """ 1520 arguments[0].style.scrollbarWidth = "none"; 1521 """, 1522 trending_list, 1523 ) 1524 time.sleep(0.5) 1525 without_scrollbar = trending_list.screenshot() 1526 assert with_scrollbar == without_scrollbar, ( 1527 "scrollbar does not cover any text" 1528 ) 1529 1530 def test_for_fastclick(self, element): 1531 # FastClick cancels touchend, breaking default actions on Fenix. 1532 # It instead fires a mousedown or click, which we can detect. 1533 self.execute_script( 1534 """ 1535 const sel = arguments[0]; 1536 window.fastclicked = false; 1537 const evt = sel.nodeName === "SELECT" ? "mousedown" : "click"; 1538 document.addEventListener(evt, e => { 1539 if (e.target === sel && !e.isTrusted) { 1540 window.fastclicked = true; 1541 } 1542 }, true); 1543 sel.style.position = "absolute"; 1544 sel.style.zIndex = 2147483647; 1545 """, 1546 element, 1547 ) 1548 self.scroll_into_view(element) 1549 self.clear_covering_elements(element) 1550 # tap a few times in case the site's other code interferes, but 1551 # FastClick can move the element out of bounds, so take care. 1552 try: 1553 self.touch.click(element=element).perform() 1554 self.touch.click(element=element).perform() 1555 self.touch.click(element=element).perform() 1556 except webdriver.error.MoveTargetOutOfBoundsException: 1557 pass 1558 return self.execute_script("return window.fastclicked") 1559 1560 async def test_aceomni_pan_and_zoom_works(self, url): 1561 await self.navigate(url, wait="none") 1562 img = self.await_css("#imageZoom", is_displayed=True) 1563 await self.stall(2) 1564 1565 def get_zoom_x(): 1566 return self.execute_script( 1567 "return arguments[0].style.cssText.match(/--zoom-x:\\s?(\\d+(\\.\\d+)?)%/)?.[1]", 1568 img, 1569 ) 1570 1571 if get_zoom_x() is not None: 1572 return False 1573 1574 await self.stall(0.5) 1575 coords = self.get_element_screen_position(img) 1576 coords = [coords[0] + 50, coords[1] + 100] 1577 await self.apz_move(coords=coords) 1578 await self.stall(0.5) 1579 old_x = float(get_zoom_x()) 1580 1581 for i in range(20): 1582 coords = [coords[0] + 10, coords[1]] 1583 await self.apz_move(coords=coords) 1584 await self.stall(0.01) 1585 x = float(get_zoom_x()) 1586 if x < old_x: 1587 return False 1588 old_x = x 1589 1590 return True 1591 1592 def is_displayed(self, element): 1593 if element is None: 1594 return False 1595 1596 try: 1597 return self.session.execute_script( 1598 """ 1599 const e = arguments[0], 1600 s = window.getComputedStyle(e), 1601 v = s.visibility === "visible", 1602 o = Math.abs(parseFloat(s.opacity)), 1603 d = s.display === "contents" || e.getClientRects().length > 0; 1604 return d && v && (isNaN(o) || o === 1.0); 1605 """, 1606 args=[element], 1607 ) 1608 except webdriver.error.StaleElementReferenceException: 1609 return False 1610 1611 def is_one_solid_color(self, element, max_fuzz=8): 1612 # max_fuzz is needed as screenshots can have slight color bleeding/fringing 1613 shotb64 = element.screenshot() 1614 shot = Image.open(BytesIO(b64decode(shotb64))).convert("RGB") 1615 for min, max in shot.getextrema(): 1616 if max - min > max_fuzz: 1617 return False 1618 return True 1619 1620 def add_stylesheet(self, sheet): 1621 self.execute_script( 1622 """ 1623 const s = document.createElement("style"); 1624 s.textContent = arguments[0]; 1625 const timer = setInterval(() => { 1626 if (document.head) { 1627 document.head.appendChild(s); 1628 clearInterval(timer); 1629 } 1630 }, 50); 1631 """, 1632 sheet, 1633 ) 1634 1635 def hide_elements(self, selector): 1636 self.add_stylesheet( 1637 f"""{selector} {{ opacity:0 !important; pointer-events:none !important; }}""" 1638 ) 1639 1640 def set_clipboard(self, string): 1641 with self.using_context("chrome"): 1642 self.execute_script( 1643 """ 1644 Cc["@mozilla.org/widget/clipboardhelper;1"] 1645 .getService(Ci.nsIClipboardHelper) 1646 .copyString(arguments[0]); 1647 """, 1648 string, 1649 ) 1650 1651 def do_paste(self): 1652 with self.using_context("chrome"): 1653 self.execute_script( 1654 """ 1655 function _getEventUtils(win) { 1656 const eventUtilsObject = { 1657 window: win, 1658 parent: win, 1659 _EU_Ci: Ci, 1660 _EU_Cc: Cc, 1661 }; 1662 Services.scriptloader.loadSubScript( 1663 "chrome://remote/content/external/EventUtils.js", 1664 eventUtilsObject 1665 ); 1666 return eventUtilsObject; 1667 } 1668 const win = browser.ownerGlobal; 1669 if (!win.EventUtils) { 1670 win.EventUtils = _getEventUtils(win); 1671 } 1672 win.EventUtils.synthesizeKey("v", { accelKey: true }, win); 1673 """ 1674 ) 1675 1676 def make_base64_xpi(self, files): 1677 buf = BytesIO() 1678 with zipfile.ZipFile(file=buf, mode="w") as zip: 1679 for filename, src in files.items(): 1680 zip.writestr(filename, data=src) 1681 buf.seek(0) 1682 return b64encode(buf.getvalue()) 1683 1684 def install_addon( 1685 self, srcfiles, method="addon", temp=True, allow_private_browsing=True 1686 ): 1687 arg = {"temporary": temp, "allowPrivateBrowsing": allow_private_browsing} 1688 arg[method] = self.make_base64_xpi(srcfiles).decode() 1689 return self.session.transport.send( 1690 "POST", 1691 f"/session/{self.session.session_id}/moz/addon/install", 1692 arg, 1693 ) 1694 1695 def uninstall_addon(self, addon_id): 1696 return self.session.transport.send( 1697 "POST", 1698 f"/session/{self.session.session_id}/moz/addon/uninstall", 1699 {"id": addon_id}, 1700 )