• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# Copyright 2016 The Chromium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import copy
7import functools
8import json
9import sys
10
11# These fields must appear in the test result output
12REQUIRED = {
13    'interrupted',
14    'num_failures_by_type',
15    'seconds_since_epoch',
16    'tests',
17}
18
19# These fields are optional, but must have the same value on all shards
20OPTIONAL_MATCHING = ('builder_name', 'build_number', 'chromium_revision',
21                     'has_pretty_patch', 'has_wdiff', 'path_delimiter',
22                     'pixel_tests_enabled', 'random_order_seed')
23
24# The last shard's value for these fields will show up in the merged results
25OPTIONAL_IGNORED = ('layout_tests_dir', 'metadata')
26
27# These fields are optional and will be summed together
28OPTIONAL_COUNTS = (
29    'fixable',
30    'num_flaky',
31    'num_passes',
32    'num_regressions',
33    'skipped',
34    'skips',
35)
36
37
38class MergeException(Exception):
39  pass
40
41
42def merge_test_results(shard_results_list):
43  """ Merge list of results.
44
45  Args:
46    shard_results_list: list of results to merge. All the results must have the
47      same format. Supported format are simplified JSON format & Chromium JSON
48      test results format version 3 (see
49      https://www.chromium.org/developers/the-json-test-results-format)
50
51  Returns:
52    a dictionary that represent the merged results. Its format follow the same
53    format of all results in |shard_results_list|.
54  """
55  shard_results_list = [x for x in shard_results_list if x]
56  if not shard_results_list:
57    return {}
58
59  if 'seconds_since_epoch' in shard_results_list[0]:
60    return _merge_json_test_result_format(shard_results_list)
61
62  return _merge_simplified_json_format(shard_results_list)
63
64
65def _merge_simplified_json_format(shard_results_list):
66  # This code is specialized to the "simplified" JSON format that used to be
67  # the standard for recipes.
68
69  # These are the only keys we pay attention to in the output JSON.
70  merged_results = {
71      'successes': [],
72      'failures': [],
73      'valid': True,
74  }
75
76  for result_json in shard_results_list:
77    successes = result_json.get('successes', [])
78    failures = result_json.get('failures', [])
79    valid = result_json.get('valid', True)
80
81    if (not isinstance(successes, list) or not isinstance(failures, list)
82        or not isinstance(valid, bool)):
83      raise MergeException('Unexpected value type in %s' %
84                           result_json)  # pragma: no cover
85
86    merged_results['successes'].extend(successes)
87    merged_results['failures'].extend(failures)
88    merged_results['valid'] = merged_results['valid'] and valid
89  return merged_results
90
91
92def _merge_json_test_result_format(shard_results_list):
93  # This code is specialized to the Chromium JSON test results format version 3:
94  # https://www.chromium.org/developers/the-json-test-results-format
95
96  # These are required fields for the JSON test result format version 3.
97  merged_results = {
98      'tests': {},
99      'interrupted': False,
100      'version': 3,
101      'seconds_since_epoch': float('inf'),
102      'num_failures_by_type': {}
103  }
104
105  # To make sure that we don't mutate existing shard_results_list.
106  shard_results_list = copy.deepcopy(shard_results_list)
107  for result_json in shard_results_list:
108    # TODO(tansell): check whether this deepcopy is actually necessary.
109    result_json = copy.deepcopy(result_json)
110
111    # Check the version first
112    version = result_json.pop('version', -1)
113    if version != 3:
114      raise MergeException(  # pragma: no cover (covered by
115          # results_merger_unittest).
116          'Unsupported version %s. Only version 3 is supported' % version)
117
118    # Check the results for each shard have the required keys
119    missing = REQUIRED - set(result_json)
120    if missing:
121      raise MergeException(  # pragma: no cover (covered by
122          # results_merger_unittest).
123          'Invalid json test results (missing %s)' % missing)
124
125    # Curry merge_values for this result_json.
126    merge = functools.partial(merge_value, result_json, merged_results)
127
128    # Traverse the result_json's test trie & merged_results's test tries in
129    # DFS order & add the n to merged['tests'].
130    merge('tests', merge_tries)
131
132    # If any were interrupted, we are interrupted.
133    merge('interrupted', lambda x, y: x | y)
134
135    # Use the earliest seconds_since_epoch value
136    merge('seconds_since_epoch', min)
137
138    # Sum the number of failure types
139    merge('num_failures_by_type', sum_dicts)
140
141    # Optional values must match
142    for optional_key in OPTIONAL_MATCHING:
143      if optional_key not in result_json:
144        continue
145
146      if optional_key not in merged_results:
147        # Set this value to None, then blindly copy over it.
148        merged_results[optional_key] = None
149        merge(optional_key, lambda src, dst: src)
150      else:
151        merge(optional_key, ensure_match)
152
153    # Optional values ignored
154    for optional_key in OPTIONAL_IGNORED:
155      if optional_key in result_json:
156        merged_results[optional_key] = result_json.pop(
157            # pragma: no cover (covered by
158            # results_merger_unittest).
159            optional_key)
160
161    # Sum optional value counts
162    for count_key in OPTIONAL_COUNTS:
163      if count_key in result_json:  # pragma: no cover
164        # TODO(mcgreevy): add coverage.
165        merged_results.setdefault(count_key, 0)
166        merge(count_key, lambda a, b: a + b)
167
168    if result_json:
169      raise MergeException(  # pragma: no cover (covered by
170          # results_merger_unittest).
171          'Unmergable values %s' % list(result_json.keys()))
172
173  return merged_results
174
175
176def merge_tries(source, dest):
177  """ Merges test tries.
178
179  This is intended for use as a merge_func parameter to merge_value.
180
181  Args:
182      source: A result json test trie.
183      dest: A json test trie merge destination.
184  """
185  # merge_tries merges source into dest by performing a lock-step depth-first
186  # traversal of dest and source.
187  # pending_nodes contains a list of all sub-tries which have been reached but
188  # need further merging.
189  # Each element consists of a trie prefix, and a sub-trie from each of dest
190  # and source which is reached via that prefix.
191  pending_nodes = [('', dest, source)]
192  while pending_nodes:
193    prefix, dest_node, curr_node = pending_nodes.pop()
194    for k, v in curr_node.items():
195      if k in dest_node:
196        if not isinstance(v, dict):
197          raise MergeException(
198              '%s:%s: %r not mergable, curr_node: %r\ndest_node: %r' %
199              (prefix, k, v, curr_node, dest_node))
200        pending_nodes.append(('%s:%s' % (prefix, k), dest_node[k], v))
201      else:
202        dest_node[k] = v
203  return dest
204
205
206def ensure_match(source, dest):
207  """ Returns source if it matches dest.
208
209  This is intended for use as a merge_func parameter to merge_value.
210
211  Raises:
212      MergeException if source != dest
213  """
214  if source != dest:
215    raise MergeException(  # pragma: no cover (covered by
216        # results_merger_unittest).
217        "Values don't match: %s, %s" % (source, dest))
218  return source
219
220
221def sum_dicts(source, dest):
222  """ Adds values from source to corresponding values in dest.
223
224  This is intended for use as a merge_func parameter to merge_value.
225  """
226  for k, v in source.items():
227    dest.setdefault(k, 0)
228    dest[k] += v
229
230  return dest
231
232
233def merge_value(source, dest, key, merge_func):
234  """ Merges a value from source to dest.
235
236  The value is deleted from source.
237
238  Args:
239    source: A dictionary from which to pull a value, identified by key.
240    dest: The dictionary into to which the value is to be merged.
241    key: The key which identifies the value to be merged.
242    merge_func(src, dst): A function which merges its src into dst,
243        and returns the result. May modify dst. May raise a MergeException.
244
245  Raises:
246    MergeException if the values can not be merged.
247  """
248  try:
249    dest[key] = merge_func(source[key], dest[key])
250  except MergeException as e:
251    message = 'MergeFailure for %s\n%s' % (key, e.args[0])
252    e.args = (message, ) + e.args[1:]
253    raise
254  del source[key]
255
256
257def main(files):
258  if len(files) < 2:
259    sys.stderr.write('Not enough JSON files to merge.\n')
260    return 1
261  sys.stderr.write('Starting with %s\n' % files[0])
262  result = json.load(open(files[0]))
263  for f in files[1:]:
264    sys.stderr.write('Merging %s\n' % f)
265    result = merge_test_results([result, json.load(open(f))])
266  print(json.dumps(result))
267  return 0
268
269
270if __name__ == '__main__':
271  sys.exit(main(sys.argv[1:]))
272