# 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 element. There can be multiple tags # within 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 = {} # 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.utcnow() while datetime.datetime.utcnow() - 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")