blob: 75bb3c76b32134b6b291fea0ecee255ea3d7e16a [file] [log] [blame]
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
from dataclasses import dataclass
from datetime import datetime
import enum
import os
import pathlib
import re
import sys
import typing
import termout
import selection_action
LOG_TO_STDOUT_OPTION = "-"
class PrevOption(enum.StrEnum):
LOG = "log"
PATH = "path"
REPLAY = "replay"
ARTIFACT_PATH = "artifact-path"
FAILED_TESTS = "failed-tests"
HELP = "help"
def help(self) -> str:
"""Get the help string for this option.
Raises:
RuntimeError: If an invalid option is passed.
Returns:
str: Human-readable information about how this options is used.
"""
if self is PrevOption.LOG:
return "Print all test logs from the previous run. Logs are grouped by test suite."
elif self is PrevOption.PATH:
return "Print the path of the log from the previous run."
elif self is PrevOption.REPLAY:
return "Replay the previous run, using new display options."
elif self is PrevOption.ARTIFACT_PATH:
return "Print the path where artifacts were stored in the previous run."
elif self is PrevOption.FAILED_TESTS:
return "Print the previously failed tests and how to run them."
elif self is PrevOption.HELP:
return "Print this help output."
else:
raise RuntimeError("BUG: Invalid prev option")
@dataclass
class Flags:
"""Command line flags for fx test.
See `parse_args` for documentation.
"""
dry: bool
list: bool
list_runtime_deps: bool
previous: PrevOption | None
remote_suggestions: bool
remote_suggestion_builder: typing.List[str]
gemini_analysis: bool
gemini_model: str
build: bool
updateifinbase: bool
build_updates: bool
host: bool
device: bool
exact: bool
e2e: bool
only_e2e: bool
selection: typing.List[str]
fuzzy: int
show_suggestions: bool
suggestion_count: int
parallel: int
parallel_cases: int
random: bool
count: int
limit: int | None
offset: int
min_severity_logs: typing.List[str]
timeout: float | None
timeout_grace_period: float
test_filter: typing.List[str]
fail: bool
fail_by_group: bool
use_package_hash: bool
restrict_logs: bool
also_run_disabled_tests: bool
show_full_moniker_in_logs: bool
break_on_failure: bool
breakpoints: typing.List[str]
use_existing_debugger: bool
use_test_pilot: bool
extra_args: typing.List[str]
env: typing.List[str]
allow_temporary_package_server: bool
output: bool
simple: bool
style: bool
log: bool
logpath: str | None
status: bool | None
verbose: bool
status_lines: int
status_delay: float
timestamp_artifacts: bool
artifact_output_directory: str | None
slow: float
quiet: bool
replay_speed: float
def validate(self) -> None:
"""Validate incoming flags, raising an exception on failure.
Raises:
FlagError: If the flags are invalid.
"""
if self.simple and self.status:
raise FlagError("--simple is incompatible with --status")
if self.simple and self.style:
raise FlagError("--simple is incompatible with --style")
if self.device and self.host:
raise FlagError("--device is incompatible with --host")
if self.status_delay < 0.005:
raise FlagError("--status-delay must be at least 0.005 (5ms)")
if self.timeout and self.timeout < 0:
raise FlagError("--timeout must be non-negative")
if self.timeout_grace_period and self.timeout_grace_period <= 0:
raise FlagError("--timeout-grace-period must be greater than 0")
if self.count < 1:
raise FlagError("--count must be a positive number")
if self.suggestion_count < 0:
raise FlagError("--suggestion-count must be non-negative")
if (
self.artifact_output_directory is not None
and pathlib.Path(self.artifact_output_directory).is_file()
):
raise FlagError("--artifact-output-directory cannot be a file")
if self.parallel < 0:
raise FlagError("--parallel must be non-negative")
if self.parallel_cases < 0:
raise FlagError("--parallel-cases must be non-negative")
if self.debugger_will_attach() and self.host:
raise FlagError(
"--break-on-failure and --breakpoint flags are not supported with host tests."
)
if self.use_existing_debugger and self.breakpoints:
raise FlagError(
"--breakpoint does not support --use-existing-debugger."
)
if self.use_existing_debugger and not self.break_on_failure:
raise FlagError(
"--break-on-failure must be set when passing --use-existing-debugger."
)
if self.replay_speed <= 0:
raise FlagError("--replay-speed must be a positive number")
if not termout.is_valid() and self.status:
raise FlagError(
"Refusing to output interactive status to a non-TTY."
)
if (
self.artifact_output_directory is not None
and self.timestamp_artifacts
):
self.artifact_output_directory = os.path.join(
self.artifact_output_directory,
datetime.now().strftime("%Y-%m-%d-%H:%M:%S"),
)
if self.remote_suggestion_builder and not self.remote_suggestions:
raise FlagError(
"--remote-suggestion-builder is only effective when --remote-suggestions is enabled"
)
# Compute environment and check it is valid.
self.computed_env()
if self.only_e2e:
self.e2e = True
if self.simple:
self.style = False
self.status = False
else:
if self.style is None:
self.style = termout.is_valid()
if self.status is None:
self.status = termout.is_valid()
def update_artifacts_directory_with_out_path(self, path: str) -> None:
if (
self.artifact_output_directory is not None
and "FUCHSIA_OUT" in self.artifact_output_directory
):
self.artifact_output_directory = re.sub(
r"(\$FUCHSIA_OUT|\$\{FUCHSIA_OUT\})",
path,
self.artifact_output_directory,
)
def debugger_will_attach(self) -> bool:
"""Determine if this set of flags enables debugging features.
This is distinct from `Flags.debugger_should_spawn`, which indicates that debugger should be
spawned.
Returns:
bool: True if a debugger will be attached, False otherwise.
"""
return bool(self.break_on_failure or self.breakpoints)
def debugger_should_spawn(self) -> bool:
"""Determine if this set of flags warrants the launch of a debugger.
This is distinct from `Flags.debugger_will_attach`, which indicates that a debugger will be
attached to the test upon execution.
Returns:
bool: True if a debugger needs to be spawned, False otherwise.
"""
return bool(
self.debugger_will_attach() and not self.use_existing_debugger
)
def is_replay(self) -> bool:
"""Determine if these flags specify that replay mode is active.
Returns:
bool: True if replay mode is active, False otherwise.
"""
return self.previous == PrevOption.REPLAY
def computed_env(self) -> dict[str, str]:
"""Compute and return the environment denoted by --env flags.
Warning: This method recomputes the environment on each call, if this
operation is too expensive we will need to memoize the return value.
Raises:
FlagError: If an environment variable is not formatted correctly.
Returns:
dict[str, str]: Mapping from key to value of environment
variables for tests executed by this invocation of the
command.
"""
ret = {}
for val in self.env:
split = val.split("=", maxsplit=1)
if len(split) != 2:
raise FlagError(
f'Environment variable "{val}" must be formatted as "name=value"'
)
ret[split[0]] = split[1]
return ret
class FlagError(Exception):
"""Raised if there was a problem parsing command line flags."""
def parse_args(
cli_args: typing.List[str] | None = None, defaults: Flags | None = None
) -> Flags:
"""Parse command line flags.
Args:
cli_args (List[str], optional): Arguments to parse. If
unset, read arguments from actual command line.
defaults (Flags, optional): Default set of flags. If set,
overrides the defaults from the command line.
Returns:
Flags: Typed representation of the command line for this program.
"""
extra_args: typing.List[str] = []
cli_args = cli_args if cli_args is not None else sys.argv[1:]
if cli_args is not None and "--" in cli_args:
extra_index = cli_args.index("--")
(cli_args, extra_args) = (
cli_args[:extra_index],
cli_args[extra_index + 1 :],
)
parser = argparse.ArgumentParser(
"fx test",
description="Test Executor for Humans",
exit_on_error=False,
)
utility = parser.add_argument_group("Utility Options")
utility.add_argument(
"--dry",
action="store_true",
help="Do not actually run tests. Instead print out the tests that would have been run.",
)
utility.add_argument(
"--list",
action="store_true",
help="Do not actually run tests. Instead print out the list of test cases each test contains.",
)
utility.add_argument(
"--gemini-analysis",
nargs="?",
type=int,
const=1,
default=None,
choices=range(1, 4),
help="""If specified, requests an AI-powered analysis of stack traces from test failures.
Requires the GEMINI_API_KEY environment variable to be set.
The Gemini model can be specified using the `--gemini-model` flag.
An optional verbosity level from 1-3 can be provided (eg., --gemini-analysis=3).
If no level is provided, it defaults to 1.
Level 1: Key lines from the stack trace.
Level 2: Key lines and a potential error from the git diff.
Level 3: Full analysis with file contents and code snippets.
""",
dest="gemini_analysis",
)
utility.add_argument(
"--gemini-model",
type=str,
default="gemini-2.5-flash-lite-preview-09-2025",
help="The Gemini model to use for the analysis.",
dest="gemini_model",
)
utility.add_argument(
"--list-runtime-deps",
action="store_true",
help="Do not actually run tests. Instead print out the contents of the `runtime_deps` for each test. This can be useful for debugging whether the correct artifacts are being uploaded to test runners",
)
utility.add_argument(
"--remote-suggestions",
action=argparse.BooleanOptionalAction,
default=False,
help="Whether to use remote tests.json files for tests suggestions.",
)
parser.add_argument(
"--remote-suggestion-builder",
type=str,
action="append",
help="Add additional builder to query. May be specified multiple times.",
default=[],
)
utility.add_argument(
"-pr",
"--prev",
"--previous",
dest="previous",
type=PrevOption,
choices=list(PrevOption),
help=f"Do not actually run tests. Instead print information from the previous run. Input is read from the last log file, and it respects the value of --logpath.",
)
build = parser.add_argument_group("Build Options")
build.add_argument(
"--build",
action=argparse.BooleanOptionalAction,
help="Invoke `fx build` before running the test suite (defaults to on)",
default=True,
)
build.add_argument(
"--updateifinbase",
action=argparse.BooleanOptionalAction,
help="Invoke `fx update-if-in-base` before running device tests (defaults to on)",
default=True,
)
build.add_argument(
"--build-updates",
action=argparse.BooleanOptionalAction,
help="Build the updates package if there are device tests (defaults to on)",
default=True,
)
selection = parser.add_argument_group("Test Selection Options")
selection.add_argument(
"--host",
action="store_true",
default=False,
help="Only run host tests. The opposite of `--device`",
)
selection.add_argument(
"-d",
"--device",
action="store_true",
default=False,
help="Only run device tests. The opposite of `--host`",
)
selection.add_argument(
"--exact",
action="store_true",
default=False,
help="""Only match tests whose name exactly matches the selection.
Cannot be specified along with --host or --device.""",
)
selection.add_argument(
"--e2e",
action=argparse.BooleanOptionalAction,
help="Run selected end to end tests. Default is to not run e2e tests.",
default=False,
)
selection.add_argument(
"--only-e2e",
action="store_true",
default=False,
help="Only run end to end tests. Implies --e2e.",
)
selection.add_argument(
"-p",
"--package",
action=selection_action.InvalidAction,
nargs=0,
dest="selection",
help="Match tests against their Fuchsia package name",
)
selection.add_argument(
"-c",
"--component",
action=selection_action.InvalidAction,
nargs=0,
dest="selection",
help="Match tests against their Fuchsia component name",
)
selection.add_argument(
"-a",
"--and",
action=selection_action.InvalidAction,
nargs=0,
dest="selection",
help="Add requirements to the preceding filter",
)
selection.add_argument(
"selection",
action=selection_action.SelectionAction,
nargs="*",
)
selection.add_argument(
"--fuzzy",
type=int,
default=3,
help="The Damerau–Levenshtein distance threshold for fuzzy matching tests",
)
selection.add_argument(
"--show-suggestions",
action=argparse.BooleanOptionalAction,
type=bool,
help="If True and no tests match, suggest matching tests from the build directory. Default is True.",
default=True,
)
selection.add_argument(
"--suggestion-count",
type=int,
help="Show this number of suggestions if no tests match. Default is 6.",
default=6,
)
execution = parser.add_argument_group("Execution Options")
execution.add_argument(
"--use-package-hash",
action=argparse.BooleanOptionalAction,
type=bool,
help="Use the package Merkle root hash from the build artifacts to ensure you are running the most recently built device test code.",
default=True,
)
execution.add_argument(
"--parallel",
type=int,
help="Maximum number of test suites to run in parallel. Does not affect per-suite parallelism.",
default=4,
)
execution.add_argument(
"--parallel-cases",
type=int,
help="Instruct on-device test runners to prefer running this number of cases in parallel.",
default=0,
)
execution.add_argument(
"-r",
"--random",
action="store_true",
help="Randomize test execution order",
default=False,
)
execution.add_argument(
"--timeout",
type=float,
help="Terminate tests that take longer than this number of seconds to complete. By default, uses test default timeout values. A zero timeout value disables the timeout.",
)
execution.add_argument(
"--timeout-grace-period",
type=float,
help="Number of seconds following timeout after which the test process will be killed. Default 15 seconds.",
default=15.0,
)
execution.add_argument(
"--test-filter",
type=str,
action="append",
default=[],
help="Run specific test cases in a test suite. Can be specified multiple times to pass in multiple patterns.",
)
execution.add_argument(
"--count",
type=int,
help="Execute each test this many times. If any iteration of a test times out, no further iterations will be executed",
default=1,
)
execution.add_argument(
"--limit",
type=int,
help="Stop execution after this many tests",
default=None,
)
execution.add_argument(
"--offset",
type=int,
help="Skip this many tests at the beginning of the test list. Combine with --limit to deterministically select a subrange of tests.",
default=0,
)
execution.add_argument(
"-f",
"--fail",
action="store_true",
help="Stop running tests after the first failed test suite. This will abort all tests in progress and end with a failure code.",
default=False,
)
execution.add_argument(
"--fail-by-group",
action=argparse.BooleanOptionalAction,
help="When repeating tests with --count, stop repeating if any execution fails. If --no-fail-by-group is sets, continue repeating test executions despite failures to identify flakes.",
default=True,
)
execution.add_argument(
"--restrict-logs",
action=argparse.BooleanOptionalAction,
help="If False, do not limit maximum log severity regardless of the test's configuration. Default is True.",
default=True,
)
execution.add_argument(
"--min-severity-logs",
nargs="*",
help="""Modifies the minimum log severity level emitted by components during the test execution.
Specify using the format <component-selector>#<log-level>, or just <log-level> (in which
case the severity will apply to all components under the test, including the test component
itself) with level as one of FATAL|ERROR|WARN|INFO|DEBUG|TRACE.""",
default=[],
)
execution.add_argument(
"--also-run-disabled-tests",
action="store_true",
help="If True, also run tests that are disabled by the test author. This only affects test components. Default is False.",
default=False,
)
execution.add_argument(
"--show-full-moniker-in-logs",
action=argparse.BooleanOptionalAction,
help="""If set, show the full moniker in log output for on-device tests.
Otherwise only the last segment of the moniker is displayed.
Default is False.""",
default=False,
)
execution.add_argument(
"-e",
"--env",
action="append",
type=str,
help="Add an environment variable to each test invocation. May be specified multiple times.",
default=[],
)
execution.add_argument(
"--break-on-failure",
action="store_true",
help="""If set and supported by the test runner, any test case failures will stop test
execution. zxdb is automatically launched and attached to the failed test case unless
`--use-existing-debugger` is set.""",
default=False,
)
execution.add_argument(
"--breakpoint",
metavar="BREAKPOINT", # This is to make the help text singular.
dest="breakpoints",
action="append",
help="""Run the test with zxdb attached and set the given breakpoint. For example,
`--breakpoint my_source_file.cc:37` will insert a breakpoint at line 37 of any file
named my_source_file.cc. May be specified multiple times to add multiple breakpoints.""",
default=[],
)
execution.add_argument(
"--use-existing-debugger",
action="store_true",
help="""If set, suppresses the automatic launch and attach of zxdb when `--break-on-failure`
is set. Incompatible with `--breakpoint`.""",
default=False,
)
execution.add_argument(
"--allow-temporary-package-server",
action=argparse.BooleanOptionalAction,
help="Allow this script to start a temporary package server if one is not already running. Default is True.",
default=True,
)
execution.add_argument(
"--use-test-pilot",
action=argparse.BooleanOptionalAction,
help="""Run test components using test-pilot. Note: this flag is experimental""",
default=False,
)
output = parser.add_argument_group("Output Options")
output.add_argument(
"-o",
"--output",
action=argparse.BooleanOptionalAction,
help="Display the output from passing tests. Some test arguments may be needed.",
)
output.add_argument(
"--simple",
action="store_true",
help="Remove any color or decoration from output. Disable pretty status printing. Implies --no-style",
)
output.add_argument(
"--style",
action=argparse.BooleanOptionalAction,
default=None,
help="Remove color and decoration from output. Does not disable pretty status printing. Default is to only style for TTY output.",
)
output.add_argument(
"--log",
action=argparse.BooleanOptionalAction,
help="Emit command events to a file. Turned on when running real tests unless `--no-log` is passed.",
default=True,
)
output.add_argument(
"--logpath",
help="If passed and --log is enabled, customizes the destination of the target log.",
default=None,
)
output.add_argument(
"-v",
"--verbose",
action="store_true",
default=False,
help="Print verbose logs to the console",
)
output.add_argument(
"--status",
action=argparse.BooleanOptionalAction,
default=None,
help="Toggle interactive status printing to console. Default is to vary behavior depending on if output is to a TTY. Setting to True on a non-TTY is an error.",
)
output.add_argument(
"--status-lines",
default=8,
type=int,
help="Number of lines used to display status output.",
)
output.add_argument(
"--status-delay",
default=0.033,
type=float,
help="Control how frequently the status output is updated. Default is every 0.033s, but you can increase the number for calmer output on slower connections.",
)
output.add_argument(
"--timestamp-artifacts",
default=False,
action=argparse.BooleanOptionalAction,
type=bool,
help="If set, output artifacts in a timestamped directory under the given output directory. Default is False.",
)
output.add_argument(
"--outdir",
"--ffx-output-directory", # For compatibility
"--artifact-output-directory",
default=None,
dest="artifact_output_directory",
help="If set, write test artifact output to this directory for post processing.",
)
output.add_argument(
"-s",
"--slow",
type=float,
default=0,
help="If non-zero, automatically show output for tests taking longer than this many seconds.",
)
output.add_argument(
"-q",
"--quiet",
action="store_true",
default=False,
help="Silence INFO and INSTRUCTION messages from the tool",
)
output.add_argument(
"--replay-speed",
type=float,
default=1,
help="Speed up replays by this amount. Can be less than 1 for slow motion.",
)
if defaults is not None:
actions = parser._actions.copy()
groups_to_process: typing.List[
argparse._ArgumentGroup
] = parser._action_groups.copy()
# Recursively find all actions.
while groups_to_process:
group = groups_to_process.pop()
actions.extend(group._actions)
groups_to_process.extend(group._action_groups)
# Apply defaults for all identified actions from the given defaults.
for action in actions:
if hasattr(defaults, action.dest):
action.default = getattr(defaults, action.dest)
cli_args = selection_action.SelectionAction.preprocess_args(cli_args)
namespace = parser.parse_intermixed_args(cli_args)
flags: Flags = Flags(**vars(namespace), extra_args=extra_args)
return flags