b2gautomation.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  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 file,
  3. # You can obtain one at http://mozilla.org/MPL/2.0/.
  4. import datetime
  5. import mozcrash
  6. import threading
  7. import os
  8. import posixpath
  9. import Queue
  10. import re
  11. import shutil
  12. import signal
  13. import tempfile
  14. import time
  15. import traceback
  16. import zipfile
  17. from automation import Automation
  18. from mozlog import get_default_logger
  19. from mozprocess import ProcessHandlerMixin
  20. class StdOutProc(ProcessHandlerMixin):
  21. """Process handler for b2g which puts all output in a Queue.
  22. """
  23. def __init__(self, cmd, queue, **kwargs):
  24. self.queue = queue
  25. kwargs.setdefault('processOutputLine', []).append(self.handle_output)
  26. ProcessHandlerMixin.__init__(self, cmd, **kwargs)
  27. def handle_output(self, line):
  28. self.queue.put_nowait(line)
  29. class B2GRemoteAutomation(Automation):
  30. _devicemanager = None
  31. def __init__(self, deviceManager, appName='', remoteLog=None,
  32. marionette=None):
  33. self._devicemanager = deviceManager
  34. self._appName = appName
  35. self._remoteProfile = None
  36. self._remoteLog = remoteLog
  37. self.marionette = marionette
  38. self._is_emulator = False
  39. self.test_script = None
  40. self.test_script_args = None
  41. # Default our product to b2g
  42. self._product = "b2g"
  43. self.lastTestSeen = "b2gautomation.py"
  44. # Default log finish to mochitest standard
  45. self.logFinish = 'INFO SimpleTest FINISHED'
  46. Automation.__init__(self)
  47. def setEmulator(self, is_emulator):
  48. self._is_emulator = is_emulator
  49. def setDeviceManager(self, deviceManager):
  50. self._devicemanager = deviceManager
  51. def setAppName(self, appName):
  52. self._appName = appName
  53. def setRemoteProfile(self, remoteProfile):
  54. self._remoteProfile = remoteProfile
  55. def setProduct(self, product):
  56. self._product = product
  57. def setRemoteLog(self, logfile):
  58. self._remoteLog = logfile
  59. def getExtensionIDFromRDF(self, rdfSource):
  60. """
  61. Retrieves the extension id from an install.rdf file (or string).
  62. """
  63. from xml.dom.minidom import parse, parseString, Node
  64. if isinstance(rdfSource, file):
  65. document = parse(rdfSource)
  66. else:
  67. document = parseString(rdfSource)
  68. # Find the <em:id> element. There can be multiple <em:id> tags
  69. # within <em:targetApplication> tags, so we have to check this way.
  70. for rdfChild in document.documentElement.childNodes:
  71. if rdfChild.nodeType == Node.ELEMENT_NODE and rdfChild.tagName == "Description":
  72. for descChild in rdfChild.childNodes:
  73. if descChild.nodeType == Node.ELEMENT_NODE and descChild.tagName == "em:id":
  74. return descChild.childNodes[0].data
  75. return None
  76. def installExtension(self, extensionSource, profileDir, extensionID=None):
  77. # Bug 827504 - installing special-powers extension separately causes problems in B2G
  78. if extensionID != "special-powers@mozilla.org":
  79. if not os.path.isdir(profileDir):
  80. self.log.info("INFO | automation.py | Cannot install extension, invalid profileDir at: %s", profileDir)
  81. return
  82. installRDFFilename = "install.rdf"
  83. extensionsRootDir = os.path.join(profileDir, "extensions", "staged")
  84. if not os.path.isdir(extensionsRootDir):
  85. os.makedirs(extensionsRootDir)
  86. if os.path.isfile(extensionSource):
  87. reader = zipfile.ZipFile(extensionSource, "r")
  88. for filename in reader.namelist():
  89. # Sanity check the zip file.
  90. if os.path.isabs(filename):
  91. self.log.info("INFO | automation.py | Cannot install extension, bad files in xpi")
  92. return
  93. # We may need to dig the extensionID out of the zip file...
  94. if extensionID is None and filename == installRDFFilename:
  95. extensionID = self.getExtensionIDFromRDF(reader.read(filename))
  96. # We must know the extensionID now.
  97. if extensionID is None:
  98. self.log.info("INFO | automation.py | Cannot install extension, missing extensionID")
  99. return
  100. # Make the extension directory.
  101. extensionDir = os.path.join(extensionsRootDir, extensionID)
  102. os.mkdir(extensionDir)
  103. # Extract all files.
  104. reader.extractall(extensionDir)
  105. elif os.path.isdir(extensionSource):
  106. if extensionID is None:
  107. filename = os.path.join(extensionSource, installRDFFilename)
  108. if os.path.isfile(filename):
  109. with open(filename, "r") as installRDF:
  110. extensionID = self.getExtensionIDFromRDF(installRDF)
  111. if extensionID is None:
  112. self.log.info("INFO | automation.py | Cannot install extension, missing extensionID")
  113. return
  114. # Copy extension tree into its own directory.
  115. # "destination directory must not already exist".
  116. shutil.copytree(extensionSource, os.path.join(extensionsRootDir, extensionID))
  117. else:
  118. self.log.info("INFO | automation.py | Cannot install extension, invalid extensionSource at: %s", extensionSource)
  119. # Set up what we need for the remote environment
  120. def environment(self, env=None, xrePath=None, crashreporter=True, debugger=False):
  121. # Because we are running remote, we don't want to mimic the local env
  122. # so no copying of os.environ
  123. if env is None:
  124. env = {}
  125. # We always hide the results table in B2G; it's much slower if we don't.
  126. env['MOZ_HIDE_RESULTS_TABLE'] = '1'
  127. return env
  128. def waitForNet(self):
  129. active = False
  130. time_out = 0
  131. while not active and time_out < 40:
  132. data = self._devicemanager._runCmd(['shell', '/system/bin/netcfg']).stdout.readlines()
  133. data.pop(0)
  134. for line in data:
  135. if (re.search(r'UP\s+(?:[0-9]{1,3}\.){3}[0-9]{1,3}', line)):
  136. active = True
  137. break
  138. time_out += 1
  139. time.sleep(1)
  140. return active
  141. def checkForCrashes(self, directory, symbolsPath):
  142. crashed = False
  143. remote_dump_dir = self._remoteProfile + '/minidumps'
  144. print "checking for crashes in '%s'" % remote_dump_dir
  145. if self._devicemanager.dirExists(remote_dump_dir):
  146. local_dump_dir = tempfile.mkdtemp()
  147. self._devicemanager.getDirectory(remote_dump_dir, local_dump_dir)
  148. try:
  149. logger = get_default_logger()
  150. if logger is not None:
  151. crashed = mozcrash.log_crashes(logger, local_dump_dir, symbolsPath, test=self.lastTestSeen)
  152. else:
  153. crashed = mozcrash.check_for_crashes(local_dump_dir, symbolsPath, test_name=self.lastTestSeen)
  154. except:
  155. traceback.print_exc()
  156. finally:
  157. shutil.rmtree(local_dump_dir)
  158. self._devicemanager.removeDir(remote_dump_dir)
  159. return crashed
  160. def buildCommandLine(self, app, debuggerInfo, profileDir, testURL, extraArgs):
  161. # if remote profile is specified, use that instead
  162. if (self._remoteProfile):
  163. profileDir = self._remoteProfile
  164. cmd, args = Automation.buildCommandLine(self, app, debuggerInfo, profileDir, testURL, extraArgs)
  165. return app, args
  166. def waitForFinish(self, proc, utilityPath, timeout, maxTime, startTime,
  167. debuggerInfo, symbolsPath, outputHandler=None):
  168. """ Wait for tests to finish (as evidenced by a signature string
  169. in logcat), or for a given amount of time to elapse with no
  170. output.
  171. """
  172. timeout = timeout or 120
  173. while True:
  174. lines = proc.getStdoutLines(timeout)
  175. if lines:
  176. currentlog = '\n'.join(lines)
  177. if outputHandler:
  178. for line in lines:
  179. outputHandler(line)
  180. else:
  181. print(currentlog)
  182. # Match the test filepath from the last TEST-START line found in the new
  183. # log content. These lines are in the form:
  184. # ... INFO TEST-START | /filepath/we/wish/to/capture.html\n
  185. testStartFilenames = re.findall(r"TEST-START \| ([^\s]*)", currentlog)
  186. if testStartFilenames:
  187. self.lastTestSeen = testStartFilenames[-1]
  188. if (outputHandler and outputHandler.suite_finished) or (
  189. hasattr(self, 'logFinish') and self.logFinish in currentlog):
  190. return 0
  191. else:
  192. self.log.info("TEST-UNEXPECTED-FAIL | %s | application timed "
  193. "out after %d seconds with no output",
  194. self.lastTestSeen, int(timeout))
  195. self._devicemanager.killProcess('/system/b2g/b2g', sig=signal.SIGABRT)
  196. timeout = 10 # seconds
  197. starttime = datetime.datetime.utcnow()
  198. while datetime.datetime.utcnow() - starttime < datetime.timedelta(seconds=timeout):
  199. if not self._devicemanager.processExist('/system/b2g/b2g'):
  200. break
  201. time.sleep(1)
  202. else:
  203. print "timed out after %d seconds waiting for b2g process to exit" % timeout
  204. return 1
  205. self.checkForCrashes(None, symbolsPath)
  206. return 1
  207. def getDeviceStatus(self, serial=None):
  208. # Get the current status of the device. If we know the device
  209. # serial number, we look for that, otherwise we use the (presumably
  210. # only) device shown in 'adb devices'.
  211. serial = serial or self._devicemanager._deviceSerial
  212. status = 'unknown'
  213. for line in self._devicemanager._runCmd(['devices']).stdout.readlines():
  214. result = re.match('(.*?)\t(.*)', line)
  215. if result:
  216. thisSerial = result.group(1)
  217. if not serial or thisSerial == serial:
  218. serial = thisSerial
  219. status = result.group(2)
  220. return (serial, status)
  221. def restartB2G(self):
  222. # TODO hangs in subprocess.Popen without this delay
  223. time.sleep(5)
  224. self._devicemanager._checkCmd(['shell', 'stop', 'b2g'])
  225. # Wait for a bit to make sure B2G has completely shut down.
  226. time.sleep(10)
  227. self._devicemanager._checkCmd(['shell', 'start', 'b2g'])
  228. if self._is_emulator:
  229. self.marionette.emulator.wait_for_port(self.marionette.port)
  230. def rebootDevice(self):
  231. # find device's current status and serial number
  232. serial, status = self.getDeviceStatus()
  233. # reboot!
  234. self._devicemanager._runCmd(['shell', '/system/bin/reboot'])
  235. # The above command can return while adb still thinks the device is
  236. # connected, so wait a little bit for it to disconnect from adb.
  237. time.sleep(10)
  238. # wait for device to come back to previous status
  239. print 'waiting for device to come back online after reboot'
  240. start = time.time()
  241. rserial, rstatus = self.getDeviceStatus(serial)
  242. while rstatus != 'device':
  243. if time.time() - start > 120:
  244. # device hasn't come back online in 2 minutes, something's wrong
  245. raise Exception("Device %s (status: %s) not back online after reboot" % (serial, rstatus))
  246. time.sleep(5)
  247. rserial, rstatus = self.getDeviceStatus(serial)
  248. print 'device:', serial, 'status:', rstatus
  249. def Process(self, cmd, stdout=None, stderr=None, env=None, cwd=None):
  250. # On a desktop or fennec run, the Process method invokes a gecko
  251. # process in which to the tests. For B2G, we simply
  252. # reboot the device (which was configured with a test profile
  253. # already), wait for B2G to start up, and then navigate to the
  254. # test url using Marionette. There doesn't seem to be any way
  255. # to pass env variables into the B2G process, but this doesn't
  256. # seem to matter.
  257. # reboot device so it starts up with the mochitest profile
  258. # XXX: We could potentially use 'stop b2g' + 'start b2g' to achieve
  259. # a similar effect; will see which is more stable while attempting
  260. # to bring up the continuous integration.
  261. if not self._is_emulator:
  262. self.rebootDevice()
  263. time.sleep(5)
  264. #wait for wlan to come up
  265. if not self.waitForNet():
  266. raise Exception("network did not come up, please configure the network" +
  267. " prior to running before running the automation framework")
  268. # stop b2g
  269. self._devicemanager._runCmd(['shell', 'stop', 'b2g'])
  270. time.sleep(5)
  271. # For some reason user.js in the profile doesn't get picked up.
  272. # Manually copy it over to prefs.js. See bug 1009730 for more details.
  273. self._devicemanager.moveTree(posixpath.join(self._remoteProfile, 'user.js'),
  274. posixpath.join(self._remoteProfile, 'prefs.js'))
  275. # relaunch b2g inside b2g instance
  276. instance = self.B2GInstance(self._devicemanager, env=env)
  277. time.sleep(5)
  278. # Set up port forwarding again for Marionette, since any that
  279. # existed previously got wiped out by the reboot.
  280. if not self._is_emulator:
  281. self._devicemanager._checkCmd(['forward',
  282. 'tcp:%s' % self.marionette.port,
  283. 'tcp:%s' % self.marionette.port])
  284. if self._is_emulator:
  285. self.marionette.emulator.wait_for_port(self.marionette.port)
  286. else:
  287. time.sleep(5)
  288. # start a marionette session
  289. session = self.marionette.start_session()
  290. if 'b2g' not in session:
  291. raise Exception("bad session value %s returned by start_session" % session)
  292. with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
  293. self.marionette.execute_script("""
  294. let SECURITY_PREF = "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer";
  295. Components.utils.import("resource://gre/modules/Services.jsm");
  296. Services.prefs.setBoolPref(SECURITY_PREF, true);
  297. if (!testUtils.hasOwnProperty("specialPowersObserver")) {
  298. let loader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"]
  299. .getService(Components.interfaces.mozIJSSubScriptLoader);
  300. loader.loadSubScript("chrome://specialpowers/content/SpecialPowersObserver.jsm",
  301. testUtils);
  302. testUtils.specialPowersObserver = new testUtils.SpecialPowersObserver();
  303. testUtils.specialPowersObserver.init();
  304. }
  305. """)
  306. # run the script that starts the tests
  307. if self.test_script:
  308. if os.path.isfile(self.test_script):
  309. script = open(self.test_script, 'r')
  310. self.marionette.execute_script(script.read(), script_args=self.test_script_args)
  311. script.close()
  312. elif isinstance(self.test_script, basestring):
  313. self.marionette.execute_script(self.test_script, script_args=self.test_script_args)
  314. else:
  315. # assumes the tests are started on startup automatically
  316. pass
  317. return instance
  318. # be careful here as this inner class doesn't have access to outer class members
  319. class B2GInstance(object):
  320. """Represents a B2G instance running on a device, and exposes
  321. some process-like methods/properties that are expected by the
  322. automation.
  323. """
  324. def __init__(self, dm, env=None):
  325. self.dm = dm
  326. self.env = env or {}
  327. self.stdout_proc = None
  328. self.queue = Queue.Queue()
  329. # Launch b2g in a separate thread, and dump all output lines
  330. # into a queue. The lines in this queue are
  331. # retrieved and returned by accessing the stdout property of
  332. # this class.
  333. cmd = [self.dm._adbPath]
  334. if self.dm._deviceSerial:
  335. cmd.extend(['-s', self.dm._deviceSerial])
  336. cmd.append('shell')
  337. for k, v in self.env.iteritems():
  338. cmd.append("%s=%s" % (k, v))
  339. cmd.append('/system/bin/b2g.sh')
  340. proc = threading.Thread(target=self._save_stdout_proc, args=(cmd, self.queue))
  341. proc.daemon = True
  342. proc.start()
  343. def _save_stdout_proc(self, cmd, queue):
  344. self.stdout_proc = StdOutProc(cmd, queue)
  345. self.stdout_proc.run()
  346. if hasattr(self.stdout_proc, 'processOutput'):
  347. self.stdout_proc.processOutput()
  348. self.stdout_proc.wait()
  349. self.stdout_proc = None
  350. @property
  351. def pid(self):
  352. # a dummy value to make the automation happy
  353. return 0
  354. def getStdoutLines(self, timeout):
  355. # Return any lines in the queue used by the
  356. # b2g process handler.
  357. lines = []
  358. # get all of the lines that are currently available
  359. while True:
  360. try:
  361. lines.append(self.queue.get_nowait())
  362. except Queue.Empty:
  363. break
  364. # wait 'timeout' for any additional lines
  365. if not lines:
  366. try:
  367. lines.append(self.queue.get(True, timeout))
  368. except Queue.Empty:
  369. pass
  370. return lines
  371. def wait(self, timeout=None):
  372. # this should never happen
  373. raise Exception("'wait' called on B2GInstance")
  374. def kill(self):
  375. # this should never happen
  376. raise Exception("'kill' called on B2GInstance")