blob: 6bb41455c0141ef2da31b1efcd59b2f5a377a3e7 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2019 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Evaluate ChromeOS tast tests."""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from typing import Optional
from bisect_kit import bisector_cli
from bisect_kit import catapult_util
from bisect_kit import cli
from bisect_kit import common
from bisect_kit import core
from bisect_kit import cros_lab_util
from bisect_kit import cros_util
from bisect_kit import errors
from bisect_kit import git_util
from bisect_kit import repo_util
from bisect_kit import util
logger = logging.getLogger(__name__)
OLD = 'old'
NEW = 'new'
SKIP = 'skip'
FATAL = 'fatal'
EXIT_CODE_MAP = {
OLD: cli.EXIT_CODE_OLD,
NEW: cli.EXIT_CODE_NEW,
SKIP: cli.EXIT_CODE_SKIP,
FATAL: cli.EXIT_CODE_FATAL,
}
def create_argument_parser():
parents = [cli.create_session_optional_parser()]
parser = cli.ArgumentParser(description=__doc__, parents=parents)
parser.add_argument(
'--rich-result',
action='store_true',
help='Instead of mere exit code, output detailed information in json',
)
parser.add_argument(
'dut',
nargs='?',
type=cli.argtype_notempty,
metavar='DUT',
default=os.environ.get('DUT', ''),
)
parser.add_argument(
'--chromeos-root',
type=cli.argtype_dir_path,
metavar='CHROMEOS_ROOT',
default=os.environ.get('CHROMEOS_ROOT', ''),
help='ChromeOS tree root',
)
parser.add_argument(
'--prebuilt',
action='store_true',
help='Run tast using existing server prebuilt package if specified; '
'otherwise use the default one',
)
parser.add_argument(
'--tast-build',
action='store_true',
help='Build tast test bundle (-build=true) if specified; '
'default is using prebuilt bundle on the DUT',
)
parser.add_argument(
'--tast-patch-cl',
action='append',
help='A gerrit CL to patch the chromiumos/platform/tast-tests repository; ',
)
parser.add_argument(
'--tast-revision',
help='A git revision of chromiumos/platform/tast-tests repository '
'to be specified',
)
parser.add_argument(
'--tast-private-revision',
help='A git revision of chromeos/platform/tast-tests-private '
'repository to be specified',
)
parser.add_argument(
'--tast-runtime-revision',
help='A git revision of chromiumos/platform/tast repository to be '
'specified',
)
parser.add_argument(
'--with-private-bundles',
action='store_true',
help='Whether search tests in private bundles or not',
)
parser.add_argument(
'--reboot-before-test',
action='store_true',
help='Reboot before test run',
)
parser.add_argument(
'--args',
help='Extra variables passed to `tast -var`, as "key=value"',
type=cli.argtype_key_value,
action='append',
default=[],
)
group = parser.add_argument_group(title='Options for normal autotest tests')
group.add_argument(
'--test-name',
help='Test name, like "video_VideoDecodeAccelerator.h264"',
default='bisector.Bisect',
)
group.add_argument(
'--fail-to-pass',
action='store_true',
help='For functional tests: old behavior is FAIL and new behavior is '
'PASS; If not specified, default = old behavior is PASS and new '
'behavior is FAIL',
)
group.add_argument(
'--consider-test-crash-as-failure',
action='store_true',
help='Consider test crashes as failure instead of skip',
)
group.add_argument(
'--metric',
help='Metric name of performance test; example: '
'"cheets_SystemRawImageSize"',
)
return parser
def add_tast_patch_cl(
chromeos_root: str,
tast_patch_cls: list[str],
tast_revision: Optional[str],
tast_private_revision: Optional[str],
tast_runtime_revision: Optional[str],
):
# Use 'main' branch code as the base if the patch CL is specified.
# It's compatible as the previous behavior.
if tast_patch_cls:
if not tast_runtime_revision:
tast_runtime_revision = 'main'
if not tast_private_revision:
tast_private_revision = 'main'
if not tast_revision:
tast_revision = 'main'
# checkout tast runtime repo to main or the specified revision.
if tast_runtime_revision:
tast_runtime_repo = os.path.join(
chromeos_root, 'src', 'platform', 'tast'
)
if tast_runtime_revision == 'main':
git_util.checkout_branch(tast_runtime_repo, 'main')
git_util.reset_hard_to_remote_branch(tast_runtime_repo, 'cros/main')
else:
git_util.checkout_version(tast_runtime_repo, tast_runtime_revision)
git_util.reset_hard(tast_runtime_repo)
# checkout tast tests private repo to main or the specified revision.
if tast_private_revision:
tast_tests_private_repo = os.path.join(
chromeos_root, 'src', 'platform', 'tast-tests-private'
)
if tast_private_revision == 'main':
git_util.checkout_branch(tast_tests_private_repo, 'main')
git_util.reset_hard_to_remote_branch(
tast_tests_private_repo, 'cros-internal/main'
)
else:
git_util.checkout_version(
tast_tests_private_repo, tast_private_revision
)
git_util.reset_hard(tast_tests_private_repo)
# checkout tast tests repo to main or the specified revision.
if tast_revision:
tast_tests_repo = os.path.join(
chromeos_root, 'src', 'platform', 'tast-tests'
)
if tast_revision == 'main':
git_util.checkout_branch(tast_tests_repo, 'main')
git_util.reset_hard_to_remote_branch(tast_tests_repo, 'cros/main')
else:
git_util.checkout_version(tast_tests_repo, tast_revision)
git_util.reset_hard(tast_tests_repo)
# Patch the CL to the tast tests repo.
for patch_cl in tast_patch_cls:
repo_util.cherry_pick(tast_tests_repo, patch_cl)
def prepare_to_run_test(opts):
if opts.reboot_before_test:
cros_util.reboot(
opts.dut, force_reboot_callback=cros_lab_util.reboot_via_servo
)
# opts.tast_patch_cl can be None.
tast_patch_cls = opts.tast_patch_cl or []
if (
tast_patch_cls
or opts.tast_revision
or opts.tast_private_revision
or opts.tast_runtime_revision
):
add_tast_patch_cl(
opts.chromeos_root,
tast_patch_cls,
opts.tast_revision,
opts.tast_private_revision,
opts.tast_runtime_revision,
)
def tast_cmd(opts, cmd, *args):
flags = []
use_custom_tast_test = (
opts.tast_patch_cl
or opts.tast_revision
or opts.tast_private_revision
or opts.tast_runtime_revision
)
if opts.prebuilt and not use_custom_tast_test:
tast_dir = os.path.join(
cros_util.chromeos_root_inside_chroot, cros_util.prebuilt_tast_dir
)
tast_bin = os.path.join(tast_dir, 'tast')
flags += [
'-remotebundledir',
os.path.join(tast_dir, 'bundles', 'remote'),
]
flags += ['-remotedatadir', os.path.join(tast_dir, 'data')]
flags += ['-remoterunner', os.path.join(tast_dir, 'remote_test_runner')]
flags += [
'-defaultvarsdir',
os.path.join(tast_dir, 'vars', 'private'),
'-defaultvarsdir',
os.path.join(tast_dir, 'vars', 'public'),
]
else:
tast_bin = 'tast'
if cmd == 'run':
flags += ['-var=%s=%s' % x for x in opts.args]
# TODO(zjchang): ensure the tast version for buildbucket builds is correct
if not opts.tast_build and not use_custom_tast_test:
flags.append('-build=false')
if opts.with_private_bundles:
flags.append('-downloadprivatebundles=true')
return [tast_bin, '-verbose', cmd] + flags + list(args)
def get_tast_bundle_info(opts, pattern=None):
bundles = ['cros']
if opts.with_private_bundles:
bundles.append('crosint')
args = [opts.dut]
if pattern:
args.append(pattern)
result = {}
for bundle in bundles:
cmd = tast_cmd(opts, 'list', '-json', '-buildbundle=' + bundle, *args)
try:
json_text = cros_util.cros_sdk(
opts.chromeos_root,
*cmd,
log_stdout=False,
)
except subprocess.CalledProcessError as e:
raise errors.ExternalError(
'failed to get tast bundle info, assume it is temporary: %s' % e
)
for entry in json.loads(json_text):
result[entry['name']] = bundle
return result
def run_test(opts: argparse.Namespace, test_name: str, bundle: dict) -> str:
"""Runs an autotest test.
Args:
opts: An argparse.Namespace to hold command line arguments.
test_name: Name of the test
bundle: tast's test bundle
Returns:
path of test result (outside chroot)
"""
# Set results dir inside source tree, so it's easier to access them outside
# chroot.
results_dir = os.path.join(
cros_util.chromeos_root_inside_chroot, 'tmp/tast_results_tmp'
)
results_dir_output_chroot = results_dir.replace(
cros_util.chromeos_root_inside_chroot, opts.chromeos_root
)
# Don't reuse existing results dir, otherwise tast may rename output files.
if os.path.exists(results_dir_output_chroot):
shutil.rmtree(results_dir_output_chroot)
# TODO(kcwu): add -timeout
flags = ['-resultsdir', results_dir]
if opts.tast_build:
flags.append('-buildbundle=' + bundle)
cmd = tast_cmd(opts, 'run', *flags, opts.dut, test_name)
cros_util.cros_sdk(opts.chromeos_root, *cmd)
return results_dir_output_chroot
# Be careful that both tauto and tast generate results.json, but their format
# is totally different.
def parse_results_json(result_dir, test_name) -> tuple[bool, str | None]:
passed = None
reason = None
results_path = os.path.join(result_dir, 'results.json')
util.copy_file_to_log_folder(results_path)
with open(results_path) as f:
for result in json.load(f):
if result['name'] != test_name:
logger.warning('unexpected test ran: %s', result['name'])
continue
if not result['errors']:
passed = True
else:
passed = False
# We capture the reason of the last error because usually the last one
# is fatal.
for error in reversed(result['errors']):
reason = error.get('reason')
if reason:
break
# In case of multiple test runs, return passed=False if any of the
# test failed.
break
if passed is None:
raise errors.ExternalError('no test result for "%s"?' % test_name)
return passed, reason
def gather_test_result(
opts: argparse.Namespace, test_name: str, result_dir: str
) -> core.StepResult:
error_path = os.path.join(result_dir, 'run_error.txt')
if os.path.exists(error_path):
with open(error_path) as f:
message = f.read()
raise errors.ExternalError('tast global error: %s' % message)
passed, reason = parse_results_json(result_dir, test_name)
if opts.metric:
chart_path = os.path.join(
result_dir, 'tests', test_name, 'results-chart.json'
)
util.copy_file_to_log_folder(chart_path)
try:
values = catapult_util.get_benchmark_values(chart_path, opts.metric)
except Exception as e:
if not passed:
raise errors.BisectionTemporaryError(
'test failed to generate metric values, reason: %s' % reason
) from e
raise
return core.StepResult('value', values=values)
if opts.fail_to_pass:
if passed:
logger.info('passed')
return core.StepResult('new', reason)
logger.info('failed')
return core.StepResult('old', reason)
if passed:
logger.info('passed')
return core.StepResult('old', reason)
logger.info('failed')
return core.StepResult('new', reason)
def step_main(args: tuple[str] | None) -> core.StepResult:
parser = create_argument_parser()
opts = parser.parse_args(args)
common.config_logging(opts)
if cros_lab_util.is_satlab_dut(opts.dut):
cros_lab_util.write_satlab_ssh_config(opts.dut)
if not cros_util.is_dut(opts.dut):
raise errors.BrokenDutException(
'%r is not a valid DUT address' % opts.dut
)
if not cros_util.is_good_dut(opts.dut):
logger.fatal('%r is not a good DUT', opts.dut)
if not cros_lab_util.repair(opts.dut, opts.chromeos_root):
raise errors.BrokenDutException('%r is not a good DUT' % opts.dut)
tests: list[str] = [
value.strip() for value in opts.test_name.split(',') if value.strip()
]
if len(tests) == 0:
raise errors.ArgumentError('--test-name', 'no valid test name found.')
if len(tests) > 1 and opts.metric:
raise errors.ArgumentError(
'--test-name', 'multiple performance tests are not supported.'
)
# Private bundles are not included in the OS image, make sure they are
# available.
if opts.with_private_bundles and not opts.tast_build:
if not cros_util.query_dut_is_by_official_builder(opts.dut):
# Official builders uploaded private tast bundles and tast can download
# them with -downloadprivatebundles=true, so --tast-build is not
# required. Otherwise, we must build the bundles by ourselves.
raise errors.ArgumentError(
'--tast-build',
'for non-official chromeos image, --tast-build must be specified',
)
# Verify command line options.
if opts.metric:
if opts.fail_to_pass:
raise errors.ArgumentError(
'--fail-to-pass',
'--fail-to-pass is not for benchmark test (--metric)',
)
cros_util.prepare_chroot(opts.chromeos_root)
try:
prepare_to_run_test(opts)
except Exception as e:
raise errors.BisectionTemporaryError(
'failed when prepare, assume it is temporary: %s' % e
)
last_test_result: core.StepResult = None
for test in tests:
# Remove the "tast." prefix prepended by autotest.
test = re.sub(r'^tast\.', '', test)
tast_bundle_info = get_tast_bundle_info(opts, test)
if not tast_bundle_info:
tast_bundle_info = get_tast_bundle_info(opts)
util.report_similar_candidates(
'test name', test, list(tast_bundle_info)
)
assert 0 # unreachable
if len(tast_bundle_info) != 1 or test not in tast_bundle_info:
# For example, tast in chroot after 12205.0.0 is IPC incompatible with
# tast on DUT earlier than 12028.0.0 (crbug/932307)
raise errors.ExecutionFatalError(
'"tast list" returns unexpected tests; '
'incompatible tast on DUT and in chroot?'
)
bundle = tast_bundle_info[test]
try:
result_dir = run_test(opts, test, bundle)
except subprocess.CalledProcessError as e:
reason = (
f'failed to run {test}; maybe build, ssh, or setup failures'
)
# TODO(njrafi): Differentiate between ssh failure and test crash
if opts.consider_test_crash_as_failure:
return util.get_test_crash_step_result(
reason, opts.fail_to_pass
)
raise errors.BisectionTemporaryError(reason) from e
last_test_result = gather_test_result(opts, test, result_dir)
if last_test_result.data['status'] in ['fatal', 'skip']:
break
return last_test_result
def action() -> bisector_cli.EvalAction:
return bisector_cli.EvalAction.WITH_DUT
def main(args: tuple[str] | None = None) -> int:
return bisector_cli.step_main_wrapper(step_main, args)
if __name__ == '__main__':
sys.exit(main())