• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / mozregression / 11573984674

29 Oct 2024 12:36PM UTC coverage: 35.112%. First build
11573984674

Pull #1450

github

web-flow
Merge 9d8d3c8da into f558f7daa
Pull Request #1450: Bug 1763188 - Add Snap support using TC builds

62 of 191 new or added lines in 7 files covered. (32.46%)

1053 of 2999 relevant lines covered (35.11%)

1.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

36.65
/mozregression/launchers.py
1
"""
2
Define the launcher classes, responsible of running the tested applications.
3
"""
4

5
from __future__ import absolute_import, print_function
3✔
6

7
import hashlib
3✔
8
import json
3✔
9
import os
3✔
10
import stat
3✔
11
import subprocess
3✔
12
import sys
3✔
13
import time
3✔
14
import zipfile
3✔
15
from abc import ABCMeta, abstractmethod
3✔
16
from enum import Enum
3✔
17
from shutil import move
3✔
18
from subprocess import STDOUT, CalledProcessError, call, check_output
3✔
19
from threading import Thread
3✔
20

21
import mozinfo
3✔
22
import mozinstall
3✔
23
import mozversion
3✔
24
from mozdevice import ADBDeviceFactory, ADBError, ADBHost
3✔
25
from mozfile import remove
3✔
26
from mozlog.structured import get_default_logger, get_proxy_logger
3✔
27
from mozprofile import Profile, ThunderbirdProfile
3✔
28
from mozrunner import GeckoRuntimeRunner, Runner
3✔
29

30
from mozregression.class_registry import ClassRegistry
3✔
31
from mozregression.errors import LauncherError, LauncherNotRunnable
3✔
32
from mozregression.tempdir import safe_mkdtemp
3✔
33

34
LOG = get_proxy_logger("Test Runner")
3✔
35

36
# This enum is used to transform output from codesign (on macs).
37
CodesignResult = Enum("Result", "PASS UNSIGNED INVALID OTHER")
3✔
38

39

40
class Launcher(metaclass=ABCMeta):
3✔
41
    """
42
    Handle the logic of downloading a build file, installing and
43
    running an application.
44
    """
45

46
    profile_class = Profile
3✔
47

48
    @classmethod
3✔
49
    def check_is_runnable(cls):
3✔
50
        """
51
        Check that the launcher can be created and can run on the system.
52

53
        :raises: :class:`LauncherNotRunnable`.
54
        """
55
        pass
56

57
    def __init__(self, dest, **kwargs):
3✔
58
        self._running = False
×
59
        self._stopping = False
×
60

61
        try:
×
62
            self._install(dest)
×
63
        except Exception as e:
×
64
            msg = "Unable to install {} (error: {})".format(dest, e)
×
65
            LOG.error(msg)
×
66
            raise LauncherError(msg).with_traceback(sys.exc_info()[2])
×
67

68
    def start(self, **kwargs):
3✔
69
        """
70
        Start the application.
71
        """
72
        if not self._running:
×
73
            try:
×
74
                self._start(**kwargs)
×
75
            except Exception as e:
×
76
                msg = "Unable to start the application (error: {})".format(e)
×
77
                LOG.error(msg)
×
78
                raise LauncherError(msg).with_traceback(sys.exc_info()[2])
×
79
            self._running = True
×
80

81
    def wait(self):
3✔
82
        """
83
        Wait for the application to be finished and return the error code
84
        when available.
85
        """
86
        if self._running:
×
87
            return_code = self._wait()
×
88
            self.stop()
×
89
            return return_code
×
90

91
    def stop(self):
3✔
92
        """
93
        Stop the application.
94
        """
95
        if self._running:
×
96
            self._stopping = True
×
97
            try:
×
98
                self._stop()
×
99
            except Exception as e:
×
100
                msg = "Unable to stop the application (error: {})".format(e)
×
101
                LOG.error(msg)
×
102
                raise LauncherError(msg).with_traceback(sys.exc_info()[2])
×
103
            self._running = False
×
104
            self._stopping = False
