1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5from __future__ import absolute_import
6import datetime
7import os
8import posixpath
9import shutil
10import sys
11import tempfile
12import traceback
13import uuid
14
15sys.path.insert(0, os.path.abspath(os.path.realpath(os.path.dirname(__file__))))
16
17from runtests import MochitestDesktop, MessageLogger
18from mochitest_options import MochitestArgumentParser, build_obj
19from mozdevice import ADBDeviceFactory, ADBTimeoutError, RemoteProcessMonitor
20from mozscreenshot import dump_screen, dump_device_screen
21import mozcrash
22import mozinfo
23
24SCRIPT_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__)))
25
26
27class MochiRemote(MochitestDesktop):
28    localProfile = None
29    logMessages = []
30
31    def __init__(self, options):
32        MochitestDesktop.__init__(self, options.flavor, vars(options))
33
34        verbose = False
35        if (
36            options.log_mach_verbose
37            or options.log_tbpl_level == "debug"
38            or options.log_mach_level == "debug"
39            or options.log_raw_level == "debug"
40        ):
41            verbose = True
42        if hasattr(options, "log"):
43            delattr(options, "log")
44
45        self.certdbNew = True
46        self.chromePushed = False
47
48        expected = options.app.split("/")[-1]
49        self.device = ADBDeviceFactory(
50            adb=options.adbPath or "adb",
51            device=options.deviceSerial,
52            test_root=options.remoteTestRoot,
53            verbose=verbose,
54            run_as_package=expected,
55        )
56
57        if options.remoteTestRoot is None:
58            options.remoteTestRoot = self.device.test_root
59        options.dumpOutputDirectory = options.remoteTestRoot
60        self.remoteLogFile = posixpath.join(
61            options.remoteTestRoot, "logs", "mochitest.log"
62        )
63        logParent = posixpath.dirname(self.remoteLogFile)
64        self.device.rm(logParent, force=True, recursive=True)
65        self.device.mkdir(logParent, parents=True)
66
67        self.remoteProfile = posixpath.join(options.remoteTestRoot, "profile")
68        self.device.rm(self.remoteProfile, force=True, recursive=True)
69
70        self.message_logger = MessageLogger(logger=None)
71        self.message_logger.logger = self.log
72
73        # Check that Firefox is installed
74        expected = options.app.split("/")[-1]
75        if not self.device.is_app_installed(expected):
76            raise Exception("%s is not installed on this device" % expected)
77
78        self.device.clear_logcat()
79
80        self.remoteModulesDir = posixpath.join(options.remoteTestRoot, "modules/")
81
82        self.remoteCache = posixpath.join(options.remoteTestRoot, "cache/")
83        self.device.rm(self.remoteCache, force=True, recursive=True)
84
85        # move necko cache to a location that can be cleaned up
86        options.extraPrefs += [
87            "browser.cache.disk.parent_directory=%s" % self.remoteCache
88        ]
89
90        self.remoteMozLog = posixpath.join(options.remoteTestRoot, "mozlog")
91        self.device.rm(self.remoteMozLog, force=True, recursive=True)
92        self.device.mkdir(self.remoteMozLog, parents=True)
93
94        self.remoteChromeTestDir = posixpath.join(options.remoteTestRoot, "chrome")
95        self.device.rm(self.remoteChromeTestDir, force=True, recursive=True)
96        self.device.mkdir(self.remoteChromeTestDir, parents=True)
97
98        self.appName = options.remoteappname
99        self.device.stop_application(self.appName)
100        if self.device.process_exist(self.appName):
101            self.log.warning("unable to kill %s before running tests!" % self.appName)
102
103        # Add Android version (SDK level) to mozinfo so that manifest entries
104        # can be conditional on android_version.
105        self.log.info(
106            "Android sdk version '%s'; will use this to filter manifests"
107            % str(self.device.version)
108        )
109        mozinfo.info["android_version"] = str(self.device.version)
110        mozinfo.info["is_fennec"] = not ("geckoview" in options.app)
111        mozinfo.info["is_emulator"] = self.device._device_serial.startswith("emulator-")
112
113    def cleanup(self, options, final=False):
114        if final:
115            self.device.rm(self.remoteChromeTestDir, force=True, recursive=True)
116            self.chromePushed = False
117            uploadDir = os.environ.get("MOZ_UPLOAD_DIR", None)
118            if uploadDir and self.device.is_dir(self.remoteMozLog):
119                self.device.pull(self.remoteMozLog, uploadDir)
120        self.device.rm(self.remoteLogFile, force=True)
121        self.device.rm(self.remoteProfile, force=True, recursive=True)
122        self.device.rm(self.remoteCache, force=True, recursive=True)
123        MochitestDesktop.cleanup(self, options, final)
124        self.localProfile = None
125
126    def dumpScreen(self, utilityPath):
127        if self.haveDumpedScreen:
128            self.log.info(
129                "Not taking screenshot here: see the one that was previously logged"
130            )
131            return
132        self.haveDumpedScreen = True
133        if self.device._device_serial.startswith("emulator-"):
134            dump_screen(utilityPath, self.log)
135        else:
136            dump_device_screen(self.device, self.log)
137
138    def findPath(self, paths, filename=None):
139        for path in paths:
140            p = path
141            if filename:
142                p = os.path.join(p, filename)
143            if os.path.exists(self.getFullPath(p)):
144                return path
145        return None
146
147    # This seems kludgy, but this class uses paths from the remote host in the
148    # options, except when calling up to the base class, which doesn't
149    # understand the distinction.  This switches out the remote values for local
150    # ones that the base class understands.  This is necessary for the web
151    # server, SSL tunnel and profile building functions.
152    def switchToLocalPaths(self, options):
153        """ Set local paths in the options, return a function that will restore remote values """
154        remoteXrePath = options.xrePath
155        remoteProfilePath = options.profilePath
156        remoteUtilityPath = options.utilityPath
157
158        paths = [
159            options.xrePath,
160        ]
161        if build_obj:
162            paths.append(os.path.join(build_obj.topobjdir, "dist", "bin"))
163        options.xrePath = self.findPath(paths)
164        if options.xrePath is None:
165            self.log.error(
166                "unable to find xulrunner path for %s, please specify with --xre-path"
167                % os.name
168            )
169            sys.exit(1)
170
171        xpcshell = "xpcshell"
172        if os.name == "nt":
173            xpcshell += ".exe"
174
175        if options.utilityPath:
176            paths = [options.utilityPath, options.xrePath]
177        else:
178            paths = [options.xrePath]
179        options.utilityPath = self.findPath(paths, xpcshell)
180
181        if options.utilityPath is None:
182            self.log.error(
183                "unable to find utility path for %s, please specify with --utility-path"
184                % os.name
185            )
186            sys.exit(1)
187
188        xpcshell_path = os.path.join(options.utilityPath, xpcshell)
189        if RemoteProcessMonitor.elf_arm(xpcshell_path):
190            self.log.error(
191                "xpcshell at %s is an ARM binary; please use "
192                "the --utility-path argument to specify the path "
193                "to a desktop version." % xpcshell_path
194            )
195            sys.exit(1)
196
197        if self.localProfile:
198            options.profilePath = self.localProfile
199        else:
200            options.profilePath = None
201
202        def fixup():
203            options.xrePath = remoteXrePath
204            options.utilityPath = remoteUtilityPath
205            options.profilePath = remoteProfilePath
206
207        return fixup
208
209    def startServers(self, options, debuggerInfo, public=None):
210        """ Create the servers on the host and start them up """
211        restoreRemotePaths = self.switchToLocalPaths(options)
212        MochitestDesktop.startServers(self, options, debuggerInfo, public=True)
213        restoreRemotePaths()
214
215    def buildProfile(self, options):
216        restoreRemotePaths = self.switchToLocalPaths(options)
217        if options.testingModulesDir:
218            try:
219                self.device.push(options.testingModulesDir, self.remoteModulesDir)
220                self.device.chmod(self.remoteModulesDir, recursive=True)
221            except Exception:
222                self.log.error(
223                    "Automation Error: Unable to copy test modules to device."
224                )
225                raise
226            savedTestingModulesDir = options.testingModulesDir
227            options.testingModulesDir = self.remoteModulesDir
228        else:
229            savedTestingModulesDir = None
230        manifest = MochitestDesktop.buildProfile(self, options)
231        if savedTestingModulesDir:
232            options.testingModulesDir = savedTestingModulesDir
233        self.localProfile = options.profilePath
234
235        restoreRemotePaths()
236        options.profilePath = self.remoteProfile
237        return manifest
238
239    def buildURLOptions(self, options, env):
240        saveLogFile = options.logFile
241        options.logFile = self.remoteLogFile
242        options.profilePath = self.localProfile
243        env["MOZ_HIDE_RESULTS_TABLE"] = "1"
244        retVal = MochitestDesktop.buildURLOptions(self, options, env)
245
246        # we really need testConfig.js (for browser chrome)
247        try:
248            self.device.push(options.profilePath, self.remoteProfile)
249            self.device.chmod(self.remoteProfile, recursive=True)
250        except Exception:
251            self.log.error("Automation Error: Unable to copy profile to device.")
252            raise
253
254        options.profilePath = self.remoteProfile
255        options.logFile = saveLogFile
256        return retVal
257
258    def getChromeTestDir(self, options):
259        local = super(MochiRemote, self).getChromeTestDir(options)
260        remote = self.remoteChromeTestDir
261        if options.flavor == "chrome" and not self.chromePushed:
262            self.log.info("pushing %s to %s on device..." % (local, remote))
263            local = os.path.join(local, "chrome")
264            self.device.push(local, remote)
265            self.chromePushed = True
266        return remote
267
268    def getLogFilePath(self, logFile):
269        return logFile
270
271    def getGMPPluginPath(self, options):
272        # TODO: bug 1149374
273        return None
274
275    def environment(self, env=None, crashreporter=True, **kwargs):
276        # Since running remote, do not mimic the local env: do not copy os.environ
277        if env is None:
278            env = {}
279
280        if crashreporter:
281            env["MOZ_CRASHREPORTER_NO_REPORT"] = "1"
282            env["MOZ_CRASHREPORTER"] = "1"
283            env["MOZ_CRASHREPORTER_SHUTDOWN"] = "1"
284        else:
285            env["MOZ_CRASHREPORTER_DISABLE"] = "1"
286
287        # Crash on non-local network connections by default.
288        # MOZ_DISABLE_NONLOCAL_CONNECTIONS can be set to "0" to temporarily
289        # enable non-local connections for the purposes of local testing.
290        # Don't override the user's choice here.  See bug 1049688.
291        env.setdefault("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "1")
292
293        # Send an env var noting that we are in automation. Passing any
294        # value except the empty string will declare the value to exist.
295        #
296        # This may be used to disabled network connections during testing, e.g.
297        # Switchboard & telemetry uploads.
298        env.setdefault("MOZ_IN_AUTOMATION", "1")
299
300        # Set WebRTC logging in case it is not set yet.
301        env.setdefault("R_LOG_LEVEL", "6")
302        env.setdefault("R_LOG_DESTINATION", "stderr")
303        env.setdefault("R_LOG_VERBOSE", "1")
304
305        return env
306
307    def buildBrowserEnv(self, options, debugger=False):
308        browserEnv = MochitestDesktop.buildBrowserEnv(self, options, debugger=debugger)
309        # remove desktop environment not used on device
310        if "XPCOM_MEM_BLOAT_LOG" in browserEnv:
311            del browserEnv["XPCOM_MEM_BLOAT_LOG"]
312        if self.mozLogs:
313            browserEnv["MOZ_LOG_FILE"] = os.path.join(
314                self.remoteMozLog, "moz-pid=%PID-uid={}.log".format(str(uuid.uuid4()))
315            )
316        if options.dmd:
317            browserEnv["DMD"] = "1"
318        # Contents of remoteMozLog will be pulled from device and copied to the
319        # host MOZ_UPLOAD_DIR, to be made available as test artifacts. Make
320        # MOZ_UPLOAD_DIR available to the browser environment so that tests
321        # can use it as though they were running on the host.
322        browserEnv["MOZ_UPLOAD_DIR"] = self.remoteMozLog
323        return browserEnv
324
325    def runApp(
326        self,
327        testUrl,
328        env,
329        app,
330        profile,
331        extraArgs,
332        utilityPath,
333        debuggerInfo=None,
334        valgrindPath=None,
335        valgrindArgs=None,
336        valgrindSuppFiles=None,
337        symbolsPath=None,
338        timeout=-1,
339        detectShutdownLeaks=False,
340        screenshotOnFail=False,
341        bisectChunk=None,
342        marionette_args=None,
343        e10s=True,
344        runFailures=False,
345        crashAsPass=False,
346    ):
347        """
348        Run the app, log the duration it took to execute, return the status code.
349        Kill the app if it outputs nothing for |timeout| seconds.
350        """
351
352        if timeout == -1:
353            timeout = self.DEFAULT_TIMEOUT
354
355        rpm = RemoteProcessMonitor(
356            self.appName,
357            self.device,
358            self.log,
359            self.message_logger,
360            self.remoteLogFile,
361            self.remoteProfile,
362        )
363        startTime = datetime.datetime.now()
364        status = 0
365        profileDirectory = self.remoteProfile + "/"
366        args = []
367        args.extend(extraArgs)
368        args.extend(("-no-remote", "-profile", profileDirectory))
369
370        pid = rpm.launch(
371            app,
372            debuggerInfo,
373            testUrl,
374            args,
375            env=self.environment(env=env, crashreporter=not debuggerInfo),
376            e10s=e10s,
377        )
378
379        # TODO: not using runFailures or crashAsPass, if we choose to use them
380        # we need to adjust status and check_for_crashes
381        self.log.info("runtestsremote.py | Application pid: %d" % pid)
382        if not rpm.wait(timeout):
383            status = 1
384        self.log.info(
385            "runtestsremote.py | Application ran for: %s"
386            % str(datetime.datetime.now() - startTime)
387        )
388        crashed = self.check_for_crashes(symbolsPath, rpm.last_test_seen)
389        if crashed:
390            status = 1
391
392        self.countpass += rpm.counts["pass"]
393        self.countfail += rpm.counts["fail"]
394        self.counttodo += rpm.counts["todo"]
395
396        return status, rpm.last_test_seen
397
398    def check_for_crashes(self, symbols_path, last_test_seen):
399        """
400        Pull any minidumps from remote profile and log any associated crashes.
401        """
402        try:
403            dump_dir = tempfile.mkdtemp()
404            remote_crash_dir = posixpath.join(self.remoteProfile, "minidumps")
405            if not self.device.is_dir(remote_crash_dir):
406                return False
407            self.device.pull(remote_crash_dir, dump_dir)
408            crashed = mozcrash.log_crashes(
409                self.log, dump_dir, symbols_path, test=last_test_seen
410            )
411        finally:
412            try:
413                shutil.rmtree(dump_dir)
414            except Exception as e:
415                self.log.warning(
416                    "unable to remove directory %s: %s" % (dump_dir, str(e))
417                )
418        return crashed
419
420
421def run_test_harness(parser, options):
422    parser.validate(options)
423
424    if options is None:
425        raise ValueError(
426            "Invalid options specified, use --help for a list of valid options"
427        )
428
429    options.runByManifest = True
430
431    mochitest = MochiRemote(options)
432
433    try:
434        if options.verify:
435            retVal = mochitest.verifyTests(options)
436        else:
437            retVal = mochitest.runTests(options)
438    except Exception as e:
439        mochitest.log.error("Automation Error: Exception caught while running tests")
440        traceback.print_exc()
441        if isinstance(e, ADBTimeoutError):
442            mochitest.log.info("Device disconnected. Will not run mochitest.cleanup().")
443        else:
444            try:
445                mochitest.cleanup(options)
446            except Exception:
447                # device error cleaning up... oh well!
448                traceback.print_exc()
449        retVal = 1
450
451    mochitest.archiveMozLogs()
452    mochitest.message_logger.finish()
453
454    return retVal
455
456
457def main(args=sys.argv[1:]):
458    parser = MochitestArgumentParser(app="android")
459    options = parser.parse_args(args)
460
461    return run_test_harness(parser, options)
462
463
464if __name__ == "__main__":
465    sys.exit(main())
466