tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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        )