×
105

106
    def get_app_info(self):
3✔
107
        """
108
        Return information about the application.
109
        """
110
        raise NotImplementedError
111

112
    def cleanup(self):
3✔
113
        self.stop()
×
114

115
    def __enter__(self):
3✔
116
        return self
×
117

118
    def __exit__(self, *exc):
3✔
119
        self.cleanup()
×
120

121
    @abstractmethod
3✔
122
    def _install(self, dest):
3✔
123
        raise NotImplementedError
124

125
    @abstractmethod
3✔
126
    def _start(self, **kwargs):
3✔
127
        raise NotImplementedError
128

129
    @abstractmethod
3✔
130
    def _wait(self):
3✔
131
        raise NotImplementedError
132

133
    @abstractmethod
3✔
134
    def _stop(self):
3✔
135
        raise NotImplementedError
136

137
    def _create_profile(self, profile=None, addons=(), preferences=None):
3✔
138
        if isinstance(profile, Profile):
×
139
            return profile
×
140
        else:
141
            return self.create_profile(profile=profile, addons=addons, preferences=preferences)
×
142

143
    @classmethod
3✔
144
    def create_profile(cls, profile=None, addons=(), preferences=None, clone=True):
3✔
145
        if profile:
×
146
            if not os.path.exists(profile):
×
147
                LOG.warning("Creating directory '%s' to put the profile in there" % profile)
×
148
                os.makedirs(profile)
×
149
                # since the user gave an empty dir for the profile,
150
                # let's keep it on the disk in any case.
151
                clone = False
×
152
            if clone:
×
153
                # mozprofile makes some changes in the profile that can not
154
                # be undone. Let's clone the profile to not have side effect
155
                # on existing profile.
156
                # see https://bugzilla.mozilla.org/show_bug.cgi?id=999009
157
                profile = cls.profile_class.clone(profile, addons=addons, preferences=preferences)
×
158
            else:
159
                profile = cls.profile_class(profile, addons=addons, preferences=preferences)
×
160
        elif len(addons):
×
161
            profile = cls.profile_class(addons=addons, preferences=preferences)
×
162
        else:
163
            profile = cls.profile_class(preferences=preferences)
×
164
        return profile
×
165

166

167
def safe_get_version(**kwargs):
3✔
168
    # some really old firefox builds are not supported by mozversion
169
    # and let's be paranoid and handle any error (but report them!)
170
    try:
×
171
        return mozversion.get_version(**kwargs)
×
172
    except mozversion.VersionError as exc:
×
173
        LOG.warning("Unable to get app version: %s" % exc)
×
174
        return {}
×
175

176

177
class MozRunnerLauncher(Launcher):
3✔
178
    tempdir = None
3✔
179
    runner = None
3✔
180
    app_name = "undefined"
3✔
181
    binary = None
3✔
182

183
    @staticmethod
3✔
184
    def _codesign_verify(appdir):
3✔
185
        """Calls `codesign` to verify signature, and returns state."""
186
        if mozinfo.os != "mac":
×
187
            raise Exception("_codesign_verify should only be called on macOS.")
×
188

189
        try:
×
190
            output = check_output(["codesign", "-v", appdir], stderr=STDOUT)
×
191
        except CalledProcessError as e:
×
192
            output = e.output
×
193
            exit_code = e.returncode
×
194
        else:
195
            exit_code = 0
×
196

197
        LOG.debug(f"codesign verify exit code: {exit_code}")
×
198

199
        if exit_code == 0:
×
200
            return CodesignResult.PASS
×
201
        elif exit_code == 1:
×
202
            if b"code object is not signed at all" in output:
×
203
                # NOTE: this output message was tested on macOS 11, 12, and 13.
204
                return CodesignResult.UNSIGNED
×
205
            else:
206
                return CodesignResult.INVALID
×
207
        # NOTE: Remaining codes normally mean the command was called with incorrect
208
        # arguments or if there is any other unforeseen issue with running the command.
209
        return CodesignResult.OTHER
×
210

211
    @staticmethod
