blob: 1551f10e46bcec680f5d178264af1ca17fd4f25d [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A script to bisect field trials to pin point a culprit for certain behavior.
Chrome runs with many experiments and variations (field trials) that are
randomly selected based on a configuration from a server. They lead to
different code paths and different Chrome behaviors. When a bug is caused by
one of the experiments or variations, it is useful to be able to bisect into
the set and pin-point which one is responsible.
Go to chrome://version/?show-variations-cmd. At the bottom, a few commandline
switches define the current experiments and variations Chrome runs with.
Sample use:
python bisect_variations.py --input-file="variations_cmd.txt"
--output-dir=".\out" --browser=canary --url="https://www.youtube.com/"
"variations_cmd.txt" is the command line switches data saved from
chrome://version/?show-variations-cmd.
Run with --help to get a complete list of options this script runs with.
"""
import logging
import optparse
import os
import shutil
import subprocess
import sys
import tempfile
import split_variations_cmd
_CHROME_PATH_WIN = {
# The following three paths are relative to %ProgramFiles%
"stable": r"Google\Chrome\Application\chrome.exe",
"beta": r"Google\Chrome Beta\Application\chrome.exe",
"dev": r"Google\Chrome Dev\Application\chrome.exe",
# The following two paths are relative to %LOCALAPPDATA%
"canary": r"Google\Chrome SxS\Application\chrome.exe",
"chromium": r"Chromium\Application\chrome.exe",
}
_CHROME_PATH_MAC = {
"stable": r"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"beta": r"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"dev": r"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"canary": (r"/Applications/Google Chrome Canary.app/Contents/MacOS/"
r"Google Chrome Canary"),
}
_CHROME_PATH_LINUX = {
"stable": r"/usr/bin/google-chrome",
"beta": r"/usr/bin/google-chrome-beta",
"dev": r"/usr/bin/google-chrome-unstable",
"chromium": r"/usr/bin/chromium",
}
# Maximum command length is 32767. Constant below is reduced to leave space
# for executable and chrome arguments.
_MAX_ARGS_LENGTH_WIN = 32000
def _GetSupportedBrowserTypes():
"""Returns the supported browser types on this platform."""
if sys.platform.startswith('win'):
return _CHROME_PATH_WIN.keys()
if sys.platform == 'darwin':
return _CHROME_PATH_MAC.keys();
if sys.platform.startswith('linux'):
return _CHROME_PATH_LINUX.keys();
raise NotImplementedError('Unsupported platform')
def _LocateBrowser_Win(browser_type):
"""Locates browser executable path based on input browser type.
Args:
browser_type: 'stable', 'beta', 'dev', 'canary', or 'chromium'.
Returns:
Browser executable path.
"""
if browser_type in ['stable', 'beta', 'dev']:
return os.path.join(os.getenv('ProgramFiles'),
_CHROME_PATH_WIN[browser_type])
else:
assert browser_type in ['canary', 'chromium']
return os.path.join(os.getenv('LOCALAPPDATA'),
_CHROME_PATH_WIN[browser_type])
def _LocateBrowser_Mac(browser_type):
"""Locates browser executable path based on input browser type.
Args:
browser_type: A supported browser type on Mac.
Returns:
Browser executable path.
"""
return _CHROME_PATH_MAC[browser_type]
def _LocateBrowser_Linux(browser_type):
"""Locates browser executable path based on input browser type.
Args:
browser_type: A supported browser type on Linux.
Returns:
Browser executable path.
"""
return _CHROME_PATH_LINUX[browser_type]
def _LocateBrowser(browser_type):
"""Locates browser executable path based on input browser type.
Args:
browser_type: A supported browser types on this platform.
Returns:
Browser executable path.
"""
supported_browser_types = _GetSupportedBrowserTypes()
if browser_type not in supported_browser_types:
raise ValueError('Invalid browser type. Supported values are: %s.' %
', '.join(supported_browser_types))
if sys.platform.startswith('win'):
return _LocateBrowser_Win(browser_type)
elif sys.platform == 'darwin':
return _LocateBrowser_Mac(browser_type)
elif sys.platform.startswith('linux'):
return _LocateBrowser_Linux(browser_type)
else:
raise NotImplementedError('Unsupported platform')
def _LoadVariations(filename):
"""Reads variations commandline switches from a file.
Args:
filename: A file that contains variations commandline switches.
Returns:
A list of commandline switches.
"""
with open(filename, 'r') as f:
data = f.read().replace('\n', ' ')
switches = split_variations_cmd.ParseCommandLineSwitchesString(data)
return ['--%s=%s' % (switch_name, switch_value) for
switch_name, switch_value in switches.items()]
def _BuildBrowserArgs(user_data_dir, extra_browser_args, variations_args):
"""Builds commandline switches browser runs with.
Args:
user_data_dir: A path that is used as user data dir.
extra_browser_args: A list of extra commandline switches browser runs
with.
variations_args: A list of commandline switches that defines the
variations cmd browser runs with.
Returns:
A list of commandline switches.
"""
# Make sure each run is fresh, but avoid first run setup steps.
browser_args = [
'--no-first-run',
'--no-default-browser-check',
'--user-data-dir=%s' % user_data_dir,
'--disable-field-trial-config',
]
browser_args.extend(extra_browser_args)
browser_args.extend(variations_args)
return browser_args
def _RunVariations(browser_path, url, extra_browser_args, variations_args):
"""Launches browser with given variations.
Args:
browser_path: Browser executable file.
url: The webpage URL browser goes to after it launches.
extra_browser_args: A list of extra commandline switches browser runs
with.
variations_args: A list of commandline switches that defines the
variations cmd browser runs with.
Returns:
A set of (returncode, stdout, stderr) from browser subprocess.
"""
command = [os.path.abspath(browser_path)]
if url:
command.append(url)
tempdir = tempfile.mkdtemp(prefix='bisect_variations_tmp')
command.extend(_BuildBrowserArgs(user_data_dir=tempdir,
extra_browser_args=extra_browser_args,
variations_args=variations_args))
logging.debug(' '.join(command))
subproc = subprocess.Popen(
command, bufsize=-1, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = subproc.communicate()
shutil.rmtree(tempdir, True)
return (subproc.returncode, stdout, stderr)
def _AskCanReproduce(exit_status, stdout, stderr):
"""Asks whether running Chrome with given variations reproduces the issue.
Args:
exit_status: Chrome subprocess return code.
stdout: Chrome subprocess stdout.
stderr: Chrome subprocess stderr.
Returns:
One of ['y', 'n', 'r']:
'y': yes
'n': no
'r': retry
"""
# Loop until we get a response that we can parse.
while True:
response = input('Can we reproduce with given variations file '
'[(y)es/(n)o/(r)etry/(s)tdout/(q)uit]: ').lower()
if response in ('y', 'n', 'r'):
return response
if response == 'q':
sys.exit()
if response == 's':
logging.info(stdout)
logging.info(stderr)
def Bisect(browser_type, url, extra_browser_args, variations_file, output_dir):
"""Bisect variations interactively.
Args:
browser_type: One of the supported browser type on this platform. See
--help for the list.
url: The webpage URL browser launches with.
extra_browser_args: A list of commandline switches browser runs with.
variations_file: A file contains variations commandline switches that
need to be bisected.
output_dir: A folder where intermediate bisecting data are stored.
"""
browser_path = _LocateBrowser(browser_type)
if sys.platform.startswith('win'):
runs = _EnsureCommandLineLength(variations_file, output_dir)
else:
runs = [variations_file]
while runs:
run = runs[0]
print('Run Chrome with variations file', run)
variations_args = _LoadVariations(run)
exit_status, stdout, stderr = _RunVariations(
browser_path=browser_path, url=url,
extra_browser_args=extra_browser_args,
variations_args=variations_args)
answer = _AskCanReproduce(exit_status, stdout, stderr)
if answer == 'y':
runs = split_variations_cmd.SplitVariationsCmdFromFile(run, output_dir)
if len(runs) == 1:
# Can divide no further.
print('Bisecting succeeded:', ' '.join(variations_args))
return
elif answer == 'n':
if len(runs) == 1:
raise ValueError('Bisecting failed: should reproduce but did not: %s' %
' '.join(variations_args))
runs = runs[1:]
else:
assert answer == 'r'
def _EnsureCommandLineLength(filename, output_dir):
"""Splits command-line to ensure it isn't too long for Windows.
Args:
filename: A file that contains variations commandline switches.
output_dir: A folder where intermediate bisecting data are stored.
Returns:
List of files containing variations from the input file, split
such that no file has a command line too long for Windows.
"""
files_to_process = [filename]
result = []
while len(files_to_process) > 0:
new_files = []
for f in files_to_process:
variations_args = ' '.join(_LoadVariations(f))
if len(variations_args) <= _MAX_ARGS_LENGTH_WIN:
result.append(f)
else:
split = split_variations_cmd.SplitVariationsCmdFromFile(f, output_dir)
if len(split) == 1:
raise ValueError('Can not split long argument list %s' %
variations_args)
new_files.extend(split)
files_to_process = new_files
return result
def main():
parser = optparse.OptionParser()
parser.add_option("--browser",
help="select which browser to run. Options include: %s."
" By default, stable is selected." %
", ".join(_GetSupportedBrowserTypes()))
parser.add_option("-v", "--verbose", action="store_true", default=False,
help="print out debug information.")
parser.add_option("--extra-browser-args",
help="specify extra command line switches for the browser "
"that are separated by spaces (quoted).")
parser.add_option("--url",
help="specify the webpage URL the browser launches with. "
"This is optional.")
parser.add_option("--input-file",
help="specify the filename that contains variations cmd "
"to bisect. This has to be specified.")
parser.add_option("--output-dir",
help="specify a folder where output files are saved. "
"If not specified, it is the folder of the input file.")
options, _ = parser.parse_args()
if options.verbose:
logging.basicConfig(level=logging.DEBUG)
if options.input_file is None:
raise ValueError('Missing input through --input-file.')
output_dir = options.output_dir
if output_dir is None:
output_dir, _ = os.path.split(options.input_file)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
browser_type = options.browser
if browser_type is None:
browser_type = 'stable'
extra_browser_args = []
if options.extra_browser_args is not None:
extra_browser_args = options.extra_browser_args.split()
Bisect(browser_type=browser_type, url=options.url,
extra_browser_args=extra_browser_args,
variations_file=options.input_file, output_dir=output_dir)
return 0
if __name__ == '__main__':
sys.exit(main())