| # Copyright (c) 2010 Google Inc. All rights reserved. |
| # |
| # Redistribution and use in source and binary forms, with or without |
| # modification, are permitted provided that the following conditions are |
| # met: |
| # |
| # * Redistributions of source code must retain the above copyright |
| # notice, this list of conditions and the following disclaimer. |
| # * Redistributions in binary form must reproduce the above |
| # copyright notice, this list of conditions and the following disclaimer |
| # in the documentation and/or other materials provided with the |
| # distribution. |
| # * Neither the name of Google Inc. nor the names of its |
| # contributors may be used to endorse or promote products derived from |
| # this software without specific prior written permission. |
| # |
| # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| # (INCLUDING NEGLIGENCE OR/ OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| import collections |
| import json |
| import logging |
| import optparse |
| import re |
| |
| from webkitpy.common.net.buildbot import Build |
| from webkitpy.layout_tests.models.test_expectations import BASELINE_SUFFIX_LIST |
| from webkitpy.layout_tests.models.test_expectations import TestExpectations |
| from webkitpy.layout_tests.models.testharness_results import is_all_pass_testharness_result |
| from webkitpy.layout_tests.port import factory |
| from webkitpy.tool.commands.command import Command |
| |
| _log = logging.getLogger(__name__) |
| |
| |
| class AbstractRebaseliningCommand(Command): |
| """Base class for rebaseline-related commands.""" |
| # Not overriding execute() - pylint: disable=abstract-method |
| |
| no_optimize_option = optparse.make_option( |
| '--no-optimize', dest='optimize', action='store_false', default=True, |
| help=('Do not optimize (de-duplicate) the expectations after rebaselining ' |
| '(default is to de-dupe automatically). You can use "webkit-patch ' |
| 'optimize-baselines" to optimize separately.')) |
| platform_options = factory.platform_options(use_globs=True) |
| results_directory_option = optparse.make_option( |
| '--results-directory', help='Local results directory to use.') |
| suffixes_option = optparse.make_option( |
| '--suffixes', default=','.join(BASELINE_SUFFIX_LIST), action='store', |
| help='Comma-separated-list of file types to rebaseline.') |
| builder_option = optparse.make_option( |
| '--builder', |
| help=('Name of the builder to pull new baselines from, ' |
| 'e.g. "WebKit Mac10.12".')) |
| port_name_option = optparse.make_option( |
| '--port-name', |
| help=('Fully-qualified name of the port that new baselines belong to, ' |
| 'e.g. "mac-mac10.12". If not given, this is determined based on ' |
| '--builder.')) |
| test_option = optparse.make_option('--test', help='Test to rebaseline.') |
| build_number_option = optparse.make_option( |
| '--build-number', default=None, type='int', |
| help='Optional build number; if not given, the latest build is used.') |
| |
| def __init__(self, options=None): |
| super(AbstractRebaseliningCommand, self).__init__(options=options) |
| self._baseline_suffix_list = BASELINE_SUFFIX_LIST |
| self.expectation_line_changes = ChangeSet() |
| self._tool = None |
| |
| def baseline_directory(self, builder_name): |
| port = self._tool.port_factory.get_from_builder_name(builder_name) |
| return port.baseline_version_dir() |
| |
| def _test_root(self, test_name): |
| return self._tool.filesystem.splitext(test_name)[0] |
| |
| def _file_name_for_actual_result(self, test_name, suffix): |
| return '%s-actual.%s' % (self._test_root(test_name), suffix) |
| |
| def _file_name_for_expected_result(self, test_name, suffix): |
| return '%s-expected.%s' % (self._test_root(test_name), suffix) |
| |
| |
| class ChangeSet(object): |
| """A record of TestExpectation lines to remove. |
| |
| TODO(qyearsley): Remove this class, track list of lines to remove directly |
| in an attribute of AbstractRebaseliningCommand. |
| """ |
| |
| def __init__(self, lines_to_remove=None): |
| self.lines_to_remove = lines_to_remove or {} |
| |
| def remove_line(self, test, port_name): |
| if test not in self.lines_to_remove: |
| self.lines_to_remove[test] = [] |
| self.lines_to_remove[test].append(port_name) |
| |
| def to_dict(self): |
| remove_lines = [] |
| for test in self.lines_to_remove: |
| for port_name in self.lines_to_remove[test]: |
| remove_lines.append({'test': test, 'port_name': port_name}) |
| return {'remove-lines': remove_lines} |
| |
| @staticmethod |
| def from_dict(change_dict): |
| lines_to_remove = {} |
| if 'remove-lines' in change_dict: |
| for line_to_remove in change_dict['remove-lines']: |
| test = line_to_remove['test'] |
| port_name = line_to_remove['port_name'] |
| if test not in lines_to_remove: |
| lines_to_remove[test] = [] |
| lines_to_remove[test].append(port_name) |
| return ChangeSet(lines_to_remove=lines_to_remove) |
| |
| def update(self, other): |
| assert isinstance(other, ChangeSet) |
| assert isinstance(other.lines_to_remove, dict) |
| for test in other.lines_to_remove: |
| if test not in self.lines_to_remove: |
| self.lines_to_remove[test] = [] |
| self.lines_to_remove[test].extend(other.lines_to_remove[test]) |
| |
| |
| class TestBaselineSet(object): |
| """Represents a collection of tests and platforms that can be rebaselined. |
| |
| A TestBaselineSet specifies tests to rebaseline along with information |
| about where to fetch the baselines from. |
| """ |
| |
| def __init__(self, host): |
| self._host = host |
| self._port = self._host.port_factory.get() |
| self._builder_names = set() |
| self._test_prefix_map = collections.defaultdict(list) |
| |
| def __iter__(self): |
| return iter(self._iter_combinations()) |
| |
| def __bool__(self): |
| return bool(self._test_prefix_map) |
| |
| def _iter_combinations(self): |
| """Iterates through (test, build, port) combinations.""" |
| for test_prefix, build_port_pairs in self._test_prefix_map.iteritems(): |
| for test in self._port.tests([test_prefix]): |
| for build, port_name in build_port_pairs: |
| yield (test, build, port_name) |
| |
| def __str__(self): |
| if not self._test_prefix_map: |
| return '<Empty TestBaselineSet>' |
| return ('<TestBaselineSet with:\n ' + |
| '\n '.join('%s: %s, %s' % triple for triple in self._iter_combinations()) + |
| '>') |
| |
| def test_prefixes(self): |
| return sorted(self._test_prefix_map) |
| |
| def build_port_pairs(self, test_prefix): |
| # Return a copy in case the caller modifies the returned list. |
| return list(self._test_prefix_map[test_prefix]) |
| |
| def add(self, test_prefix, build, port_name=None): |
| """Adds an entry for baselines to download for some set of tests. |
| |
| Args: |
| test_prefix: This can be a full test path, or directory of tests, or a path with globs. |
| build: A Build object. This specifies where to fetch baselines from. |
| port_name: This specifies what platform the baseline is for. |
| """ |
| port_name = port_name or self._host.builders.port_name_for_builder_name(build.builder_name) |
| self._builder_names.add(build.builder_name) |
| self._test_prefix_map[test_prefix].append((build, port_name)) |
| |
| def all_builders(self): |
| """Returns all builder names in in this collection.""" |
| return self._builder_names |
| |
| |
| class AbstractParallelRebaselineCommand(AbstractRebaseliningCommand): |
| """Base class for rebaseline commands that do some tasks in parallel.""" |
| # Not overriding execute() - pylint: disable=abstract-method |
| |
| def __init__(self, options=None): |
| super(AbstractParallelRebaselineCommand, self).__init__(options=options) |
| |
| def _release_builders(self): |
| """Returns a list of builder names for continuous release builders. |
| |
| The release builders cycle much faster than the debug ones and cover all the platforms. |
| """ |
| release_builders = [] |
| for builder_name in self._tool.builders.all_continuous_builder_names(): |
| port = self._tool.port_factory.get_from_builder_name(builder_name) |
| if port.test_configuration().build_type == 'release': |
| release_builders.append(builder_name) |
| return release_builders |
| |
| def _builders_to_fetch_from(self, builders_to_check): |
| """Returns the subset of builders that will cover all of the baseline |
| search paths used in the input list. |
| |
| In particular, if the input list contains both Release and Debug |
| versions of a configuration, we *only* return the Release version |
| (since we don't save debug versions of baselines). |
| |
| Args: |
| builders_to_check: A collection of builder names. |
| |
| Returns: |
| A set of builders that we may fetch from, which is a subset |
| of the input list. |
| """ |
| release_builders = set() |
| debug_builders = set() |
| for builder in builders_to_check: |
| port = self._tool.port_factory.get_from_builder_name(builder) |
| if port.test_configuration().build_type == 'release': |
| release_builders.add(builder) |
| else: |
| debug_builders.add(builder) |
| |
| builders_to_fallback_paths = {} |
| for builder in list(release_builders) + list(debug_builders): |
| port = self._tool.port_factory.get_from_builder_name(builder) |
| fallback_path = port.baseline_search_path() |
| if fallback_path not in builders_to_fallback_paths.values(): |
| builders_to_fallback_paths[builder] = fallback_path |
| |
| return set(builders_to_fallback_paths) |
| |
| def _rebaseline_commands(self, test_baseline_set, options): |
| path_to_webkit_patch = self._tool.path() |
| cwd = self._tool.git().checkout_root |
| copy_baseline_commands = [] |
| rebaseline_commands = [] |
| lines_to_remove = {} |
| |
| builders_to_fetch_from = self._builders_to_fetch_from(test_baseline_set.all_builders()) |
| for test, build, port_name in test_baseline_set: |
| if build.builder_name not in builders_to_fetch_from: |
| continue |
| |
| suffixes = self._suffixes_for_actual_failures(test, build) |
| if not suffixes: |
| # If we're not going to rebaseline the test because it's passing on this |
| # builder, we still want to remove the line from TestExpectations. |
| if test not in lines_to_remove: |
| lines_to_remove[test] = [] |
| lines_to_remove[test].append(port_name) |
| continue |
| |
| args = [] |
| if options.verbose: |
| args.append('--verbose') |
| args.extend([ |
| '--test', test, |
| '--suffixes', ','.join(suffixes), |
| '--port-name', port_name, |
| ]) |
| |
| copy_command = [self._tool.executable, path_to_webkit_patch, 'copy-existing-baselines-internal'] + args |
| copy_baseline_commands.append(tuple([copy_command, cwd])) |
| |
| args.extend(['--builder', build.builder_name]) |
| if build.build_number: |
| args.extend(['--build-number', str(build.build_number)]) |
| if options.results_directory: |
| args.extend(['--results-directory', options.results_directory]) |
| |
| rebaseline_command = [self._tool.executable, path_to_webkit_patch, 'rebaseline-test-internal'] + args |
| rebaseline_commands.append(tuple([rebaseline_command, cwd])) |
| |
| return copy_baseline_commands, rebaseline_commands, lines_to_remove |
| |
| @staticmethod |
| def _extract_expectation_line_changes(command_results): |
| """Parses the JSON lines from sub-command output and returns the result as a ChangeSet.""" |
| change_set = ChangeSet() |
| for _, stdout, _ in command_results: |
| updated = False |
| for line in filter(None, stdout.splitlines()): |
| try: |
| parsed_line = json.loads(line) |
| change_set.update(ChangeSet.from_dict(parsed_line)) |
| updated = True |
| except ValueError: |
| _log.debug('"%s" is not a JSON object, ignoring', line) |
| if not updated: |
| # TODO(qyearsley): This probably should be an error. See http://crbug.com/649412. |
| _log.debug('Could not add file based off output "%s"', stdout) |
| return change_set |
| |
| def _optimize_baselines(self, test_baseline_set, verbose=False): |
| """Returns a list of commands to run in parallel to de-duplicate baselines.""" |
| tests_to_suffixes = collections.defaultdict(set) |
| builders_to_fetch_from = self._builders_to_fetch_from(test_baseline_set.all_builders()) |
| for test, build, _ in test_baseline_set: |
| if build.builder_name not in builders_to_fetch_from: |
| continue |
| tests_to_suffixes[test].update(self._suffixes_for_actual_failures(test, build)) |
| |
| optimize_commands = [] |
| for test, suffixes in tests_to_suffixes.iteritems(): |
| # No need to optimize baselines for a test with no failures. |
| if not suffixes: |
| continue |
| # FIXME: We should propagate the platform options as well. |
| args = [] |
| if verbose: |
| args.append('--verbose') |
| args.extend(['--suffixes', ','.join(suffixes), test]) |
| path_to_webkit_patch = self._tool.path() |
| cwd = self._tool.git().checkout_root |
| command = [self._tool.executable, path_to_webkit_patch, 'optimize-baselines'] + args |
| optimize_commands.append(tuple([command, cwd])) |
| |
| return optimize_commands |
| |
| def _update_expectations_files(self, lines_to_remove): |
| # FIXME: This routine is way too expensive. We're creating O(n ports) TestExpectations objects. |
| # This is slow and uses a lot of memory. |
| tests = lines_to_remove.keys() |
| to_remove = [] |
| |
| # This is so we remove lines for builders that skip this test, e.g. Android skips most |
| # tests and we don't want to leave stray [ Android ] lines in TestExpectations.. |
| # This is only necessary for "webkit-patch rebaseline" and for rebaselining expected |
| # failures from garden-o-matic. rebaseline-expectations and auto-rebaseline will always |
| # pass the exact set of ports to rebaseline. |
| for port_name in self._tool.port_factory.all_port_names(): |
| port = self._tool.port_factory.get(port_name) |
| generic_expectations = TestExpectations(port, tests=tests, include_overrides=False) |
| full_expectations = TestExpectations(port, tests=tests, include_overrides=True) |
| for test in tests: |
| if port.skips_test(test, generic_expectations, full_expectations): |
| for test_configuration in port.all_test_configurations(): |
| if test_configuration.version == port.test_configuration().version: |
| to_remove.append((test, test_configuration)) |
| |
| for test in lines_to_remove: |
| for port_name in lines_to_remove[test]: |
| port = self._tool.port_factory.get(port_name) |
| for test_configuration in port.all_test_configurations(): |
| if test_configuration.version == port.test_configuration().version: |
| to_remove.append((test, test_configuration)) |
| |
| port = self._tool.port_factory.get() |
| expectations = TestExpectations(port, include_overrides=False) |
| expectations_string = expectations.remove_configurations(to_remove) |
| path = port.path_to_generic_test_expectations_file() |
| self._tool.filesystem.write_text_file(path, expectations_string) |
| |
| def _run_in_parallel(self, commands): |
| if not commands: |
| return {} |
| |
| command_results = self._tool.executive.run_in_parallel(commands) |
| for _, _, stderr in command_results: |
| if stderr: |
| _log.error(stderr) |
| |
| change_set = self._extract_expectation_line_changes(command_results) |
| |
| return change_set.lines_to_remove |
| |
| def rebaseline(self, options, test_baseline_set): |
| """Fetches new baselines and removes related test expectation lines. |
| |
| Args: |
| options: An object with the command line options. |
| test_baseline_set: A TestBaselineSet instance, which represents |
| a set of tests/platform combinations to rebaseline. |
| """ |
| if self._tool.git().has_working_directory_changes(pathspec=self._layout_tests_dir()): |
| _log.error('There are uncommitted changes in the layout tests directory; aborting.') |
| return |
| |
| for test in sorted({t for t, _, _ in test_baseline_set}): |
| _log.info('Rebaselining %s', test) |
| |
| copy_baseline_commands, rebaseline_commands, extra_lines_to_remove = self._rebaseline_commands( |
| test_baseline_set, options) |
| lines_to_remove = {} |
| |
| self._run_in_parallel(copy_baseline_commands) |
| lines_to_remove = self._run_in_parallel(rebaseline_commands) |
| |
| for test in extra_lines_to_remove: |
| if test in lines_to_remove: |
| lines_to_remove[test] = lines_to_remove[test] + extra_lines_to_remove[test] |
| else: |
| lines_to_remove[test] = extra_lines_to_remove[test] |
| |
| if lines_to_remove: |
| self._update_expectations_files(lines_to_remove) |
| |
| if options.optimize: |
| # TODO(wkorman): Consider changing temporary branch to base off of HEAD rather than |
| # origin/master to ensure we run baseline optimization processes with the same code as |
| # auto-rebaseline itself. |
| self._run_in_parallel(self._optimize_baselines(test_baseline_set, options.verbose)) |
| |
| self._remove_all_pass_testharness_baselines(test_baseline_set) |
| |
| self._tool.git().add_list(self.unstaged_baselines()) |
| |
| def unstaged_baselines(self): |
| """Returns absolute paths for unstaged (including untracked) baselines.""" |
| baseline_re = re.compile(r'.*[\\/]LayoutTests[\\/].*-expected\.(txt|png|wav)$') |
| unstaged_changes = self._tool.git().unstaged_changes() |
| return sorted(self._tool.git().absolute_path(path) for path in unstaged_changes if re.match(baseline_re, path)) |
| |
| def _remove_all_pass_testharness_baselines(self, test_baseline_set): |
| """Removes all of the all-PASS baselines for the given builders and tests. |
| |
| In general, for testharness.js tests, the absence of a baseline |
| indicates that the test is expected to pass. When rebaselining, |
| new all-PASS baselines may be downloaded, but they should not be kept. |
| """ |
| filesystem = self._tool.filesystem |
| baseline_paths = self._possible_baseline_paths(test_baseline_set) |
| for path in baseline_paths: |
| if not (filesystem.exists(path) and filesystem.splitext(path)[1] == '.txt'): |
| continue |
| contents = filesystem.read_text_file(path) |
| if is_all_pass_testharness_result(contents): |
| _log.info('Removing all-PASS testharness baseline: %s', path) |
| filesystem.remove(path) |
| |
| def _possible_baseline_paths(self, test_baseline_set): |
| """Returns file paths for all baselines for the given tests and builders. |
| |
| Args: |
| test_baseline_set: A TestBaselineSet instance. |
| |
| Returns: |
| A list of absolute paths where baselines could possibly exist. |
| """ |
| filesystem = self._tool.filesystem |
| baseline_paths = set() |
| for test, build, _ in test_baseline_set: |
| filenames = [self._file_name_for_expected_result(test, suffix) for suffix in BASELINE_SUFFIX_LIST] |
| port_baseline_dir = self.baseline_directory(build.builder_name) |
| baseline_paths.update({filesystem.join(port_baseline_dir, filename) for filename in filenames}) |
| baseline_paths.update({filesystem.join(self._layout_tests_dir(), filename) for filename in filenames}) |
| return sorted(baseline_paths) |
| |
| def _layout_tests_dir(self): |
| return self._tool.port_factory.get().layout_tests_dir() |
| |
| def _suffixes_for_actual_failures(self, test, build): |
| """Gets the baseline suffixes for actual mismatch failures in some results. |
| |
| Args: |
| test: A full test path string. |
| build: A Build object. |
| |
| Returns: |
| A set of file suffix strings. |
| """ |
| results = self._tool.buildbot.fetch_results(build) |
| if not results: |
| _log.debug('No results found for build %s', build) |
| return set() |
| test_result = results.result_for_test(test) |
| if not test_result: |
| _log.debug('No test result for test %s in build %s', test, build) |
| return set() |
| return TestExpectations.suffixes_for_test_result(test_result) |
| |
| |
| class RebaselineExpectations(AbstractParallelRebaselineCommand): |
| name = 'rebaseline-expectations' |
| help_text = 'Rebaselines the tests indicated in TestExpectations.' |
| show_in_main_help = True |
| |
| def __init__(self): |
| super(RebaselineExpectations, self).__init__(options=[ |
| self.no_optimize_option, |
| ] + self.platform_options) |
| self._test_baseline_set = None |
| |
| @staticmethod |
| def _tests_to_rebaseline(port): |
| tests_to_rebaseline = [] |
| for path, value in port.expectations_dict().items(): |
| expectations = TestExpectations(port, include_overrides=False, expectations_dict={path: value}) |
| for test in expectations.get_rebaselining_failures(): |
| tests_to_rebaseline.append(test) |
| return tests_to_rebaseline |
| |
| def _add_tests_to_rebaseline(self, port_name): |
| builder_name = self._tool.builders.builder_name_for_port_name(port_name) |
| if not builder_name: |
| return |
| tests = self._tests_to_rebaseline(self._tool.port_factory.get(port_name)) |
| |
| if tests: |
| _log.info('Retrieving results for %s from %s.', port_name, builder_name) |
| |
| for test_name in tests: |
| _log.info(' %s', test_name) |
| self._test_baseline_set.add(test_name, Build(builder_name)) |
| |
| def execute(self, options, args, tool): |
| self._tool = tool |
| self._test_baseline_set = TestBaselineSet(tool) |
| options.results_directory = None |
| port_names = tool.port_factory.all_port_names(options.platform) |
| for port_name in port_names: |
| self._add_tests_to_rebaseline(port_name) |
| if not self._test_baseline_set: |
| _log.warning('Did not find any tests marked Rebaseline.') |
| return |
| |
| self.rebaseline(options, self._test_baseline_set) |
| |
| |
| class Rebaseline(AbstractParallelRebaselineCommand): |
| name = 'rebaseline' |
| help_text = 'Rebaseline tests with results from the continuous builders.' |
| show_in_main_help = True |
| argument_names = '[TEST_NAMES]' |
| |
| def __init__(self): |
| super(Rebaseline, self).__init__(options=[ |
| self.no_optimize_option, |
| # FIXME: should we support the platform options in addition to (or instead of) --builders? |
| self.results_directory_option, |
| optparse.make_option('--builders', default=None, action='append', |
| help=('Comma-separated-list of builders to pull new baselines from ' |
| '(can also be provided multiple times).')), |
| ]) |
| |
| def _builders_to_pull_from(self): |
| return self._tool.user.prompt_with_list( |
| 'Which builder to pull results from:', self._release_builders(), can_choose_multiple=True) |
| |
| def execute(self, options, args, tool): |
| self._tool = tool |
| if not args: |
| _log.error('Must list tests to rebaseline.') |
| return |
| |
| if options.builders: |
| builders_to_check = [] |
| for builder_names in options.builders: |
| builders_to_check += builder_names.split(',') |
| else: |
| builders_to_check = self._builders_to_pull_from() |
| |
| test_baseline_set = TestBaselineSet(tool) |
| |
| for builder in builders_to_check: |
| for test_prefix in args: |
| test_baseline_set.add(test_prefix, Build(builder)) |
| |
| _log.debug('Rebaselining: %s', test_baseline_set) |
| |
| self.rebaseline(options, test_baseline_set) |