3✔
212
    def _codesign_sign(appdir):
3✔
213
        """Calls `codesign` to sign `appdir` with ad-hoc identity."""
214
        if mozinfo.os != "mac":
×
215
            raise Exception("_codesign_sign should only be called on macOS.")
×
216
        # NOTE: The `codesign` command appears to have maintained all the same
217
        # arguments since macOS 10, however this was tested on macOS 12.
218
        return call(["codesign", "--force", "--deep", "--sign", "-", appdir])
×
219

220
    @property
3✔
221
    def _codesign_invalid_on_macOS_13(self):
3✔
222
        """Return True if codesign verify fails on macOS 13+, otherwise return False."""
223
        return (
×
224
            mozinfo.os == "mac"
225
            and mozinfo.os_version >= "13.0"
226
            and self._codesign_verify(self.appdir) == CodesignResult.INVALID
227
        )
228

229
    def _install(self, dest):
3✔
230
        self.tempdir = safe_mkdtemp()
×
231
        try:
×
232
            self.binary = mozinstall.get_binary(
×
233
                mozinstall.install(src=dest, dest=self.tempdir), self.app_name
234
            )
235
        except Exception:
×
236
            remove(self.tempdir)
×
237
            raise
×
238

239
        self.binarydir = os.path.dirname(self.binary)
×
240
        self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
×
241
        if mozinfo.os == "mac" and self._codesign_verify(self.appdir) == CodesignResult.UNSIGNED:
×
242
            LOG.debug(f"codesign verification failed for {self.appdir}, resigning...")
×
243
            self._codesign_sign(self.appdir)
×
244

245
    def _disableUpdateByPolicy(self):
3✔
246
        updatePolicy = {"policies": {"DisableAppUpdate": True}}
×
247
        installdir = os.path.dirname(self.binary)
×
248
        if mozinfo.os == "mac":
×
249
            # macOS has the following filestructure:
250
            # binary at:
251
            #     PackageName.app/Contents/MacOS/firefox
252
            # we need policies.json in:
253
            #     PackageName.app/Contents/Resources/distribution
254
            installdir = os.path.normpath(os.path.join(installdir, "..", "Resources"))
×
255
        os.makedirs(os.path.join(installdir, "distribution"))
×
256
        policyFile = os.path.join(installdir, "distribution", "policies.json")
×
257
        with open(policyFile, "w") as fp:
×
258
            json.dump(updatePolicy, fp, indent=2)
×
259

260
    def _start(
3✔
261
        self,
262
        profile=None,
263
        addons=(),
264
        cmdargs=(),
265
        preferences=None,
266
        adb_profile_dir=None,
267
    ):
268
        profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
×
269

270
        LOG.info("Launching %s" % self.binary)
×
271
        self.runner = Runner(binary=self.binary, cmdargs=cmdargs, profile=profile)
×
272

273
        def _on_exit():
×
274
            # if we are stopping the process do not log anything.
275
            if not self._stopping:
×
276
                # mozprocess (behind mozrunner) fire 'onFinish'
277
                # a bit early - let's ensure the process is finished.
278
                # we have to call wait() directly on the subprocess
279
                # instance of the ProcessHandler, else on windows
280
                # None is returned...
281
                # TODO: search that bug and fix that in mozprocess or
282
                # mozrunner. (likely mozproces)
283
                try:
×
284
                    exitcode = self.runner.process_handler.proc.wait()
×
285
                except Exception:
×
286
                    print()
×
287
                    LOG.error(
×
288
                        "Error while waiting process, consider filing a bug.",
289
                        exc_info=True,
290
                    )
291
                    return
×
292
                if exitcode != 0:
×
293
                    # first print a blank line, to be sure we don't
294
                    # write on an already printed line without EOL.
295
                    print()
×
296
                    LOG.warning("Process exited with code %s" % exitcode)
×
297

298
        # we don't need stdin, and GUI will not work in Windowed mode if set
299
        # see: https://stackoverflow.com/a/40108817
