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

mozilla / mozregression / 11802123193

12 Nov 2024 05:03PM CUT coverage: 89.227%. First build
11802123193

Pull #1868

github

web-flow
Merge f3ed285b9 into 533473201
Pull Request #1868: workflows, requirements: python 3.13 changes (bug 1914897)

2518 of 2822 relevant lines covered (89.23%)

10.8 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
12✔
6

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

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

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

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

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

36

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

43
    profile_class = Profile
12✔
44

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

163

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

173

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

337

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

340

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

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

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

401

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

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

413

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

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

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

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

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

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

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

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

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

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

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

531

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

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

541

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

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

551

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

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

565

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

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

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

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

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

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

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

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