| # 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, context_chrome=True): |
| self._devicemanager = deviceManager |
| self._appName = appName |
| self._remoteProfile = None |
| self._remoteLog = remoteLog |
| self.marionette = marionette |
| self.context_chrome = context_chrome |
| 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): |
| """ 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: |
| currentlog = proc.getStdoutLines(timeout) |
| if currentlog: |
| 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 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) |
| |
| self.marionette.set_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(); |
| } |
| """) |
| |
| if not self.context_chrome: |
| self.marionette.set_context(self.marionette.CONTEXT_CONTENT) |
| |
| # 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 |
| try: |
| lines.append(self.queue.get(True, timeout)) |
| except Queue.Empty: |
| pass |
| return '\n'.join(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") |
| |