300
        # also, don't stream to stdout: https://bugzilla.mozilla.org/show_bug.cgi?id=1653349
301
        devnull = open(os.devnull, "wb")
×
302
        self.runner.process_args = {
×
303
            "processOutputLine": [get_default_logger("process").info],
304
            "stdin": devnull,
305
            "stream": None,
306
            "onFinish": _on_exit,
307
        }
308
        self.runner.start()
×
309

310
    def _wait(self):
3✔
311
        return self.runner.wait()
×
312

313
    def _stop(self):
3✔
314
        if mozinfo.os == "win" and self.app_name == "firefox":
×
315
            # for some reason, stopping the runner may hang on windows. For
316
            # example restart the browser in safe mode, it will hang for a
317
            # couple of minutes. As a workaround, we call that in a thread and
318
            # wait a bit for the completion. If the stop() can't complete we
319
            # forgot about that thread.
320
            thread = Thread(target=self.runner.stop)
×
321
            thread.daemon = True
×
322
            thread.start()
×
323
            thread.join(0.7)
×
324
        else:
325
            self.runner.stop()
×
326
        # release the runner since it holds a profile reference
327
        del self.runner
×
328

329
    def cleanup(self):
3✔
330
        try:
×
331
            Launcher.cleanup(self)
×
332
        finally:
333
            # always remove tempdir
334
            if self.tempdir is not None:
×
335
                remove(self.tempdir)
×
336

337
    def get_app_info(self):
3✔
338
        return safe_get_version(binary=self.binary)
×
339

340

341
REGISTRY = ClassRegistry("app_name")
3✔
342

343

344
def create_launcher(buildinfo, launcher_args=None):
3✔
345
    """
346
    Create and returns an instance launcher for the given buildinfo.
347
    """
NEW
348
    return REGISTRY.get(buildinfo.app_name)(
×
349
        buildinfo.build_file,
350
        task_id=buildinfo.task_id,
351
        launcher_args=launcher_args,
352
    )
353

354

355
class FirefoxRegressionProfile(Profile):
3✔
356
    """
357
    Specialized Profile subclass for Firefox / Fennec
358

359
    Some preferences may only apply to one or the other
360
    """
361

362
    preferences = {
363
        # Don't automatically update the application (only works on older
364
        # versions of Firefox)
365
        "app.update.enabled": False,
366
        # On newer versions of Firefox (where disabling automatic updates
367
        # is impossible, at least don't update automatically)
368
        "app.update.auto": False,
369
        # Don't automatically download the update (this pref is specific to
370
        # some versions of Fennec)
371
        "app.update.autodownload": "disabled",
372
        # Don't restore the last open set of tabs
373
        # if the browser has crashed
374
        "browser.sessionstore.resume_from_crash": False,
375
        # Don't check for the default web browser during startup
376
        "browser.shell.checkDefaultBrowser": False,
377
        # Don't warn on exit when multiple tabs are open
378
        "browser.tabs.warnOnClose": False,
379
        # Don't warn when exiting the browser
380
        "browser.warnOnQuit": False,
381
        # Don't send Firefox health reports to the production
382
        # server
383
        "datareporting.healthreport.uploadEnabled": False,
384
        "datareporting.healthreport.documentServerURI": "http://%(server)s/healthreport/",
385
        # Don't show tab with privacy notice on every launch
386
        "datareporting.policy.dataSubmissionPolicyBypassNotification": True,
387
        # Don't report telemetry information
388
        "toolkit.telemetry.enabled": False,
389
        # Allow sideloading extensions
390
        "extensions.autoDisableScopes": 0,
391
        # Disable what's new page
392
        "browser.startup.homepage_override.mstone": "ignore",
393
    }
394

395

396
@REGISTRY.register("firefox")
3✔
397
class FirefoxLauncher(MozRunnerLauncher):
3✔
398
    profile_class = FirefoxRegressionProfile
3✔
399

400
    def _install(self, dest):
3✔
401
        super(FirefoxLauncher, self)._install(dest)
×
402
        self._disableUpdateByPolicy()
×
403

