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

mozilla / mozregression / 14574260233

21 Apr 2025 01:24PM CUT coverage: 89.377%. First build
14574260233

Pull #1979

github

web-flow
Merge f536934c5 into 807564865
Pull Request #1979: build(deps): bump setuptools from 75.1.0 to 79.0.0

2524 of 2824 relevant lines covered (89.38%)

7.83 hits per line

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

89.13
/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
10✔
6

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

18
import mozinfo
10✔
19
import mozinstall
10✔
20
import mozversion
10✔
21
from mozdevice import ADBDeviceFactory, ADBError, ADBHost
10✔
22
from mozfile import remove
10✔
23
from mozlog.structured import get_default_logger, get_proxy_logger
10✔
24
from mozprofile import Profile, ThunderbirdProfile
10✔
25
from mozrunner import Runner
10✔
26

27
from mozregression.class_registry import ClassRegistry
10✔
28
from mozregression.errors import LauncherError, LauncherNotRunnable
10✔
29
from mozregression.tempdir import safe_mkdtemp
10✔
30

31
LOG = get_proxy_logger("Test Runner")
10✔
32

33
# This enum is used to transform output from codesign (on macs).
34
CodesignResult = Enum("Result", "PASS UNSIGNED INVALID OTHER")
10✔
35

36

37
class Launcher(metaclass=ABCMeta):
10✔
38
    """
39
    Handle the logic of downloading a build file, installing and
40
    running an application.
41
    """
42

43
    profile_class = Profile
10✔
44

45
    @classmethod
10✔
46
    def check_is_runnable(cls):
10✔
47
        """
48
        Check that the launcher can be created and can run on the system.
49

50
        :raises: :class:`LauncherNotRunnable`.
51
        """
52
        pass
53

54
    def __init__(self, dest, **kwargs):
10✔
55
        self._running = False
8✔
56
        self._stopping = False
8✔
57

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

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

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

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

103
    def get_app_info(self):
10✔
104
        """
105
        Return information about the application.
106
        """
107
        raise NotImplementedError
108

109
    def cleanup(self):
10✔
110
        self.stop()
8✔
111

112
    def __enter__(self):
10✔
113
        return self
8✔
114

115
    def __exit__(self, *exc):
10✔
116
        self.cleanup()
8✔
117

118
    @abstractmethod
10✔
119
    def _install(self, dest):
10✔
120
        raise NotImplementedError
121

122
    @abstractmethod
10✔
123
    def _start(self, **kwargs):
10✔
124
        raise NotImplementedError
125

126
    @abstractmethod
10✔
127
    def _wait(self):
10✔
128
        raise NotImplementedError
129

130
    @abstractmethod
10✔
131
    def _stop(self):
10✔
132
        raise NotImplementedError
133

134
    def _create_profile(self, profile=None, addons=(), preferences=None):
10✔
135
        if isinstance(profile, Profile):
8✔
136
            return profile
×
137
        else:
138
            return self.create_profile(profile=profile, addons=addons, preferences=preferences)
8✔
139

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

163

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

173

174
class MozRunnerLauncher(Launcher):
10✔
175
    tempdir = None
10✔
176
    runner = None
10✔
177
    app_name = "undefined"
10✔
178
    binary = None
10✔
179

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

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

194
        LOG.debug(f"codesign verify exit code: {exit_code}")
8✔
195

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

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

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

226
    def _install(self, dest):
10✔
227
        self.tempdir = safe_mkdtemp()
8✔
228
        try:
8✔
229
            self.binary = mozinstall.get_binary(
8✔
230
                mozinstall.install(src=dest, dest=self.tempdir), self.app_name
231
            )
232
        except Exception:
×
233
            remove(self.tempdir)
×
234
            raise
×
235

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

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

257
    def _start(
10✔
258
        self,
259
        profile=None,
260
        addons=(),
261
        cmdargs=(),
262
        preferences=None,
263
        adb_profile_dir=None,
264
    ):
265
        profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
8✔
266

267
        LOG.info("Launching %s" % self.binary)
8✔
268
        self.runner = Runner(binary=self.binary, cmdargs=cmdargs, profile=profile)
8✔
269

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

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

307
    def _wait(self):
10✔
308
        return self.runner.wait()
8✔
309

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

326
    def cleanup(self):
10✔
327
        try:
8✔
328
            Launcher.cleanup(self)
8✔
329
        finally:
330
            # always remove tempdir
331
            if self.tempdir is not None:
8✔
332
                remove(self.tempdir)
8✔
333

334
    def get_app_info(self):
10✔
335
        return safe_get_version(binary=self.binary)
8✔
336

337

338
REGISTRY = ClassRegistry("app_name")
10✔
339

340

341
def create_launcher(buildinfo):
10✔
342
    """
343
    Create and returns an instance launcher for the given buildinfo.
344
    """
345
    return REGISTRY.get(buildinfo.app_name)(buildinfo.build_file, task_id=buildinfo.task_id)
×
346

347

348
class FirefoxRegressionProfile(Profile):
10✔
349
    """
350
    Specialized Profile subclass for Firefox / Fennec
351

352
    Some preferences may only apply to one or the other
353
    """
354

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

388

389
@REGISTRY.register("firefox")
10✔
390
class FirefoxLauncher(MozRunnerLauncher):
10✔
391
    profile_class = FirefoxRegressionProfile
10✔
392

393
    def _install(self, dest):
10✔
394
        super(FirefoxLauncher, self)._install(dest)
8✔
395
        self._disableUpdateByPolicy()
8✔
396

397
        if self._codesign_invalid_on_macOS_13:
8✔
398
            LOG.warning(f"codesign verification failed for {self.appdir}, re-signing...")
×
399
            self._codesign_sign(self.appdir)
×
400

401

402
class ThunderbirdRegressionProfile(ThunderbirdProfile):
10✔
403
    """
404
    Specialized Profile subclass for Thunderbird
405
    """
406

407
    preferences = {
10✔
408
        # Don't automatically update the application
409
        "app.update.enabled": False,
410
        "app.update.auto": False,
411
    }
412

413

414
@REGISTRY.register("thunderbird")
10✔
415
class ThunderbirdLauncher(MozRunnerLauncher):
10✔
416
    profile_class = ThunderbirdRegressionProfile
10✔
417

418
    def _install(self, dest):
10✔
419
        super(ThunderbirdLauncher, self)._install(dest)
×
420
        self._disableUpdateByPolicy()
×
421

422
        if self._codesign_invalid_on_macOS_13:
×
423
            LOG.warning(f"codesign verification failed for {self.appdir}, re-signing...")
×
424
            self._codesign_sign(self.appdir)
×
425

426

427
class AndroidLauncher(Launcher):
10✔
428
    app_info = None
10✔
429
    adb = None
10✔
430
    package_name = None
10✔
431
    profile_class = FirefoxRegressionProfile
10✔
432
    remote_profile = None
10✔
433

434
    @abstractmethod
10✔
435
    def _get_package_name(self):
10✔
436
        raise NotImplementedError
437

438
    @abstractmethod
10✔
439
    def _launch(self):
10✔
440
        raise NotImplementedError
441

442
    @classmethod
10✔
443
    def check_is_runnable(cls):
10✔
444
        try:
8✔
445
            devices = ADBHost().devices()
8✔
446
        except ADBError as adb_error:
8✔
447
            raise LauncherNotRunnable(str(adb_error))
8✔
448
        if not devices:
8✔
449
            raise LauncherNotRunnable(
8✔
450
                "No android device connected." " Connect a device and try again."
451
            )
452

453
    def _install(self, dest):
10✔
454
        # get info now, as dest may be removed
455
        self.app_info = safe_get_version(binary=dest)
8✔
456
        self.package_name = self.app_info.get("package_name", self._get_package_name())
8✔
457
        self.adb = ADBDeviceFactory()
8✔
458
        try:
8✔
459
            self.adb.uninstall_app(self.package_name)
8✔
460
        except ADBError as msg:
8✔
461
            LOG.warning(
8✔
462
                "Failed to uninstall %s (%s)\nThis is normal if it is the"
463
                " first time the application is installed." % (self.package_name, msg)
464
            )
465
        self.adb.run_as_package = self.adb.install_app(dest)
8✔
466

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

494
    def _wait(self):
10✔
495
        while self.adb.process_exist(self.package_name):
8✔
496
            time.sleep(0.1)
8✔
497

498
    def _stop(self):
10✔
499
        self.adb.stop_application(self.package_name)
8✔
500
        if self.adb.exists(self.remote_profile):
