diff options
Diffstat (limited to 'build/mobile')
-rw-r--r-- | build/mobile/b2gautomation.py | 455 | ||||
-rw-r--r-- | build/mobile/remoteautomation.py | 432 |
2 files changed, 887 insertions, 0 deletions
diff --git a/build/mobile/b2gautomation.py b/build/mobile/b2gautomation.py new file mode 100644 index 000000000..d49a5f1ac --- /dev/null +++ b/build/mobile/b2gautomation.py @@ -0,0 +1,455 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import datetime +import mozcrash +import threading +import os +import posixpath +import Queue +import re +import shutil +import signal +import tempfile +import time +import traceback +import zipfile + +from automation import Automation +from mozlog import get_default_logger +from mozprocess import ProcessHandlerMixin + + +class StdOutProc(ProcessHandlerMixin): + """Process handler for b2g which puts all output in a Queue. + """ + + def __init__(self, cmd, queue, **kwargs): + self.queue = queue + kwargs.setdefault('processOutputLine', []).append(self.handle_output) + ProcessHandlerMixin.__init__(self, cmd, **kwargs) + + def handle_output(self, line): + self.queue.put_nowait(line) + + +class B2GRemoteAutomation(Automation): + _devicemanager = None + + def __init__(self, deviceManager, appName='', remoteLog=None, + marionette=None): + self._devicemanager = deviceManager + self._appName = appName + self._remoteProfile = None + self._remoteLog = remoteLog + self.marionette = marionette + self._is_emulator = False + self.test_script = None + self.test_script_args = None + + # Default our product to b2g + self._product = "b2g" + self.lastTestSeen = "b2gautomation.py" + # Default log finish to mochitest standard + self.logFinish = 'INFO SimpleTest FINISHED' + Automation.__init__(self) + + def setEmulator(self, is_emulator): + self._is_emulator = is_emulator + + def setDeviceManager(self, deviceManager): + self._devicemanager = deviceManager + + def setAppName(self, appName): + self._appName = appName + + def setRemoteProfile(self, remoteProfile): + self._remoteProfile = remoteProfile + + def setProduct(self, product): + self._product = product + + def setRemoteLog(self, logfile): + self._remoteLog = logfile + + def getExtensionIDFromRDF(self, rdfSource): + """ + Retrieves the extension id from an install.rdf file (or string). + """ + from xml.dom.minidom import parse, parseString, Node + + if isinstance(rdfSource, file): + document = parse(rdfSource) + else: + document = parseString(rdfSource) + + # Find the <em:id> element. There can be multiple <em:id> tags + # within <em:targetApplication> tags, so we have to check this way. + for rdfChild in document.documentElement.childNodes: + if rdfChild.nodeType == Node.ELEMENT_NODE and rdfChild.tagName == "Description": + for descChild in rdfChild.childNodes: + if descChild.nodeType == Node.ELEMENT_NODE and descChild.tagName == "em:id": + return descChild.childNodes[0].data + return None + + def installExtension(self, extensionSource, profileDir, extensionID=None): + # Bug 827504 - installing special-powers extension separately causes problems in B2G + if extensionID != "special-powers@mozilla.org": + if not os.path.isdir(profileDir): + self.log.info("INFO | automation.py | Cannot install extension, invalid profileDir at: %s", profileDir) + return + + installRDFFilename = "install.rdf" + + extensionsRootDir = os.path.join(profileDir, "extensions", "staged") + if not os.path.isdir(extensionsRootDir): + os.makedirs(extensionsRootDir) + + if os.path.isfile(extensionSource): + reader = zipfile.ZipFile(extensionSource, "r") + + for filename in reader.namelist(): + # Sanity check the zip file. + if os.path.isabs(filename): + self.log.info("INFO | automation.py | Cannot install extension, bad files in xpi") + return + + # We may need to dig the extensionID out of the zip file... + if extensionID is None and filename == installRDFFilename: + extensionID = self.getExtensionIDFromRDF(reader.read(filename)) + + # We must know the extensionID now. + if extensionID is None: + self.log.info("INFO | automation.py | Cannot install extension, missing extensionID") + return + + # Make the extension directory. + extensionDir = os.path.join(extensionsRootDir, extensionID) + os.mkdir(extensionDir) + + # Extract all files. + reader.extractall(extensionDir) + + elif os.path.isdir(extensionSource): + if extensionID is None: + filename = os.path.join(extensionSource, installRDFFilename) + if os.path.isfile(filename): + with open(filename, "r") as installRDF: + extensionID = self.getExtensionIDFromRDF(installRDF) + + if extensionID is None: + self.log.info("INFO | automation.py | Cannot install extension, missing extensionID") + return + + # Copy extension tree into its own directory. + # "destination directory must not already exist". + shutil.copytree(extensionSource, os.path.join(extensionsRootDir, extensionID)) + + else: + self.log.info("INFO | automation.py | Cannot install extension, invalid extensionSource at: %s", extensionSource) + + # Set up what we need for the remote environment + def environment(self, env=None, xrePath=None, crashreporter=True, debugger=False): + # Because we are running remote, we don't want to mimic the local env + # so no copying of os.environ + if env is None: + env = {} + + if crashreporter: + env['MOZ_CRASHREPORTER'] = '1' + env['MOZ_CRASHREPORTER_NO_REPORT'] = '1' + + # We always hide the results table in B2G; it's much slower if we don't. + env['MOZ_HIDE_RESULTS_TABLE'] = '1' + return env + + def waitForNet(self): + active = False + time_out = 0 + while not active and time_out < 40: + data = self._devicemanager._runCmd(['shell', '/system/bin/netcfg']).stdout.readlines() + data.pop(0) + for line in data: + if (re.search(r'UP\s+(?:[0-9]{1,3}\.){3}[0-9]{1,3}', line)): + active = True + break + time_out += 1 + time.sleep(1) + return active + + def checkForCrashes(self, directory, symbolsPath): + crashed = False + remote_dump_dir = self._remoteProfile + '/minidumps' + print "checking for crashes in '%s'" % remote_dump_dir + if self._devicemanager.dirExists(remote_dump_dir): + local_dump_dir = tempfile.mkdtemp() + self._devicemanager.getDirectory(remote_dump_dir, local_dump_dir) + try: + logger = get_default_logger() + if logger is not None: + crashed = mozcrash.log_crashes(logger, local_dump_dir, symbolsPath, test=self.lastTestSeen) + else: + crashed = mozcrash.check_for_crashes(local_dump_dir, symbolsPath, test_name=self.lastTestSeen) + except: + traceback.print_exc() + finally: + shutil.rmtree(local_dump_dir) + self._devicemanager.removeDir(remote_dump_dir) + return crashed + + def buildCommandLine(self, app, debuggerInfo, profileDir, testURL, extraArgs): + # if remote profile is specified, use that instead + if (self._remoteProfile): + profileDir = self._remoteProfile + + cmd, args = Automation.buildCommandLine(self, app, debuggerInfo, profileDir, testURL, extraArgs) + + return app, args + + def waitForFinish(self, proc, utilityPath, timeout, maxTime, startTime, + debuggerInfo, symbolsPath, outputHandler=None): + """ Wait for tests to finish (as evidenced by a signature string + in logcat), or for a given amount of time to elapse with no + output. + """ + timeout = timeout or 120 + while True: + lines = proc.getStdoutLines(timeout) + if lines: + currentlog = '\n'.join(lines) + + if outputHandler: + for line in lines: + outputHandler(line) + else: + print(currentlog) + + # Match the test filepath from the last TEST-START line found in the new + # log content. These lines are in the form: + # ... INFO TEST-START | /filepath/we/wish/to/capture.html\n + testStartFilenames = re.findall(r"TEST-START \| ([^\s]*)", currentlog) + if testStartFilenames: + self.lastTestSeen = testStartFilenames[-1] + if (outputHandler and outputHandler.suite_finished) or ( + hasattr(self, 'logFinish') and self.logFinish in currentlog): + return 0 + else: + self.log.info("TEST-UNEXPECTED-FAIL | %s | application timed " + "out after %d seconds with no output", + self.lastTestSeen, int(timeout)) + self._devicemanager.killProcess('/system/b2g/b2g', sig=signal.SIGABRT) + + timeout = 10 # seconds + starttime = datetime.datetime.now() + while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout): + if not self._devicemanager.processExist('/system/b2g/b2g'): + break + time.sleep(1) + else: + print "timed out after %d seconds waiting for b2g process to exit" % timeout + return 1 + + self.checkForCrashes(None, symbolsPath) + return 1 + + def getDeviceStatus(self, serial=None): + # Get the current status of the device. If we know the device + # serial number, we look for that, otherwise we use the (presumably + # only) device shown in 'adb devices'. + serial = serial or self._devicemanager._deviceSerial + status = 'unknown' + + for line in self._devicemanager._runCmd(['devices']).stdout.readlines(): + result = re.match('(.*?)\t(.*)', line) + if result: + thisSerial = result.group(1) + if not serial or thisSerial == serial: + serial = thisSerial + status = result.group(2) + + return (serial, status) + + def restartB2G(self): + # TODO hangs in subprocess.Popen without this delay + time.sleep(5) + self._devicemanager._checkCmd(['shell', 'stop', 'b2g']) + # Wait for a bit to make sure B2G has completely shut down. + time.sleep(10) + self._devicemanager._checkCmd(['shell', 'start', 'b2g']) + if self._is_emulator: + self.marionette.emulator.wait_for_port(self.marionette.port) + + def rebootDevice(self): + # find device's current status and serial number + serial, status = self.getDeviceStatus() + + # reboot! + self._devicemanager._runCmd(['shell', '/system/bin/reboot']) + + # The above command can return while adb still thinks the device is + # connected, so wait a little bit for it to disconnect from adb. + time.sleep(10) + + # wait for device to come back to previous status + print 'waiting for device to come back online after reboot' + start = time.time() + rserial, rstatus = self.getDeviceStatus(serial) + while rstatus != 'device': + if time.time() - start > 120: + # device hasn't come back online in 2 minutes, something's wrong + raise Exception("Device %s (status: %s) not back online after reboot" % (serial, rstatus)) + time.sleep(5) + rserial, rstatus = self.getDeviceStatus(serial) + print 'device:', serial, 'status:', rstatus + + def Process(self, cmd, stdout=None, stderr=None, env=None, cwd=None): + # On a desktop or fennec run, the Process method invokes a gecko + # process in which to the tests. For B2G, we simply + # reboot the device (which was configured with a test profile + # already), wait for B2G to start up, and then navigate to the + # test url using Marionette. There doesn't seem to be any way + # to pass env variables into the B2G process, but this doesn't + # seem to matter. + + # reboot device so it starts up with the mochitest profile + # XXX: We could potentially use 'stop b2g' + 'start b2g' to achieve + # a similar effect; will see which is more stable while attempting + # to bring up the continuous integration. + if not self._is_emulator: + self.rebootDevice() + time.sleep(5) + #wait for wlan to come up + if not self.waitForNet(): + raise Exception("network did not come up, please configure the network" + + " prior to running before running the automation framework") + + # stop b2g + self._devicemanager._runCmd(['shell', 'stop', 'b2g']) + time.sleep(5) + + # For some reason user.js in the profile doesn't get picked up. + # Manually copy it over to prefs.js. See bug 1009730 for more details. + self._devicemanager.moveTree(posixpath.join(self._remoteProfile, 'user.js'), + posixpath.join(self._remoteProfile, 'prefs.js')) + + # relaunch b2g inside b2g instance + instance = self.B2GInstance(self._devicemanager, env=env) + + time.sleep(5) + + # Set up port forwarding again for Marionette, since any that + # existed previously got wiped out by the reboot. + if not self._is_emulator: + self._devicemanager._checkCmd(['forward', + 'tcp:%s' % self.marionette.port, + 'tcp:%s' % self.marionette.port]) + + if self._is_emulator: + self.marionette.emulator.wait_for_port(self.marionette.port) + else: + time.sleep(5) + + # start a marionette session + session = self.marionette.start_session() + if 'b2g' not in session: + raise Exception("bad session value %s returned by start_session" % session) + + with self.marionette.using_context(self.marionette.CONTEXT_CHROME): + self.marionette.execute_script(""" + let SECURITY_PREF = "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer"; + Components.utils.import("resource://gre/modules/Services.jsm"); + Services.prefs.setBoolPref(SECURITY_PREF, true); + + if (!testUtils.hasOwnProperty("specialPowersObserver")) { + let loader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Components.interfaces.mozIJSSubScriptLoader); + loader.loadSubScript("chrome://specialpowers/content/SpecialPowersObserver.jsm", + testUtils); + testUtils.specialPowersObserver = new testUtils.SpecialPowersObserver(); + testUtils.specialPowersObserver.init(); + } + """) + + # run the script that starts the tests + if self.test_script: + if os.path.isfile(self.test_script): + script = open(self.test_script, 'r') + self.marionette.execute_script(script.read(), script_args=self.test_script_args) + script.close() + elif isinstance(self.test_script, basestring): + self.marionette.execute_script(self.test_script, script_args=self.test_script_args) + else: + # assumes the tests are started on startup automatically + pass + + return instance + + # be careful here as this inner class doesn't have access to outer class members + class B2GInstance(object): + """Represents a B2G instance running on a device, and exposes + some process-like methods/properties that are expected by the + automation. + """ + + def __init__(self, dm, env=None): + self.dm = dm + self.env = env or {} + self.stdout_proc = None + self.queue = Queue.Queue() + + # Launch b2g in a separate thread, and dump all output lines + # into a queue. The lines in this queue are + # retrieved and returned by accessing the stdout property of + # this class. + cmd = [self.dm._adbPath] + if self.dm._deviceSerial: + cmd.extend(['-s', self.dm._deviceSerial]) + cmd.append('shell') + for k, v in self.env.iteritems(): + cmd.append("%s=%s" % (k, v)) + cmd.append('/system/bin/b2g.sh') + proc = threading.Thread(target=self._save_stdout_proc, args=(cmd, self.queue)) + proc.daemon = True + proc.start() + + def _save_stdout_proc(self, cmd, queue): + self.stdout_proc = StdOutProc(cmd, queue) + self.stdout_proc.run() + if hasattr(self.stdout_proc, 'processOutput'): + self.stdout_proc.processOutput() + self.stdout_proc.wait() + self.stdout_proc = None + + @property + def pid(self): + # a dummy value to make the automation happy + return 0 + + def getStdoutLines(self, timeout): + # Return any lines in the queue used by the + # b2g process handler. + lines = [] + # get all of the lines that are currently available + while True: + try: + lines.append(self.queue.get_nowait()) + except Queue.Empty: + break + + # wait 'timeout' for any additional lines + if not lines: + try: + lines.append(self.queue.get(True, timeout)) + except Queue.Empty: + pass + return lines + + def wait(self, timeout=None): + # this should never happen + raise Exception("'wait' called on B2GInstance") + + def kill(self): + # this should never happen + raise Exception("'kill' called on B2GInstance") + diff --git a/build/mobile/remoteautomation.py b/build/mobile/remoteautomation.py new file mode 100644 index 000000000..7b2fad6cb --- /dev/null +++ b/build/mobile/remoteautomation.py @@ -0,0 +1,432 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import datetime +import glob +import time +import re +import os +import posixpath +import tempfile +import shutil +import subprocess +import sys + +from automation import Automation +from mozdevice import DMError, DeviceManager +from mozlog import get_default_logger +import mozcrash + +# signatures for logcat messages that we don't care about much +fennecLogcatFilters = [ "The character encoding of the HTML document was not declared", + "Use of Mutation Events is deprecated. Use MutationObserver instead.", + "Unexpected value from nativeGetEnabledTags: 0" ] + +class RemoteAutomation(Automation): + _devicemanager = None + + # Part of a hack for Robocop: "am COMMAND" is handled specially if COMMAND + # is in this set. See usages below. + _specialAmCommands = ('instrument', 'start') + + def __init__(self, deviceManager, appName = '', remoteLog = None, + processArgs=None): + self._devicemanager = deviceManager + self._appName = appName + self._remoteProfile = None + self._remoteLog = remoteLog + self._processArgs = processArgs or {}; + + # Default our product to fennec + self._product = "fennec" + self.lastTestSeen = "remoteautomation.py" + Automation.__init__(self) + + def setDeviceManager(self, deviceManager): + self._devicemanager = deviceManager + + def setAppName(self, appName): + self._appName = appName + + def setRemoteProfile(self, remoteProfile): + self._remoteProfile = remoteProfile + + def setProduct(self, product): + self._product = product + + def setRemoteLog(self, logfile): + self._remoteLog = logfile + + # Set up what we need for the remote environment + def environment(self, env=None, xrePath=None, crashreporter=True, debugger=False, dmdPath=None, lsanPath=None): + # Because we are running remote, we don't want to mimic the local env + # so no copying of os.environ + if env is None: + env = {} + + if dmdPath: + env['MOZ_REPLACE_MALLOC_LIB'] = os.path.join(dmdPath, 'libdmd.so') + + # Except for the mochitest results table hiding option, which isn't + # passed to runtestsremote.py as an actual option, but through the + # MOZ_HIDE_RESULTS_TABLE environment variable. + if 'MOZ_HIDE_RESULTS_TABLE' in os.environ: + env['MOZ_HIDE_RESULTS_TABLE'] = os.environ['MOZ_HIDE_RESULTS_TABLE'] + + if crashreporter and not debugger: + env['MOZ_CRASHREPORTER_NO_REPORT'] = '1' + env['MOZ_CRASHREPORTER'] = '1' + else: + env['MOZ_CRASHREPORTER_DISABLE'] = '1' + + # Crash on non-local network connections by default. + # MOZ_DISABLE_NONLOCAL_CONNECTIONS can be set to "0" to temporarily + # enable non-local connections for the purposes of local testing. + # Don't override the user's choice here. See bug 1049688. + env.setdefault('MOZ_DISABLE_NONLOCAL_CONNECTIONS', '1') + + # Send an env var noting that we are in automation. Passing any + # value except the empty string will declare the value to exist. + # + # This may be used to disabled network connections during testing, e.g. + # Switchboard & telemetry uploads. + env.setdefault('MOZ_IN_AUTOMATION', '1') + + # Set WebRTC logging in case it is not set yet. + # On Android, environment variables cannot contain ',' so the + # standard WebRTC setting for NSPR_LOG_MODULES is not available. + # env.setdefault('NSPR_LOG_MODULES', 'signaling:5,mtransport:5,datachannel:5,jsep:5,MediaPipelineFactory:5') + env.setdefault('R_LOG_LEVEL', '6') + env.setdefault('R_LOG_DESTINATION', 'stderr') + env.setdefault('R_LOG_VERBOSE', '1') + + return env + + def waitForFinish(self, proc, utilityPath, timeout, maxTime, startTime, debuggerInfo, symbolsPath, outputHandler=None): + """ Wait for tests to finish. + If maxTime seconds elapse or no output is detected for timeout + seconds, kill the process and fail the test. + """ + # maxTime is used to override the default timeout, we should honor that + status = proc.wait(timeout = maxTime, noOutputTimeout = timeout) + self.lastTestSeen = proc.getLastTestSeen + + topActivity = self._devicemanager.getTopActivity() + if topActivity == proc.procName: + proc.kill(True) + if status == 1: + if maxTime: + print "TEST-UNEXPECTED-FAIL | %s | application ran for longer than " \ + "allowed maximum time of %s seconds" % (self.lastTestSeen, maxTime) + else: + print "TEST-UNEXPECTED-FAIL | %s | application ran for longer than " \ + "allowed maximum time" % (self.lastTestSeen) + if status == 2: + print "TEST-UNEXPECTED-FAIL | %s | application timed out after %d seconds with no output" \ + % (self.lastTestSeen, int(timeout)) + + return status + + def deleteANRs(self): + # empty ANR traces.txt file; usually need root permissions + # we make it empty and writable so we can test the ANR reporter later + traces = "/data/anr/traces.txt" + try: + self._devicemanager.shellCheckOutput(['echo', '', '>', traces], root=True, + timeout=DeviceManager.short_timeout) + self._devicemanager.shellCheckOutput(['chmod', '666', traces], root=True, + timeout=DeviceManager.short_timeout) + except DMError: + print "Error deleting %s" % traces + pass + + def checkForANRs(self): + traces = "/data/anr/traces.txt" + if self._devicemanager.fileExists(traces): + try: + t = self._devicemanager.pullFile(traces) + if t: + stripped = t.strip() + if len(stripped) > 0: + print "Contents of %s:" % traces + print t + # Once reported, delete traces + self.deleteANRs() + except DMError: + print "Error pulling %s" % traces + except IOError: + print "Error pulling %s" % traces + else: + print "%s not found" % traces + + def deleteTombstones(self): + # delete any existing tombstone files from device + remoteDir = "/data/tombstones" + try: + self._devicemanager.shellCheckOutput(['rm', '-r', remoteDir], root=True, + timeout=DeviceManager.short_timeout) + except DMError: + # This may just indicate that the tombstone directory is missing + pass + + def checkForTombstones(self): + # pull any tombstones from device and move to MOZ_UPLOAD_DIR + remoteDir = "/data/tombstones" + blobberUploadDir = os.environ.get('MOZ_UPLOAD_DIR', None) + if blobberUploadDir: + if not os.path.exists(blobberUploadDir): + os.mkdir(blobberUploadDir) + if self._devicemanager.dirExists(remoteDir): + # copy tombstone files from device to local blobber upload directory + try: + self._devicemanager.shellCheckOutput(['chmod', '777', remoteDir], root=True, + timeout=DeviceManager.short_timeout) + self._devicemanager.shellCheckOutput(['chmod', '666', os.path.join(remoteDir, '*')], root=True, + timeout=DeviceManager.short_timeout) + self._devicemanager.getDirectory(remoteDir, blobberUploadDir, False) + except DMError: + # This may just indicate that no tombstone files are present + pass + self.deleteTombstones() + # add a .txt file extension to each tombstone file name, so + # that blobber will upload it + for f in glob.glob(os.path.join(blobberUploadDir, "tombstone_??")): + # add a unique integer to the file name, in case there are + # multiple tombstones generated with the same name, for + # instance, after multiple robocop tests + for i in xrange(1, sys.maxint): + newname = "%s.%d.txt" % (f, i) + if not os.path.exists(newname): + os.rename(f, newname) + break + else: + print "%s does not exist; tombstone check skipped" % remoteDir + else: + print "MOZ_UPLOAD_DIR not defined; tombstone check skipped" + + def checkForCrashes(self, directory, symbolsPath): + self.checkForANRs() + self.checkForTombstones() + + logcat = self._devicemanager.getLogcat(filterOutRegexps=fennecLogcatFilters) + + javaException = mozcrash.check_for_java_exception(logcat, test_name=self.lastTestSeen) + if javaException: + return True + + # If crash reporting is disabled (MOZ_CRASHREPORTER!=1), we can't say + # anything. + if not self.CRASHREPORTER: + return False + + try: + dumpDir = tempfile.mkdtemp() + remoteCrashDir = posixpath.join(self._remoteProfile, 'minidumps') + if not self._devicemanager.dirExists(remoteCrashDir): + # If crash reporting is enabled (MOZ_CRASHREPORTER=1), the + # minidumps directory is automatically created when Fennec + # (first) starts, so its lack of presence is a hint that + # something went wrong. + print "Automation Error: No crash directory (%s) found on remote device" % remoteCrashDir + # Whilst no crash was found, the run should still display as a failure + return True + self._devicemanager.getDirectory(remoteCrashDir, dumpDir) + + logger = get_default_logger() + if logger is not None: + crashed = mozcrash.log_crashes(logger, dumpDir, symbolsPath, test=self.lastTestSeen) + else: + crashed = Automation.checkForCrashes(self, dumpDir, symbolsPath) + + finally: + try: + shutil.rmtree(dumpDir) + except: + print "WARNING: unable to remove directory: %s" % dumpDir + return crashed + + def buildCommandLine(self, app, debuggerInfo, profileDir, testURL, extraArgs): + # If remote profile is specified, use that instead + if (self._remoteProfile): + profileDir = self._remoteProfile + + # Hack for robocop, if app & testURL == None and extraArgs contains the rest of the stuff, lets + # assume extraArgs is all we need + if app == "am" and extraArgs[0] in RemoteAutomation._specialAmCommands: + return app, extraArgs + + cmd, args = Automation.buildCommandLine(self, app, debuggerInfo, profileDir, testURL, extraArgs) + # Remove -foreground if it exists, if it doesn't this just returns + try: + args.remove('-foreground') + except: + pass +#TODO: figure out which platform require NO_EM_RESTART +# return app, ['--environ:NO_EM_RESTART=1'] + args + return app, args + + def Process(self, cmd, stdout = None, stderr = None, env = None, cwd = None): + if stdout == None or stdout == -1 or stdout == subprocess.PIPE: + stdout = self._remoteLog + + return self.RProcess(self._devicemanager, cmd, stdout, stderr, env, cwd, self._appName, + **self._processArgs) + + # be careful here as this inner class doesn't have access to outer class members + class RProcess(object): + # device manager process + dm = None + def __init__(self, dm, cmd, stdout=None, stderr=None, env=None, cwd=None, app=None, + messageLogger=None): + self.dm = dm + self.stdoutlen = 0 + self.lastTestSeen = "remoteautomation.py" + self.proc = dm.launchProcess(cmd, stdout, cwd, env, True) + self.messageLogger = messageLogger + + if (self.proc is None): + if cmd[0] == 'am': + self.proc = stdout + else: + raise Exception("unable to launch process") + self.procName = cmd[0].split('/')[-1] + if cmd[0] == 'am' and cmd[1] in RemoteAutomation._specialAmCommands: + self.procName = app + + # Setting timeout at 1 hour since on a remote device this takes much longer. + # Temporarily increased to 90 minutes because no more chunks can be created. + self.timeout = 5400 + # The benefit of the following sleep is unclear; it was formerly 15 seconds + time.sleep(1) + + # Used to buffer log messages until we meet a line break + self.logBuffer = "" + + @property + def pid(self): + pid = self.dm.processExist(self.procName) + # HACK: we should probably be more sophisticated about monitoring + # running processes for the remote case, but for now we'll assume + # that this method can be called when nothing exists and it is not + # an error + if pid is None: + return 0 + return pid + + def read_stdout(self): + """ + Fetch the full remote log file using devicemanager, process them and + return whether there were any new log entries since the last call. + """ + if not self.dm.fileExists(self.proc): + return False + try: + newLogContent = self.dm.pullFile(self.proc, self.stdoutlen) + except DMError: + # we currently don't retry properly in the pullFile + # function in dmSUT, so an error here is not necessarily + # the end of the world + return False + if not newLogContent: + return False + + self.stdoutlen += len(newLogContent) + + if self.messageLogger is None: + testStartFilenames = re.findall(r"TEST-START \| ([^\s]*)", newLogContent) + if testStartFilenames: + self.lastTestSeen = testStartFilenames[-1] + print newLogContent + return True + + self.logBuffer += newLogContent + lines = self.logBuffer.split('\n') + lines = [l for l in lines if l] + + if lines: + if self.logBuffer.endswith('\n'): + # all lines are complete; no need to buffer + self.logBuffer = "" + else: + # keep the last (unfinished) line in the buffer + self.logBuffer = lines[-1] + del lines[-1] + + if not lines: + return False + + for line in lines: + # This passes the line to the logger (to be logged or buffered) + parsed_messages = self.messageLogger.write(line) + for message in parsed_messages: + if isinstance(message, dict) and message.get('action') == 'test_start': + self.lastTestSeen = message['test'] + return True + + @property + def getLastTestSeen(self): + return self.lastTestSeen + + # Wait for the remote process to end (or for its activity to go to background). + # While waiting, periodically retrieve the process output and print it. + # If the process is still running after *timeout* seconds, return 1; + # If the process is still running but no output is received in *noOutputTimeout* + # seconds, return 2; + # Else, once the process exits/goes to background, return 0. + def wait(self, timeout = None, noOutputTimeout = None): + timer = 0 + noOutputTimer = 0 + interval = 10 + if timeout == None: + timeout = self.timeout + status = 0 + top = self.procName + slowLog = False + while (top == self.procName): + # Get log updates on each interval, but if it is taking + # too long, only do it every 60 seconds + if (not slowLog) or (timer % 60 == 0): + startRead = datetime.datetime.now() + hasOutput = self.read_stdout() + if (datetime.datetime.now() - startRead) > datetime.timedelta(seconds=5): + slowLog = True + if hasOutput: + noOutputTimer = 0 + time.sleep(interval) + timer += interval + noOutputTimer += interval + if (timer > timeout): + status = 1 + break + if (noOutputTimeout and noOutputTimer > noOutputTimeout): + status = 2 + break + top = self.dm.getTopActivity() + # Flush anything added to stdout during the sleep + self.read_stdout() + return status + + def kill(self, stagedShutdown = False): + if stagedShutdown: + # Trigger an ANR report with "kill -3" (SIGQUIT) + self.dm.killProcess(self.procName, 3) + time.sleep(3) + # Trigger a breakpad dump with "kill -6" (SIGABRT) + self.dm.killProcess(self.procName, 6) + # Wait for process to end + retries = 0 + while retries < 3: + pid = self.dm.processExist(self.procName) + if pid and pid > 0: + print "%s still alive after SIGABRT: waiting..." % self.procName + time.sleep(5) + else: + return + retries += 1 + self.dm.killProcess(self.procName, 9) + pid = self.dm.processExist(self.procName) + if pid and pid > 0: + self.dm.killProcess(self.procName) + else: + self.dm.killProcess(self.procName) |