404
        if self._codesign_invalid_on_macOS_13:
×
405
            LOG.warning(f"codesign verification failed for {self.appdir}, re-signing...")
×
406
            self._codesign_sign(self.appdir)
×
407

408

409
class ThunderbirdRegressionProfile(ThunderbirdProfile):
3✔
410
    """
411
    Specialized Profile subclass for Thunderbird
412
    """
413

414
    preferences = {
3✔
415
        # Don't automatically update the application
416
        "app.update.enabled": False,
417
        "app.update.auto": False,
418
    }
419

420

421
@REGISTRY.register("thunderbird")
3✔
422
class ThunderbirdLauncher(MozRunnerLauncher):
3✔
423
    profile_class = ThunderbirdRegressionProfile
3✔
424

425
    def _install(self, dest):
3✔
426
        super(ThunderbirdLauncher, self)._install(dest)
×
427
        self._disableUpdateByPolicy()
×
428

429
        if self._codesign_invalid_on_macOS_13:
×
430
            LOG.warning(f"codesign verification failed for {self.appdir}, re-signing...")
×
431
            self._codesign_sign(self.appdir)
×
432

433

434
class AndroidLauncher(Launcher):
3✔
435
    app_info = None
3✔
436
    adb = None
3✔
437
    package_name = None
3✔
438
    profile_class = FirefoxRegressionProfile
3✔
439
    remote_profile = None
3✔
440

441
    @abstractmethod
3✔
442
    def _get_package_name(self):
3✔
443
        raise NotImplementedError
444

445
    @abstractmethod
3✔
446
    def _launch(self):
3✔
447
        raise NotImplementedError
448

449
    @classmethod
3✔
450
    def check_is_runnable(cls):
3✔
451
        try:
×
452
            devices = ADBHost().devices()
×
453
        except ADBError as adb_error:
×
454
            raise LauncherNotRunnable(str(adb_error))
×
455
        if not devices:
×
456
            raise LauncherNotRunnable(
×
457
                "No android device connected." " Connect a device and try again."
458
            )
459

460
    def _install(self, dest):
3✔
461
        # get info now, as dest may be removed
462
        self.app_info = safe_get_version(binary=dest)
×
463
        self.package_name = self.app_info.get("package_name", self._get_package_name())
×
464
        self.adb = ADBDeviceFactory()
×
465
        try:
×
466
            self.adb.uninstall_app(self.package_name)
×
467
        except ADBError as msg:
×
468
            LOG.warning(
×
469
                "Failed to uninstall %s (%s)\nThis is normal if it is the"
470
                " first time the application is installed." % (self.package_name, msg)
471
            )
472
        self.adb.run_as_package = self.adb.install_app(dest)
×
473

474
    def _start(
3✔
475
        self,
476
        profile=None,
477
        addons=(),
478
        cmdargs=(),
479
        preferences=None,
480
        adb_profile_dir=None,
481
    ):
482
        # for now we don't handle addons on the profile for fennec
483
        profile = self._create_profile(profile=profile, preferences=preferences)
×
484
        # send the profile on the device
485
        if not adb_profile_dir:
×
486
            adb_profile_dir = self.adb.test_root
×
487
        self.remote_profile = "/".join([adb_profile_dir, os.path.basename(profile.profile)])
×
488
        if self.adb.exists(self.remote_profile):
×
489
            self.adb.rm(self.remote_profile, recursive=True)
×
490
        LOG.debug("Pushing profile to device (%s -> %s)" % (profile.profile, self.remote_profile))
×
491
        self.adb.push(profile.profile, self.remote_profile)
×
492
        if cmdargs and len(cmdargs) == 1 and not cmdargs[0].startswith("-"):
×
493
            url = cmdargs[0]
×
494
        else:
495
            url = None
×
496
        if isinstance(self, (FenixLauncher, FennecLauncher, FocusLauncher)):
×
497
            self._launch(url=url)
×
498
        else:
499
            self._launch()
×
500

501
    def _wait(self):
3✔
502
        while self.adb.process_exist(self.package_name):
