chromium/tools/bisect/bisect_gtests.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 is a script helping developer bisect test failures.
#
# Currently this only supports bisecting gtest based test failures.
# Say you're assigned a BrowserTest.TestCase1 failure. You would generally do
# 1 Find a good commit and bad commit.
# 2 `git bisect start`
# 3 `git bisect good <good commit id>`
# 4 `git bisect bad <bad commit id>`
# 5 `gclient sync`
# 6 `autoninja -C out/Default browser_tests`
# 7 `out/Default/browser_tests --gtest_filter=BrowserTest.TestCase1`
# 8 if the test pass, `git bisect good`, otherwise `git bisect bad`.
# 9 repeat 5 - 8 until finding the culprit.
# This script will help you on 2 - 9. You first do 1, then run
# `python3 tools/bisect/bisect.py -g <good commit id> -b <bad commit id>
#   --build_command 'autoninja -C out/Default browser_tests'
#   --test_command 'out/Default/browser_tests
#                   --gtest_filter=BrowserTest.TestCase1'`
# The script will run until it finds the culprit cl breaking the test.
#
# Note1: We only support non-flaky -> failure, or non-flaky -> flaky.
# Flaky -> failure can't get correct result. For non-flaky -> flaky,
# you can use `--gtest_repeat`.
# Note2: For tests using python launching script, this is supported. e.g.
# `--test_command 'build/lacros/test_runner.py test
#      out/lacrosdesktop/lacros_chrome_browsertests
#      --ash-chrome-path=out/lacrosdesktop/ash_clang_x64/test_ash_chrome
#      --gtest_filter=BrowserTest.TestCase1'`

import argparse
import subprocess
import sys

# This is the message from `git bisect` when it
# finds the culprit cl.
GIT_BAD_COMMENT_MSG = 'is the first bad commit'
GIT_BISECT_IN_PROCESS_MSG = 'left to test after this'


def Run(command, print_stdout_on_error=True):
  print(command)
  c = subprocess.run(command, shell=True)
  if print_stdout_on_error and c.returncode != 0:
    print(c.stdout)
  return c.returncode == 0


def StartBisect(good_rev, bad_rev, build_command, test_command):
  assert (Run('git bisect start'))
  assert (Run('git bisect bad %s' % bad_rev))
  assert (Run('git bisect good %s' % good_rev))

  while True:
    assert (Run('gclient sync'))
    assert (Run(build_command))
    test_ret = None
    # If the test result is different running twice, then
    # try again.
    for _ in range(5):
      c1 = Run(test_command, print_stdout_on_error=False)
      c2 = Run(test_command, print_stdout_on_error=False)
      if c1 == c2:
        test_ret = c2
        break

    gitcp = None
    if test_ret:
      print('git bisect good')
      gitcp = subprocess.run('git bisect good',
                             shell=True,
                             capture_output=True,
                             text=True)
    else:
      print('git bisect bad')
      gitcp = subprocess.run('git bisect bad',
                             shell=True,
                             capture_output=True,
                             text=True)
    # git should always print 'left to test after this'. No stdout
    # means something is wrong.
    if not gitcp.stdout:
      print('Something is wrong! Exit bisect.')
      if gitcp.stderr:
        print(gitcp.stderr)
      break

    print(gitcp.stdout)
    first_line = gitcp.stdout[:gitcp.stdout.find('\n')]
    # Found the culprit!
    if GIT_BAD_COMMENT_MSG in first_line:
      print('Found the culprit change!')
      return 0
    if GIT_BISECT_IN_PROCESS_MSG not in first_line:
      print('Something is wrong! Exit bisect.')
      if gitcp.stderr:
        print(gitcp.stderr)
      break
  return 1


def main():
  parser = argparse.ArgumentParser()
  parser.add_argument('-b',
                      '--bad',
                      type=str,
                      help='A bad revision to start bisection.')
  parser.add_argument('-g',
                      '--good',
                      type=str,
                      help='A good revision to start bisection.')
  parser.add_argument('--build_command',
                      type=str,
                      help='Command to build test target.')
  parser.add_argument('--test_command', type=str, help='Command to run test.')
  args = parser.parse_args()
  return StartBisect(args.good, args.bad, args.build_command, args.test_command)


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