chromium/tools/utr/run.py

#!/bin/env vpython3
# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Command-line interface of the UTR

Using a specified builder name, this tool can build and/or launch a test the
same way it's done on the bots. See the README.md in //tools/utr/ for more info.

Any additional args passed at the end of the invocation will be passed down
as-is to all triggered tests. Example uses:

- vpython3 run.py -B $BUCKET -b $BUILDER -t $TEST compile
- vpython3 run.py -B $BUCKET -b $BUILDER -t $TEST compile-and-test
- vpython3 run.py -B $BUCKET -b $BUILDER -t $TEST test --gtest_filter=Test.Case
"""

import argparse
import logging
import os
import pathlib
import re
import sys

import builders
import cipd
import recipe

from rich.logging import RichHandler

_THIS_DIR = pathlib.Path(__file__).resolve().parent
_SRC_DIR = _THIS_DIR.parents[1]


def add_common_args(parser):
  parser.add_argument('--verbose',
                      '-v',
                      dest='verbosity',
                      default=0,
                      action='count',
                      help='Enable additional runtime logging. Pass multiple '
                      'times for increased logging.')
  parser.add_argument('--force',
                      '-f',
                      action='store_true',
                      help='Skip all prompts about config mismatches.')
  parser.add_argument('--test',
                      '-t',
                      action='append',
                      default=[],
                      dest='tests',
                      help='Name of test suite(s) to replicate. Pass multiple '
                      'times for multiple tests. Optional with the "compile" '
                      'run mode which will compile "all".')
  parser.add_argument('--builder',
                      '-b',
                      required=True,
                      help='Name of the builder we want to replicate.')
  parser.add_argument(
      '--project',
      '-p',
      help="Name of the project of the builder. Note: if you're on a release "
      'branch, you can exclude the milestone part of the name (eg: you can '
      'pass "chrome" instead of "chrome-m123"). Will attempt to automatically '
      'determine if not specified.')
  parser.add_argument(
      '--bucket',
      '-B',
      help='Name of the bucket of the builder. Will attempt to automatically '
      'determine if not specified.')
  parser.add_argument(
      '--build-dir',
      '--out-dir',
      '-o',
      type=pathlib.Path,
      help='Path to the build dir to use for compilation and/or for invoking '
      'test binaries. Will use the output path used by the builder if not '
      'specified (likely //out/Release/).')
  parser.add_argument(
      '--recipe-dir',
      '--recipe-path',
      '-r',
      type=pathlib.Path,
      help='Path to override the recipe bundle with a local bundle. To create '
      'a bundle locally, run `./recipes.py bundle` in your desired recipe '
      'checkout. This creates a dir called "bundle" that can be pointed to '
      'with this arg.')
  parser.add_argument('--reuse-task',
                      type=str,
                      help='Ruse the cas digest of the provided swarming task')


def add_compile_args(parser):
  parser.add_argument(
      '--no-rbe',
      action='store_true',
      help='Disables the use of rbe ("use_remoteexec" GN arg) in the compile. '
      "Will use the builder's settings if not specified.")
  parser.add_argument(
      '--no-siso',
      action='store_true',
      help='Disables the use of siso ("use_siso" GN arg) in the compile. '
      "Will use the builder's settings if not specified.")
  parser.add_argument(
      '--no-coverage-instrumentation',
      action='store_true',
      help='Skips instrumenting code-coverage, even if the builder is '
      'configured to instrument. Instrumentation can inflate both build sizes '
      "and runtimes. But some failures may only occur when it's enabled.")


def add_test_args(parser):
  parser.add_argument(
      'additional_test_args',
      nargs='*',
      help='The args listed here will be appended to the test cmd-lines.')


def parse_args(args=None):
  """Parse cmd line args.

  Args:
    args: Cmd line args to parse. Only passed in unittests. Otherwise uses argv.
  Returns:
    An argparse.ArgumentParser.
  """
  parser = argparse.ArgumentParser(
      description=__doc__,
      # Custom formatter to preserve line breaks in the docstring
      formatter_class=argparse.RawDescriptionHelpFormatter)
  add_common_args(parser)
  subparsers = parser.add_subparsers(dest='run_mode')

  compile_subp = subparsers.add_parser(
      'compile',
      aliases=['build'],
      help='Only compiles. WARNING: this mode is not yet supported.')
  add_compile_args(compile_subp)

  test_subp = subparsers.add_parser(
      'test',
      help='Only run/trigger tests. WARNING: this mode is not yet supported.')
  add_test_args(test_subp)

  compile_and_test_subp = subparsers.add_parser(
      'compile-and-test',
      aliases=['build-and-test', 'run'],
      help='Both compile and run/trigger tests. WARNING: this mode is not yet '
      'supported.')
  add_compile_args(compile_and_test_subp)
  add_test_args(compile_and_test_subp)

  rr_subp = subparsers.add_parser(
      'rr',
      aliases=['rr-record', 'record'],
      help='Compile, run tests with rr tool and upload recorded traces. '
      'WARNING: this mode is not yet supported.')
  add_compile_args(rr_subp)
  add_test_args(rr_subp)

  args = parser.parse_args(args)
  if not args.run_mode:
    parser.print_help()
    parser.error('Please select a run_mode: compile,test,compile-and-test')
  if args.run_mode == 'rr':
    parser.print_help()
    parser.error('The rr mode is not yet supported in UTR')
  if args.reuse_task and args.run_mode != 'test':
    parser.print_help()
    parser.error('reuse-task is only compatible with "test"')
  if not args.tests:
    # Only compile mode should default to compile all
    if args.run_mode != 'compile':
      parser.print_help()
      parser.error('Please provide a test to run')
  if args.project:
    if re.fullmatch(r'chromium(-m\d+)?', args.project):
      args.project = 'chromium'
    elif re.fullmatch(r'chrome(-m\d+)?', args.project):
      args.project = 'chrome'
    else:
      parser.error(
          f'Unknown project: "{args.project}". Please select "chrome" or '
          '"chromium".')
  return args


def main():
  args = parse_args()
  logging.basicConfig(level=logging.DEBUG if args.verbosity else logging.INFO,
                      format='%(message)s',
                      handlers=[
                          RichHandler(show_time=False,
                                      show_level=False,
                                      show_path=False,
                                      markup=True)
                      ])

  cipd_bin_path = _SRC_DIR.joinpath('third_party', 'depot_tools', '.cipd_bin')
  if not cipd_bin_path.exists():
    logging.warning(
        ".cipd_bin folder not found. 'gclient sync' may need to be run")
  else:
    os.environ["PATH"] = str(cipd_bin_path) + os.pathsep + os.environ["PATH"]

  if not recipe.check_luci_context_auth():
    return 1

  builder_props, project = builders.find_builder_props(
      args.builder, bucket_name=args.bucket, project_name=args.project)
  if not builder_props:
    return 1

  if not args.recipe_dir:
    recipes_path = cipd.fetch_recipe_bundle(project,
                                            args.verbosity).joinpath('recipes')
  else:
    recipes_path = args.recipe_dir.joinpath('recipes')

  skip_compile = args.run_mode == 'test'
  skip_test = args.run_mode == 'compile'
  recipe_runner = recipe.LegacyRunner(
      recipes_path,
      builder_props,
      project,
      args.bucket,
      args.builder,
      args.tests,
      skip_compile,
      skip_test,
      args.force,
      args.build_dir,
      additional_test_args=None if skip_test else args.additional_test_args,
      reuse_task=args.reuse_task,
      skip_coverage=not skip_compile and args.no_coverage_instrumentation,
  )
  exit_code, error_msg = recipe_runner.run_recipe(
      filter_stdout=args.verbosity < 2)
  if error_msg:
    logging.error('\nUTR failure:')
    logging.error(error_msg)
  return exit_code


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