| # Copyright 2022 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Process WPT results for upload to ResultDB.""" |
| |
| import argparse |
| import base64 |
| import json |
| import logging |
| import optparse |
| |
| import mozinfo |
| import six |
| |
| from blinkpy.common import path_finder |
| from blinkpy.common.html_diff import html_diff |
| from blinkpy.common.system.log_utils import configure_logging |
| from blinkpy.common.unified_diff import unified_diff |
| from blinkpy.web_tests.models import test_failures |
| from blinkpy.web_tests.models.typ_types import ( |
| Artifacts, |
| Result, |
| ResultSinkReporter, |
| ) |
| |
| path_finder.bootstrap_wpt_imports() |
| from wptrunner import manifestexpected, wptmanifest |
| |
| _log = logging.getLogger(__name__) |
| |
| |
| def _html_diff(expected_text, actual_text, encoding='utf-8'): |
| """A Unicode-safe wrapper around `html_diff`. |
| |
| The `html_diff` library requires `str` arguments and returns a `str` value. |
| This function accepts and returns Unicode instead. |
| """ |
| # The coercions will have no effect on Python 3, where `str` is already |
| # Unicode. In Python 2, where `str` is binary, these coercions are |
| # encode/decode calls. |
| diff_content = html_diff( |
| six.ensure_str(expected_text, encoding), |
| six.ensure_str(actual_text, encoding), |
| ) |
| return six.ensure_text(diff_content, encoding) |
| |
| |
| def _remove_query_params(test_name): |
| index = test_name.rfind('?') |
| return test_name if index == -1 else test_name[:index] |
| |
| |
| class WPTResultsProcessor(object): |
| def __init__(self, |
| host, |
| port=None, |
| web_tests_dir='', |
| artifacts_dir='', |
| results_dir='', |
| sink=None): |
| self.host = host |
| self.fs = self.host.filesystem |
| self.port = port or self.host.port_factory.get() |
| self.web_tests_dir = web_tests_dir |
| self.artifacts_dir = artifacts_dir |
| self.results_dir = results_dir |
| self.sink = sink or ResultSinkReporter() |
| self.wpt_manifest = self.port.wpt_manifest('external/wpt') |
| self.path_finder = path_finder.PathFinder(self.fs) |
| # Provide placeholder properties until the wptreport is processed. |
| self.run_info = dict(mozinfo.info) |
| |
| @classmethod |
| def from_options(cls, host, options): |
| logging_level = logging.DEBUG if options.verbose else logging.INFO |
| configure_logging(logging_level=logging_level, include_time=True) |
| |
| port_options = optparse.Values() |
| # The factory will read the configuration ("Debug" or "Release") |
| # automatically from //src/<target>. |
| port_options.ensure_value('configuration', None) |
| port_options.ensure_value('target', options.target) |
| port = host.port_factory.get(options=port_options) |
| |
| results_dir = host.filesystem.dirname(options.wpt_results) |
| return WPTResultsProcessor(host, port, options.web_tests_dir, |
| options.artifacts_dir, results_dir) |
| |
| def main(self, options): |
| self._recreate_artifacts_dir() |
| if options.wpt_report: |
| self.process_wpt_report(options.wpt_report) |
| else: |
| _log.debug('No wpt report to process') |
| self.process_wpt_results(options.wpt_results) |
| self._copy_results_viewer() |
| |
| @classmethod |
| def parse_args(cls, argv=None): |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| '-v', |
| '--verbose', |
| action='store_true', |
| help='log extra details helpful for debugging', |
| ) |
| parser.add_argument( |
| '-t', |
| '--target', |
| default='Release', |
| help='target build subdirectory under //out', |
| ) |
| parser.add_argument( |
| '--web-tests-dir', |
| required=True, |
| help='path to the web tests root', |
| ) |
| parser.add_argument( |
| '--artifacts-dir', |
| required=True, |
| help='path to a directory to write artifacts to', |
| ) |
| parser.add_argument( |
| '--wpt-results', |
| required=True, |
| help=('path to the JSON test results file ' |
| '(created with `wpt run --log-chromium=...`)'), |
| ) |
| parser.add_argument( |
| '--wpt-report', |
| help=('path to the wptreport file ' |
| '(created with `wpt run --log-wptreport=...`)'), |
| ) |
| return parser.parse_args(argv) |
| |
| def _recreate_artifacts_dir(self): |
| if self.fs.exists(self.artifacts_dir): |
| self.fs.rmtree(self.artifacts_dir) |
| self.fs.maybe_make_directory(self.artifacts_dir) |
| _log.info('Recreated artifacts directory (%s)', self.artifacts_dir) |
| |
| def _copy_results_viewer(self): |
| source = self.path_finder.path_from_blink_tools( |
| 'blinkpy', 'web_tests', 'results.html') |
| destination = self.fs.join(self.artifacts_dir, 'results.html') |
| self.fs.copyfile(source, destination) |
| _log.info('Copied results viewer (%s -> %s)', source, destination) |
| |
| def process_wpt_results(self, |
| raw_results_path, |
| full_results_json=None, |
| full_results_jsonp=None, |
| failing_results_jsonp=None): |
| """Postprocess the results generated by wptrunner. |
| |
| Arguments: |
| raw_results_path (str): Path to a JSON results file, which contains |
| raw contents or points to artifacts that will be extracted into |
| their own files. These fields are removed from the test results |
| tree to avoid duplication. This method will overwrite the |
| original JSON file with the processed results. |
| full_results_json (str): Path to write processed JSON results to. |
| full_results_jsonp (str): Path to write processed JSONP results to. |
| failing_results_jsonp (str): Path to write failing JSONP results to. |
| |
| See Also: |
| https://chromium.googlesource.com/chromium/src/+/HEAD/docs/testing/json_test_results_format.md |
| """ |
| full_results_json = full_results_json or self.fs.join( |
| self.artifacts_dir, 'full_results.json') |
| full_results_jsonp = full_results_jsonp or self.fs.join( |
| self.artifacts_dir, 'full_results_jsonp.js') |
| # NOTE: Despite the name, this is actually a JSONP file. |
| # https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/tools/blinkpy/web_tests/controllers/manager.py;l=636;drc=3b93609b2498af0e9dc298f64e2b4f6204af68fa |
| failing_results_jsonp = failing_results_jsonp or self.fs.join( |
| self.artifacts_dir, 'failing_results.json') |
| |
| # results is modified in place throughout this function. |
| with self.fs.open_text_file_for_reading( |
| raw_results_path) as results_file: |
| results = json.load(results_file) |
| |
| # wptrunner test names exclude the "external/wpt" prefix. Here, we |
| # reintroduce the prefix to create valid paths relative to the web test |
| # root directory in the Chromium source tree. |
| tests = results['tests'] |
| prefix_components = self.path_finder.wpt_prefix().split(self.fs.sep) |
| for component in reversed(prefix_components): |
| if component: |
| tests = {component: tests} |
| results['tests'] = tests |
| metadata = results.get('metadata') or {} |
| test_names = self._extract_artifacts( |
| results['tests'], |
| delim=results['path_delimiter'], |
| # Unlike the "external/wpt" prefix, this prefix does not actually |
| # exist on disk and only affects how the results are reported. |
| test_name_prefix=metadata.get('test_name_prefix', '')) |
| _log.info('Extracted artifacts for %d tests', len(test_names)) |
| |
| results_serialized = json.dumps(results) |
| self.fs.write_text_file(full_results_json, results_serialized) |
| self.fs.copyfile(full_results_json, raw_results_path) |
| |
| # JSONP paddings need to be the same as: |
| # https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/tools/blinkpy/web_tests/controllers/manager.py;l=629;drc=3b93609b2498af0e9dc298f64e2b4f6204af68fa |
| with self.fs.open_text_file_for_writing(full_results_jsonp) as dest: |
| dest.write('ADD_FULL_RESULTS(') |
| dest.write(results_serialized) |
| dest.write(');') |
| |
| self._trim_to_regressions(results['tests']) |
| with self.fs.open_text_file_for_writing(failing_results_jsonp) as dest: |
| dest.write('ADD_RESULTS(') |
| json.dump(results, dest) |
| dest.write(');') |
| |
| def _extract_artifacts(self, |
| current_node, |
| current_path='', |
| delim='/', |
| test_name_prefix=''): |
| """Recursively extract artifacts from the test results trie. |
| |
| The JSON results represent tests as the leaves of a trie (nested |
| objects). The trie's structure corresponds to the WPT directory |
| structure on disk. This method will traverse the trie's nodes, writing |
| files to the artifacts directory at leaf nodes and uploading them to |
| ResultSink. |
| |
| Arguments: |
| current_node: The node in the trie to be processed. |
| current_path (str): The path constructed so far, which will become |
| a test name at a leaf node. An empty path represents the WPT |
| root directory. |
| delim (str): Delimiter between components in test names. In |
| practice, the value is the POSIX directory separator. |
| test_name_prefix (str): Test name prefix to prepend to the generated |
| path when uploading results. |
| |
| Returns: |
| list[str]: A list of test names found. |
| """ |
| if test_name_prefix and not test_name_prefix.endswith(delim): |
| test_name_prefix += delim |
| if 'actual' in current_node: |
| # Leaf node detected. |
| if 'artifacts' not in current_node: |
| return [] |
| artifacts = current_node['artifacts'] |
| artifacts.pop('wpt_actual_status', None) |
| artifacts.pop('wpt_subtest_failure', None) |
| test_passed = current_node['actual'] == 'PASS' |
| self._maybe_write_text_results(artifacts, current_path, |
| test_passed) |
| self._maybe_write_screenshots(artifacts, current_path) |
| self._maybe_write_logs(artifacts, current_path) |
| # Required by blinkpy/web_tests/results.html to show stderr. |
| if 'stderr' in artifacts: |
| current_node['has_stderr'] = True |
| self._add_result_to_sink(current_node, current_path, |
| test_name_prefix) |
| _log.debug('Extracted artifacts for %s: %s', current_path, |
| ', '.join(artifacts) if artifacts else '(none)') |
| return [current_path] |
| else: |
| test_names = [] |
| for component, child_node in current_node.items(): |
| if current_path: |
| child_path = current_path + delim + component |
| else: |
| # At the web test root, do not include a leading slash. |
| child_path = component |
| test_names.extend( |
| self._extract_artifacts(child_node, child_path, delim, |
| test_name_prefix)) |
| return test_names |
| |
| def _read_expected_metadata(self, test_name): |
| """Try to locate the expected output of this test, if it exists. |
| |
| The expected output of a test is checked in to the source tree beside |
| the test itself with a ".ini" extension. Not all tests have expected |
| output. This could be print-reftests (which are unsupported by the |
| blinkpy manifest) or ".any.js" tests (which appear in the output even |
| though they do not actually run). Instead, they have corresponding |
| tests like ".any.worker.html" that are covered here. |
| |
| Raises: |
| ValueError: If the expected metadata was unreadable or unparsable. |
| """ |
| # When looking into the WPT manifest, we omit "external/wpt" from the |
| # web test name, since that part of the path is only relevant in |
| # Chromium. |
| wpt_name = self.path_finder.strip_wpt_path(test_name) |
| # TODO(crbug.com/1299650): Support virtual tests and metadata fallback. |
| test_file_subpath = self.wpt_manifest.file_path_for_test_url(wpt_name) |
| if not test_file_subpath: |
| raise ValueError('test ID did not resolve to a file') |
| metadata_root = self.path_finder.wpt_tests_dir() |
| manifest = manifestexpected.get_manifest(metadata_root, |
| test_file_subpath, '/', |
| self.run_info) |
| if not manifest: |
| raise ValueError('unable to read ".ini" file from disk') |
| test_manifest = manifest.get_test('/' + wpt_name) |
| if not test_manifest: |
| raise ValueError('test ID does not exist') |
| return wptmanifest.serialize(test_manifest.node) |
| |
| def _maybe_write_text_results(self, artifacts, test_name, test_passed): |
| """Write actual, expected, and diff text outputs to disk, if possible. |
| |
| If either the actual or expected output is missing, this method cannot |
| produce diffs. |
| |
| Arguments: |
| artifacts (dict[str, Any]): Mapping from artifact names to their |
| contents. |
| test_name (str): Web test name (a path). |
| test_passed (bool): Whether the actual test status is a "PASS". If |
| the test passed, there are no artifacts to output. Note that if |
| a baseline file (*.ini) exists, an "actual" of "PASS" means that |
| the test matched the baseline, not that the test itself passed. |
| As such, we still correctly produce artifacts in the case where |
| someone fixes a baselined test. |
| """ |
| actual_metadata = artifacts.pop('wpt_actual_metadata', None) |
| if not actual_metadata or test_passed: |
| return |
| actual_text = '\n'.join(actual_metadata) |
| actual_subpath = self._write_artifact( |
| actual_text, |
| test_name, |
| test_failures.FILENAME_SUFFIX_ACTUAL, |
| ) |
| artifacts['actual_text'] = [actual_subpath] |
| |
| try: |
| expected_text = self._read_expected_metadata(test_name) |
| except (ValueError, KeyError, wptmanifest.parser.ParseError) as error: |
| _log.error('Unable to read metadata for %s: %s', test_name, error) |
| return |
| expected_subpath = self._write_artifact( |
| expected_text, |
| test_name, |
| test_failures.FILENAME_SUFFIX_EXPECTED, |
| ) |
| artifacts['expected_text'] = [expected_subpath] |
| |
| diff_content = unified_diff( |
| expected_text, |
| actual_text, |
| expected_subpath, |
| actual_subpath, |
| ) |
| diff_subpath = self._write_artifact( |
| diff_content, |
| test_name, |
| test_failures.FILENAME_SUFFIX_DIFF, |
| ) |
| artifacts['text_diff'] = [diff_subpath] |
| |
| html_diff_content = _html_diff(expected_text, actual_text) |
| html_diff_subpath = self._write_artifact( |
| html_diff_content, |
| test_name, |
| test_failures.FILENAME_SUFFIX_HTML_DIFF, |
| extension='.html', |
| ) |
| artifacts['pretty_text_diff'] = [html_diff_subpath] |
| |
| def _maybe_write_screenshots(self, artifacts, test_name): |
| """Write actual, expected, and diff screenshots to disk, if possible. |
| |
| The raw "screenshots" artifact is a list of strings, each of which has |
| the format "<url>:<base64-encoded PNG>". Each URL-PNG pair is a |
| screenshot of either the test result, or one of its refs. We can |
| identify which screenshot is for the test by comparing the URL to the |
| test name. |
| """ |
| # Remember the two images so we can diff them later. |
| actual_image_bytes = b'' |
| expected_image_bytes = b'' |
| |
| screenshots = artifacts.pop('screenshots', None) |
| if not screenshots: |
| return |
| for screenshot in screenshots: |
| url, printable_image = screenshot.rsplit(':', 1) |
| |
| # The URL produced by wptrunner will have a leading "/", which we |
| # trim away for easier comparison to the WPT name below. |
| if url.startswith('/'): |
| url = url[1:] |
| image_bytes = base64.b64decode(printable_image.strip()) |
| |
| # When comparing the test name to the image URL, we omit |
| # "external/wpt" from the test name, since that part of the path is |
| # only relevant in Chromium. |
| wpt_name = self.path_finder.strip_wpt_path(test_name) |
| screenshot_key = 'expected_image' |
| file_suffix = test_failures.FILENAME_SUFFIX_EXPECTED |
| if wpt_name == url: |
| screenshot_key = 'actual_image' |
| file_suffix = test_failures.FILENAME_SUFFIX_ACTUAL |
| actual_image_bytes = image_bytes |
| else: |
| expected_image_bytes = image_bytes |
| |
| screenshot_subpath = self._write_png_artifact( |
| image_bytes, |
| test_name, |
| file_suffix, |
| ) |
| artifacts[screenshot_key] = [screenshot_subpath] |
| |
| diff_bytes, _, error = self.port.diff_image(expected_image_bytes, |
| actual_image_bytes) |
| if error: |
| _log.error( |
| 'Error creating diff image for %s ' |
| '(error: %s, diff_bytes is None: %s)', test_name, error, |
| diff_bytes is None) |
| elif diff_bytes: |
| diff_subpath = self._write_png_artifact( |
| diff_bytes, |
| test_name, |
| test_failures.FILENAME_SUFFIX_DIFF, |
| ) |
| artifacts['image_diff'] = [diff_subpath] |
| |
| def _maybe_write_logs(self, artifacts, test_name): |
| """Write WPT logs to disk, if possible.""" |
| log = artifacts.pop('wpt_log', None) |
| if log: |
| log_subpath = self._write_artifact( |
| '\n'.join(log), |
| test_name, |
| test_failures.FILENAME_SUFFIX_STDERR, |
| ) |
| artifacts['stderr'] = [log_subpath] |
| |
| crash_log = artifacts.pop('wpt_crash_log', None) |
| if crash_log: |
| crash_log_subpath = self._write_artifact( |
| '\n'.join(crash_log), |
| test_name, |
| test_failures.FILENAME_SUFFIX_CRASH_LOG, |
| ) |
| artifacts['crash_log'] = [crash_log_subpath] |
| |
| def _write_artifact(self, |
| contents, |
| test_name, |
| suffix, |
| extension='.txt', |
| text=True): |
| """Write an artifact to disk. |
| |
| Arguments: |
| contents: The file contents of the artifact to write. |
| test_name (str): The name of the test that generated this artifact. |
| suffix (str): The suffix of the artifact to write. Usually |
| determined from the artifact name. |
| extension (str): Filename extension to use. Defaults to ".txt" for |
| text files. |
| text (bool): Whether to write the contents as text or binary. Make |
| sure to pass a compatible argument to `contents`. |
| |
| Returns: |
| str: The path to the artifact file that was written relative to the |
| top-level results directory. |
| """ |
| filename = self.port.output_filename(test_name, suffix, extension) |
| full_path = self.fs.join(self.artifacts_dir, filename) |
| parent_dir = self.fs.dirname(full_path) |
| if not self.fs.exists(parent_dir): |
| self.fs.maybe_make_directory(parent_dir) |
| if text: |
| write_file = self.fs.write_text_file |
| else: |
| write_file = self.fs.write_binary_file |
| write_file(full_path, contents) |
| return self.fs.relpath(full_path, self.results_dir) |
| |
| def _write_png_artifact(self, artifact, test_name, suffix): |
| return self._write_artifact( |
| artifact, |
| test_name, |
| suffix, |
| extension='.png', |
| text=False, |
| ) |
| |
| |
| def _add_result_to_sink(self, node, test_name, test_name_prefix=''): |
| """Add test results to the result sink.""" |
| actual_statuses = node['actual'].split() |
| flaky = len(set(actual_statuses)) > 1 |
| expected = set(node['expected'].split()) |
| durations = node.get('times') or [0] * len(actual_statuses) |
| |
| artifacts = Artifacts( |
| output_dir=self.results_dir, |
| host=self.sink.host, |
| artifacts_base_dir=self.fs.relpath(self.artifacts_dir, |
| self.results_dir), |
| ) |
| for name, paths in (node.get('artifacts') or {}).items(): |
| for path in paths: |
| artifacts.AddArtifact(name, path) |
| test_path = self.fs.join(self.web_tests_dir, |
| _remove_query_params(test_name)) |
| |
| for iteration, (actual, |
| duration) in enumerate(zip(actual_statuses, |
| durations)): |
| # Test timeouts are a special case of aborts. We must report "ABORT" |
| # to result sink for tests that timed out. |
| if actual == 'TIMEOUT': |
| actual = 'ABORT' |
| |
| result = Result( |
| name=test_name, |
| actual=actual, |
| started=self.host.time() - duration, |
| took=duration, |
| worker=0, |
| expected=expected, |
| unexpected=actual not in expected, |
| flaky=flaky, |
| # TODO(crbug/1314847): wptrunner merges output from all runs |
| # together. Until it outputs per-test-run artifacts instead, we |
| # just upload the artifacts on the first result. No need to |
| # upload the same artifacts multiple times. |
| artifacts=(artifacts.artifacts if iteration == 0 else {}), |
| ) |
| self.sink.report_individual_test_result( |
| test_name_prefix=test_name_prefix, |
| result=result, |
| artifact_output_dir=self.results_dir, |
| expectations=None, |
| test_file_location=test_path) |
| |
| def _trim_to_regressions(self, current_node): |
| """Recursively remove non-regressions from the test results trie. |
| |
| Returns: |
| bool: Whether to remove `current_node` from the trie. |
| """ |
| if 'actual' in current_node: |
| # Found a leaf. Delete it if it's not a regression (unexpected |
| # failure). |
| return not current_node.get('is_regression') |
| |
| # Not a leaf, recurse into the subtree. Note that we make a copy of the |
| # items since we delete from the node during the loop. |
| for component, child_node in list(current_node.items()): |
| if self._trim_to_regressions(child_node): |
| del current_node[component] |
| |
| # Delete the current node if empty. |
| return len(current_node) == 0 |
| |
| def _get_wpt_revision(self): |
| version_path = self.fs.join(self.web_tests_dir, 'external', 'Version') |
| target = 'Version:' |
| with self.fs.open_text_file_for_reading(version_path) as version_file: |
| for line in version_file: |
| if line.startswith(target): |
| rev = line[len(target):].strip() |
| return rev |
| return None |
| |
| def process_wpt_report(self, report_path): |
| """Process and upload a wpt report to result sink.""" |
| with self.fs.open_text_file_for_reading(report_path) as report_file: |
| report = json.load(report_file) |
| rev = self._get_wpt_revision() |
| # Update with upstream revision |
| if rev: |
| report['run_info']['revision'] = rev |
| report_filename = self.fs.basename(report_path) |
| artifact_path = self.fs.join(self.artifacts_dir, report_filename) |
| with self.fs.open_text_file_for_writing(artifact_path) as report_file: |
| json.dump(report, report_file) |
| |
| self.run_info.update(report['run_info']) |
| _log.info('Processed wpt report (%s -> %s)', report_path, |
| artifact_path) |
| artifact = { |
| report_filename: { |
| 'filePath': artifact_path, |
| }, |
| } |
| self.sink.report_invocation_level_artifacts(artifact) |
| return artifact_path |