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

mozilla / mozregression / 14641798044

24 Apr 2025 12:38PM CUT coverage: 87.386%. First build
14641798044

Pull #1983

github

web-flow
Merge da7e48128 into 807564865
Pull Request #1983: Bug 1763188 - Add Snap support using TC builds

59 of 132 new or added lines in 4 files covered. (44.7%)

2577 of 2949 relevant lines covered (87.39%)

8.54 hits per line

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

76.7
/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
11✔
6

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

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

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

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

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

39

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

46
    profile_class = Profile
11✔
47

48
    @classmethod
11✔
49
    def check_is_runnable(cls):
11✔
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):
11✔
58
        self._running = False
9✔
59
        self._stopping = False
9✔
60

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

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

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

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

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

112
    def cleanup(self):
11✔
113
        self.stop()
9✔
114

115
    def __enter__(self):
11✔
116
        return self
9✔
117

118
    def __exit__(self, *exc):
11✔
119
        self.cleanup()
9✔
120

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

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

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

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

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

143
    @classmethod
11✔
144
    def create_profile(cls, profile=None, addons=(), preferences=None, clone=True):
11✔
145
        if profile:
9✔
146
            if not os.path.exists(profile):
9✔
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:
9✔
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)
9✔
158
            else:
159
                profile = cls.profile_class(profile, addons=addons, preferences=preferences)
×
160
        elif len(addons):
9✔
161
            profile = cls.profile_class(addons=addons, preferences=preferences)
9✔
162
        else:
163
            profile = cls.profile_class(preferences=preferences)
9✔
164
        return profile
9✔
165

166

167
def safe_get_version(**kwargs):
11✔
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:
9✔
171
        return mozversion.get_version(**kwargs)
9✔
172
    except mozversion.VersionError as exc:
9✔
173
        LOG.warning("Unable to get app version: %s" % exc)
9✔
174
        return {}
9✔
175

176

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

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

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

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

199
        if exit_code == 0:
9✔
200
            return CodesignResult.PASS
9✔
201
        elif exit_code == 1:
9✔
202
            if b"code object is not signed at all" in output:
9✔
203
                # NOTE: this output message was tested on macOS 11, 12, and 13.
204
                return CodesignResult.UNSIGNED
9✔
205
            else:
206
                return CodesignResult.INVALID
9✔
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
9✔
210

211
    @staticmethod
11✔
212
    def _codesign_sign(appdir):
11✔
213
        """Calls `codesign` to sign `appdir` with ad-hoc identity."""
214
        if mozinfo.os != "mac":
9✔
215
            raise Exception("_codesign_sign should only be called on macOS.")
9✔
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])
9✔
219

220
    @property
11✔
221
    def _codesign_invalid_on_macOS_13(self):
11✔
222
        """Return True if codesign verify fails on macOS 13+, otherwise return False."""
223
        return (
9✔
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):
11✔
230
        self.tempdir = safe_mkdtemp()
9✔
231
        try:
9✔
232
            self.binary = mozinstall.get_binary(
9✔
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)
9✔
240
        self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
9✔
241
        if mozinfo.os == "mac" and self._codesign_verify(self.appdir) == CodesignResult.UNSIGNED:
9✔
242
            LOG.debug(f"codesign verification failed for {self.appdir}, resigning...")
9✔
243
            self._codesign_sign(self.appdir)
9✔
244

245
    def _disableUpdateByPolicy(self):
11✔
246
        updatePolicy = {"policies": {"DisableAppUpdate": True}}
9✔
247
        installdir = os.path.dirname(self.binary)
9✔
248
        if mozinfo.os == "mac":
9✔
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"))
9✔
256
        policyFile = os.path.join(installdir, "distribution", "policies.json")
9✔
257
        with open(policyFile, "w") as fp:
9✔
258
            json.dump(updatePolicy, fp, indent=2)
9✔
259

260
    def _start(
11✔
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)
9✔
269

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

273
        def _on_exit():
9✔
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")
9✔
302
        self.runner.process_args = {
9✔
303
            "processOutputLine": [get_default_logger("process").info],
304
            "stdin": devnull,
305
            "stream": None,
306
            "onFinish": _on_exit,
307
        }
308
        self.runner.start()
9✔
309

310
    def _wait(self):
11✔
311
        return self.runner.wait()
9✔
312

313
    def _stop(self):
11✔
314
        if mozinfo.os == "win" and self.app_name == "firefox":
9✔
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()
9✔
326
        # release the runner since it holds a profile reference
327
        del self.runner
9✔
328

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

337
    def get_app_info(self):
11✔
338
        return safe_get_version(binary=self.binary)
9✔
339

340

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

343

344
def create_launcher(buildinfo, launcher_args=None):
11✔
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):
11✔
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")
11✔
397
class FirefoxLauncher(MozRunnerLauncher):
11✔
398
    profile_class = FirefoxRegressionProfile
11✔
399

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

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

408

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

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

420

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

425
    def _install(self, dest):
11✔
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):
11✔
435
    app_info = None
11✔
436
    adb = None
11✔
437
    package_name = None
