Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions smart_tests/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
import typer

from smart_tests.app import Application
from smart_tests.commands.detect_flakes import create_nested_command_app as create_detect_flakes_commands
from smart_tests.commands.record.tests import create_nested_commands as create_record_target_commands
from smart_tests.commands.subset import create_nested_commands as create_subset_target_commands
from smart_tests.utils.test_runner_registry import get_registry

from .commands import compare, inspect, record, stats, subset, verify
from .commands import compare, detect_flakes, inspect, record, stats, subset, verify
from .utils import logger
from .utils.env_keys import SKIP_CERT_VERIFICATION
from .version import __version__
Expand All @@ -29,6 +30,7 @@
try:
create_subset_target_commands()
create_record_target_commands()
create_detect_flakes_commands()
except Exception as e:
# If NestedCommand creation fails, continue with legacy commands
# This ensures backward compatibility
Expand All @@ -47,7 +49,8 @@ def _rebuild_nested_commands_with_plugins():

try:
# Clear existing commands from nested apps and rebuild
for module_name in ['smart_tests.commands.subset', 'smart_tests.commands.record.tests']:
for module_name in ['smart_tests.commands.subset', 'smart_tests.commands.record.tests',
'smart_tests.commands.detect_flakes']:
module = importlib.import_module(module_name)
if hasattr(module, 'nested_command_app'):
nested_app = module.nested_command_app
Expand Down Expand Up @@ -141,6 +144,7 @@ def main(

# Use NestedCommand apps if available, otherwise fall back to legacy
try:
from smart_tests.commands.detect_flakes import nested_command_app as detect_flakes_target_app
from smart_tests.commands.record.tests import nested_command_app as record_target_app
from smart_tests.commands.subset import nested_command_app as subset_target_app

Expand All @@ -150,6 +154,7 @@ def main(
app.add_typer(inspect.app, name="inspect")
app.add_typer(stats.app, name="stats")
app.add_typer(compare.app, name="compare")
app.add_typer(detect_flakes_target_app, name="detect-flakes")

# Add record-target as a sub-app to record command
record.app.add_typer(record_target_app, name="test") # Use NestedCommand version
Expand All @@ -162,6 +167,7 @@ def main(
app.add_typer(verify.app, name="verify")
app.add_typer(inspect.app, name="inspect")
app.add_typer(stats.app, name="stats")
app.add_typer(detect_flakes.app, name="detect-flakes")

app.callback()(main)

Expand Down
2 changes: 1 addition & 1 deletion smart_tests/commands/compare/subsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ def subsets(
(before, after, f"{diff:+}" if isinstance(diff, int) else diff, test)
for before, after, diff, test in rows
]
typer.echo(tabulate(tabular_data, headers=headers, tablefmt="github"))
typer.echo_via_pager(tabulate(tabular_data, headers=headers, tablefmt="github"))
106 changes: 106 additions & 0 deletions smart_tests/commands/detect_flakes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import os
from enum import Enum
from typing import Annotated

import typer

from smart_tests.app import Application
from smart_tests.commands.test_path_writer import TestPathWriter
from smart_tests.testpath import unparse_test_path
from smart_tests.utils.commands import Command
from smart_tests.utils.dynamic_commands import DynamicCommandBuilder, extract_callback_options
from smart_tests.utils.env_keys import REPORT_ERROR_KEY
from smart_tests.utils.exceptions import print_error_and_die
from smart_tests.utils.session import get_session
from smart_tests.utils.smart_tests_client import SmartTestsClient
from smart_tests.utils.tracking import Tracking, TrackingClient
from smart_tests.utils.typer_types import ignorable_error


class DetectFlakesRetryThreshold(str, Enum):
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"


app = typer.Typer(name="detect-flakes", help="Detect flaky tests")


@app.callback()
def detect_flakes(
ctx: typer.Context,
session: Annotated[str, typer.Option(
"--session",
help="In the format builds/<build-name>/test_sessions/<test-session-id>",
metavar="SESSION"
)],
retry_threshold: Annotated[DetectFlakesRetryThreshold, typer.Option(
"--retry-threshold",
help="Thoroughness of how \"flake\" is detected",
case_sensitive=False,
show_default=True,
)] = DetectFlakesRetryThreshold.MEDIUM,
):
app = ctx.obj
tracking_client = TrackingClient(Command.DETECT_FLAKE, app=app)
test_runner = getattr(ctx, 'test_runner', None)
client = SmartTestsClient(app=app, tracking_client=tracking_client, test_runner=test_runner)

test_session = None
try:
test_session = get_session(client=client, session=session)
except ValueError as e:
print_error_and_die(msg=str(e), tracking_client=tracking_client, event=Tracking.ErrorEvent.USER_ERROR)
except Exception as e:
if os.getenv(REPORT_ERROR_KEY):
raise e
else:
typer.echo(ignorable_error(e), err=True)

if test_session is None:
return

class FlakeDetection(TestPathWriter):
def __init__(self, app: Application):
super(FlakeDetection, self).__init__(app)

def run(self):
test_paths = []
try:
res = client.request(
"get",
"detect-flake",
params={
"confidence": retry_threshold.value.upper(),
"session-id": os.path.basename(session),
"test-runner": test_runner,
})

res.raise_for_status()
test_paths = res.json().get("testPaths", [])
if test_paths:
self.print(test_paths)
typer.echo("Trying to retry the following tests:", err=True)
for detail in res.json().get("testDetails", []):
typer.echo(f"{detail.get('reason'): {unparse_test_path(detail.get('fullTestPath'))}}", err=True)
except Exception as e:
tracking_client.send_error_event(
event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
stack_trace=str(e),
)
if os.getenv(REPORT_ERROR_KEY):
raise e
else:
typer.echo(ignorable_error(e), err=True)

ctx.obj = FlakeDetection(app=ctx.obj)


nested_command_app = typer.Typer(name="detect-flakes", help="Detect flaky tests from test files (NestedCommand)")


def create_nested_command_app():
builder = DynamicCommandBuilder()

callback_options = extract_callback_options(detect_flakes)
builder.create_detect_flakes_commands(nested_command_app, detect_flakes, callback_options)
2 changes: 1 addition & 1 deletion smart_tests/commands/inspect/subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def display(self):
result._estimated_duration_sec,
]
)
typer.echo(tabulate(rows, header, tablefmt="github", floatfmt=".2f"))
typer.echo_via_pager(tabulate(rows, header, tablefmt="github", floatfmt=".2f"))


class SubsetResultJSONDisplay(SubsetResultAbstractDisplay):
Expand Down
13 changes: 3 additions & 10 deletions smart_tests/commands/record/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import typer
from tabulate import tabulate

from smart_tests.commands.record.session import KeyValue, LinkKind, parse_key_value
from smart_tests.utils.link import CIRCLECI_KEY, GITHUB_ACTIONS_KEY, JENKINS_URL_KEY, capture_link
from smart_tests.commands.record.session import KeyValue, parse_key_value
from smart_tests.utils.link import CIRCLECI_KEY, GITHUB_ACTIONS_KEY, JENKINS_URL_KEY, capture_links
from smart_tests.utils.tracking import Tracking, TrackingClient

from ...utils import subprocess
Expand Down Expand Up @@ -292,14 +292,7 @@ def send(ws: List[Workspace]) -> str | None:
# TODO(Konboi): port forward #1128
# figure out all the CI links to capture
def compute_links():
_links = capture_link(os.environ)
for k, v in links:
_links.append({
"title": k,
"url": v,
"kind": LinkKind.CUSTOM_LINK.name,
})
return _links
return capture_links(link_options=links, env=os.environ)

try:
lineage = branch or ws[0].branch
Expand Down
28 changes: 10 additions & 18 deletions smart_tests/commands/record/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from smart_tests.utils.commands import Command
from smart_tests.utils.exceptions import print_error_and_die
from smart_tests.utils.fail_fast_mode import set_fail_fast_mode
from smart_tests.utils.link import LinkKind, capture_link
from smart_tests.utils.link import capture_links
from smart_tests.utils.no_build import NO_BUILD_BUILD_NAME
from smart_tests.utils.smart_tests_client import SmartTestsClient
from smart_tests.utils.tracking import Tracking, TrackingClient
Expand Down Expand Up @@ -78,24 +78,16 @@ def session(
if is_no_build:
build_name = NO_BUILD_BUILD_NAME

payload = {
"flavors": dict([(f.key, f.value) for f in flavors]),
"isObservation": is_observation,
"noBuild": is_no_build,
"testSuite": test_suite,
"timestamp": parsed_timestamp.isoformat() if parsed_timestamp else None,
}

_links = capture_link(os.environ)
for link in links:
_links.append({
"title": link.key,
"url": link.value,
"kind": LinkKind.CUSTOM_LINK.name,
})
payload["links"] = _links

try:
payload = {
"flavors": dict([(f.key, f.value) for f in flavors]),
"isObservation": is_observation,
"noBuild": is_no_build,
"testSuite": test_suite,
"timestamp": parsed_timestamp.isoformat() if parsed_timestamp else None,
"links": capture_links(link_options=[(link.key, link.value) for link in links], env=os.environ)
}

sub_path = f"builds/{build_name}/test_sessions"
res = client.request("post", sub_path, payload=payload)

Expand Down
18 changes: 17 additions & 1 deletion smart_tests/commands/subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import subprocess
import sys
from enum import Enum
from multiprocessing import Process
from os.path import join
from typing import Annotated, Any, Callable, Dict, List, TextIO
Expand Down Expand Up @@ -34,6 +35,12 @@
app = typer.Typer(name="subset", help="Subsetting tests")


class SubsetUseCase(str, Enum):
ONE_COMMIT = "one-commit"
FEATURE_BRANCH = "feature-branch"
RECURRING = "recurring"


@app.callback()
def subset(
ctx: typer.Context,
Expand Down Expand Up @@ -111,7 +118,11 @@ def subset(
is_get_tests_from_guess: Annotated[bool, typer.Option(
"--get-tests-from-guess",
help="Get subset list from guessed tests"
)] = False
)] = False,
use_case: Annotated[SubsetUseCase | None, typer.Option(
"--use-case",
hidden=True
)] = None,
):
app = ctx.obj
tracking_client = TrackingClient(Command.SUBSET, app=app)
Expand Down Expand Up @@ -352,6 +363,9 @@ def get_payload(
if prioritized_tests_mapping_file:
payload['prioritizedTestsMapping'] = json.load(prioritized_tests_mapping_file)

if use_case:
payload['changesUnderTest'] = use_case.value

return payload

def _collect_potential_test_files(self):
Expand Down Expand Up @@ -401,6 +415,8 @@ def request_subset(self) -> SubsetResult:
if res.status_code == 422:
print_error_and_die("Error: {}".format(res.reason), tracking_client, Tracking.ErrorEvent.USER_ERROR)

res.raise_for_status()

return SubsetResult.from_response(res.json())
except Exception as e:
tracking_client.send_error_event(
Expand Down
3 changes: 3 additions & 0 deletions smart_tests/test_runners/bazel.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def subset(client):
client.run()


smart_tests.CommonDetectFlakesImpls(__name__).detect_flakes()


@smart_tests.record.tests
def record_tests(
client,
Expand Down
3 changes: 3 additions & 0 deletions smart_tests/test_runners/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ def find_filename():
for r in reports:
client.report(r)
client.run()


smart_tests.CommonDetectFlakesImpls(__name__).detect_flakes()
1 change: 1 addition & 0 deletions smart_tests/test_runners/rspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

subset = smart_tests.CommonSubsetImpls(__name__).scan_files('*_spec.rb')
record_tests = smart_tests.CommonRecordTestImpls(__name__).report_files()
detect_flakes = smart_tests.CommonDetectFlakesImpls(__name__).detect_flakes()
34 changes: 34 additions & 0 deletions smart_tests/test_runners/smart_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import typer

from smart_tests.commands.detect_flakes import app as detect_flakes_cmd
from smart_tests.commands.record.tests import app as record_tests_cmd
from smart_tests.commands.subset import app as subset_cmd
from smart_tests.utils.test_runner_registry import cmdname, create_test_runner_wrapper, get_registry
Expand Down Expand Up @@ -34,6 +35,13 @@ def subset(f):
return f


def detect_flakes(f):
test_runner_name = cmdname(f.__module__)
registry = get_registry()
registry.register_detect_flakes(test_runner_name, f)
return f


record = types.SimpleNamespace()


Expand Down Expand Up @@ -152,3 +160,29 @@ def load_report_files(cls, client, source_roots, file_mask="*.xml"):
return

client.run()


class CommonDetectFlakesImpls:
def __init__(
self,
module_name,
formatter=None,
separator="\n",
):
self.cmdname = cmdname(module_name)
self._formatter = formatter
self._separator = separator

def detect_flakes(self):
def detect_flakes(client):
if self._formatter:
client.formatter = self._formatter
if self._separator:
client.separator = self._separator

client.run()

# Register with new registry system for NestedCommand
registry = get_registry()
registry.register_detect_flakes(self.cmdname, detect_flakes)
return wrap(detect_flakes, detect_flakes_cmd, self.cmdname)
1 change: 1 addition & 0 deletions smart_tests/utils/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Command(Enum):
RECORD_SESSION = 'RECORD_SESSION'
SUBSET = 'SUBSET'
COMMIT = 'COMMIT'
DETECT_FLAKE = 'DETECT_FLAKE'

def display_name(self):
return self.value.lower().replace('_', ' ')
Loading
Loading