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

mozilla / mozregression / 14311112460

07 Apr 2025 01:55PM CUT coverage: 89.377%. First build
14311112460

Pull #1967

github

web-flow
Merge 62426c9fd into 807564865
Pull Request #1967: build(deps): bump taskcluster from 75.0.0 to 83.5.0

2524 of 2824 relevant lines covered (89.38%)

13.53 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
17✔
6

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

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

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

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

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

36

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

43
    profile_class = Profile
17✔
44

45
    @classmethod
17✔
46
    def check_is_runnable(cls):
17✔
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):
17✔
55
        self._running = False
14✔
56
        self._stopping = False
14✔
57

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

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

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

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

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

109
    def cleanup(self):
17✔
110
        self.stop()
14✔
111

112
    def __enter__(self):
17✔
113
        return self
14✔
114

115
    def __exit__(self, *exc):
17✔
116
        self.cleanup()
14✔
117

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

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

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

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

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

140
    @classmethod
17✔
141
    def create_profile(cls, profile=None, addons=(), preferences=None, clone=True):
17✔
142
        if profile:
14✔
143
            if not os.path.exists(profile):
14✔
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:
14✔
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)
14✔
155
            else:
156
                profile = cls.profile_class(profile, addons=addons, preferences=preferences)
×
157
        elif len(addons):
14✔
158
            profile = cls.profile_class(addons=addons, preferences=preferences)
14✔
159
        else:
160
            profile = cls.profile_class(preferences=preferences)
14✔
161
        return profile
14✔
162

163

164
def safe_get_version(**kwargs):
17✔
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:
14✔
168
        return mozversion.get_version(**kwargs)
14✔
169
    except mozversion.VersionError as exc:
14✔
170
        LOG.warning("Unable to get app version: %s" % exc)
14✔
171
        return {}
14✔
172

173

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

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

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

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

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

208
    @staticmethod
17✔
209
    def _codesign_sign(appdir):
17✔
210
        """Calls `codesign` to sign `appdir` with ad-hoc identity."""
211
        if mozinfo.os != "mac":
14✔
212
            raise Exception("_codesign_sign should only be called on macOS.")
14✔
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])
14✔
216

217
    @property
17✔
218
    def _codesign_invalid_on_macOS_13(self):
17✔
219
        """Return True if codesign verify fails on macOS 13+, otherwise return False."""
220
        return (
14✔
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):
17✔
227
        self.tempdir = safe_mkdtemp()
14✔
228
        try:
14✔
229
            self.binary = mozinstall.get_binary(
14✔
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)
14✔
237
        self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
14✔
238
        if mozinfo.os == "mac" and self._codesign_verify(self.appdir) == CodesignResult.UNSIGNED:
14✔
239
            LOG.debug(f"codesign verification failed for {self.appdir}, resigning...")
14✔
240
            self._codesign_sign(self.appdir)
14✔
241

242
    def _disableUpdateByPolicy(self):
17✔
243
        updatePolicy = {"policies": {"DisableAppUpdate": True}}
14✔
244
        installdir = os.path.dirname(self.binary)
14✔
245
        if mozinfo.os == "mac":
14✔
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"))
14✔
253
        policyFile = os.path.join(installdir, "distribution", "policies.json")
14✔
254
        with open(policyFile, "w") as fp:
14✔
255
            json.dump(updatePolicy, fp, indent=2)
14✔
256

257
    def _start(
17✔
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)
14✔
266

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

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

307
    def _wait(self):
17✔
308
        return self.runner.wait()
14✔
309

310
    def _stop(self):
17✔
311
        if mozinfo.os == "win" and self.app_name == "firefox":
14✔
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()
14✔
323
        # release the runner since it holds a profile reference
324
        del self.runner
14✔
325

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

334
    def get_app_info(self):
17✔
335
        return safe_get_version(binary=self.binary)
14✔
336

337

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

340

341
def create_launcher(buildinfo):
17✔
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):
17✔
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")
17✔
390
class FirefoxLauncher(MozRunnerLauncher):
17✔
391
    profile_class = FirefoxRegressionProfile
17✔
392

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

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

401

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

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

413

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

418
    def _install(self, dest):
17✔
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):
17✔
428
    app_info = None
17✔
429
    adb = None
17✔
430
    package_name = None
17✔
431
    profile_class = FirefoxRegressionProfile
17✔
432
    remote_profile = None
17✔
433

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

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

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

453
    def _install(self, dest):
17✔
454
        # get info now, as dest may be removed
455
        self.app_info = safe_get_version(binary=dest)
14✔
456
        self.package_name = self.app_info.get("package_name", self._get_package_name())
14✔
457
        self.adb = ADBDeviceFactory()
14✔
458
        try:
14✔
459
            self.adb.uninstall_app(self.package_name)
14✔
460
        except ADBError as msg:
14✔
461
            LOG.warning(
14✔
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)
14✔
466

467
    def _start(
17✔
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)
14✔
477
        # send the profile on the device
478
        if not adb_profile_dir:
14✔
479
            adb_profile_dir = self.adb.test_root
14✔
480
        self.remote_profile = "/".join([adb_profile_dir, os.path.basename(profile.profile)])
14✔
481
        if self.adb.exists(self.remote_profile):
14✔
482
            self.adb.rm(self.remote_profile, recursive=True)
14✔
483
        LOG.debug("Pushing profile to device (%s -> %s)" % (profile.profile, self.remote_profile))
14✔
484
        self.adb.push(profile.profile, self.remote_profile)
14✔
485
        if cmdargs and len(cmdargs) == 1 and not cmdargs[0].startswith("-"):
14✔
486
            url = cmdargs[0]
14✔
487
        else:
488
            url = None
14✔
489
        if isinstance(self, (FenixLauncher, FennecLauncher, FocusLauncher)):
14✔
490
            self._launch(url=url)
14✔
491
        else:
492
            self._launch()
×
493

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

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

503
    def launch_browser(
17✔
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 = {}
14✔
515
        extras["args"] = f"-profile {self.remote_profile}"
14✔
516

517
        self.adb.launch_application(
14✔
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):
17✔
529
        return self.app_info
14✔
530

531

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

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

541

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

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

551

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

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

565

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

571
    def _launch(self):
17✔
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")
17✔
582
class JsShellLauncher(Launcher):
17✔
583
    temp_dir = None
17✔
584

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

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

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

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

609
    def get_app_info(self):
17✔
610
        return {}
14✔
611

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