tor-browser

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

fixtures.py (17531B)


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