Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 #
2 # This Source Code Form is subject to the terms of the Mozilla Public
3 # License, v. 2.0. If a copy of the MPL was not distributed with this
4 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
6 from __future__ import with_statement
7 import glob, logging, os, platform, shutil, subprocess, sys, tempfile, urllib2, zipfile
8 import base64
9 import re
10 import os
11 from urlparse import urlparse
12 from operator import itemgetter
13 import signal
15 try:
16 import mozinfo
17 except ImportError:
18 # Stub out fake mozinfo since this is not importable on Android 4.0 Opt.
19 # This should be fixed; see
20 # https://bugzilla.mozilla.org/show_bug.cgi?id=650881
21 mozinfo = type('mozinfo', (), dict(info={}))()
22 mozinfo.isWin = mozinfo.isLinux = mozinfo.isUnix = mozinfo.isMac = False
24 # TODO! FILE: localautomation :/
25 # mapping from would-be mozinfo attr <-> sys.platform
26 mapping = {'isMac': ['mac', 'darwin'],
27 'isLinux': ['linux', 'linux2'],
28 'isWin': ['win32', 'win64'],
29 }
30 mapping = dict(sum([[(value, key) for value in values] for key, values in mapping.items()], []))
31 attr = mapping.get(sys.platform)
32 if attr:
33 setattr(mozinfo, attr, True)
34 if mozinfo.isLinux:
35 mozinfo.isUnix = True
37 __all__ = [
38 "ZipFileReader",
39 "addCommonOptions",
40 "dumpLeakLog",
41 "isURL",
42 "processLeakLog",
43 "getDebuggerInfo",
44 "DEBUGGER_INFO",
45 "replaceBackSlashes",
46 'KeyValueParseError',
47 'parseKeyValue',
48 'systemMemory',
49 'environment',
50 'dumpScreen',
51 "ShutdownLeaks"
52 ]
54 # Map of debugging programs to information about them, like default arguments
55 # and whether or not they are interactive.
56 DEBUGGER_INFO = {
57 # gdb requires that you supply the '--args' flag in order to pass arguments
58 # after the executable name to the executable.
59 "gdb": {
60 "interactive": True,
61 "args": "-q --args"
62 },
64 "cgdb": {
65 "interactive": True,
66 "args": "-q --args"
67 },
69 "lldb": {
70 "interactive": True,
71 "args": "--",
72 "requiresEscapedArgs": True
73 },
75 # valgrind doesn't explain much about leaks unless you set the
76 # '--leak-check=full' flag. But there are a lot of objects that are
77 # semi-deliberately leaked, so we set '--show-possibly-lost=no' to avoid
78 # uninteresting output from those objects. We set '--smc-check==all-non-file'
79 # and '--vex-iropt-register-updates=allregs-at-mem-access' so that valgrind
80 # deals properly with JIT'd JavaScript code.
81 "valgrind": {
82 "interactive": False,
83 "args": " ".join(["--leak-check=full",
84 "--show-possibly-lost=no",
85 "--smc-check=all-non-file",
86 "--vex-iropt-register-updates=allregs-at-mem-access"])
87 }
88 }
90 class ZipFileReader(object):
91 """
92 Class to read zip files in Python 2.5 and later. Limited to only what we
93 actually use.
94 """
96 def __init__(self, filename):
97 self._zipfile = zipfile.ZipFile(filename, "r")
99 def __del__(self):
100 self._zipfile.close()
102 def _getnormalizedpath(self, path):
103 """
104 Gets a normalized path from 'path' (or the current working directory if
105 'path' is None). Also asserts that the path exists.
106 """
107 if path is None:
108 path = os.curdir
109 path = os.path.normpath(os.path.expanduser(path))
110 assert os.path.isdir(path)
111 return path
113 def _extractname(self, name, path):
114 """
115 Extracts a file with the given name from the zip file to the given path.
116 Also creates any directories needed along the way.
117 """
118 filename = os.path.normpath(os.path.join(path, name))
119 if name.endswith("/"):
120 os.makedirs(filename)
121 else:
122 path = os.path.split(filename)[0]
123 if not os.path.isdir(path):
124 os.makedirs(path)
125 with open(filename, "wb") as dest:
126 dest.write(self._zipfile.read(name))
128 def namelist(self):
129 return self._zipfile.namelist()
131 def read(self, name):
132 return self._zipfile.read(name)
134 def extract(self, name, path = None):
135 if hasattr(self._zipfile, "extract"):
136 return self._zipfile.extract(name, path)
138 # This will throw if name is not part of the zip file.
139 self._zipfile.getinfo(name)
141 self._extractname(name, self._getnormalizedpath(path))
143 def extractall(self, path = None):
144 if hasattr(self._zipfile, "extractall"):
145 return self._zipfile.extractall(path)
147 path = self._getnormalizedpath(path)
149 for name in self._zipfile.namelist():
150 self._extractname(name, path)
152 log = logging.getLogger()
154 def isURL(thing):
155 """Return True if |thing| looks like a URL."""
156 # We want to download URLs like http://... but not Windows paths like c:\...
157 return len(urlparse(thing).scheme) >= 2
159 # Python does not provide strsignal() even in the very latest 3.x.
160 # This is a reasonable fake.
161 def strsig(n):
162 # Signal numbers run 0 through NSIG-1; an array with NSIG members
163 # has exactly that many slots
164 _sigtbl = [None]*signal.NSIG
165 for k in dir(signal):
166 if k.startswith("SIG") and not k.startswith("SIG_") and k != "SIGCLD" and k != "SIGPOLL":
167 _sigtbl[getattr(signal, k)] = k
168 # Realtime signals mostly have no names
169 if hasattr(signal, "SIGRTMIN") and hasattr(signal, "SIGRTMAX"):
170 for r in range(signal.SIGRTMIN+1, signal.SIGRTMAX+1):
171 _sigtbl[r] = "SIGRTMIN+" + str(r - signal.SIGRTMIN)
172 # Fill in any remaining gaps
173 for i in range(signal.NSIG):
174 if _sigtbl[i] is None:
175 _sigtbl[i] = "unrecognized signal, number " + str(i)
176 if n < 0 or n >= signal.NSIG:
177 return "out-of-range signal, number "+str(n)
178 return _sigtbl[n]
180 def printstatus(status, name = ""):
181 # 'status' is the exit status
182 if os.name != 'posix':
183 # Windows error codes are easier to look up if printed in hexadecimal
184 if status < 0:
185 status += 2**32
186 print "TEST-INFO | %s: exit status %x\n" % (name, status)
187 elif os.WIFEXITED(status):
188 print "TEST-INFO | %s: exit %d\n" % (name, os.WEXITSTATUS(status))
189 elif os.WIFSIGNALED(status):
190 # The python stdlib doesn't appear to have strsignal(), alas
191 print "TEST-INFO | {}: killed by {}".format(name,strsig(os.WTERMSIG(status)))
192 else:
193 # This is probably a can't-happen condition on Unix, but let's be defensive
194 print "TEST-INFO | %s: undecodable exit status %04x\n" % (name, status)
196 def addCommonOptions(parser, defaults={}):
197 parser.add_option("--xre-path",
198 action = "store", type = "string", dest = "xrePath",
199 # individual scripts will set a sane default
200 default = None,
201 help = "absolute path to directory containing XRE (probably xulrunner)")
202 if 'SYMBOLS_PATH' not in defaults:
203 defaults['SYMBOLS_PATH'] = None
204 parser.add_option("--symbols-path",
205 action = "store", type = "string", dest = "symbolsPath",
206 default = defaults['SYMBOLS_PATH'],
207 help = "absolute path to directory containing breakpad symbols, or the URL of a zip file containing symbols")
208 parser.add_option("--debugger",
209 action = "store", dest = "debugger",
210 help = "use the given debugger to launch the application")
211 parser.add_option("--debugger-args",
212 action = "store", dest = "debuggerArgs",
213 help = "pass the given args to the debugger _before_ "
214 "the application on the command line")
215 parser.add_option("--debugger-interactive",
216 action = "store_true", dest = "debuggerInteractive",
217 help = "prevents the test harness from redirecting "
218 "stdout and stderr for interactive debuggers")
220 def getFullPath(directory, path):
221 "Get an absolute path relative to 'directory'."
222 return os.path.normpath(os.path.join(directory, os.path.expanduser(path)))
224 def searchPath(directory, path):
225 "Go one step beyond getFullPath and try the various folders in PATH"
226 # Try looking in the current working directory first.
227 newpath = getFullPath(directory, path)
228 if os.path.isfile(newpath):
229 return newpath
231 # At this point we have to fail if a directory was given (to prevent cases
232 # like './gdb' from matching '/usr/bin/./gdb').
233 if not os.path.dirname(path):
234 for dir in os.environ['PATH'].split(os.pathsep):
235 newpath = os.path.join(dir, path)
236 if os.path.isfile(newpath):
237 return newpath
238 return None
240 def getDebuggerInfo(directory, debugger, debuggerArgs, debuggerInteractive = False):
242 debuggerInfo = None
244 if debugger:
245 debuggerPath = searchPath(directory, debugger)
246 if not debuggerPath:
247 print "Error: Path %s doesn't exist." % debugger
248 sys.exit(1)
250 debuggerName = os.path.basename(debuggerPath).lower()
252 def getDebuggerInfo(type, default):
253 if debuggerName in DEBUGGER_INFO and type in DEBUGGER_INFO[debuggerName]:
254 return DEBUGGER_INFO[debuggerName][type]
255 return default
257 debuggerInfo = {
258 "path": debuggerPath,
259 "interactive" : getDebuggerInfo("interactive", False),
260 "args": getDebuggerInfo("args", "").split(),
261 "requiresEscapedArgs": getDebuggerInfo("requiresEscapedArgs", False)
262 }
264 if debuggerArgs:
265 debuggerInfo["args"] = debuggerArgs.split()
266 if debuggerInteractive:
267 debuggerInfo["interactive"] = debuggerInteractive
269 return debuggerInfo
272 def dumpLeakLog(leakLogFile, filter = False):
273 """Process the leak log, without parsing it.
275 Use this function if you want the raw log only.
276 Use it preferably with the |XPCOM_MEM_LEAK_LOG| environment variable.
277 """
279 # Don't warn (nor "info") if the log file is not there.
280 if not os.path.exists(leakLogFile):
281 return
283 with open(leakLogFile, "r") as leaks:
284 leakReport = leaks.read()
286 # Only |XPCOM_MEM_LEAK_LOG| reports can be actually filtered out.
287 # Only check whether an actual leak was reported.
288 if filter and not "0 TOTAL " in leakReport:
289 return
291 # Simply copy the log.
292 log.info(leakReport.rstrip("\n"))
294 def processSingleLeakFile(leakLogFileName, processType, leakThreshold):
295 """Process a single leak log.
296 """
298 # Per-Inst Leaked Total Rem ...
299 # 0 TOTAL 17 192 419115886 2 ...
300 # 833 nsTimerImpl 60 120 24726 2 ...
301 lineRe = re.compile(r"^\s*\d+\s+(?P<name>\S+)\s+"
302 r"(?P<size>-?\d+)\s+(?P<bytesLeaked>-?\d+)\s+"
303 r"-?\d+\s+(?P<numLeaked>-?\d+)")
305 processString = ""
306 if processType:
307 # eg 'plugin'
308 processString = " %s process:" % processType
310 crashedOnPurpose = False
311 totalBytesLeaked = None
312 leakAnalysis = []
313 leakedObjectNames = []
314 with open(leakLogFileName, "r") as leaks:
315 for line in leaks:
316 if line.find("purposefully crash") > -1:
317 crashedOnPurpose = True
318 matches = lineRe.match(line)
319 if not matches:
320 # eg: the leak table header row
321 log.info(line.rstrip())
322 continue
323 name = matches.group("name")
324 size = int(matches.group("size"))
325 bytesLeaked = int(matches.group("bytesLeaked"))
326 numLeaked = int(matches.group("numLeaked"))
327 # Output the raw line from the leak log table if it is the TOTAL row,
328 # or is for an object row that has been leaked.
329 if numLeaked != 0 or name == "TOTAL":
330 log.info(line.rstrip())
331 # Analyse the leak log, but output later or it will interrupt the leak table
332 if name == "TOTAL":
333 totalBytesLeaked = bytesLeaked
334 if size < 0 or bytesLeaked < 0 or numLeaked < 0:
335 leakAnalysis.append("TEST-UNEXPECTED-FAIL | leakcheck |%s negative leaks caught!"
336 % processString)
337 continue
338 if name != "TOTAL" and numLeaked != 0:
339 leakedObjectNames.append(name)
340 leakAnalysis.append("TEST-INFO | leakcheck |%s leaked %d %s (%s bytes)"
341 % (processString, numLeaked, name, bytesLeaked))
342 log.info('\n'.join(leakAnalysis))
344 if totalBytesLeaked is None:
345 # We didn't see a line with name 'TOTAL'
346 if crashedOnPurpose:
347 log.info("TEST-INFO | leakcheck |%s deliberate crash and thus no leak log"
348 % processString)
349 else:
350 # TODO: This should be a TEST-UNEXPECTED-FAIL, but was changed to a warning
351 # due to too many intermittent failures (see bug 831223).
352 log.info("WARNING | leakcheck |%s missing output line for total leaks!"
353 % processString)
354 return
356 if totalBytesLeaked == 0:
357 log.info("TEST-PASS | leakcheck |%s no leaks detected!" % processString)
358 return
360 # totalBytesLeaked was seen and is non-zero.
361 if totalBytesLeaked > leakThreshold:
362 # Fail the run if we're over the threshold (which defaults to 0)
363 prefix = "TEST-UNEXPECTED-FAIL"
364 else:
365 prefix = "WARNING"
366 # Create a comma delimited string of the first N leaked objects found,
367 # to aid with bug summary matching in TBPL. Note: The order of the objects
368 # had no significance (they're sorted alphabetically).
369 maxSummaryObjects = 5
370 leakedObjectSummary = ', '.join(leakedObjectNames[:maxSummaryObjects])
371 if len(leakedObjectNames) > maxSummaryObjects:
372 leakedObjectSummary += ', ...'
373 log.info("%s | leakcheck |%s %d bytes leaked (%s)"
374 % (prefix, processString, totalBytesLeaked, leakedObjectSummary))
376 def processLeakLog(leakLogFile, leakThreshold = 0):
377 """Process the leak log, including separate leak logs created
378 by child processes.
380 Use this function if you want an additional PASS/FAIL summary.
381 It must be used with the |XPCOM_MEM_BLOAT_LOG| environment variable.
382 """
384 if not os.path.exists(leakLogFile):
385 log.info("WARNING | leakcheck | refcount logging is off, so leaks can't be detected!")
386 return
388 if leakThreshold != 0:
389 log.info("TEST-INFO | leakcheck | threshold set at %d bytes" % leakThreshold)
391 (leakLogFileDir, leakFileBase) = os.path.split(leakLogFile)
392 fileNameRegExp = re.compile(r".*?_([a-z]*)_pid\d*$")
393 if leakFileBase[-4:] == ".log":
394 leakFileBase = leakFileBase[:-4]
395 fileNameRegExp = re.compile(r".*?_([a-z]*)_pid\d*.log$")
397 for fileName in os.listdir(leakLogFileDir):
398 if fileName.find(leakFileBase) != -1:
399 thisFile = os.path.join(leakLogFileDir, fileName)
400 processType = None
401 m = fileNameRegExp.search(fileName)
402 if m:
403 processType = m.group(1)
404 processSingleLeakFile(thisFile, processType, leakThreshold)
406 def replaceBackSlashes(input):
407 return input.replace('\\', '/')
409 class KeyValueParseError(Exception):
410 """error when parsing strings of serialized key-values"""
411 def __init__(self, msg, errors=()):
412 self.errors = errors
413 Exception.__init__(self, msg)
415 def parseKeyValue(strings, separator='=', context='key, value: '):
416 """
417 parse string-serialized key-value pairs in the form of
418 `key = value`. Returns a list of 2-tuples.
419 Note that whitespace is not stripped.
420 """
422 # syntax check
423 missing = [string for string in strings if separator not in string]
424 if missing:
425 raise KeyValueParseError("Error: syntax error in %s" % (context,
426 ','.join(missing)),
427 errors=missing)
428 return [string.split(separator, 1) for string in strings]
430 def systemMemory():
431 """
432 Returns total system memory in kilobytes.
433 Works only on unix-like platforms where `free` is in the path.
434 """
435 return int(os.popen("free").readlines()[1].split()[1])
437 def environment(xrePath, env=None, crashreporter=True, debugger=False, dmdPath=None):
438 """populate OS environment variables for mochitest"""
440 env = os.environ.copy() if env is None else env
442 assert os.path.isabs(xrePath)
444 ldLibraryPath = xrePath
446 envVar = None
447 dmdLibrary = None
448 preloadEnvVar = None
449 if mozinfo.isUnix:
450 envVar = "LD_LIBRARY_PATH"
451 env['MOZILLA_FIVE_HOME'] = xrePath
452 dmdLibrary = "libdmd.so"
453 preloadEnvVar = "LD_PRELOAD"
454 elif mozinfo.isMac:
455 envVar = "DYLD_LIBRARY_PATH"
456 dmdLibrary = "libdmd.dylib"
457 preloadEnvVar = "DYLD_INSERT_LIBRARIES"
458 elif mozinfo.isWin:
459 envVar = "PATH"
460 dmdLibrary = "dmd.dll"
461 preloadEnvVar = "MOZ_REPLACE_MALLOC_LIB"
462 if envVar:
463 envValue = ((env.get(envVar), str(ldLibraryPath))
464 if mozinfo.isWin
465 else (ldLibraryPath, dmdPath, env.get(envVar)))
466 env[envVar] = os.path.pathsep.join([path for path in envValue if path])
468 if dmdPath and dmdLibrary and preloadEnvVar:
469 env['DMD'] = '1'
470 env[preloadEnvVar] = os.path.join(dmdPath, dmdLibrary)
472 # crashreporter
473 env['GNOME_DISABLE_CRASH_DIALOG'] = '1'
474 env['XRE_NO_WINDOWS_CRASH_DIALOG'] = '1'
475 env['NS_TRACE_MALLOC_DISABLE_STACKS'] = '1'
477 if crashreporter and not debugger:
478 env['MOZ_CRASHREPORTER_NO_REPORT'] = '1'
479 env['MOZ_CRASHREPORTER'] = '1'
480 else:
481 env['MOZ_CRASHREPORTER_DISABLE'] = '1'
483 # Crash on non-local network connections.
484 env['MOZ_DISABLE_NONLOCAL_CONNECTIONS'] = '1'
486 # Set WebRTC logging in case it is not set yet
487 env.setdefault('NSPR_LOG_MODULES', 'signaling:5,mtransport:5,datachannel:5')
488 env.setdefault('R_LOG_LEVEL', '6')
489 env.setdefault('R_LOG_DESTINATION', 'stderr')
490 env.setdefault('R_LOG_VERBOSE', '1')
492 # ASan specific environment stuff
493 asan = bool(mozinfo.info.get("asan"))
494 if asan and (mozinfo.isLinux or mozinfo.isMac):
495 try:
496 # Symbolizer support
497 llvmsym = os.path.join(xrePath, "llvm-symbolizer")
498 if os.path.isfile(llvmsym):
499 env["ASAN_SYMBOLIZER_PATH"] = llvmsym
500 log.info("ASan using symbolizer at %s", llvmsym)
502 totalMemory = systemMemory()
504 # Only 4 GB RAM or less available? Use custom ASan options to reduce
505 # the amount of resources required to do the tests. Standard options
506 # will otherwise lead to OOM conditions on the current test slaves.
507 message = "INFO | runtests.py | ASan running in %s configuration"
508 if totalMemory <= 1024 * 1024 * 4:
509 message = message % 'low-memory'
510 env["ASAN_OPTIONS"] = "quarantine_size=50331648:malloc_context_size=5"
511 else:
512 message = message % 'default memory'
513 except OSError,err:
514 log.info("Failed determine available memory, disabling ASan low-memory configuration: %s", err.strerror)
515 except:
516 log.info("Failed determine available memory, disabling ASan low-memory configuration")
517 else:
518 log.info(message)
520 return env
522 def dumpScreen(utilityPath):
523 """dumps a screenshot of the entire screen to a directory specified by
524 the MOZ_UPLOAD_DIR environment variable"""
525 import mozfile
527 # Need to figure out which OS-dependent tool to use
528 if mozinfo.isUnix:
529 utility = [os.path.join(utilityPath, "screentopng")]
530 utilityname = "screentopng"
531 elif mozinfo.isMac:
532 utility = ['/usr/sbin/screencapture', '-C', '-x', '-t', 'png']
533 utilityname = "screencapture"
534 elif mozinfo.isWin:
535 utility = [os.path.join(utilityPath, "screenshot.exe")]
536 utilityname = "screenshot"
538 # Get dir where to write the screenshot file
539 parent_dir = os.environ.get('MOZ_UPLOAD_DIR', None)
540 if not parent_dir:
541 log.info('Failed to retrieve MOZ_UPLOAD_DIR env var')
542 return
544 # Run the capture
545 try:
546 tmpfd, imgfilename = tempfile.mkstemp(prefix='mozilla-test-fail-screenshot_', suffix='.png', dir=parent_dir)
547 os.close(tmpfd)
548 returncode = subprocess.call(utility + [imgfilename])
549 printstatus(returncode, utilityname)
550 except OSError, err:
551 log.info("Failed to start %s for screenshot: %s",
552 utility[0], err.strerror)
553 return
555 class ShutdownLeaks(object):
556 """
557 Parses the mochitest run log when running a debug build, assigns all leaked
558 DOM windows (that are still around after test suite shutdown, despite running
559 the GC) to the tests that created them and prints leak statistics.
560 """
562 def __init__(self, logger):
563 self.logger = logger
564 self.tests = []
565 self.leakedWindows = {}
566 self.leakedDocShells = set()
567 self.currentTest = None
568 self.seenShutdown = False
570 def log(self, line):
571 if line[2:11] == "DOMWINDOW":
572 self._logWindow(line)
573 elif line[2:10] == "DOCSHELL":
574 self._logDocShell(line)
575 elif line.startswith("TEST-START"):
576 fileName = line.split(" ")[-1].strip().replace("chrome://mochitests/content/browser/", "")
577 self.currentTest = {"fileName": fileName, "windows": set(), "docShells": set()}
578 elif line.startswith("INFO TEST-END"):
579 # don't track a test if no windows or docShells leaked
580 if self.currentTest and (self.currentTest["windows"] or self.currentTest["docShells"]):
581 self.tests.append(self.currentTest)
582 self.currentTest = None
583 elif line.startswith("INFO TEST-START | Shutdown"):
584 self.seenShutdown = True
586 def process(self):
587 for test in self._parseLeakingTests():
588 for url, count in self._zipLeakedWindows(test["leakedWindows"]):
589 self.logger("TEST-UNEXPECTED-FAIL | %s | leaked %d window(s) until shutdown [url = %s]", test["fileName"], count, url)
591 if test["leakedDocShells"]:
592 self.logger("TEST-UNEXPECTED-FAIL | %s | leaked %d docShell(s) until shutdown", test["fileName"], len(test["leakedDocShells"]))
594 def _logWindow(self, line):
595 created = line[:2] == "++"
596 pid = self._parseValue(line, "pid")
597 serial = self._parseValue(line, "serial")
599 # log line has invalid format
600 if not pid or not serial:
601 self.logger("TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line <%s>", line)
602 return
604 key = pid + "." + serial
606 if self.currentTest:
607 windows = self.currentTest["windows"]
608 if created:
609 windows.add(key)
610 else:
611 windows.discard(key)
612 elif self.seenShutdown and not created:
613 self.leakedWindows[key] = self._parseValue(line, "url")
615 def _logDocShell(self, line):
616 created = line[:2] == "++"
617 pid = self._parseValue(line, "pid")
618 id = self._parseValue(line, "id")
620 # log line has invalid format
621 if not pid or not id:
622 self.logger("TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line <%s>", line)
623 return
625 key = pid + "." + id
627 if self.currentTest:
628 docShells = self.currentTest["docShells"]
629 if created:
630 docShells.add(key)
631 else:
632 docShells.discard(key)
633 elif self.seenShutdown and not created:
634 self.leakedDocShells.add(key)
636 def _parseValue(self, line, name):
637 match = re.search("\[%s = (.+?)\]" % name, line)
638 if match:
639 return match.group(1)
640 return None
642 def _parseLeakingTests(self):
643 leakingTests = []
645 for test in self.tests:
646 test["leakedWindows"] = [self.leakedWindows[id] for id in test["windows"] if id in self.leakedWindows]
647 test["leakedDocShells"] = [id for id in test["docShells"] if id in self.leakedDocShells]
648 test["leakCount"] = len(test["leakedWindows"]) + len(test["leakedDocShells"])
650 if test["leakCount"]:
651 leakingTests.append(test)
653 return sorted(leakingTests, key=itemgetter("leakCount"), reverse=True)
655 def _zipLeakedWindows(self, leakedWindows):
656 counts = []
657 counted = set()
659 for url in leakedWindows:
660 if not url in counted:
661 counts.append((url, leakedWindows.count(url)))
662 counted.add(url)
664 return sorted(counts, key=itemgetter(1), reverse=True)