×
503
            time.sleep(0.1)
×
504

505
    def _stop(self):
3✔
506
        self.adb.stop_application(self.package_name)
×
507
        if self.adb.exists(self.remote_profile):
×
508
            self.adb.rm(self.remote_profile, recursive=True)
×
509

510
    def launch_browser(
3✔
511
        self,
512
        app_name,
513
        activity,
514
        intent="android.intent.action.VIEW",
515
        moz_env=None,
516
        url=None,
517
        wait=True,
518
        fail_if_running=True,
519
        timeout=None,
520
    ):
521
        extras = {}
×
522
        extras["args"] = f"-profile {self.remote_profile}"
×
523

524
        self.adb.launch_application(
×
525
            app_name,
526
            activity,
527
            intent,
528
            url=url,
529
            extras=extras,
530
            wait=wait,
531
            fail_if_running=fail_if_running,
532
            timeout=timeout,
533
        )
534

535
    def get_app_info(self):
3✔
536
        return self.app_info
×
537

538

539
@REGISTRY.register("fennec")
3✔
540
class FennecLauncher(AndroidLauncher):
3✔
541
    def _get_package_name(self):
3✔
542
        return "org.mozilla.fennec"
×
543

544
    def _launch(self, url=None):
3✔
545
        LOG.debug("Launching fennec")
×
546
        self.launch_browser(self.package_name, "org.mozilla.gecko.BrowserApp", url=url)
×
547

548

549
@REGISTRY.register("fenix")
3✔
550
class FenixLauncher(AndroidLauncher):
3✔
551
    def _get_package_name(self):
3✔
552
        return "org.mozilla.fenix"
×
553

554
    def _launch(self, url=None):
3✔
555
        LOG.debug("Launching fenix")
×
556
        self.launch_browser(self.package_name, ".IntentReceiverActivity", url=url)
×
557

558

559
@REGISTRY.register("focus")
3✔
560
class FocusLauncher(AndroidLauncher):
3✔
561
    def _get_package_name(self):
3✔
562
        return "org.mozilla.focus.nightly"
×
563

564
    def _launch(self, url=None):
3✔
565
        LOG.debug("Launching focus")
×
566
        self.launch_browser(
×
567
            self.package_name,
568
            "org.mozilla.focus.activity.IntentReceiverActivity",
569
            url=url,
570
        )
571

572

573
@REGISTRY.register("gve")
3✔
574
class GeckoViewExampleLauncher(AndroidLauncher):
3✔
575
    def _get_package_name(self):
3✔
576
        return "org.mozilla.geckoview_example"
×
577

578
    def _launch(self):
3✔
579
        LOG.debug("Launching geckoview_example")
×
580
        self.adb.launch_activity(
×
581
            self.package_name,
582
            activity_name="GeckoViewActivity",
583
            extra_args=["-profile", self.remote_profile],
584
            e10s=True,
585
        )
586

587

588
@REGISTRY.register("jsshell")
3✔
589
class JsShellLauncher(Launcher):
3✔
590
    temp_dir = None
3✔
591

592
    def _install(self, dest):
3✔
593
        self.tempdir = safe_mkdtemp()
×
594
        try:
×
595
            with zipfile.ZipFile(dest, "r") as z:
×
596
                z.extractall(self.tempdir)
×
597
            self.binary = os.path.join(self.tempdir, "js" if mozinfo.os != "win" else "js.exe")
×
598
            # set the file executable
599
            os.chmod(self.binary, os.stat(self.binary).st_mode | stat.S_IEXEC)
×
600
        except Exception:
×
601
            remove(self.tempdir)
×
602
            raise
×
603

604
    def _start(self, **kwargs):
3✔
605
        LOG.info("Launching %s" % self.binary)
×
606
        res = call([self.binary], cwd=self.tempdir)
×
607
        if res != 0:
×
608
            LOG.warning("jsshell exited with code %d." % res)
×
609

610
    def _wait(self):
3✔
611
        pass
612

613
    def _stop(self, **kwargs):
