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

mozilla / mozregression / 11572196313

29 Oct 2024 10:42AM CUT coverage: 35.046%. First build
11572196313

Pull #1450

github

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

65 of 209 new or added lines in 8 files covered. (31.1%)

1057 of 3016 relevant lines covered (35.05%)

1.05 hits per line

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

36.74
/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, allow_sudo, disable_snap_connect):
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
        allow_sudo=allow_sudo,
352
        disable_snap_connect=disable_snap_connect,
353
    )
354

355

356
class FirefoxRegressionProfile(Profile):
3✔
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")
3✔
398
class FirefoxLauncher(MozRunnerLauncher):
3✔
399
    profile_class = FirefoxRegressionProfile
3✔
400

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

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

409

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

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

421

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

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

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

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

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

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

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

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

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

511
    def launch_browser(
3✔
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 = {}
×
523
        extras["args"] = f"-profile {self.remote_profile}"
×
524

525
        self.adb.launch_application(
×
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):
3✔
537
        return self.app_info
×
538

539

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

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

549

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

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

559

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

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

573

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

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

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

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

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

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

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

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

628

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

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

661
    def __init__(self, dest, allow_sudo, disable_snap_connect, **kwargs):
3✔
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):
3✔
NEW
667
        return FirefoxSnapLauncher._get_snap_command(self.allow_sudo, action, extra)
×
668

669
    def _get_snap_command(allow_sudo, action, extra):
3✔
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):
3✔
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):
3✔
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
    def _create_profile(self, profile=None, addons=(), preferences=None):
3✔
731
        """
732
        Let's create a profile as usual, but rewrite its path to be in Snap's
733
        dir because it looks like MozProfile class will consider a profile=xxx
734
        to be a pre-existing one
735
        """
NEW
736
        real_profile = super(MozRunnerLauncher, self)._create_profile(profile, addons, preferences)
×
NEW
737
        snap_profile_dir = os.path.abspath(
×
738
            os.path.expanduser("~/snap/{}/common/.mozilla/firefox/".format(self.snap_pkg))
739
        )
NEW
740
        if not os.path.exists(snap_profile_dir):
×
NEW
741
            os.makedirs(snap_profile_dir)
×
NEW
742
        profile_dir_name = os.path.basename(real_profile.profile)
×
NEW
743
        snap_profile = os.path.join(snap_profile_dir, profile_dir_name)
×
NEW
744
        move(real_profile.profile, snap_profile_dir)
×
NEW
745
        real_profile.profile = snap_profile
×
NEW
746
        return real_profile
×
747

748
    def _start(
3✔
749
        self,
750
        profile=None,
751
        addons=(),
752
        cmdargs=(),
753
        preferences=None,
754
        adb_profile_dir=None,
755
        allow_sudo=False,
756
        disable_snap_connect=False,
757
    ):
NEW
758
        profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
×
759

NEW
760
        LOG.info("Launching %s [%s]" % (self.binary, self.allow_sudo))
×
NEW
761
        self.runner = SnapRunner(
×
762
            binary=self.binary,
763
            cmdargs=cmdargs,
764
            profile=profile,
765
            allow_sudo=self.allow_sudo,
766
            snap_pkg=self.snap_pkg,
767
        )
NEW
768
        self.runner.start()
×
769

770
    def _wait(self):
3✔
NEW
771
        self.runner.wait()
×
772

773
    def _stop(self):
3✔
NEW
774
        self.runner.stop()
×
775
        # release the runner since it holds a profile reference
NEW
776
        del self.runner
×
777

778
    def cleanup(self):
3✔
NEW
779
        try:
×
NEW
780
            Launcher.cleanup(self)
×
781
        finally:
NEW
782
            subprocess.run(self.get_snap_command("remove", [self.snap_pkg]))
×
783

784
    def get_app_info(self):
3✔
NEW
785
        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