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

mozilla / mozregression / 9283882049

29 May 2024 09:44AM CUT coverage: 86.286%. First build
9283882049

Pull #1450

github

web-flow
Merge ee901c877 into 3e46da41f
Pull Request #1450: Bug 1763188 - Add Snap support using TC builds

96 of 217 new or added lines in 8 files covered. (44.24%)

2586 of 2997 relevant lines covered (86.29%)

5.86 hits per line

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

75.6
/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
8✔
6

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

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

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

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

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

39

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

46
    profile_class = Profile
8✔
47

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

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

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

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

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

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

112
    def cleanup(self):
8✔
113
        self.stop()
6✔
114

115
    def __enter__(self):
8✔
116
        return self
6✔
117

118
    def __exit__(self, *exc):
8✔
119
        self.cleanup()
6✔
120

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

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

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

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

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

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

166

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

176

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

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

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

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

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

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

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

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

260
    def _start(
8✔
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)
6✔
269

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

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

310
    def _wait(self):
8✔
311
        return self.runner.wait()
6✔
312

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

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

337
    def get_app_info(self):
8✔
338
        return safe_get_version(binary=self.binary)
6✔
339

340

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

343

344
def create_launcher(buildinfo, allow_sudo, disable_snap_connect):
8✔
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
        allow_sudo=allow_sudo,
352
        disable_snap_connect=disable_snap_connect,
353
    )
354

355

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

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

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

396

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

401
    def _install(self, dest):
8✔
402
        super(FirefoxLauncher, self)._install(dest)
6✔
403
        self._disableUpdateByPolicy()
6✔
404

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

409

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

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

421

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

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

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

434

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

442
    @abstractmethod
8✔
443
    def _get_package_name(self):
8✔
444
        raise NotImplementedError
445

446
    @abstractmethod
8✔
447
    def _launch(self):
8✔
448
        raise NotImplementedError
449

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

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

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

502
    def _wait(self):
8✔
503
        while self.adb.process_exist(self.package_name):
6✔
504
            time.sleep(0.1)
6✔
505

506
    def _stop(self):
8✔
507
        self.adb.stop_application(self.package_name)
6✔
508
        if self.adb.exists(self.remote_profile):
6✔
509
            self.adb.rm(self.remote_profile, recursive=True)
6✔
510

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

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

536
    def get_app_info(self):
8✔
537
        return self.app_info
6✔
538

539

540
@REGISTRY.register("fennec")
8✔
541
class FennecLauncher(AndroidLauncher):
8✔
542
    def _get_package_name(self):
8✔
543
        return "org.mozilla.fennec"
6✔
544

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

549

550
@REGISTRY.register("fenix")
8✔
551
class FenixLauncher(AndroidLauncher):
8✔
552
    def _get_package_name(self):
8✔
553
        return "org.mozilla.fenix"
6✔
554

555
    def _launch(self, url=None):
8✔
556
        LOG.debug("Launching fenix")
6✔
557
        self.launch_browser(self.package_name, ".IntentReceiverActivity", url=url)
6✔
558

559

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

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

573

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

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

588

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

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

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

611
    def _wait(self):
8✔
612
        pass
613

614
    def _stop(self, **kwargs):
8✔
615
        pass
616

617
    def get_app_info(self):
8✔
618
        return {}
6✔
619

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

628

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

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

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

650

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

661
    def __init__(self, dest, allow_sudo, disable_snap_connect, **kwargs):
8✔
NEW
662
        self.allow_sudo = allow_sudo
×
NEW
663
        self.disable_snap_connect = disable_snap_connect
×
NEW
664
        super(MozRunnerLauncher, self).__init__(dest)
×
665

666
    def get_snap_command(self, action, extra):
8✔
NEW
667
        return FirefoxSnapLauncher._get_snap_command(self.allow_sudo, action, extra)
×
668

669
    def _get_snap_command(allow_sudo, action, extra):
8✔
NEW
670
        if action not in ("connect", "install", "run", "refresh", "remove"):
×
NEW
671
            raise LauncherError(f"Snap operation {action} unsupported")
×
672

NEW
673
        cmd = []
×
NEW
674
        if allow_sudo and action in ("connect", "install", "refresh", "remove"):
×
NEW
675
            cmd += ["sudo"]
×
676

NEW
677
        cmd += ["snap", action]
×
NEW
678
        cmd += extra
×
679

NEW
680
        return cmd
×
681

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

NEW
692
        subprocess.run(
×
693
            self.get_snap_command(
694
                "install", ["--name", self.snap_pkg, "--dangerous", "{}".format(dest)]
695
            ),
696
            check=True,
697
        )
NEW
698
        self._fix_connections()
×
699

NEW
700
        self.binarydir = os.path.dirname(self.binary)
×
NEW
701
        self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
×
702

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

705
        # On Snap updates are already disabled
706

707
    def _fix_connections(self):
8✔
NEW
708
        if self.disable_snap_connect:
×
NEW
709
            return
×
710

NEW
711
        existing = {}
×
NEW
712
        for line in subprocess.getoutput("snap connections {}".format(self.snap_pkg)).splitlines()[
×
713
            1:
714
        ]:
NEW
715
            interface, plug, slot, _ = line.split()
×
NEW
716
            existing[plug] = slot
×
717

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

730
        # Without, you end up with half-connected interfaces
731
        # The check=True seems to be required otherwise command completes but
732
        # it's like it did nothing
733
        # https://bugs.launchpad.net/ubuntu/+source/snapd/+bug/2043993
NEW
734
        refresh_cmd = self.get_snap_command("refresh", ["--amend", "{}".format(self.snap_pkg)])
×
NEW
735
        LOG.debug(f"snap refresh: {refresh_cmd}")
×
NEW
736
        try:
×
NEW
737
            subprocess.run(refresh_cmd, check=True)
×
NEW
738
        except subprocess.CalledProcessError as ex:
×
739
            # We will have errors because of the aliases, but we dont really care
NEW
740
            print(ex.cmd, ex.returncode, ex.output)
×
NEW
741
            LOG.debug(f"snap refresh failed: {ex.cmd} => {ex.returncode}")
×
742

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

761
    def _start(
8✔
762
        self,
763
        profile=None,
764
        addons=(),
765
        cmdargs=(),
766
        preferences=None,
767
        adb_profile_dir=None,
768
        allow_sudo=False,
769
        disable_snap_connect=False,
770
    ):
NEW
771
        profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
×
772

NEW
773
        LOG.info("Launching %s [%s]" % (self.binary, self.allow_sudo))
×
NEW
774
        self.runner = SnapRunner(
×
775
            binary=self.binary,
776
            cmdargs=cmdargs,
777
            profile=profile,
778
            allow_sudo=self.allow_sudo,
779
            snap_pkg=self.snap_pkg,
780
        )
NEW
781
        self.runner.start()
×
782

783
    def _wait(self):
8✔
NEW
784
        self.runner.wait()
×
785

786
    def _stop(self):
8✔
NEW
787
        self.runner.stop()
×
788
        # release the runner since it holds a profile reference
NEW
789
        del self.runner
×
790

791
    def cleanup(self):
8✔
NEW
792
        try:
×
NEW
793
            Launcher.cleanup(self)
×
794
        finally:
NEW
795
            subprocess.run(self.get_snap_command("remove", [self.snap_pkg]))
×
796

797
    def get_app_info(self):
8✔
NEW
798
        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