3✔
614
        pass
615

616
    def get_app_info(self):
3✔
617
        return {}
×
618

619
    def cleanup(self):
3✔
620
        try:
×
621
            Launcher.cleanup(self)
×
622
        finally:
623
            # always remove tempdir
624
            if self.tempdir is not None:
×
625
                remove(self.tempdir)
×
626

627

628
# Should this be part of mozrunner ?
629
class SnapRunner(GeckoRuntimeRunner):
3✔
630
    _allow_sudo = False
3✔
631
    _snap_pkg = None
3✔
632

633
    def __init__(self, binary, cmdargs, allow_sudo=False, snap_pkg=None, **runner_args):
3✔
NEW
634
        self._allow_sudo = allow_sudo
×
NEW
635
        self._snap_pkg = snap_pkg
×
NEW
636
        super().__init__(binary, cmdargs, **runner_args)
×
637

638
    @property
3✔
639
    def command(self):
3✔
640
        """
641
        Rewrite the command for performing the actual execution with
642
        "snap run PKG", keeping everything else
643
        """
NEW
644
        self._command = FirefoxSnapLauncher._get_snap_command(
×
645
            self._allow_sudo, "run", [self._snap_pkg] + super().command[1:]
646
        )
NEW
647
        return self._command
×
648

649

650
@REGISTRY.register("firefox-snap")
3✔
651
class FirefoxSnapLauncher(MozRunnerLauncher):
3✔
652
    profile_class = FirefoxRegressionProfile
3✔
653
    instanceKey = None
3✔
654
    snap_pkg = None
3✔
655
    binary = None
3✔
656
    allow_sudo = False
3✔
657
    disable_snap_connect = False
3✔
658
    runner = None
3✔
659

660
    def __init__(self, dest, **kwargs):
3✔
NEW
661
        self.allow_sudo = kwargs["launcher_args"]["allow_sudo"]
×
NEW
662
        self.disable_snap_connect = kwargs["launcher_args"]["disable_snap_connect"]
×
663

NEW
664
        if not self.allow_sudo:
×
665
            LOG.info("Working with snap requires several 'sudo snap' commands. Not allowing the use of sudo will trigger many password confirmation dialog boxes.")
666
        else:
667
            LOG.info("Usage of sudo enabled, you should be prompted for your password once.")
668

NEW
669
        super().__init__(dest)
×
670

671
    def get_snap_command(self, action, extra):
3✔
NEW
672
        return FirefoxSnapLauncher._get_snap_command(self.allow_sudo, action, extra)
×
673

674
    def _get_snap_command(allow_sudo, action, extra):
3✔
NEW
675
        if action not in ("connect", "install", "run", "refresh", "remove"):
×
NEW
676
            raise LauncherError(f"Snap operation {action} unsupported")
×
677

NEW
678
        cmd = []
×
NEW
679
        if allow_sudo and action in ("connect", "install", "refresh", "remove"):
×
NEW
680
            cmd += ["sudo"]
×
681

NEW
682
        cmd += ["snap", action]
×
NEW
683
        cmd += extra
×
684

NEW
685
        return cmd
×
686

687
    def _install(self, dest):
3✔
688
        # From https://snapcraft.io/docs/parallel-installs#heading--naming
689
        #  - The instance key needs to be manually appended to the snap name,
690
        #    and takes the following format: <snap>_<instance-key>
691
        #  - The instance key must match the following regular expression:
692
        #    ^[a-z0-9]{1,10}$.
NEW
693
        self.instanceKey = hashlib.sha1(os.path.basename(dest).encode("utf8")).hexdigest()[0:9]
×
NEW
694
        self.snap_pkg = "firefox_{}".format(self.instanceKey)
×
NEW
695
        self.binary = "/snap/{}/current/usr/lib/firefox/firefox".format(self.snap_pkg)
×
696

NEW
697
        subprocess.run(
×
698
            self.get_snap_command(
699
                "install", ["--name", self.snap_pkg, "--dangerous", "{}".format(dest)]
700
            ),
701
            check=True,
702
        )
