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

mozilla / mozregression / 10832269551

12 Sep 2024 01:52PM CUT coverage: 89.37%. First build
10832269551

Pull #1813

github

web-flow
Merge cc732d578 into c39e24675
Pull Request #1813: pyproject: fully migrate to pyproject.toml from setup.py (bug 1917431)

2497 of 2794 relevant lines covered (89.37%)

0.89 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
1✔
6

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

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

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

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

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

36

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

43
    profile_class = Profile
1✔
44

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

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

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

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

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

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

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

112
    def __enter__(self):
1✔
113
        return self
1✔
114

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

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

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

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

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

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

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

163

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

173

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

337

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

340

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

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

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

401

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

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

413

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

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

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

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

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

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

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

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

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

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

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

531

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

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

541

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

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

551

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

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

565

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

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

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

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

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

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

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

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