blob: 7b99ab3e7b786ab630f0bcfa57ba1a4816009a70 [file] [log] [blame]
Christopher Wiley8e8b67f2015-12-07 10:13:04 -08001#!/usr/bin/python
2# Copyright (c) 2010 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6
7"""Parses and displays the contents of one or more autoserv result directories.
8
9This script parses the contents of one or more autoserv results folders and
10generates test reports.
11"""
12
13import datetime
14import glob
Christopher Wiley519624e2015-12-07 10:42:05 -080015import logging
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080016import operator
17import optparse
18import os
19import re
20import sys
21
Christopher Wiley519624e2015-12-07 10:42:05 -080022import common
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080023try:
Christopher Wiley519624e2015-12-07 10:42:05 -080024 # Ensure the chromite site-package is installed.
25 from chromite.lib import terminal
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080026except ImportError:
Christopher Wiley519624e2015-12-07 10:42:05 -080027 import subprocess
28 build_externals_path = os.path.join(
29 os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
30 'utils', 'build_externals.py')
31 subprocess.check_call([build_externals_path, 'chromiterepo'])
32 # Restart the script so python now finds the autotest site-packages.
33 sys.exit(os.execv(__file__, sys.argv))
34
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080035
36_STDOUT_IS_TTY = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
Bogineni Kasaiahbdd6f182018-02-01 12:42:47 +053037_HTML_COLUMNS = ['testdir', 'status', 'error_msg', 'crashes']
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080038
Christopher Wiley519624e2015-12-07 10:42:05 -080039
40def Die(message_format, *args, **kwargs):
41 """Log a message and kill the current process.
42
43 @param message_format: string for logging.error.
44
45 """
46 logging.error(message_format, *args, **kwargs)
47 sys.exit(1)
48
49
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080050class CrashWaiver:
Christopher Wiley519624e2015-12-07 10:42:05 -080051 """Represents a crash that we want to ignore for now."""
52 def __init__(self, signals, deadline, url, person):
53 self.signals = signals
54 self.deadline = datetime.datetime.strptime(deadline, '%Y-%b-%d')
55 self.issue_url = url
56 self.suppressor = person
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080057
58# List of crashes which are okay to ignore. This list should almost always be
59# empty. If you add an entry, include the bug URL and your name, something like
60# 'crashy':CrashWaiver(
61# ['sig 11'], '2011-Aug-18', 'http://crosbug/123456', 'developer'),
62
63_CRASH_WHITELIST = {
64}
65
66
67class ResultCollector(object):
Christopher Wiley519624e2015-12-07 10:42:05 -080068 """Collects status and performance data from an autoserv results dir."""
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080069
Christopher Wiley519624e2015-12-07 10:42:05 -080070 def __init__(self, collect_perf=True, collect_attr=False,
71 collect_info=False, escape_error=False,
72 whitelist_chrome_crashes=False):
73 """Initialize ResultsCollector class.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080074
Christopher Wiley519624e2015-12-07 10:42:05 -080075 @param collect_perf: Should perf keyvals be collected?
76 @param collect_attr: Should attr keyvals be collected?
77 @param collect_info: Should info keyvals be collected?
78 @param escape_error: Escape error message text for tools.
79 @param whitelist_chrome_crashes: Treat Chrome crashes as non-fatal.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080080
Christopher Wiley519624e2015-12-07 10:42:05 -080081 """
82 self._collect_perf = collect_perf
83 self._collect_attr = collect_attr
84 self._collect_info = collect_info
85 self._escape_error = escape_error
86 self._whitelist_chrome_crashes = whitelist_chrome_crashes
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080087
Christopher Wiley519624e2015-12-07 10:42:05 -080088 def _CollectPerf(self, testdir):
89 """Parses keyval file under testdir and return the perf keyval pairs.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080090
Christopher Wiley519624e2015-12-07 10:42:05 -080091 @param testdir: autoserv test result directory path.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080092
Christopher Wiley519624e2015-12-07 10:42:05 -080093 @return dict of perf keyval pairs.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080094
Christopher Wiley519624e2015-12-07 10:42:05 -080095 """
96 if not self._collect_perf:
97 return {}
98 return self._CollectKeyval(testdir, 'perf')
Christopher Wiley8e8b67f2015-12-07 10:13:04 -080099
Christopher Wiley519624e2015-12-07 10:42:05 -0800100 def _CollectAttr(self, testdir):
101 """Parses keyval file under testdir and return the attr keyval pairs.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800102
Christopher Wiley519624e2015-12-07 10:42:05 -0800103 @param testdir: autoserv test result directory path.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800104
Christopher Wiley519624e2015-12-07 10:42:05 -0800105 @return dict of attr keyval pairs.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800106
Christopher Wiley519624e2015-12-07 10:42:05 -0800107 """
108 if not self._collect_attr:
109 return {}
110 return self._CollectKeyval(testdir, 'attr')
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800111
Christopher Wiley519624e2015-12-07 10:42:05 -0800112 def _CollectKeyval(self, testdir, keyword):
113 """Parses keyval file under testdir.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800114
Christopher Wiley519624e2015-12-07 10:42:05 -0800115 If testdir contains a result folder, process the keyval file and return
116 a dictionary of perf keyval pairs.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800117
Christopher Wiley519624e2015-12-07 10:42:05 -0800118 @param testdir: The autoserv test result directory.
119 @param keyword: The keyword of keyval, either 'perf' or 'attr'.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800120
Christopher Wiley519624e2015-12-07 10:42:05 -0800121 @return If the perf option is disabled or the there's no keyval file
122 under testdir, returns an empty dictionary. Otherwise, returns
123 a dictionary of parsed keyvals. Duplicate keys are uniquified
124 by their instance number.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800125
Christopher Wiley519624e2015-12-07 10:42:05 -0800126 """
127 keyval = {}
128 keyval_file = os.path.join(testdir, 'results', 'keyval')
129 if not os.path.isfile(keyval_file):
130 return keyval
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800131
Christopher Wiley519624e2015-12-07 10:42:05 -0800132 instances = {}
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800133
Christopher Wiley519624e2015-12-07 10:42:05 -0800134 for line in open(keyval_file):
135 match = re.search(r'^(.+){%s}=(.+)$' % keyword, line)
136 if match:
137 key = match.group(1)
138 val = match.group(2)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800139
Christopher Wiley519624e2015-12-07 10:42:05 -0800140 # If the same key name was generated multiple times, uniquify
141 # all instances other than the first one by adding the instance
142 # count to the key name.
143 key_inst = key
144 instance = instances.get(key, 0)
145 if instance:
146 key_inst = '%s{%d}' % (key, instance)
147 instances[key] = instance + 1
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800148
Christopher Wiley519624e2015-12-07 10:42:05 -0800149 keyval[key_inst] = val
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800150
Christopher Wiley519624e2015-12-07 10:42:05 -0800151 return keyval
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800152
Christopher Wiley519624e2015-12-07 10:42:05 -0800153 def _CollectCrashes(self, status_raw):
154 """Parses status_raw file for crashes.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800155
Christopher Wiley519624e2015-12-07 10:42:05 -0800156 Saves crash details if crashes are discovered. If a whitelist is
157 present, only records whitelisted crashes.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800158
Christopher Wiley519624e2015-12-07 10:42:05 -0800159 @param status_raw: The contents of the status.log or status file from
160 the test.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800161
Christopher Wiley519624e2015-12-07 10:42:05 -0800162 @return a list of crash entries to be reported.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800163
Christopher Wiley519624e2015-12-07 10:42:05 -0800164 """
165 crashes = []
166 regex = re.compile(
167 'Received crash notification for ([-\w]+).+ (sig \d+)')
168 chrome_regex = re.compile(r'^supplied_[cC]hrome|^chrome$')
169 for match in regex.finditer(status_raw):
170 w = _CRASH_WHITELIST.get(match.group(1))
171 if (self._whitelist_chrome_crashes and
172 chrome_regex.match(match.group(1))):
173 print '@@@STEP_WARNINGS@@@'
174 print '%s crashed with %s' % (match.group(1), match.group(2))
175 elif (w is not None and match.group(2) in w.signals and
176 w.deadline > datetime.datetime.now()):
177 print 'Ignoring crash in %s for waiver that expires %s' % (
178 match.group(1), w.deadline.strftime('%Y-%b-%d'))
179 else:
180 crashes.append('%s %s' % match.groups())
181 return crashes
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800182
Christopher Wiley519624e2015-12-07 10:42:05 -0800183 def _CollectInfo(self, testdir, custom_info):
184 """Parses *_info files under testdir/sysinfo/var/log.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800185
Christopher Wiley519624e2015-12-07 10:42:05 -0800186 If the sysinfo/var/log/*info files exist, save information that shows
187 hw, ec and bios version info.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800188
Christopher Wiley519624e2015-12-07 10:42:05 -0800189 This collection of extra info is disabled by default (this funtion is
190 a no-op). It is enabled only if the --info command-line option is
191 explicitly supplied. Normal job parsing does not supply this option.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800192
Christopher Wiley519624e2015-12-07 10:42:05 -0800193 @param testdir: The autoserv test result directory.
194 @param custom_info: Dictionary to collect detailed ec/bios info.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800195
Christopher Wiley519624e2015-12-07 10:42:05 -0800196 @return a dictionary of info that was discovered.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800197
Christopher Wiley519624e2015-12-07 10:42:05 -0800198 """
199 if not self._collect_info:
200 return {}
201 info = custom_info
202
203 sysinfo_dir = os.path.join(testdir, 'sysinfo', 'var', 'log')
204 for info_file, info_keys in {'ec_info.txt': ['fw_version'],
205 'bios_info.txt': ['fwid',
206 'hwid']}.iteritems():
207 info_file_path = os.path.join(sysinfo_dir, info_file)
208 if not os.path.isfile(info_file_path):
209 continue
210 # Some example raw text that might be matched include:
211 #
212 # fw_version | snow_v1.1.332-cf20b3e
213 # fwid = Google_Snow.2711.0.2012_08_06_1139 # Active firmware ID
214 # hwid = DAISY TEST A-A 9382 # Hardware ID
215 info_regex = re.compile(r'^(%s)\s*[|=]\s*(.*)' %
216 '|'.join(info_keys))
217 with open(info_file_path, 'r') as f:
218 for line in f:
219 line = line.strip()
220 line = line.split('#')[0]
221 match = info_regex.match(line)
222 if match:
223 info[match.group(1)] = str(match.group(2)).strip()
224 return info
225
226 def _CollectEndTimes(self, status_raw, status_re='', is_end=True):
227 """Helper to match and collect timestamp and localtime.
228
229 Preferred to locate timestamp and localtime with an
230 'END GOOD test_name...' line. However, aborted tests occasionally fail
231 to produce this line and then need to scrape timestamps from the 'START
232 test_name...' line.
233
234 @param status_raw: multi-line text to search.
235 @param status_re: status regex to seek (e.g. GOOD|FAIL)
236 @param is_end: if True, search for 'END' otherwise 'START'.
237
238 @return Tuple of timestamp, localtime retrieved from the test status
239 log.
240
241 """
242 timestamp = ''
243 localtime = ''
244
245 localtime_re = r'\w+\s+\w+\s+[:\w]+'
246 match_filter = (
247 r'^\s*%s\s+(?:%s).*timestamp=(\d*).*localtime=(%s).*$' % (
248 'END' if is_end else 'START', status_re, localtime_re))
249 matches = re.findall(match_filter, status_raw, re.MULTILINE)
250 if matches:
251 # There may be multiple lines with timestamp/localtime info.
252 # The last one found is selected because it will reflect the end
253 # time.
254 for i in xrange(len(matches)):
255 timestamp_, localtime_ = matches[-(i+1)]
256 if not timestamp or timestamp_ > timestamp:
257 timestamp = timestamp_
258 localtime = localtime_
259 return timestamp, localtime
260
261 def _CheckExperimental(self, testdir):
262 """Parses keyval file and return the value of `experimental`.
263
264 @param testdir: The result directory that has the keyval file.
265
266 @return The value of 'experimental', which is a boolean value indicating
267 whether it is an experimental test or not.
268
269 """
270 keyval_file = os.path.join(testdir, 'keyval')
271 if not os.path.isfile(keyval_file):
272 return False
273
274 with open(keyval_file) as f:
275 for line in f:
276 match = re.match(r'experimental=(.+)', line)
277 if match:
278 return match.group(1) == 'True'
279 else:
280 return False
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800281
282
Christopher Wiley519624e2015-12-07 10:42:05 -0800283 def _CollectResult(self, testdir, results, is_experimental=False):
284 """Collects results stored under testdir into a dictionary.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800285
Christopher Wiley519624e2015-12-07 10:42:05 -0800286 The presence/location of status files (status.log, status and
287 job_report.html) varies depending on whether the job is a simple
288 client test, simple server test, old-style suite or new-style
289 suite. For example:
290 -In some cases a single job_report.html may exist but many times
291 multiple instances are produced in a result tree.
292 -Most tests will produce a status.log but client tests invoked
293 by a server test will only emit a status file.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800294
Christopher Wiley519624e2015-12-07 10:42:05 -0800295 The two common criteria that seem to define the presence of a
296 valid test result are:
297 1. Existence of a 'status.log' or 'status' file. Note that if both a
298 'status.log' and 'status' file exist for a test, the 'status' file
299 is always a subset of the 'status.log' fle contents.
300 2. Presence of a 'debug' directory.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800301
Christopher Wiley519624e2015-12-07 10:42:05 -0800302 In some cases multiple 'status.log' files will exist where the parent
303 'status.log' contains the contents of multiple subdirectory 'status.log'
304 files. Parent and subdirectory 'status.log' files are always expected
305 to agree on the outcome of a given test.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800306
Christopher Wiley519624e2015-12-07 10:42:05 -0800307 The test results discovered from the 'status*' files are included
308 in the result dictionary. The test directory name and a test directory
309 timestamp/localtime are saved to be used as sort keys for the results.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800310
Christopher Wiley519624e2015-12-07 10:42:05 -0800311 The value of 'is_experimental' is included in the result dictionary.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800312
Christopher Wiley519624e2015-12-07 10:42:05 -0800313 @param testdir: The autoserv test result directory.
314 @param results: A list to which a populated test-result-dictionary will
315 be appended if a status file is found.
316 @param is_experimental: A boolean value indicating whether the result
317 directory is for an experimental test.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800318
Christopher Wiley519624e2015-12-07 10:42:05 -0800319 """
320 status_file = os.path.join(testdir, 'status.log')
321 if not os.path.isfile(status_file):
322 status_file = os.path.join(testdir, 'status')
323 if not os.path.isfile(status_file):
324 return
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800325
Christopher Wiley519624e2015-12-07 10:42:05 -0800326 # Status is True if GOOD, else False for all others.
327 status = False
328 error_msg = None
329 status_raw = open(status_file, 'r').read()
330 failure_tags = 'ABORT|ERROR|FAIL'
331 warning_tag = 'WARN|TEST_NA'
332 failure = re.search(r'%s' % failure_tags, status_raw)
333 warning = re.search(r'%s' % warning_tag, status_raw) and not failure
334 good = (re.search(r'GOOD.+completed successfully', status_raw) and
335 not (failure or warning))
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800336
Christopher Wiley519624e2015-12-07 10:42:05 -0800337 # We'd like warnings to allow the tests to pass, but still gather info.
338 if good or warning:
339 status = True
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800340
Christopher Wiley519624e2015-12-07 10:42:05 -0800341 if not good:
342 match = re.search(r'^\t+(%s|%s)\t(.+)' % (failure_tags,
343 warning_tag),
344 status_raw, re.MULTILINE)
345 if match:
346 failure_type = match.group(1)
347 reason = match.group(2).split('\t')[4]
348 if self._escape_error:
349 reason = re.escape(reason)
350 error_msg = ': '.join([failure_type, reason])
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800351
Christopher Wiley519624e2015-12-07 10:42:05 -0800352 # Grab the timestamp - can be used for sorting the test runs.
353 # Grab the localtime - may be printed to enable line filtering by date.
354 # Designed to match a line like this:
355 # END GOOD testname ... timestamp=1347324321 localtime=Sep 10 17:45:21
356 status_re = r'GOOD|%s|%s' % (failure_tags, warning_tag)
357 timestamp, localtime = self._CollectEndTimes(status_raw, status_re)
358 # Hung tests will occasionally skip printing the END line so grab
359 # a default timestamp from the START line in those cases.
360 if not timestamp:
361 timestamp, localtime = self._CollectEndTimes(status_raw,
362 is_end=False)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800363
Christopher Wiley519624e2015-12-07 10:42:05 -0800364 results.append({
365 'testdir': testdir,
366 'crashes': self._CollectCrashes(status_raw),
367 'status': status,
368 'error_msg': error_msg,
369 'localtime': localtime,
370 'timestamp': timestamp,
371 'perf': self._CollectPerf(testdir),
372 'attr': self._CollectAttr(testdir),
373 'info': self._CollectInfo(testdir, {'localtime': localtime,
374 'timestamp': timestamp}),
375 'experimental': is_experimental})
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800376
Christopher Wiley519624e2015-12-07 10:42:05 -0800377 def RecursivelyCollectResults(self, resdir, parent_experimental_tag=False):
378 """Recursively collect results into a list of dictionaries.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800379
Christopher Wiley519624e2015-12-07 10:42:05 -0800380 Only recurses into directories that possess a 'debug' subdirectory
381 because anything else is not considered a 'test' directory.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800382
Christopher Wiley519624e2015-12-07 10:42:05 -0800383 The value of 'experimental' in keyval file is used to determine whether
384 the result is for an experimental test. If it is, all its sub
385 directories are considered to be experimental tests too.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800386
Christopher Wiley519624e2015-12-07 10:42:05 -0800387 @param resdir: results/test directory to parse results from and recurse
388 into.
389 @param parent_experimental_tag: A boolean value, used to keep track of
390 whether its parent directory is for an experimental test.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800391
Christopher Wiley519624e2015-12-07 10:42:05 -0800392 @return List of dictionaries of results.
393
394 """
395 results = []
396 is_experimental = (parent_experimental_tag or
397 self._CheckExperimental(resdir))
398 self._CollectResult(resdir, results, is_experimental)
399 for testdir in glob.glob(os.path.join(resdir, '*')):
400 # Remove false positives that are missing a debug dir.
401 if not os.path.exists(os.path.join(testdir, 'debug')):
402 continue
403
404 results.extend(self.RecursivelyCollectResults(
405 testdir, is_experimental))
406 return results
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800407
408
409class ReportGenerator(object):
Christopher Wiley519624e2015-12-07 10:42:05 -0800410 """Collects and displays data from autoserv results directories.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800411
Christopher Wiley519624e2015-12-07 10:42:05 -0800412 This class collects status and performance data from one or more autoserv
413 result directories and generates test reports.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800414 """
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800415
Christopher Wiley519624e2015-12-07 10:42:05 -0800416 _KEYVAL_INDENT = 2
417 _STATUS_STRINGS = {'hr': {'pass': '[ PASSED ]', 'fail': '[ FAILED ]'},
418 'csv': {'pass': 'PASS', 'fail': 'FAIL'}}
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800419
Christopher Wiley519624e2015-12-07 10:42:05 -0800420 def __init__(self, options, args):
421 self._options = options
422 self._args = args
423 self._color = terminal.Color(options.color)
424 self._results = []
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800425
Christopher Wiley519624e2015-12-07 10:42:05 -0800426 def _CollectAllResults(self):
427 """Parses results into the self._results list.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800428
Christopher Wiley519624e2015-12-07 10:42:05 -0800429 Builds a list (self._results) where each entry is a dictionary of
430 result data from one test (which may contain other tests). Each
431 dictionary will contain values such as: test folder, status, localtime,
432 crashes, error_msg, perf keyvals [optional], info [optional].
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800433
Christopher Wiley519624e2015-12-07 10:42:05 -0800434 """
435 collector = ResultCollector(
436 collect_perf=self._options.perf,
437 collect_attr=self._options.attr,
438 collect_info=self._options.info,
439 escape_error=self._options.escape_error,
440 whitelist_chrome_crashes=self._options.whitelist_chrome_crashes)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800441
Christopher Wiley519624e2015-12-07 10:42:05 -0800442 for resdir in self._args:
443 if not os.path.isdir(resdir):
444 Die('%r does not exist', resdir)
445 self._results.extend(collector.RecursivelyCollectResults(resdir))
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800446
Christopher Wiley519624e2015-12-07 10:42:05 -0800447 if not self._results:
448 Die('no test directories found')
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800449
Christopher Wiley519624e2015-12-07 10:42:05 -0800450 def _GenStatusString(self, status):
451 """Given a bool indicating success or failure, return the right string.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800452
Christopher Wiley519624e2015-12-07 10:42:05 -0800453 Also takes --csv into account, returns old-style strings if it is set.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800454
Christopher Wiley519624e2015-12-07 10:42:05 -0800455 @param status: True or False, indicating success or failure.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800456
Christopher Wiley519624e2015-12-07 10:42:05 -0800457 @return The appropriate string for printing..
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800458
Christopher Wiley519624e2015-12-07 10:42:05 -0800459 """
460 success = 'pass' if status else 'fail'
461 if self._options.csv:
462 return self._STATUS_STRINGS['csv'][success]
463 return self._STATUS_STRINGS['hr'][success]
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800464
Christopher Wiley519624e2015-12-07 10:42:05 -0800465 def _Indent(self, msg):
466 """Given a message, indents it appropriately.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800467
Christopher Wiley519624e2015-12-07 10:42:05 -0800468 @param msg: string to indent.
469 @return indented version of msg.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800470
Christopher Wiley519624e2015-12-07 10:42:05 -0800471 """
472 return ' ' * self._KEYVAL_INDENT + msg
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800473
Christopher Wiley519624e2015-12-07 10:42:05 -0800474 def _GetTestColumnWidth(self):
475 """Returns the test column width based on the test data.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800476
Christopher Wiley519624e2015-12-07 10:42:05 -0800477 The test results are aligned by discovering the longest width test
478 directory name or perf key stored in the list of result dictionaries.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800479
Christopher Wiley519624e2015-12-07 10:42:05 -0800480 @return The width for the test column.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800481
Christopher Wiley519624e2015-12-07 10:42:05 -0800482 """
483 width = 0
484 for result in self._results:
485 width = max(width, len(result['testdir']))
486 perf = result.get('perf')
487 if perf:
488 perf_key_width = len(max(perf, key=len))
489 width = max(width, perf_key_width + self._KEYVAL_INDENT)
490 return width
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800491
Christopher Wiley519624e2015-12-07 10:42:05 -0800492 def _PrintDashLine(self, width):
493 """Prints a line of dashes as a separator in output.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800494
Christopher Wiley519624e2015-12-07 10:42:05 -0800495 @param width: an integer.
496 """
497 if not self._options.csv:
498 print ''.ljust(width + len(self._STATUS_STRINGS['hr']['pass']), '-')
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800499
Christopher Wiley519624e2015-12-07 10:42:05 -0800500 def _PrintEntries(self, entries):
501 """Prints a list of strings, delimited based on --csv flag.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800502
Christopher Wiley519624e2015-12-07 10:42:05 -0800503 @param entries: a list of strings, entities to output.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800504
Christopher Wiley519624e2015-12-07 10:42:05 -0800505 """
506 delimiter = ',' if self._options.csv else ' '
507 print delimiter.join(entries)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800508
Christopher Wiley519624e2015-12-07 10:42:05 -0800509 def _PrintErrors(self, test, error_msg):
510 """Prints an indented error message, unless the --csv flag is set.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800511
Christopher Wiley519624e2015-12-07 10:42:05 -0800512 @param test: the name of a test with which to prefix the line.
513 @param error_msg: a message to print. None is allowed, but ignored.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800514
Christopher Wiley519624e2015-12-07 10:42:05 -0800515 """
516 if not self._options.csv and error_msg:
517 self._PrintEntries([test, self._Indent(error_msg)])
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800518
Christopher Wiley519624e2015-12-07 10:42:05 -0800519 def _PrintErrorLogs(self, test, test_string):
520 """Prints the error log for |test| if --debug is set.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800521
Christopher Wiley519624e2015-12-07 10:42:05 -0800522 @param test: the name of a test suitable for embedding in a path
523 @param test_string: the name of a test with which to prefix the line.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800524
Christopher Wiley519624e2015-12-07 10:42:05 -0800525 """
526 if self._options.print_debug:
527 debug_file_regex = os.path.join(
528 'results.', test, 'debug',
529 '%s*.ERROR' % os.path.basename(test))
530 for path in glob.glob(debug_file_regex):
531 try:
532 with open(path) as fh:
533 for line in fh:
534 # Ensure line is not just WS.
535 if len(line.lstrip()) <= 0:
536 continue
537 self._PrintEntries(
538 [test_string, self._Indent(line.rstrip())])
539 except IOError:
540 print 'Could not open %s' % path
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800541
Christopher Wiley519624e2015-12-07 10:42:05 -0800542 def _PrintResultDictKeyVals(self, test_entry, result_dict):
543 """Formatted print a dict of keyvals like 'perf' or 'info'.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800544
Christopher Wiley519624e2015-12-07 10:42:05 -0800545 This function emits each keyval on a single line for uncompressed
546 review. The 'perf' dictionary contains performance keyvals while the
547 'info' dictionary contains ec info, bios info and some test timestamps.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800548
Christopher Wiley519624e2015-12-07 10:42:05 -0800549 @param test_entry: The unique name of the test (dir) - matches other
550 test output.
551 @param result_dict: A dict of keyvals to be presented.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800552
Christopher Wiley519624e2015-12-07 10:42:05 -0800553 """
554 if not result_dict:
555 return
556 dict_keys = result_dict.keys()
557 dict_keys.sort()
558 width = self._GetTestColumnWidth()
559 for dict_key in dict_keys:
560 if self._options.csv:
561 key_entry = dict_key
562 else:
563 key_entry = dict_key.ljust(width - self._KEYVAL_INDENT)
564 key_entry = key_entry.rjust(width)
565 value_entry = self._color.Color(
566 self._color.BOLD, result_dict[dict_key])
567 self._PrintEntries([test_entry, key_entry, value_entry])
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800568
Christopher Wiley519624e2015-12-07 10:42:05 -0800569 def _GetSortedTests(self):
570 """Sort the test result dicts in preparation for results printing.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800571
Christopher Wiley519624e2015-12-07 10:42:05 -0800572 By default sorts the results directionaries by their test names.
573 However, when running long suites, it is useful to see if an early test
574 has wedged the system and caused the remaining tests to abort/fail. The
575 datetime-based chronological sorting allows this view.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800576
Christopher Wiley519624e2015-12-07 10:42:05 -0800577 Uses the --sort-chron command line option to control.
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800578
Christopher Wiley519624e2015-12-07 10:42:05 -0800579 """
580 if self._options.sort_chron:
581 # Need to reverse sort the test dirs to ensure the suite folder
582 # shows at the bottom. Because the suite folder shares its datetime
583 # with the last test it shows second-to-last without the reverse
584 # sort first.
585 tests = sorted(self._results, key=operator.itemgetter('testdir'),
586 reverse=True)
587 tests = sorted(tests, key=operator.itemgetter('timestamp'))
588 else:
589 tests = sorted(self._results, key=operator.itemgetter('testdir'))
590 return tests
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800591
Bogineni Kasaiahbdd6f182018-02-01 12:42:47 +0530592 def _GetResultsForHTMLReport(self):
593 """Return cleaned results for HTML report.!"""
594 import copy
595 tests = copy.deepcopy(self._GetSortedTests())
596 tests_info = []
597 # Get the info of columns which are included in _HTML_COLUMNS
598 for test_status in tests:
599 for html_column in test_status.keys():
600 if html_column not in _HTML_COLUMNS:
601 del test_status[html_column]
602 tests_info.append(test_status)
603 html_results = {}
604 pass_tag = "Pass"
605 fail_tag = "Fail"
606 na_tag = "NA"
607 individual_tc_results = {}
608 for test_status in tests_info:
609 test_name_num_matched = re.search(r'results-(\d[0-9]*)-(.*)',
610 test_status['testdir'])
611 test_log_dir_matched = re.search(r'(.*)results-', test_status[
612 'testdir'])
613 if not test_name_num_matched and not test_log_dir_matched:
614 continue
615 test_name = test_name_num_matched.group(2)
616 test_number = test_name_num_matched.group(1)
617
618 if test_status['error_msg'] is None:
619 test_status['error_msg'] = ''
620 test_log_dir = test_log_dir_matched.group(1)
621 test_num_log_dir = test_number + '-' + test_log_dir
622 if not html_results.get(test_num_log_dir):
623 individual_tc_results = {} # Empty dictionary after each TC
624 # Iterating through each html column to get in order.
625 for col in _HTML_COLUMNS:
626 if col == 'testdir':
627 individual_tc_results['test'] = test_name
628 else:
629 individual_tc_results[col] = test_status[col]
630 else:
631 if test_status['status'] is False:
632 for col in _HTML_COLUMNS:
633 if col != 'testdir':
634 if type(test_status[col]) == "<type 'bool'>":
635 html_results[test_num_log_dir][col] = \
636 test_status[col]
637 else:
638 html_results[test_num_log_dir][col] = \
639 html_results[test_num_log_dir][col] + \
640 test_status[col]
641
642 html_results[test_num_log_dir] = individual_tc_results
643
644 # Mapping the Test case status if True->Pass, False->Fail and if
645 # True and the error message then NA
646 for key in html_results.keys():
647 if html_results[key]['status']:
648 if html_results[key]['error_msg'] != '':
649 html_results[key]['status'] = na_tag
650 else:
651 html_results[key]['status'] = pass_tag
652 else:
653 html_results[key]['status'] = fail_tag
654
655 return html_results
656
657
658 def GenerateReportHTML(self):
659 """Generate clean HTMl report for the results."""
660
661 results = self._GetResultsForHTMLReport()
662 passed_tests = [test_key for test_key in results.keys() if results[
663 test_key]['status'].lower() == 'pass']
664 failed_tests = [test_key for test_key in results.keys() if results[
665 test_key]['status'].lower() == 'fail']
666 na_tests = [test_key for test_key in results.keys() if results[
667 test_key]['status'].lower() == 'na']
668 total_tests = len(passed_tests) + len(failed_tests) + len(na_tests)
669 html_table_header = "<th>S.No</th><th>Results Directory</th>"
670 for column in _HTML_COLUMNS:
671 # tabs are for making generated HTML file in format.
672 if column == 'testdir':
673 column = 'test'
674 html_table_header = html_table_header + \
675 '\t\t\t\t\t\t<th>%s</th>\n' % \
676 column.capitalize()
677 html_table_body = ''
678 for key in sorted(results.keys()):
679 html_table_body = html_table_body + \
680 "\t\t\t\t\t<tr><td>%s</td><td>%s</td>\n" % (
681 key.split('-')[0], key.split('-')[1])
682 for column in _HTML_COLUMNS:
683 if column == 'testdir':
684 column = 'test'
685 html_table_body = html_table_body + \
686 '\t\t\t\t\t\t<td>%s</td>\n' % results[
687 key][column]
688 html_table_body = html_table_body + '\t\t\t\t\t</tr>\n'
689
690 html_page = """
691 <!DOCTYPE html>
692 <html lang="en">
693 <head>
694 <title>Automation Results</title>
695 <meta charset="utf-8">
696 <meta name="viewport" content="width=device-width,initial-scale=1">
697 <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
698 <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
699 <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
700 </head>
701 <body>
702 <div class="container">
703 <h2>Automation Report</h2>
704 <table class="table table-bordered">
705 <thead>
706 <tr>
707 \n%s
708 </tr>
709 </thead>
710 <tbody>
711 \n%s
712 </tbody>
713 </table>
714 <div class="row">
715 <div class="col-sm-4">Passed: %s</div>
716 <div class="col-sm-4">Failed: %s</div>
717 <div class="col-sm-4">NA: %s</div>
718 </div>
719 <div class="row">
720 <div class="col-sm-4">Total: %s</div>
721 </div>
722 </div>
723 </body>
724 </html>
725
726 """ % (html_table_header, html_table_body,
727 len(passed_tests), len(failed_tests), len(na_tests),
728 total_tests)
729 with open(os.path.join(self._options.html_report_dir,
730 "test_report.html"), 'w') as html_file:
731 html_file.write(html_page)
732
Christopher Wiley519624e2015-12-07 10:42:05 -0800733 def _GenerateReportText(self):
734 """Prints a result report to stdout.
735
736 Prints a result table to stdout. Each row of the table contains the
737 test result directory and the test result (PASS, FAIL). If the perf
738 option is enabled, each test entry is followed by perf keyval entries
739 from the test results.
740
741 """
742 tests = self._GetSortedTests()
743 width = self._GetTestColumnWidth()
744
745 crashes = {}
746 tests_pass = 0
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800747 self._PrintDashLine(width)
748
Christopher Wiley519624e2015-12-07 10:42:05 -0800749 for result in tests:
750 testdir = result['testdir']
751 test_entry = testdir if self._options.csv else testdir.ljust(width)
752
753 status_entry = self._GenStatusString(result['status'])
754 if result['status']:
755 color = self._color.GREEN
756 tests_pass += 1
757 else:
758 color = self._color.RED
759
760 test_entries = [test_entry, self._color.Color(color, status_entry)]
761
762 info = result.get('info', {})
763 info.update(result.get('attr', {}))
764 if self._options.csv and (self._options.info or self._options.attr):
765 if info:
766 test_entries.extend(['%s=%s' % (k, info[k])
767 for k in sorted(info.keys())])
768 if not result['status'] and result['error_msg']:
769 test_entries.append('reason="%s"' % result['error_msg'])
770
771 self._PrintEntries(test_entries)
772 self._PrintErrors(test_entry, result['error_msg'])
773
774 # Print out error log for failed tests.
775 if not result['status']:
776 self._PrintErrorLogs(testdir, test_entry)
777
778 # Emit the perf keyvals entries. There will be no entries if the
779 # --no-perf option is specified.
780 self._PrintResultDictKeyVals(test_entry, result['perf'])
781
782 # Determine that there was a crash during this test.
783 if result['crashes']:
784 for crash in result['crashes']:
785 if not crash in crashes:
786 crashes[crash] = set([])
787 crashes[crash].add(testdir)
788
789 # Emit extra test metadata info on separate lines if not --csv.
790 if not self._options.csv:
791 self._PrintResultDictKeyVals(test_entry, info)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800792
793 self._PrintDashLine(width)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800794
Christopher Wiley519624e2015-12-07 10:42:05 -0800795 if not self._options.csv:
796 total_tests = len(tests)
797 percent_pass = 100 * tests_pass / total_tests
798 pass_str = '%d/%d (%d%%)' % (tests_pass, total_tests, percent_pass)
799 print 'Total PASS: ' + self._color.Color(self._color.BOLD, pass_str)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800800
Christopher Wiley519624e2015-12-07 10:42:05 -0800801 if self._options.crash_detection:
802 print ''
803 if crashes:
804 print self._color.Color(self._color.RED,
805 'Crashes detected during testing:')
806 self._PrintDashLine(width)
807
808 for crash_name, crashed_tests in sorted(crashes.iteritems()):
809 print self._color.Color(self._color.RED, crash_name)
810 for crashed_test in crashed_tests:
811 print self._Indent(crashed_test)
812
813 self._PrintDashLine(width)
814 print ('Total unique crashes: ' +
815 self._color.Color(self._color.BOLD, str(len(crashes))))
816
817 # Sometimes the builders exit before these buffers are flushed.
818 sys.stderr.flush()
819 sys.stdout.flush()
820
821 def Run(self):
822 """Runs report generation."""
823 self._CollectAllResults()
Jacob Kopczynski2cefa1f2018-01-10 17:25:38 -0800824 if not self._options.just_status_code:
825 self._GenerateReportText()
Bogineni Kasaiahbdd6f182018-02-01 12:42:47 +0530826 if self._options.html:
827 print "\nLogging the data into test_report.html file."
828 try:
829 self.GenerateReportHTML()
830 except Exception as e:
831 print "Failed to generate HTML report %s" % str(e)
Christopher Wiley519624e2015-12-07 10:42:05 -0800832 for d in self._results:
833 if d['experimental'] and self._options.ignore_experimental_tests:
834 continue
835 if not d['status'] or (
836 self._options.crash_detection and d['crashes']):
837 sys.exit(1)
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800838
839
840def main():
Christopher Wiley519624e2015-12-07 10:42:05 -0800841 usage = 'Usage: %prog [options] result-directories...'
842 parser = optparse.OptionParser(usage=usage)
843 parser.add_option('--color', dest='color', action='store_true',
844 default=_STDOUT_IS_TTY,
845 help='Use color for text reports [default if TTY stdout]')
846 parser.add_option('--no-color', dest='color', action='store_false',
847 help='Don\'t use color for text reports')
848 parser.add_option('--no-crash-detection', dest='crash_detection',
849 action='store_false', default=True,
850 help='Don\'t report crashes or error out when detected')
851 parser.add_option('--csv', dest='csv', action='store_true',
852 help='Output test result in CSV format. '
853 'Implies --no-debug --no-crash-detection.')
Bogineni Kasaiahbdd6f182018-02-01 12:42:47 +0530854 parser.add_option('--html', dest='html', action='store_true',
855 help='To generate HTML File. '
856 'Implies --no-debug --no-crash-detection.')
857 parser.add_option('--html-report-dir', dest='html_report_dir',
858 action='store', default=None, help='Path to generate '
859 'html report')
Christopher Wiley519624e2015-12-07 10:42:05 -0800860 parser.add_option('--info', dest='info', action='store_true',
861 default=False,
862 help='Include info keyvals in the report')
863 parser.add_option('--escape-error', dest='escape_error',
864 action='store_true', default=False,
865 help='Escape error message text for tools.')
866 parser.add_option('--perf', dest='perf', action='store_true',
867 default=True,
868 help='Include perf keyvals in the report [default]')
869 parser.add_option('--attr', dest='attr', action='store_true',
870 default=False,
871 help='Include attr keyvals in the report')
872 parser.add_option('--no-perf', dest='perf', action='store_false',
873 help='Don\'t include perf keyvals in the report')
874 parser.add_option('--sort-chron', dest='sort_chron', action='store_true',
875 default=False,
876 help='Sort results by datetime instead of by test name.')
877 parser.add_option('--no-debug', dest='print_debug', action='store_false',
878 default=True,
879 help='Don\'t print out logs when tests fail.')
880 parser.add_option('--whitelist_chrome_crashes',
881 dest='whitelist_chrome_crashes',
882 action='store_true', default=False,
883 help='Treat Chrome crashes as non-fatal.')
884 parser.add_option('--ignore_experimental_tests',
885 dest='ignore_experimental_tests',
886 action='store_true', default=False,
887 help='If set, experimental test results will not '
888 'influence the exit code.')
Jacob Kopczynski2cefa1f2018-01-10 17:25:38 -0800889 parser.add_option('--just_status_code',
890 dest='just_status_code',
891 action='store_true', default=False,
892 help='Skip generating a report, just return status code.')
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800893
Christopher Wiley519624e2015-12-07 10:42:05 -0800894 (options, args) = parser.parse_args()
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800895
Christopher Wiley519624e2015-12-07 10:42:05 -0800896 if not args:
897 parser.print_help()
898 Die('no result directories provided')
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800899
Christopher Wiley519624e2015-12-07 10:42:05 -0800900 if options.csv and (options.print_debug or options.crash_detection):
901 Warning('Forcing --no-debug --no-crash-detection')
902 options.print_debug = False
903 options.crash_detection = False
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800904
Jacob Kopczynski2cefa1f2018-01-10 17:25:38 -0800905 report_options = ['color', 'csv', 'info', 'escape_error', 'perf', 'attr',
Bogineni Kasaiahbdd6f182018-02-01 12:42:47 +0530906 'sort_chron', 'print_debug', 'html', 'html_report_dir']
Jacob Kopczynski2cefa1f2018-01-10 17:25:38 -0800907 if options.just_status_code and any(
908 getattr(options, opt) for opt in report_options):
909 Warning('Passed --just_status_code and incompatible options %s' %
910 ' '.join(opt for opt in report_options if getattr(options,opt)))
911
Christopher Wiley519624e2015-12-07 10:42:05 -0800912 generator = ReportGenerator(options, args)
913 generator.Run()
Christopher Wiley8e8b67f2015-12-07 10:13:04 -0800914
915
916if __name__ == '__main__':
Christopher Wiley519624e2015-12-07 10:42:05 -0800917 main()