chromium/tools/disable_tests/disable.py

#!/usr/bin/env python3
# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""This script automatically disables tests, given an ID and a set of
configurations on which it should be disabled. See the README for more details.
"""

import argparse
import os
import sys
import subprocess
import traceback
from typing import List, Optional, Tuple
import urllib.parse

import conditions
import errors
import expectations
import gtest
import resultdb

SRC_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))


def main(argv: List[str]) -> int:
  valid_conds = ' '.join(
      sorted(f'\t{term.name}' for term in conditions.TERMINALS))

  parser = argparse.ArgumentParser(
      description='Disables tests.',
      epilog=f"Valid conditions are:\n{valid_conds}")

  parser.add_argument(
      'build',
      type=str,
      help='the Buildbucket build ID to search for tests to disable ')
  parser.add_argument('test_regex',
                      type=str,
                      help='the regex for the test to disable. For example: ' +
                      '".*CompressionUtilsTest.GzipCompression.*". Currently' +
                      'we assume that there is at most one test matching' +
                      'the regex. Disabling multiple tests at the same time' +
                      'is not currently supported (crbug.com/1364416)')
  parser.add_argument('conditions',
                      type=str,
                      nargs='*',
                      help="the conditions under which to disable the test. " +
                      "Each entry consists of any number of conditions joined" +
                      " with '&', specifying the conjunction of these values." +
                      " All entries will be 'OR'ed together, along with any " +
                      "existing conditions from the file.")
  parser.add_argument('-c',
                      '--cache',
                      action='store_true',
                      help='cache ResultDB rpc results, useful for testing.')

  # group = parser.add_mutually_exclusive_group()
  parser.add_argument(
      '-b',
      '--bug',
      help="write a TODO referencing this bug in a comment " +
      "next to the disabled test. Bug can be given as just the" +
      " ID or a URL (e.g. 123456, crbug.com/v8/654321).")
  parser.add_argument('-m',
                      '--message',
                      help="write a comment containing this message next to " +
                      "the disabled test.")

  args = parser.parse_args(argv[1:])

  if args.cache:
    resultdb.CANNED_RESPONSE_FILE = os.path.join(os.path.dirname(__file__),
                                                 '.canned_responses.json')

  message = args.message
  if args.bug is not None:
    try:
      message = make_bug_message(args.bug, message)
    except Exception:
      print(
          'Invalid value for --bug. Should have one of the following forms:\n' +
          '\t1234\n' + '\tcrbug/1234\n' + '\tcrbug/project/1234\n' +
          '\tcrbug.com/1234\n' + '\tcrbug.com/project/1234\n' +
          '\tbugs.chromium.org/p/project/issues/detail?id=1234\n',
          file=sys.stderr)
      return 1

  try:
    disable_test(args.build, args.test_regex, args.conditions, message)
    return 0
  except errors.UserError as e:
    print(e, file=sys.stderr)
    return 1
  except errors.InternalError as e:
    trace = traceback.format_exc()
    print(f"Internal error: {e}", file=sys.stderr)
    print('Please file a bug using the following link:', file=sys.stderr)
    print(generate_bug_link(args, trace), file=sys.stderr)
    return 1
  except Exception:
    trace = traceback.format_exc()
    print(f'Error: unhandled exception at top-level\n{trace}', file=sys.stderr)
    print('Please file a bug using the following link:', file=sys.stderr)
    print(generate_bug_link(args, trace), file=sys.stderr)
    return 1


def make_bug_message(bug: str, message: str) -> str:
  bug_id, project = parse_bug(bug)
  project_component = '' if project == 'chromium' else f'{project}/'
  bug_url = f"crbug.com/{project_component}{bug_id}"
  if not message:
    # if no message given, set default message for TODO.
    message = "Re-enable this test"
  return f"TODO({bug_url}): {message}"


def parse_bug(bug: str) -> Tuple[int, str]:
  # bug can be in a few different forms:
  # * Just the ID, e.g. "1281261"
  # * Monorail URL, e.g.
  #     "https://bugs.chromium.org/p/chromium/issues/detail?id=1281261"
  # * Monorail short URL, e.g.
  #     "https://crbug.com/1281261"
  #     or "crbug/1281261"
  try:
    bug_id = int(bug)
    # Assume chromium host if only the ID is specified
    return bug_id, 'chromium'
  except ValueError:
    pass

  # Otherwise it should be a URL.
  # Slight hack to ensure the domain is always in 'netloc'
  if '//' not in bug:
    bug = f"https://{bug}"
  url = urllib.parse.urlparse(bug)

  # Match crbug.com/ and crbug/
  if url.netloc in {'crbug', 'crbug.com'}:
    parts = url.path.split('/')[1:]
    if len(parts) == 1:
      return int(parts[0]), 'chromium'

    return int(parts[1]), parts[0]

  # Match full Monorail URLs.
  if url.netloc == 'bugs.chromium.org':
    parts = url.path.split('/')[1:]
    project = parts[1]

    bug_id = int(urllib.parse.parse_qs(url.query)['id'][0])
    return bug_id, project

  raise ValueError()


# TODO: Extra command line flags for:
#   * Opening the right file at the right line, for when you want to do
#     something manually. Use $EDITOR.
#   * Printing out all valid configs.
#   * Overwrite the existing state rather than adding to it. Probably leave this
#     until it's requested.
def disable_test(build: str, test_regex: str, cond_strs: List[str],
                 message: Optional[str]):
  conds = conditions.parse(cond_strs)
  invocation = "invocations/build-" + build
  test_name, filename = resultdb.get_test_metadata(invocation, test_regex)
  test_name = extract_name_and_suite(test_name)

  # Paths returned from ResultDB look like //foo/bar, where // refers to the
  # root of the chromium/src repo.
  full_path = os.path.join(SRC_ROOT, filename.lstrip('/'))
  _, extension = os.path.splitext(full_path)
  extension = extension.lstrip('.')

  if extension == 'html':
    full_path = expectations.search_for_expectations(full_path, test_name)

  try:
    with open(full_path, 'r') as f:
      source_file = f.read()
  except FileNotFoundError as e:
    raise errors.UserError(
        f"Couldn't open file {filename}. Either this test has moved file very" +
        "recently, or your checkout isn't up-to-date.") from e

  if extension == 'cc':
    disabler = gtest.disabler
  elif extension == 'html':
    disabler = expectations.disabler
  else:
    raise errors.UserError(
        f"Don't know how to disable tests for this file format ({extension})")

  new_content = disabler(test_name, source_file, conds, message)
  with open(full_path, 'w') as f:
    f.write(new_content)


def extract_name_and_suite(test_name: str) -> str:
  # Web tests just use the filename as the test name, so don't mess with it.
  if test_name.endswith('.html'):
    return test_name

  # GTest Test names always have a suite name and test name, separated by '.'s.
  # They may also have extra slash-separated parts on the beginning and the end,
  # for parameterised tests.
  for part in test_name.split('/'):
    if '.' in part:
      return part

  raise errors.UserError(f"Couldn't parse test name: {test_name}")


def get_current_commit_hash() -> Optional[str]:
  proc = subprocess.run(['git', 'rev-parse', 'HEAD'],
                        check=False,
                        capture_output=True,
                        text=True)
  if proc.returncode != 0:
    return None

  return proc.stdout.strip()


# TODO: Ideally we'd also capture all RPC results so we can 100% reproduce it.
def generate_bug_link(args: argparse.Namespace, trace: str) -> str:
  # Strip path prefixes to avoid leaking info about the user.
  trace = trace.replace(SRC_ROOT, '/')

  args_list = '\n'.join(f'{k} = {v}' for k, v in args.__dict__.items())

  summary = f'Test disabler failed for {args.test_id}'
  description = f'''
<Please describe the problem here>

========== Debug info ==========

Exception:
{trace}
Args:
{args_list}'''

  if (git_hash := get_current_commit_hash()) is not None:
    description += f'''

Checked out chromium/src revision:
{git_hash}
'''

  params = urllib.parse.urlencode(
      dict(
          labels='Type-Bug,Pri-2',
          # TODO: Consider separating the tool out into its own component. Or
          # perhaps just adding a label like 'Test-Disabling-Tool'.
          components='Infra>Sheriffing>SheriffOMatic',
          summary=summary,
          description=description,
      ))

  return urllib.parse.urlunsplit(
      ('https', 'bugs.chromium.org', '/p/chromium/issues/entry', params, ''))


if __name__ == '__main__':
  sys.exit(main(sys.argv))