| # Copyright 2016 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. |
| |
| """A command to fetch new baselines from try jobs for the current CL.""" |
| |
| import json |
| import logging |
| import optparse |
| |
| from blinkpy.common.net.git_cl import GitCL, TryJobStatus |
| from blinkpy.common.path_finder import PathFinder |
| from blinkpy.tool.commands.rebaseline import AbstractParallelRebaselineCommand |
| from blinkpy.tool.commands.rebaseline import TestBaselineSet |
| from blinkpy.w3c.wpt_manifest import WPTManifest |
| |
| |
| _log = logging.getLogger(__name__) |
| |
| |
| class RebaselineCL(AbstractParallelRebaselineCommand): |
| name = 'rebaseline-cl' |
| help_text = 'Fetches new baselines for a CL from test runs on try bots.' |
| long_help = ('This command downloads new baselines for failing layout ' |
| 'tests from archived try job test results. Cross-platform ' |
| 'baselines are deduplicated after downloading. Without ' |
| 'positional parameters or --test-name-file, all failing tests ' |
| 'are rebaselined. If positional parameters are provided, ' |
| 'they are interpreted as test names to rebaseline.') |
| |
| show_in_main_help = True |
| argument_names = '[testname,...]' |
| |
| def __init__(self): |
| super(RebaselineCL, self).__init__(options=[ |
| optparse.make_option( |
| '--dry-run', action='store_true', default=False, |
| help='Dry run mode; list actions that would be performed but ' |
| 'do not actually download any new baselines.'), |
| optparse.make_option( |
| '--only-changed-tests', action='store_true', default=False, |
| help='Only download new baselines for tests that are directly ' |
| 'modified in the CL.'), |
| optparse.make_option( |
| '--no-trigger-jobs', dest='trigger_jobs', action='store_false', |
| default=True, |
| help='Do not trigger any try jobs.'), |
| optparse.make_option( |
| '--fill-missing', dest='fill_missing', action='store_true', |
| default=None, |
| help='If some platforms have no try job results, use results ' |
| 'from try job results of other platforms.'), |
| optparse.make_option( |
| '--no-fill-missing', dest='fill_missing', action='store_false'), |
| optparse.make_option( |
| '--test-name-file', dest='test_name_file', default=None, |
| help='Read names of tests to rebaseline from this file, one ' |
| 'test per line.'), |
| 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).')), |
| optparse.make_option( |
| '--patchset', default=None, |
| help='Patchset number to fetch new baselines from.'), |
| self.no_optimize_option, |
| self.results_directory_option, |
| ]) |
| self.git_cl = None |
| self._selected_try_bots = None |
| |
| def execute(self, options, args, tool): |
| self._tool = tool |
| self.git_cl = self.git_cl or GitCL(tool) |
| |
| if args and options.test_name_file: |
| _log.error('Aborted: Cannot combine --test-name-file and ' |
| 'positional parameters.') |
| return 1 |
| |
| # The WPT manifest is required when iterating through tests |
| # TestBaselineSet if there are any tests in web-platform-tests. |
| # TODO(crbug.com/698294): Consider calling ensure_manifest in BlinkTool. |
| WPTManifest.ensure_manifest(tool) |
| |
| if not self.check_ok_to_run(): |
| return 1 |
| |
| if options.builders: |
| try_builders = set() |
| for builder_names in options.builders: |
| try_builders.update(builder_names.split(',')) |
| self._selected_try_bots = frozenset(try_builders) |
| |
| jobs = self.git_cl.latest_try_jobs( |
| self.selected_try_bots, patchset=options.patchset) |
| self._log_jobs(jobs) |
| builders_with_no_jobs = self.selected_try_bots - {b.builder_name for b in jobs} |
| |
| if not options.trigger_jobs and not jobs: |
| _log.info('Aborted: no try jobs and --no-trigger-jobs passed.') |
| return 1 |
| |
| if options.trigger_jobs and builders_with_no_jobs: |
| self.trigger_try_jobs(builders_with_no_jobs) |
| return 1 |
| |
| jobs_to_results = self._fetch_results(jobs) |
| |
| builders_with_results = {b.builder_name for b in jobs_to_results} |
| builders_without_results = set(self.selected_try_bots) - builders_with_results |
| if builders_without_results: |
| _log.info('There are some builders with no results:') |
| self._log_builder_list(builders_without_results) |
| |
| if options.fill_missing is None and builders_without_results: |
| should_continue = self._tool.user.confirm( |
| 'Would you like to continue?', |
| default=self._tool.user.DEFAULT_NO) |
| if not should_continue: |
| _log.info('Aborting.') |
| return 1 |
| options.fill_missing = self._tool.user.confirm( |
| 'Would you like to try to fill in missing results with\n' |
| 'available results?\n' |
| 'Note: This will generally yield correct results\n' |
| 'as long as the results are not platform-specific.', |
| default=self._tool.user.DEFAULT_NO) |
| |
| if options.test_name_file: |
| test_baseline_set = self._make_test_baseline_set_from_file( |
| options.test_name_file, jobs_to_results) |
| elif args: |
| test_baseline_set = self._make_test_baseline_set_for_tests( |
| args, jobs_to_results) |
| else: |
| test_baseline_set = self._make_test_baseline_set( |
| jobs_to_results, options.only_changed_tests) |
| |
| if options.fill_missing: |
| self.fill_in_missing_results(test_baseline_set) |
| |
| _log.debug('Rebaselining: %s', test_baseline_set) |
| |
| if not options.dry_run: |
| self.rebaseline(options, test_baseline_set) |
| return 0 |
| |
| def check_ok_to_run(self): |
| unstaged_baselines = self.unstaged_baselines() |
| if unstaged_baselines: |
| _log.error('Aborting: there are unstaged baselines:') |
| for path in unstaged_baselines: |
| _log.error(' %s', path) |
| return False |
| if self._get_issue_number() is None: |
| _log.error('No issue number for current branch.') |
| return False |
| return True |
| |
| @property |
| def selected_try_bots(self): |
| if self._selected_try_bots: |
| return self._selected_try_bots |
| return frozenset(self._tool.builders.all_try_builder_names()) |
| |
| def _get_issue_number(self): |
| """Returns the current CL issue number, or None.""" |
| issue = self.git_cl.get_issue_number() |
| if not issue.isdigit(): |
| return None |
| return int(issue) |
| |
| def trigger_try_jobs(self, builders): |
| """Triggers try jobs for the given builders.""" |
| _log.info('Triggering try jobs:') |
| for builder in sorted(builders): |
| _log.info(' %s', builder) |
| self.git_cl.trigger_try_jobs(builders) |
| _log.info('Once all pending try jobs have finished, please re-run\n' |
| 'blink_tool.py rebaseline-cl to fetch new baselines.') |
| |
| def _log_jobs(self, jobs): |
| """Logs the current state of the try jobs. |
| |
| This includes which jobs were started or finished or missing, |
| and their current state. |
| |
| Args: |
| jobs: A dict mapping Build objects to TryJobStatus objects. |
| """ |
| finished_jobs = {b for b, s in jobs.items() if s.status == 'COMPLETED'} |
| if self.selected_try_bots.issubset({b.builder_name for b in finished_jobs}): |
| _log.info('Finished try jobs found for all try bots.') |
| return |
| |
| if finished_jobs: |
| _log.info('Finished try jobs:') |
| self._log_builder_list({b.builder_name for b in finished_jobs}) |
| else: |
| _log.info('No finished try jobs.') |
| |
| unfinished_jobs = {b for b in jobs if b not in finished_jobs} |
| if unfinished_jobs: |
| _log.info('Scheduled or started try jobs:') |
| self._log_builder_list({b.builder_name for b in unfinished_jobs}) |
| |
| def _log_builder_list(self, builders): |
| for builder in sorted(builders): |
| _log.info(' %s', builder) |
| |
| def _fetch_results(self, jobs): |
| """Fetches results for all of the given builds. |
| |
| There should be a one-to-one correspondence between Builds, supported |
| platforms, and try bots. If not all of the builds can be fetched, then |
| continuing with rebaselining may yield incorrect results, when the new |
| baselines are deduped, an old baseline may be kept for the platform |
| that's missing results. |
| |
| Args: |
| jobs: A dict mapping Build objects to TryJobStatus objects. |
| |
| Returns: |
| A dict mapping Build to WebTestResults for all completed jobs. |
| """ |
| buildbot = self._tool.buildbot |
| results = {} |
| for build, status in jobs.iteritems(): |
| if status == TryJobStatus('COMPLETED', 'SUCCESS'): |
| # Builds with passing try jobs are mapped to None, to indicate |
| # that there are no baselines to download. |
| results[build] = None |
| continue |
| if status != TryJobStatus('COMPLETED', 'FAILURE'): |
| # Only completed failed builds will contain actual failed |
| # layout tests to download baselines for. |
| continue |
| results_url = buildbot.results_url(build.builder_name, build.build_number) |
| web_test_results = buildbot.fetch_results(build) |
| if web_test_results is None: |
| _log.info('Failed to fetch results for "%s".', build.builder_name) |
| _log.info('Results URL: %s/results.html', results_url) |
| continue |
| results[build] = web_test_results |
| return results |
| |
| def _make_test_baseline_set_from_file(self, filename, builds_to_results): |
| test_baseline_set = TestBaselineSet(self._tool) |
| try: |
| with self._tool.filesystem.open_text_file_for_reading(filename) as fh: |
| _log.info('Reading list of tests to rebaseline ' |
| 'from %s', filename) |
| for test in fh.readlines(): |
| test = test.strip() |
| if not test or test.startswith('#'): |
| continue |
| for build in builds_to_results: |
| test_baseline_set.add(test, build) |
| except IOError: |
| _log.info('Could not read test names from %s', filename) |
| return test_baseline_set |
| |
| def _make_test_baseline_set_for_tests(self, tests, builds_to_results): |
| """Determines the set of test baselines to fetch from a list of tests. |
| |
| Args: |
| tests: A list of tests. |
| builds_to_results: A dict mapping Builds to WebTestResults. |
| |
| Returns: |
| A TestBaselineSet object. |
| """ |
| test_baseline_set = TestBaselineSet(self._tool) |
| for test in tests: |
| for build in builds_to_results: |
| test_baseline_set.add(test, build) |
| return test_baseline_set |
| |
| def _make_test_baseline_set(self, builds_to_results, only_changed_tests): |
| """Determines the set of test baselines to fetch. |
| |
| The list of tests are not explicitly provided, so all failing tests or |
| modified tests will be rebaselined (depending on only_changed_tests). |
| |
| Args: |
| builds_to_results: A dict mapping Builds to WebTestResults. |
| only_changed_tests: Whether to only include baselines for tests that |
| are changed in this CL. If False, all new baselines for failing |
| tests will be downloaded, even for tests that were not modified. |
| |
| Returns: |
| A TestBaselineSet object. |
| """ |
| builds_to_tests = {} |
| for build, results in builds_to_results.iteritems(): |
| builds_to_tests[build] = self._tests_to_rebaseline(build, results) |
| if only_changed_tests: |
| files_in_cl = self._tool.git().changed_files(diff_filter='AM') |
| # In the changed files list from Git, paths always use "/" as |
| # the path separator, and they're always relative to repo root. |
| test_base = self._test_base_path() |
| tests_in_cl = [f[len(test_base):] for f in files_in_cl if f.startswith(test_base)] |
| |
| test_baseline_set = TestBaselineSet(self._tool) |
| for build, tests in builds_to_tests.iteritems(): |
| for test in tests: |
| if only_changed_tests and test not in tests_in_cl: |
| continue |
| test_baseline_set.add(test, build) |
| return test_baseline_set |
| |
| def _test_base_path(self): |
| """Returns the relative path from the repo root to the layout tests.""" |
| finder = PathFinder(self._tool.filesystem) |
| return self._tool.filesystem.relpath( |
| finder.layout_tests_dir(), |
| finder.path_from_chromium_base()) + '/' |
| |
| def _tests_to_rebaseline(self, build, layout_test_results): |
| """Fetches a list of tests that should be rebaselined for some build. |
| |
| Args: |
| build: A Build instance. |
| layout_test_results: A WebTestResults instance or None. |
| |
| Returns: |
| A sorted list of tests to rebaseline for this build. |
| """ |
| if layout_test_results is None: |
| return [] |
| |
| unexpected_results = layout_test_results.didnt_run_as_expected_results() |
| tests = sorted( |
| r.test_name() for r in unexpected_results |
| if r.is_missing_baseline() or r.has_mismatch_result()) |
| |
| new_failures = self._fetch_tests_with_new_failures(build) |
| if new_failures is None: |
| _log.warning('No retry summary available for "%s".', build.builder_name) |
| else: |
| tests = [t for t in tests if t in new_failures] |
| return tests |
| |
| def _fetch_tests_with_new_failures(self, build): |
| """For a given try job, lists tests that only failed with the patch. |
| |
| If a test failed only with the patch but not without, then that |
| indicates that the failure is actually related to the patch and |
| is not failing at HEAD. |
| |
| If the list of new failures could not be obtained, this returns None. |
| """ |
| buildbot = self._tool.buildbot |
| content = buildbot.fetch_retry_summary_json(build) |
| if content is None: |
| return None |
| try: |
| retry_summary = json.loads(content) |
| return retry_summary['failures'] |
| except (ValueError, KeyError): |
| _log.warning('Unexpected retry summary content:\n%s', content) |
| return None |
| |
| def fill_in_missing_results(self, test_baseline_set): |
| """Adds entries, filling in results for missing jobs. |
| |
| For each test prefix, if there is an entry missing for some port, |
| then an entry should be added for that port using a build that is |
| available. |
| |
| For example, if there's no entry for the port "win-win7", but there |
| is an entry for the "win-win10" port, then an entry might be added |
| for "win-win7" using the results from "win-win10". |
| """ |
| all_ports = {self._tool.builders.port_name_for_builder_name(b) for b in self.selected_try_bots} |
| for test_prefix in test_baseline_set.test_prefixes(): |
| build_port_pairs = test_baseline_set.build_port_pairs(test_prefix) |
| missing_ports = all_ports - {p for _, p in build_port_pairs} |
| if not missing_ports: |
| continue |
| _log.info('For %s:', test_prefix) |
| for port in missing_ports: |
| build = self._choose_fill_in_build(port, build_port_pairs) |
| _log.info( |
| 'Using "%s" build %d for %s.', |
| build.builder_name, build.build_number, port) |
| test_baseline_set.add(test_prefix, build, port) |
| return test_baseline_set |
| |
| def _choose_fill_in_build(self, target_port, build_port_pairs): |
| """Returns a Build to use to supply results for the given port. |
| |
| Ideally, this should return a build for a similar port so that the |
| results from the selected build may also be correct for the target port. |
| """ |
| # A full port name should normally always be of the form <os>-<version>; |
| # for example "win-win7", or "linux-trusty". For the test port used in |
| # unit tests, though, the full port name may be "test-<os>-<version>". |
| def os_name(port): |
| if '-' not in port: |
| return port |
| return port[:port.rfind('-')] |
| |
| # If any Build exists with the same OS, use the first one. |
| target_os = os_name(target_port) |
| same_os_builds = sorted(b for b, p in build_port_pairs if os_name(p) == target_os) |
| if same_os_builds: |
| return same_os_builds[0] |
| |
| # Otherwise, perhaps any build will do, for example if the results are |
| # the same on all platforms. In this case, just return the first build. |
| return sorted(build_port_pairs)[0][0] |