11✔
438
    profile_class = FirefoxRegressionProfile
11✔
439
    remote_profile = None
11✔
440

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

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

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

460
    def _install(self, dest):
11✔
461
        # get info now, as dest may be removed
462
        self.app_info = safe_get_version(binary=dest)
9✔
463
        self.package_name = self.app_info.get("package_name", self._get_package_name())
9✔
464
        self.adb = ADBDeviceFactory()
9✔
465
        try:
9✔
466
            self.adb.uninstall_app(self.package_name)
9✔
467
        except ADBError as msg:
9✔
468
            LOG.warning(
9✔
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)
9✔
473

474
    def _start(
11✔
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)
9✔
484
        # send the profile on the device
485
        if not adb_profile_dir:
9✔
486
            adb_profile_dir = self.adb.test_root
9✔
487
        self.remote_profile = "/".join([adb_profile_dir, os.path.basename(profile.profile)])
9✔
488
        if self.adb.exists(self.remote_profile):
9✔
489
            self.adb.rm(self.remote_profile, recursive=True)
9✔
490
        LOG.debug("Pushing profile to device (%s -> %s)" % (profile.profile, self.remote_profile))
9✔
491
        self.adb.push(profile.profile, self.remote_profile)
9✔
492
        if cmdargs and len(cmdargs) == 1 and not cmdargs[0].startswith("-"):
9✔
493
            url = cmdargs[0]
9✔
494
        else:
495
            url = None
9✔
496
        if isinstance(self, (FenixLauncher, FennecLauncher, FocusLauncher)):
9✔
497
            self._launch(url=url)
9✔
498
        else:
499
            self._launch()
×
500

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

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

510
    def launch_browser(
11✔
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 = {}
9✔
522
        extras["args"] = f"-profile {self.remote_profile}"
9✔
523

524
        self.adb.launch_application(
9✔
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):
11✔
536
        return self.app_info
9✔
537

538

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

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

548

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

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

558

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

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

572

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

578
    def _launch(self):
11✔
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")
11✔
589
class JsShellLauncher(Launcher):
11✔
590
    temp_dir = None
11✔
591

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

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

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

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

616
    def get_app_info(self):
11✔
617
        return {}
9✔
618

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

627

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

633
    def __init__(self, binary, cmdargs, allow_sudo=False, snap_pkg=None, **runner_args):
11✔
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
11✔
639
    def command(self):
11✔
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")
11✔
651
class FirefoxSnapLauncher(MozRunnerLauncher):
11✔
652
    profile_class = FirefoxRegressionProfile
11✔
653
    instanceKey = None
11✔
654
    snap_pkg = None
11✔
655
    binary = None
11✔
656
    allow_sudo = False
11✔
657
    disable_snap_connect = False
11✔
658
    runner = None
11✔
659

660
    def __init__(self, dest, **kwargs):
11✔
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(
666
                "Working with snap requires several 'sudo snap' commands. "
667
                "Not allowing the use of sudo will trigger many password confirmation dialog boxes."
668
            )
669
        else:
670
            LOG.info("Usage of sudo enabled, you should be prompted for your password once.")
671

NEW
672
        super().__init__(dest)
×
673

674
    def get_snap_command(self, action, extra):
11✔
NEW
675
        return FirefoxSnapLauncher._get_snap_command(self.allow_sudo, action, extra)
×
676

677
    def _get_snap_command(allow_sudo, action, extra):
11✔
NEW
678
        if action not in ("connect", "install", "run", "refresh", "remove"):
×
NEW
679
            raise LauncherError(f"Snap operation {action} unsupported")
×
680

NEW
681
        cmd = []
×
NEW
682
        if allow_sudo and action in ("connect", "install", "refresh", "remove"):
×
NEW
683
            cmd += ["sudo"]
×
684

NEW
685
        cmd += ["snap", action]
×
NEW
686
        cmd += extra
×
687

NEW
688
        return cmd
×
689

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

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

NEW
708
        self.binarydir = os.path.dirname(self.binary)
×
NEW
709
        self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
×
710

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

713
        # On Snap updates are already disabled
714

715
    def _fix_connections(self):
11✔
NEW
716
        if self.disable_snap_connect:
×
NEW
717
            return
×
718

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

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

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

756
    def _start(
11✔
757
        self,
758
        profile=None,
759
        addons=(),
760
        cmdargs=(),
761
        preferences=None,
762
        adb_profile_dir=None,
763
        allow_sudo=False,
764
        disable_snap_connect=False,
765
    ):
NEW
766
        profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
×
767

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

778
    def _wait(self):
11✔
NEW
779
        self.runner.wait()
×
780

781
    def _stop(self):
11✔
NEW
782
        self.runner.stop()
×
783
        # release the runner since it holds a profile reference
NEW
784
        del self.runner
×
785

786
    def cleanup(self):
11✔
NEW
787
        try:
×
NEW
788
            Launcher.cleanup(self)
×
789
        finally:
NEW
790
            subprocess.run(self.get_snap_command("remove", [self.snap_pkg]))
×
791

792
    def get_app_info(self):
11✔
NEW
793
        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