1 # Copyright 2014 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
6 # Most of this file was ported over from Blink's
7 # Tools/Scripts/webkitpy/layout_tests/layout_package/json_results_generator.py
8 # Tools/Scripts/webkitpy/common/net/file_uploader.py
18 _log
= logging
.getLogger(__name__
)
20 _JSON_PREFIX
= 'ADD_RESULTS('
24 def HasJSONWrapper(string
):
25 return string
.startswith(_JSON_PREFIX
) and string
.endswith(_JSON_SUFFIX
)
28 def StripJSONWrapper(json_content
):
29 # FIXME: Kill this code once the server returns json instead of jsonp.
30 if HasJSONWrapper(json_content
):
31 return json_content
[len(_JSON_PREFIX
):len(json_content
) - len(_JSON_SUFFIX
)]
35 def WriteJSON(json_object
, file_path
, callback
=None):
36 # Specify separators in order to get compact encoding.
37 json_string
= json
.dumps(json_object
, separators
=(',', ':'))
39 json_string
= callback
+ '(' + json_string
+ ');'
40 with
open(file_path
, 'w') as fp
:
44 def ConvertTrieToFlatPaths(trie
, prefix
=None):
45 """Flattens the trie of paths, prepending a prefix to each."""
47 for name
, data
in trie
.iteritems():
49 name
= prefix
+ '/' + name
51 if len(data
) and not 'results' in data
:
52 result
.update(ConvertTrieToFlatPaths(data
, name
))
59 def AddPathToTrie(path
, value
, trie
):
60 """Inserts a single path and value into a directory trie structure."""
65 directory
, _
, rest
= path
.partition('/')
66 if not directory
in trie
:
68 AddPathToTrie(rest
, value
, trie
[directory
])
71 def TestTimingsTrie(individual_test_timings
):
72 """Breaks a test name into dicts by directory
75 foo/bar/baz1.html: 3ms
86 for test_result
in individual_test_timings
:
87 test
= test_result
.test_name
89 AddPathToTrie(test
, int(1000 * test_result
.test_run_time
), trie
)
94 class TestResult(object):
95 """A simple class that represents a single test result."""
97 # Test modifier constants.
98 (NONE
, FAILS
, FLAKY
, DISABLED
) = range(4)
100 def __init__(self
, test
, failed
=False, elapsed_time
=0):
101 self
.test_name
= test
103 self
.test_run_time
= elapsed_time
107 test_name
= test
.split('.')[1]
109 _log
.warn('Invalid test name: %s.', test
)
111 if test_name
.startswith('FAILS_'):
112 self
.modifier
= self
.FAILS
113 elif test_name
.startswith('FLAKY_'):
114 self
.modifier
= self
.FLAKY
115 elif test_name
.startswith('DISABLED_'):
116 self
.modifier
= self
.DISABLED
118 self
.modifier
= self
.NONE
121 return self
.failed
or self
.modifier
== self
.DISABLED
124 class JSONResultsGeneratorBase(object):
125 """A JSON results generator for generic tests."""
127 MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG
= 750
128 # Min time (seconds) that will be added to the JSON.
131 # Note that in non-chromium tests those chars are used to indicate
132 # test modifiers (FAILS, FLAKY, etc) but not actual test results.
139 MODIFIER_TO_CHAR
= {TestResult
.NONE
: PASS_RESULT
,
140 TestResult
.DISABLED
: SKIP_RESULT
,
141 TestResult
.FAILS
: FAIL_RESULT
,
142 TestResult
.FLAKY
: FLAKY_RESULT
}
145 VERSION_KEY
= 'version'
148 BUILD_NUMBERS
= 'buildNumbers'
149 TIME
= 'secondsSinceEpoch'
152 FIXABLE_COUNT
= 'fixableCount'
153 FIXABLE
= 'fixableCounts'
154 ALL_FIXABLE_COUNT
= 'allFixableCount'
156 RESULTS_FILENAME
= 'results.json'
157 TIMES_MS_FILENAME
= 'times_ms.json'
158 INCREMENTAL_RESULTS_FILENAME
= 'incremental_results.json'
160 # line too long pylint: disable=line-too-long
161 URL_FOR_TEST_LIST_JSON
= (
162 'http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s&master=%s')
163 # pylint: enable=line-too-long
165 def __init__(self
, builder_name
, build_name
, build_number
,
166 results_file_base_path
, builder_base_url
,
167 test_results_map
, svn_repositories
=None,
168 test_results_server
=None,
171 """Modifies the results.json file. Grabs it off the archive directory
172 if it is not found locally.
175 builder_name: the builder name (e.g. Webkit).
176 build_name: the build name (e.g. webkit-rel).
177 build_number: the build number.
178 results_file_base_path: Absolute path to the directory containing the
180 builder_base_url: the URL where we have the archived test results.
181 If this is None no archived results will be retrieved.
182 test_results_map: A dictionary that maps test_name to TestResult.
183 svn_repositories: A (json_field_name, svn_path) pair for SVN
184 repositories that tests rely on. The SVN revision will be
185 included in the JSON with the given json_field_name.
186 test_results_server: server that hosts test results json.
187 test_type: test type string (e.g. 'layout-tests').
188 master_name: the name of the buildbot master.
190 self
._builder
_name
= builder_name
191 self
._build
_name
= build_name
192 self
._build
_number
= build_number
193 self
._builder
_base
_url
= builder_base_url
194 self
._results
_directory
= results_file_base_path
196 self
._test
_results
_map
= test_results_map
197 self
._test
_results
= test_results_map
.values()
199 self
._svn
_repositories
= svn_repositories
200 if not self
._svn
_repositories
:
201 self
._svn
_repositories
= {}
203 self
._test
_results
_server
= test_results_server
204 self
._test
_type
= test_type
205 self
._master
_name
= master_name
207 self
._archived
_results
= None
209 def GenerateJSONOutput(self
):
210 json_object
= self
.GetJSON()
214 self
._results
_directory
,
215 self
.INCREMENTAL_RESULTS_FILENAME
))
216 WriteJSON(json_object
, file_path
)
218 def GenerateTimesMSFile(self
):
219 times
= TestTimingsTrie(self
._test
_results
_map
.values())
220 file_path
= os
.path
.join(self
._results
_directory
, self
.TIMES_MS_FILENAME
)
221 WriteJSON(times
, file_path
)
224 """Gets the results for the results.json file."""
228 results_json
, error
= self
._GetArchivedJSONResults
()
230 # If there was an error don't write a results.json
231 # file at all as it would lose all the information on the
233 _log
.error('Archive directory is inaccessible. Not '
234 'modifying or clobbering the results.json '
235 'file: ' + str(error
))
238 builder_name
= self
._builder
_name
239 if results_json
and builder_name
not in results_json
:
240 _log
.debug('Builder name (%s) is not in the results.json file.',
243 self
._ConvertJSONToCurrentVersion
(results_json
)
245 if builder_name
not in results_json
:
246 results_json
[builder_name
] = (
247 self
._CreateResultsForBuilderJSON
())
249 results_for_builder
= results_json
[builder_name
]
252 self
._InsertGenericMetaData
(results_for_builder
)
254 self
._InsertFailureSummaries
(results_for_builder
)
256 # Update the all failing tests with result type and time.
257 tests
= results_for_builder
[self
.TESTS
]
258 all_failing_tests
= self
._GetFailedTestNames
()
259 all_failing_tests
.update(ConvertTrieToFlatPaths(tests
))
261 for test
in all_failing_tests
:
262 self
._InsertTestTimeAndResult
(test
, tests
)
266 def SetArchivedResults(self
, archived_results
):
267 self
._archived
_results
= archived_results
269 def UploadJSONFiles(self
, json_files
):
270 """Uploads the given json_files to the test_results_server (if the
271 test_results_server is given)."""
272 if not self
._test
_results
_server
:
275 if not self
._master
_name
:
277 '--test-results-server was set, but --master-name was not. Not '
278 'uploading JSON files.')
281 _log
.info('Uploading JSON files for builder: %s', self
._builder
_name
)
282 attrs
= [('builder', self
._builder
_name
),
283 ('testtype', self
._test
_type
),
284 ('master', self
._master
_name
)]
286 files
= [(json_file
, os
.path
.join(self
._results
_directory
, json_file
))
287 for json_file
in json_files
]
289 url
= 'http://%s/testfile/upload' % self
._test
_results
_server
290 # Set uploading timeout in case appengine server is having problems.
291 # 120 seconds are more than enough to upload test results.
292 uploader
= _FileUploader(url
, 120)
294 response
= uploader
.UploadAsMultipartFormData(files
, attrs
)
296 if response
.code
== 200:
297 _log
.info('JSON uploaded.')
300 "JSON upload failed, %d: '%s'", response
.code
, response
.read())
302 _log
.error('JSON upload failed; no response returned')
303 except Exception, err
: # pylint: disable=broad-except
304 _log
.error('Upload failed: %s', err
)
307 def _GetTestTiming(self
, test_name
):
308 """Returns test timing data (elapsed time) in second
309 for the given test_name."""
310 if test_name
in self
._test
_results
_map
:
311 # Floor for now to get time in seconds.
312 return int(self
._test
_results
_map
[test_name
].test_run_time
)
315 def _GetFailedTestNames(self
):
316 """Returns a set of failed test names."""
317 return set([r
.test_name
for r
in self
._test
_results
if r
.failed
])
319 def _GetModifierChar(self
, test_name
):
320 """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
321 PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier
322 for the given test_name.
324 if test_name
not in self
._test
_results
_map
:
325 return self
.__class
__.NO_DATA_RESULT
327 test_result
= self
._test
_results
_map
[test_name
]
328 if test_result
.modifier
in self
.MODIFIER_TO_CHAR
.keys():
329 return self
.MODIFIER_TO_CHAR
[test_result
.modifier
]
331 return self
.__class
__.PASS_RESULT
333 def _get_result_char(self
, test_name
):
334 """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
335 PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result
336 for the given test_name.
338 if test_name
not in self
._test
_results
_map
:
339 return self
.__class
__.NO_DATA_RESULT
341 test_result
= self
._test
_results
_map
[test_name
]
342 if test_result
.modifier
== TestResult
.DISABLED
:
343 return self
.__class
__.SKIP_RESULT
345 if test_result
.failed
:
346 return self
.__class
__.FAIL_RESULT
348 return self
.__class
__.PASS_RESULT
350 def _GetSVNRevision(self
, in_directory
):
351 """Returns the svn revision for the given directory.
354 in_directory: The directory where svn is to be run.
356 # This is overridden in flakiness_dashboard_results_uploader.py.
357 raise NotImplementedError()
359 def _GetArchivedJSONResults(self
):
360 """Download JSON file that only contains test
361 name list from test-results server. This is for generating incremental
362 JSON so the file generated has info for tests that failed before but
363 pass or are skipped from current run.
365 Returns (archived_results, error) tuple where error is None if results
366 were successfully read.
372 if not self
._test
_results
_server
:
375 results_file_url
= (self
.URL_FOR_TEST_LIST_JSON
%
376 (urllib2
.quote(self
._test
_results
_server
),
377 urllib2
.quote(self
._builder
_name
),
378 self
.RESULTS_FILENAME
,
379 urllib2
.quote(self
._test
_type
),
380 urllib2
.quote(self
._master
_name
)))
383 # FIXME: We should talk to the network via a Host object.
384 results_file
= urllib2
.urlopen(results_file_url
)
385 old_results
= results_file
.read()
386 except urllib2
.HTTPError
, http_error
:
387 # A non-4xx status code means the bot is hosed for some reason
388 # and we can't grab the results.json file off of it.
389 if http_error
.code
< 400 and http_error
.code
>= 500:
391 except urllib2
.URLError
, url_error
:
395 # Strip the prefix and suffix so we can get the actual JSON object.
396 old_results
= StripJSONWrapper(old_results
)
399 results_json
= json
.loads(old_results
)
400 except Exception: # pylint: disable=broad-except
401 _log
.debug('results.json was not valid JSON. Clobbering.')
402 # The JSON file is not valid JSON. Just clobber the results.
405 _log
.debug('Old JSON results do not exist. Starting fresh.')
408 return results_json
, error
410 def _InsertFailureSummaries(self
, results_for_builder
):
411 """Inserts aggregate pass/failure statistics into the JSON.
412 This method reads self._test_results and generates
413 FIXABLE, FIXABLE_COUNT and ALL_FIXABLE_COUNT entries.
416 results_for_builder: Dictionary containing the test results for a
419 # Insert the number of tests that failed or skipped.
420 fixable_count
= len([r
for r
in self
._test
_results
if r
.Fixable()])
421 self
._InsertItemIntoRawList
(results_for_builder
,
422 fixable_count
, self
.FIXABLE_COUNT
)
424 # Create a test modifiers (FAILS, FLAKY etc) summary dictionary.
426 for test_name
in self
._test
_results
_map
.iterkeys():
427 result_char
= self
._GetModifierChar
(test_name
)
428 entry
[result_char
] = entry
.get(result_char
, 0) + 1
430 # Insert the pass/skip/failure summary dictionary.
431 self
._InsertItemIntoRawList
(results_for_builder
, entry
,
434 # Insert the number of all the tests that are supposed to pass.
435 all_test_count
= len(self
._test
_results
)
436 self
._InsertItemIntoRawList
(results_for_builder
,
437 all_test_count
, self
.ALL_FIXABLE_COUNT
)
439 def _InsertItemIntoRawList(self
, results_for_builder
, item
, key
):
440 """Inserts the item into the list with the given key in the results for
441 this builder. Creates the list if no such list exists.
444 results_for_builder: Dictionary containing the test results for a
446 item: Number or string to insert into the list.
447 key: Key in results_for_builder for the list to insert into.
449 if key
in results_for_builder
:
450 raw_list
= results_for_builder
[key
]
454 raw_list
.insert(0, item
)
455 raw_list
= raw_list
[:self
.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG
]
456 results_for_builder
[key
] = raw_list
458 def _InsertItemRunLengthEncoded(self
, item
, encoded_results
):
459 """Inserts the item into the run-length encoded results.
462 item: String or number to insert.
463 encoded_results: run-length encoded results. An array of arrays, e.g.
464 [[3,'A'],[1,'Q']] encodes AAAQ.
466 if len(encoded_results
) and item
== encoded_results
[0][1]:
467 num_results
= encoded_results
[0][0]
468 if num_results
<= self
.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG
:
469 encoded_results
[0][0] = num_results
+ 1
471 # Use a list instead of a class for the run-length encoding since
472 # we want the serialized form to be concise.
473 encoded_results
.insert(0, [1, item
])
475 def _InsertGenericMetaData(self
, results_for_builder
):
476 """ Inserts generic metadata (such as version number, current time etc)
480 results_for_builder: Dictionary containing the test results for
483 self
._InsertItemIntoRawList
(results_for_builder
,
484 self
._build
_number
, self
.BUILD_NUMBERS
)
486 # Include SVN revisions for the given repositories.
487 for (name
, path
) in self
._svn
_repositories
:
488 # Note: for JSON file's backward-compatibility we use 'chrome' rather
489 # than 'chromium' here.
490 lowercase_name
= name
.lower()
491 if lowercase_name
== 'chromium':
492 lowercase_name
= 'chrome'
493 self
._InsertItemIntoRawList
(results_for_builder
,
494 self
._GetSVNRevision
(path
),
495 lowercase_name
+ 'Revision')
497 self
._InsertItemIntoRawList
(results_for_builder
,
501 def _InsertTestTimeAndResult(self
, test_name
, tests
):
502 """ Insert a test item with its results to the given tests dictionary.
505 tests: Dictionary containing test result entries.
508 result
= self
._get
_result
_char
(test_name
)
509 test_time
= self
._GetTestTiming
(test_name
)
512 for segment
in test_name
.split('/'):
513 if segment
not in this_test
:
514 this_test
[segment
] = {}
515 this_test
= this_test
[segment
]
517 if not len(this_test
):
518 self
._PopulateResultsAndTimesJSON
(this_test
)
520 if self
.RESULTS
in this_test
:
521 self
._InsertItemRunLengthEncoded
(result
, this_test
[self
.RESULTS
])
523 this_test
[self
.RESULTS
] = [[1, result
]]
525 if self
.TIMES
in this_test
:
526 self
._InsertItemRunLengthEncoded
(test_time
, this_test
[self
.TIMES
])
528 this_test
[self
.TIMES
] = [[1, test_time
]]
530 def _ConvertJSONToCurrentVersion(self
, results_json
):
531 """If the JSON does not match the current version, converts it to the
532 current version and adds in the new version number.
534 if self
.VERSION_KEY
in results_json
:
535 archive_version
= results_json
[self
.VERSION_KEY
]
536 if archive_version
== self
.VERSION
:
542 if archive_version
== 3:
543 for results
in results_json
.values():
544 self
._ConvertTestsToTrie
(results
)
546 results_json
[self
.VERSION_KEY
] = self
.VERSION
548 def _ConvertTestsToTrie(self
, results
):
549 if not self
.TESTS
in results
:
552 test_results
= results
[self
.TESTS
]
553 test_results_trie
= {}
554 for test
in test_results
.iterkeys():
555 single_test_result
= test_results
[test
]
556 AddPathToTrie(test
, single_test_result
, test_results_trie
)
558 results
[self
.TESTS
] = test_results_trie
560 def _PopulateResultsAndTimesJSON(self
, results_and_times
):
561 results_and_times
[self
.RESULTS
] = []
562 results_and_times
[self
.TIMES
] = []
563 return results_and_times
565 def _CreateResultsForBuilderJSON(self
):
566 results_for_builder
= {}
567 results_for_builder
[self
.TESTS
] = {}
568 return results_for_builder
570 def _RemoveItemsOverMaxNumberOfBuilds(self
, encoded_list
):
571 """Removes items from the run-length encoded list after the final
572 item that exceeds the max number of builds to track.
575 encoded_results: run-length encoded results. An array of arrays, e.g.
576 [[3,'A'],[1,'Q']] encodes AAAQ.
580 for result
in encoded_list
:
581 num_builds
= num_builds
+ result
[0]
583 if num_builds
> self
.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG
:
584 return encoded_list
[:index
]
587 def _NormalizeResultsJSON(self
, test
, test_name
, tests
):
588 """ Prune tests where all runs pass or tests that no longer exist and
589 truncate all results to maxNumberOfBuilds.
592 test: ResultsAndTimes object for this test.
593 test_name: Name of the test.
594 tests: The JSON object with all the test results for this builder.
596 test
[self
.RESULTS
] = self
._RemoveItemsOverMaxNumberOfBuilds
(
598 test
[self
.TIMES
] = self
._RemoveItemsOverMaxNumberOfBuilds
(
601 is_all_pass
= self
._IsResultsAllOfType
(test
[self
.RESULTS
],
603 is_all_no_data
= self
._IsResultsAllOfType
(test
[self
.RESULTS
],
605 max_time
= max([test_time
[1] for test_time
in test
[self
.TIMES
]])
607 # Remove all passes/no-data from the results to reduce noise and
608 # filesize. If a test passes every run, but takes > MIN_TIME to run,
609 # don't throw away the data.
610 if is_all_no_data
or (is_all_pass
and max_time
<= self
.MIN_TIME
):
613 # method could be a function pylint: disable=R0201
614 def _IsResultsAllOfType(self
, results
, result_type
):
615 """Returns whether all the results are of the given type
616 (e.g. all passes)."""
617 return len(results
) == 1 and results
[0][1] == result_type
620 class _FileUploader(object):
622 def __init__(self
, url
, timeout_seconds
):
624 self
._timeout
_seconds
= timeout_seconds
626 def UploadAsMultipartFormData(self
, files
, attrs
):
628 for filename
, path
in files
:
629 with
file(path
, 'rb') as fp
:
630 file_objs
.append(('file', filename
, fp
.read()))
632 # FIXME: We should use the same variable names for the formal and actual
634 content_type
, data
= _EncodeMultipartFormData(attrs
, file_objs
)
635 return self
._UploadData
(content_type
, data
)
637 def _UploadData(self
, content_type
, data
):
639 end
= start
+ self
._timeout
_seconds
640 while time
.time() < end
:
642 request
= urllib2
.Request(self
._url
, data
,
643 {'Content-Type': content_type
})
644 return urllib2
.urlopen(request
)
645 except urllib2
.HTTPError
as e
:
646 _log
.warn("Received HTTP status %s loading \"%s\". "
647 'Retrying in 10 seconds...', e
.code
, e
.filename
)
651 def _GetMIMEType(filename
):
652 return mimetypes
.guess_type(filename
)[0] or 'application/octet-stream'
655 # FIXME: Rather than taking tuples, this function should take more
657 def _EncodeMultipartFormData(fields
, files
):
658 """Encode form fields for multipart/form-data.
661 fields: A sequence of (name, value) elements for regular form fields.
662 files: A sequence of (name, filename, value) elements for data to be
665 (content_type, body) ready for httplib.HTTP instance.
668 http://code.google.com/p/rietveld/source/browse/trunk/upload.py
670 BOUNDARY
= '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
674 for key
, value
in fields
:
675 lines
.append('--' + BOUNDARY
)
676 lines
.append('Content-Disposition: form-data; name="%s"' % key
)
678 if isinstance(value
, unicode):
679 value
= value
.encode('utf-8')
682 for key
, filename
, value
in files
:
683 lines
.append('--' + BOUNDARY
)
684 lines
.append('Content-Disposition: form-data; name="%s"; '
685 'filename="%s"' % (key
, filename
))
686 lines
.append('Content-Type: %s' % _GetMIMEType(filename
))
688 if isinstance(value
, unicode):
689 value
= value
.encode('utf-8')
692 lines
.append('--' + BOUNDARY
+ '--')
694 body
= CRLF
.join(lines
)
695 content_type
= 'multipart/form-data; boundary=%s' % BOUNDARY
696 return content_type
, body