8✔
501
            self.adb.rm(self.remote_profile, recursive=True)
8✔
502

503
    def launch_browser(
10✔
504
        self,
505
        app_name,
506
        activity,
507
        intent="android.intent.action.VIEW",
508
        moz_env=None,
509
        url=None,
510
        wait=True,
511
        fail_if_running=True,
512
        timeout=None,
513
    ):
514
        extras = {}
8✔
515
        extras["args"] = f"-profile {self.remote_profile}"
8✔
516

517
        self.adb.launch_application(
8✔
518
            app_name,
519
            activity,
520
            intent,
521
            url=url,
522
            extras=extras,
523
            wait=wait,
524
            fail_if_running=fail_if_running,
525
            timeout=timeout,
526
        )
527

528
    def get_app_info(self):
10✔
529
        return self.app_info
8✔
530

531

532
@REGISTRY.register("fennec")
10✔
533
class FennecLauncher(AndroidLauncher):
10✔
534
    def _get_package_name(self):
10✔
535
        return "org.mozilla.fennec"
8✔
536

537
    def _launch(self, url=None):
10✔
538
        LOG.debug("Launching fennec")
8✔
539
        self.launch_browser(self.package_name, "org.mozilla.gecko.BrowserApp", url=url)
8✔
540

541

542
@REGISTRY.register("fenix")
10✔
543
class FenixLauncher(AndroidLauncher):
10✔
544
    def _get_package_name(self):
10✔
545
        return "org.mozilla.fenix"
8✔
546

547
    def _launch(self, url=None):
10✔
548
        LOG.debug("Launching fenix")
8✔
549
        self.launch_browser(self.package_name, ".IntentReceiverActivity", url=url)
8✔
550

551

552
@REGISTRY.register("focus")
10✔
553
class FocusLauncher(AndroidLauncher):
10✔
554
    def _get_package_name(self):
10✔
555
        return "org.mozilla.focus.nightly"
8✔
556

557
    def _launch(self, url=None):
10✔
558
        LOG.debug("Launching focus")
8✔
559
        self.launch_browser(
8✔
560
            self.package_name,
561
            "org.mozilla.focus.activity.IntentReceiverActivity",
562
            url=url,
563
        )
564

565

566
@REGISTRY.register("gve")
10✔
567
class GeckoViewExampleLauncher(AndroidLauncher):
10✔
568
    def _get_package_name(self):
10✔
569
        return "org.mozilla.geckoview_example"
×
570

571
    def _launch(self):
10✔
572
        LOG.debug("Launching geckoview_example")
×
573
        self.adb.launch_activity(
×
574
            self.package_name,
575
            activity_name="GeckoViewActivity",
576
            extra_args=["-profile", self.remote_profile],
577
            e10s=True,
578
        )
579

580

581
@REGISTRY.register("jsshell")
10✔
582
class JsShellLauncher(Launcher):
10✔
583
    temp_dir = None
10✔
584

585
    def _install(self, dest):
10✔
586
        self.tempdir = safe_mkdtemp()
8✔
587
        try:
8✔
588
            with zipfile.ZipFile(dest, "r") as z:
8✔
589
                z.extractall(self.tempdir)
8✔
590
            self.binary = os.path.join(self.tempdir, "js" if mozinfo.os != "win" else "js.exe")
8✔
591
            # set the file executable
592
            os.chmod(self.binary, os.stat(self.binary).st_mode | stat.S_IEXEC)
8✔
593
        except Exception:
8✔
594
            remove(self.tempdir)
8✔
595
            raise
8✔
596

597
    def _start(self, **kwargs):
10✔
598
        LOG.info("Launching %s" % self.binary)
8✔
599
        res = call([self.binary], cwd=self.tempdir)
8✔
600
        if res != 0:
8✔
601
            LOG.warning("jsshell exited with code %d." % res)
8✔
602

603
    def _wait(self):
10✔
604
        pass
605

606
    def _stop(self, **kwargs):
10✔
607
        pass
608

609
    def get_app_info(self):
10✔
610
        return {}
8✔
611

612
    def cleanup(self):
10✔
613
        try:
8✔
614
            Launcher.cleanup(self)
8✔
615
        finally:
616
            # always remove tempdir
617
            if self.tempdir is not None:
8✔
618
                remove(self.tempdir)
8✔
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