NEW
703
        self._fix_connections()
×
704

NEW
705
        self.binarydir = os.path.dirname(self.binary)
×
NEW
706
        self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
×
707

NEW
708
        LOG.debug(f"snap package: {self.snap_pkg} {self.binary}")
×
709

710
        # On Snap updates are already disabled
711

712
    def _fix_connections(self):
3✔
NEW
713
        if self.disable_snap_connect:
×
NEW
714
            return
×
715

NEW
716
        existing = {}
×
NEW
717
        for line in subprocess.getoutput("snap connections {}".format(self.snap_pkg)).splitlines()[
×
718
            1:
719
        ]:
NEW
720
            interface, plug, slot, _ = line.split()
×
NEW
721
            existing[plug] = slot
×
722

NEW
723
        for line in subprocess.getoutput("snap connections firefox").splitlines()[1:]:
×
NEW
724
            interface, plug, slot, _ = line.split()
×
NEW
725
            ex_plug = plug.replace("firefox:", "{}:".format(self.snap_pkg))
×
NEW
726
            ex_slot = slot.replace("firefox:", "{}:".format(self.snap_pkg))
×
NEW
727
            if existing[ex_plug] == "-":
×
NEW
728
                if ex_plug != "-" and ex_slot != "-":
×
NEW
729
                    cmd = self.get_snap_command(
×
730
                        "connect", ["{}".format(ex_plug), "{}".format(ex_slot)]
731
                    )
NEW
732
                    LOG.debug(f"snap connect: {cmd}")
×
NEW
733
                    subprocess.run(cmd, check=True)
×
734

735
    def _create_profile(self, profile=None, addons=(), preferences=None):
3✔
736
        """
737
        Let's create a profile as usual, but rewrite its path to be in Snap's
738
        dir because it looks like MozProfile class will consider a profile=xxx
739
        to be a pre-existing one
740
        """
NEW
741
        real_profile = super()._create_profile(profile, addons, preferences)
×
NEW
742
        snap_profile_dir = os.path.abspath(
×
743
            os.path.expanduser("~/snap/{}/common/.mozilla/firefox/".format(self.snap_pkg))
744
        )
NEW
745
        if not os.path.exists(snap_profile_dir):
×
NEW
746
            os.makedirs(snap_profile_dir)
×
NEW
747
        profile_dir_name = os.path.basename(real_profile.profile)
×
NEW
748
        snap_profile = os.path.join(snap_profile_dir, profile_dir_name)
×
NEW
749
        move(real_profile.profile, snap_profile_dir)
×
NEW
750
        real_profile.profile = snap_profile
×
NEW
751
        return real_profile
×
752

753
    def _start(
3✔
754
        self,
755
        profile=None,
756
        addons=(),
757
        cmdargs=(),
758
        preferences=None,
759
        adb_profile_dir=None,
760
        allow_sudo=False,
761
        disable_snap_connect=False,
762
    ):
NEW
763
        profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
×
764

NEW
765
        LOG.info("Launching %s [%s]" % (self.binary, self.allow_sudo))
×
NEW
766
        self.runner = SnapRunner(
×
767
            binary=self.binary,
768
            cmdargs=cmdargs,
769
            profile=profile,
770
            allow_sudo=self.allow_sudo,
771
            snap_pkg=self.snap_pkg,
772
        )
NEW
773
        self.runner.start()
×
774

775
    def _wait(self):
3✔
NEW
776
        self.runner.wait()
×
777

778
    def _stop(self):
3✔
NEW
779
        self.runner.stop()
×
780
        # release the runner since it holds a profile reference
NEW
781
        del self.runner
×
782

783
    def cleanup(self):
3✔
NEW
784
        try:
×
NEW
785
            Launcher.cleanup(self)
×
786
        finally:
NEW
787
            subprocess.run(self.get_snap_command("remove", [self.snap_pkg]))
×
788

789
    def get_app_info(self):
3✔
NEW
790
        return safe_get_version(binary=self.binary)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc