smart-tests-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smart_tests/__init__.py +0 -0
- smart_tests/__main__.py +60 -0
- smart_tests/app.py +67 -0
- smart_tests/args4p/README.md +102 -0
- smart_tests/args4p/__init__.py +13 -0
- smart_tests/args4p/argument.py +45 -0
- smart_tests/args4p/command.py +593 -0
- smart_tests/args4p/converters/__init__.py +75 -0
- smart_tests/args4p/decorators.py +98 -0
- smart_tests/args4p/exceptions.py +12 -0
- smart_tests/args4p/option.py +85 -0
- smart_tests/args4p/parameter.py +84 -0
- smart_tests/args4p/typer/__init__.py +42 -0
- smart_tests/commands/__init__.py +0 -0
- smart_tests/commands/compare/__init__.py +11 -0
- smart_tests/commands/compare/subsets.py +58 -0
- smart_tests/commands/detect_flakes.py +105 -0
- smart_tests/commands/inspect/__init__.py +13 -0
- smart_tests/commands/inspect/model.py +52 -0
- smart_tests/commands/inspect/subset.py +138 -0
- smart_tests/commands/record/__init__.py +19 -0
- smart_tests/commands/record/attachment.py +38 -0
- smart_tests/commands/record/build.py +356 -0
- smart_tests/commands/record/case_event.py +190 -0
- smart_tests/commands/record/commit.py +157 -0
- smart_tests/commands/record/session.py +120 -0
- smart_tests/commands/record/tests.py +498 -0
- smart_tests/commands/stats/__init__.py +11 -0
- smart_tests/commands/stats/test_sessions.py +45 -0
- smart_tests/commands/subset.py +567 -0
- smart_tests/commands/test_path_writer.py +51 -0
- smart_tests/commands/verify.py +153 -0
- smart_tests/jar/exe_deploy.jar +0 -0
- smart_tests/plugins/__init__.py +0 -0
- smart_tests/test_runners/__init__.py +0 -0
- smart_tests/test_runners/adb.py +24 -0
- smart_tests/test_runners/ant.py +35 -0
- smart_tests/test_runners/bazel.py +103 -0
- smart_tests/test_runners/behave.py +62 -0
- smart_tests/test_runners/codeceptjs.py +33 -0
- smart_tests/test_runners/ctest.py +164 -0
- smart_tests/test_runners/cts.py +189 -0
- smart_tests/test_runners/cucumber.py +451 -0
- smart_tests/test_runners/cypress.py +46 -0
- smart_tests/test_runners/dotnet.py +106 -0
- smart_tests/test_runners/file.py +20 -0
- smart_tests/test_runners/flutter.py +251 -0
- smart_tests/test_runners/go_test.py +99 -0
- smart_tests/test_runners/googletest.py +34 -0
- smart_tests/test_runners/gradle.py +96 -0
- smart_tests/test_runners/jest.py +52 -0
- smart_tests/test_runners/maven.py +149 -0
- smart_tests/test_runners/minitest.py +40 -0
- smart_tests/test_runners/nunit.py +190 -0
- smart_tests/test_runners/playwright.py +252 -0
- smart_tests/test_runners/prove.py +74 -0
- smart_tests/test_runners/pytest.py +358 -0
- smart_tests/test_runners/raw.py +238 -0
- smart_tests/test_runners/robot.py +125 -0
- smart_tests/test_runners/rspec.py +5 -0
- smart_tests/test_runners/smart_tests.py +235 -0
- smart_tests/test_runners/vitest.py +49 -0
- smart_tests/test_runners/xctest.py +79 -0
- smart_tests/testpath.py +154 -0
- smart_tests/utils/__init__.py +0 -0
- smart_tests/utils/authentication.py +78 -0
- smart_tests/utils/ci_provider.py +7 -0
- smart_tests/utils/commands.py +14 -0
- smart_tests/utils/commit_ingester.py +59 -0
- smart_tests/utils/common_tz.py +12 -0
- smart_tests/utils/edit_distance.py +11 -0
- smart_tests/utils/env_keys.py +19 -0
- smart_tests/utils/exceptions.py +34 -0
- smart_tests/utils/fail_fast_mode.py +99 -0
- smart_tests/utils/file_name_pattern.py +4 -0
- smart_tests/utils/git_log_parser.py +53 -0
- smart_tests/utils/glob.py +44 -0
- smart_tests/utils/gzipgen.py +46 -0
- smart_tests/utils/http_client.py +169 -0
- smart_tests/utils/java.py +61 -0
- smart_tests/utils/link.py +149 -0
- smart_tests/utils/logger.py +53 -0
- smart_tests/utils/no_build.py +2 -0
- smart_tests/utils/sax.py +119 -0
- smart_tests/utils/session.py +73 -0
- smart_tests/utils/smart_tests_client.py +134 -0
- smart_tests/utils/subprocess.py +12 -0
- smart_tests/utils/tracking.py +95 -0
- smart_tests/utils/typer_types.py +241 -0
- smart_tests/version.py +7 -0
- smart_tests_cli-2.0.0.dist-info/METADATA +168 -0
- smart_tests_cli-2.0.0.dist-info/RECORD +96 -0
- smart_tests_cli-2.0.0.dist-info/WHEEL +5 -0
- smart_tests_cli-2.0.0.dist-info/entry_points.txt +2 -0
- smart_tests_cli-2.0.0.dist-info/licenses/LICENSE.txt +202 -0
- smart_tests_cli-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from io import TextIOWrapper
|
|
10
|
+
from multiprocessing import Process
|
|
11
|
+
from os.path import join
|
|
12
|
+
from typing import Annotated, Any, Callable, Dict, Iterable, List
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from tabulate import tabulate
|
|
16
|
+
|
|
17
|
+
import smart_tests.args4p.typer as typer
|
|
18
|
+
from smart_tests.utils.authentication import get_org_workspace
|
|
19
|
+
from smart_tests.utils.commands import Command
|
|
20
|
+
from smart_tests.utils.exceptions import print_error_and_die
|
|
21
|
+
from smart_tests.utils.session import get_session, parse_session
|
|
22
|
+
from smart_tests.utils.tracking import Tracking, TrackingClient
|
|
23
|
+
|
|
24
|
+
from ..app import Application
|
|
25
|
+
from ..args4p.command import Group
|
|
26
|
+
from ..args4p.converters import fileText, floatType, intType
|
|
27
|
+
from ..testpath import FilePathNormalizer, TestPath
|
|
28
|
+
from ..utils.env_keys import REPORT_ERROR_KEY
|
|
29
|
+
from ..utils.fail_fast_mode import (FailFastModeValidateParams, fail_fast_mode_validate,
|
|
30
|
+
set_fail_fast_mode, warn_and_exit_if_fail_fast_mode)
|
|
31
|
+
from ..utils.smart_tests_client import SmartTestsClient
|
|
32
|
+
from ..utils.typer_types import Duration, Percentage, parse_duration, parse_percentage
|
|
33
|
+
from .test_path_writer import TestPathWriter
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubsetUseCase(str, Enum):
|
|
37
|
+
ONE_COMMIT = "one-commit"
|
|
38
|
+
FEATURE_BRANCH = "feature-branch"
|
|
39
|
+
RECURRING = "recurring"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SubsetResult:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
subset=None,
|
|
46
|
+
rest=None,
|
|
47
|
+
subset_id: str = "",
|
|
48
|
+
summary=None,
|
|
49
|
+
is_brainless: bool = False,
|
|
50
|
+
is_observation: bool = False):
|
|
51
|
+
self.subset = subset or []
|
|
52
|
+
self.rest = rest or []
|
|
53
|
+
self.subset_id = subset_id
|
|
54
|
+
self.summary = summary or {}
|
|
55
|
+
self.is_brainless = is_brainless
|
|
56
|
+
self.is_observation = is_observation
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_response(cls, response: Dict[str, Any]) -> 'SubsetResult':
|
|
60
|
+
return cls(
|
|
61
|
+
subset=response.get("testPaths", []),
|
|
62
|
+
rest=response.get("rest", []),
|
|
63
|
+
subset_id=response.get("subsettingId", ""),
|
|
64
|
+
summary=response.get("summary", {}),
|
|
65
|
+
is_brainless=response.get("isBrainless", False),
|
|
66
|
+
is_observation=response.get("isObservation", False)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_test_paths(cls, test_paths: List[TestPath]) -> 'SubsetResult':
|
|
71
|
+
return cls(
|
|
72
|
+
subset=test_paths,
|
|
73
|
+
rest=[],
|
|
74
|
+
subset_id='',
|
|
75
|
+
summary={},
|
|
76
|
+
is_brainless=False,
|
|
77
|
+
is_observation=False
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Where we take TestPath, we also accept a path name as a string.
|
|
82
|
+
TestPathLike = str | TestPath
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Subset(TestPathWriter):
|
|
86
|
+
# test_paths: List[TestPath] # doesn't work with Python 3.5
|
|
87
|
+
# is_get_tests_from_previous_sessions: bool
|
|
88
|
+
|
|
89
|
+
input_given = False # set to True when an attempt was made to add to self.test_paths
|
|
90
|
+
|
|
91
|
+
# output_handler: Callable[[
|
|
92
|
+
# List[TestPathLike], List[TestPathLike]], None]
|
|
93
|
+
|
|
94
|
+
# (Kohsuke) function that takes (subset,rest) and output the rest part, I think.
|
|
95
|
+
# I'm actually not entirely sure what this pluggability does
|
|
96
|
+
exclusion_output_handler: Callable[[List[TestPath], List[TestPath]], None]
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
app: Application,
|
|
101
|
+
session: Annotated[str, typer.Option(
|
|
102
|
+
"--session",
|
|
103
|
+
help="In the format builds/<build-name>/test_sessions/<test-session-id>",
|
|
104
|
+
metavar="SESSION",
|
|
105
|
+
required=True
|
|
106
|
+
)],
|
|
107
|
+
target: Annotated[Percentage | None, typer.Option(
|
|
108
|
+
type=parse_percentage,
|
|
109
|
+
help="subsetting target from 0% to 100%"
|
|
110
|
+
)] = None,
|
|
111
|
+
time: Annotated[Duration | None, typer.Option(
|
|
112
|
+
type=parse_duration,
|
|
113
|
+
help="subsetting by absolute time, in seconds e.g) 300, 5m"
|
|
114
|
+
)] = None,
|
|
115
|
+
confidence: Annotated[Percentage | None, typer.Option(
|
|
116
|
+
type=parse_percentage,
|
|
117
|
+
help="subsetting by confidence from 0% to 100%"
|
|
118
|
+
)] = None,
|
|
119
|
+
goal_spec: Annotated[str | None, typer.Option(
|
|
120
|
+
help="subsetting by programmatic goal definition"
|
|
121
|
+
)] = None,
|
|
122
|
+
base_path: Annotated[str | None, typer.Option(
|
|
123
|
+
'--base',
|
|
124
|
+
help="(Advanced) base directory to make test names portable",
|
|
125
|
+
metavar="DIR"
|
|
126
|
+
)] = None,
|
|
127
|
+
rest: Annotated[str | None, typer.Option(
|
|
128
|
+
help="Output the subset remainder to a file, e.g. `--rest=remainder.txt`"
|
|
129
|
+
)] = None,
|
|
130
|
+
# TODO(Konboi): omit from the smart-tests command initial release
|
|
131
|
+
# split: Annotated[bool, typer.Option(
|
|
132
|
+
# help="split"
|
|
133
|
+
# )] = False,
|
|
134
|
+
no_base_path_inference: Annotated[bool, typer.Option(
|
|
135
|
+
"--no-base-path-inference",
|
|
136
|
+
help="Do not guess the base path to relativize the test file paths. "
|
|
137
|
+
"By default, if the test file paths are absolute file paths, it automatically "
|
|
138
|
+
"guesses the repository root directory and relativize the paths. With this "
|
|
139
|
+
"option, the command doesn't do this guess work. "
|
|
140
|
+
"If --base is specified, the absolute file paths are relativized to the "
|
|
141
|
+
"specified path irrelevant to this option. Use it if the guessed base path is incorrect."
|
|
142
|
+
)] = False,
|
|
143
|
+
ignore_new_tests: Annotated[bool, typer.Option(
|
|
144
|
+
"--ignore-new-tests",
|
|
145
|
+
help="Ignore tests that were added recently. "
|
|
146
|
+
"NOTICE: this option will ignore tests that you added just now as well"
|
|
147
|
+
)] = False,
|
|
148
|
+
is_get_tests_from_previous_sessions: Annotated[bool, typer.Option(
|
|
149
|
+
"--get-tests-from-previous-sessions",
|
|
150
|
+
help="get subset list from previous full tests"
|
|
151
|
+
)] = False,
|
|
152
|
+
is_output_exclusion_rules: Annotated[bool, typer.Option(
|
|
153
|
+
"--output-exclusion-rules",
|
|
154
|
+
help="outputs the exclude test list. Switch the subset and rest."
|
|
155
|
+
)] = False,
|
|
156
|
+
is_non_blocking: Annotated[bool, typer.Option(
|
|
157
|
+
"--non-blocking",
|
|
158
|
+
help="Do not wait for subset requests in observation mode.",
|
|
159
|
+
hidden=True
|
|
160
|
+
)] = False,
|
|
161
|
+
ignore_flaky_tests_above: Annotated[float | None, typer.Option(
|
|
162
|
+
help="Ignore flaky tests above the value set by this option. You can confirm flaky scores in WebApp",
|
|
163
|
+
type=floatType(min=0.0, max=1.0)
|
|
164
|
+
)] = None,
|
|
165
|
+
prioritize_tests_failed_within_hours: Annotated[int | None, typer.Option(
|
|
166
|
+
help="Prioritize tests that failed within the specified hours; maximum 720 hours (= 24 hours * 30 days)",
|
|
167
|
+
type=intType(min=0, max=24 * 30)
|
|
168
|
+
)] = None,
|
|
169
|
+
prioritized_tests_mapping_file: Annotated[TextIOWrapper | None, typer.Option(
|
|
170
|
+
"--prioritized-tests-mapping",
|
|
171
|
+
help="Prioritize tests based on test mapping file",
|
|
172
|
+
type=fileText(mode="r")
|
|
173
|
+
)] = None,
|
|
174
|
+
is_get_tests_from_guess: Annotated[bool, typer.Option(
|
|
175
|
+
"--get-tests-from-guess",
|
|
176
|
+
help="Get subset list from guessed tests"
|
|
177
|
+
)] = False,
|
|
178
|
+
use_case: Annotated[SubsetUseCase | None, typer.Option(
|
|
179
|
+
"--use-case",
|
|
180
|
+
hidden=True
|
|
181
|
+
)] = None,
|
|
182
|
+
test_runner: Annotated[str | None, typer.Argument()] = None,
|
|
183
|
+
):
|
|
184
|
+
super().__init__(app)
|
|
185
|
+
|
|
186
|
+
app.test_runner = test_runner
|
|
187
|
+
self.tracking_client = TrackingClient(Command.SUBSET, app=app)
|
|
188
|
+
self.client = SmartTestsClient(app=app, tracking_client=self.tracking_client)
|
|
189
|
+
|
|
190
|
+
set_fail_fast_mode(self.client.is_fail_fast_mode())
|
|
191
|
+
fail_fast_mode_validate(FailFastModeValidateParams(command=Command.SUBSET, session=session))
|
|
192
|
+
|
|
193
|
+
def warn(msg: str):
|
|
194
|
+
click.secho("Warning: " + msg, fg="yellow", err=True)
|
|
195
|
+
self.tracking_client.send_error_event(
|
|
196
|
+
event_name=Tracking.ErrorEvent.WARNING_ERROR,
|
|
197
|
+
stack_trace=msg
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Note(Konboi): when get_session throws exception, is_observation won't be defined
|
|
201
|
+
# To avoid that, we define is_observation here (out of try block)
|
|
202
|
+
is_observation = False
|
|
203
|
+
try:
|
|
204
|
+
test_session = get_session(session, self.client)
|
|
205
|
+
self.build_name = test_session.build_name
|
|
206
|
+
self.session_id = test_session.id
|
|
207
|
+
is_observation = test_session.observation_mode
|
|
208
|
+
except ValueError as e:
|
|
209
|
+
print_error_and_die(msg=str(e), tracking_client=self.tracking_client, event=Tracking.ErrorEvent.USER_ERROR)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
if os.getenv(REPORT_ERROR_KEY):
|
|
212
|
+
raise e
|
|
213
|
+
else:
|
|
214
|
+
# not to block pipeline, parse session and use it
|
|
215
|
+
self.client.print_exception_and_recover(e, "Warning: failed to check test session")
|
|
216
|
+
self.build_name, self.session_id = parse_session(session)
|
|
217
|
+
|
|
218
|
+
if is_get_tests_from_guess and is_get_tests_from_previous_sessions:
|
|
219
|
+
print_error_and_die(
|
|
220
|
+
"--get-tests-from-guess (list up tests from git ls-files and subset from there) and --get-tests-from-previous-sessions (list up tests from the recent runs and subset from there) are mutually exclusive. Which one do you want to use?", # noqa E501
|
|
221
|
+
self.tracking_client,
|
|
222
|
+
Tracking.ErrorEvent.USER_ERROR
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if is_observation and is_output_exclusion_rules:
|
|
226
|
+
warn("--observation and --output-exclusion-rules are set. No output will be generated.")
|
|
227
|
+
|
|
228
|
+
if prioritize_tests_failed_within_hours is not None and prioritize_tests_failed_within_hours > 0:
|
|
229
|
+
if ignore_new_tests or (ignore_flaky_tests_above is not None and ignore_flaky_tests_above > 0):
|
|
230
|
+
print_error_and_die(
|
|
231
|
+
"Cannot use --ignore-new-tests or --ignore-flaky-tests-above options with --prioritize-tests-failed-within-hours", # noqa E501
|
|
232
|
+
self.tracking_client,
|
|
233
|
+
Tracking.ErrorEvent.INTERNAL_CLI_ERROR)
|
|
234
|
+
|
|
235
|
+
if is_non_blocking and not is_observation:
|
|
236
|
+
print_error_and_die(
|
|
237
|
+
"You have to specify --observation option to use non-blocking mode",
|
|
238
|
+
self.tracking_client,
|
|
239
|
+
Tracking.ErrorEvent.INTERNAL_CLI_ERROR)
|
|
240
|
+
|
|
241
|
+
self.target = target
|
|
242
|
+
self.time = time
|
|
243
|
+
self.confidence = confidence
|
|
244
|
+
self.goal_spec = goal_spec
|
|
245
|
+
self.base_path = base_path
|
|
246
|
+
self.base_path_explicitly_set = (base_path is not None)
|
|
247
|
+
self.rest = rest
|
|
248
|
+
self.ignore_new_tests = ignore_new_tests
|
|
249
|
+
self.is_get_tests_from_previous_sessions = is_get_tests_from_previous_sessions
|
|
250
|
+
self.is_output_exclusion_rules = is_output_exclusion_rules
|
|
251
|
+
self.is_non_blocking = is_non_blocking
|
|
252
|
+
self.ignore_flaky_tests_above = ignore_flaky_tests_above
|
|
253
|
+
self.prioritize_tests_failed_within_hours = prioritize_tests_failed_within_hours
|
|
254
|
+
self.prioritized_tests_mapping_file = prioritized_tests_mapping_file
|
|
255
|
+
self.is_get_tests_from_guess = is_get_tests_from_guess
|
|
256
|
+
self.use_case = use_case
|
|
257
|
+
|
|
258
|
+
self.file_path_normalizer = FilePathNormalizer(base_path, no_base_path_inference=no_base_path_inference)
|
|
259
|
+
|
|
260
|
+
self.test_paths: list[list[dict[str, str]]] = []
|
|
261
|
+
self.output_handler = self._default_output_handler
|
|
262
|
+
self.exclusion_output_handler = self._default_exclusion_output_handler
|
|
263
|
+
|
|
264
|
+
def _default_output_handler(self, output: list[TestPath], rests: list[TestPath]):
|
|
265
|
+
if self.rest:
|
|
266
|
+
self.write_file(self.rest, rests)
|
|
267
|
+
|
|
268
|
+
if output:
|
|
269
|
+
self.print(output)
|
|
270
|
+
|
|
271
|
+
def _default_exclusion_output_handler(self, subset: list[TestPath], rest: list[TestPath]):
|
|
272
|
+
self.output_handler(rest, subset)
|
|
273
|
+
|
|
274
|
+
def test_path(self, path: TestPathLike):
|
|
275
|
+
"""register one test"""
|
|
276
|
+
|
|
277
|
+
def rel_base_path(path):
|
|
278
|
+
if isinstance(path, str):
|
|
279
|
+
return pathlib.Path(self.file_path_normalizer.relativize(path)).as_posix()
|
|
280
|
+
else:
|
|
281
|
+
return path
|
|
282
|
+
|
|
283
|
+
self.input_given = True
|
|
284
|
+
if isinstance(path, str) and any(s in path for s in ('*', "?")):
|
|
285
|
+
for i in glob.iglob(path, recursive=True):
|
|
286
|
+
if os.path.isfile(i):
|
|
287
|
+
self.test_paths.append(self.to_test_path(rel_base_path(i)))
|
|
288
|
+
else:
|
|
289
|
+
self.test_paths.append(self.to_test_path(rel_base_path(path)))
|
|
290
|
+
|
|
291
|
+
def stdin(self) -> Iterable[str]:
|
|
292
|
+
"""
|
|
293
|
+
Returns sys.stdin, but after ensuring that it's connected to something reasonable.
|
|
294
|
+
|
|
295
|
+
This prevents a typical problem where users think CLI is hanging because
|
|
296
|
+
they didn't feed anything from stdin
|
|
297
|
+
|
|
298
|
+
HACK(Kohsuke): When is_get_tests_from_previous_sessions was added, that flag should have
|
|
299
|
+
selected the code path that doesn't use stdin. But instead, for some reasons the change
|
|
300
|
+
was made to make stdin() return empty list. Until we fix that, this function is returning
|
|
301
|
+
Iterable[str], so that we can return [] as "empty stdin".
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
# To avoid the cli continue to wait from stdin
|
|
305
|
+
if self.is_get_tests_from_previous_sessions or self.is_get_tests_from_guess:
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
if sys.stdin.isatty():
|
|
309
|
+
warn_and_exit_if_fail_fast_mode(
|
|
310
|
+
"Warning: this command reads from stdin but it doesn't appear to be connected to anything. "
|
|
311
|
+
"Did you forget to pipe from another command?"
|
|
312
|
+
)
|
|
313
|
+
return sys.stdin
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def to_test_path(x: TestPathLike) -> TestPath:
|
|
317
|
+
"""Convert input to a TestPath"""
|
|
318
|
+
if isinstance(x, str):
|
|
319
|
+
# default representation for a file
|
|
320
|
+
return [{'type': 'file', 'name': x}]
|
|
321
|
+
else:
|
|
322
|
+
return x
|
|
323
|
+
|
|
324
|
+
def scan(self, base: str, pattern: str,
|
|
325
|
+
path_builder: Callable[[str], TestPathLike | None] | None = None):
|
|
326
|
+
"""
|
|
327
|
+
Starting at the 'base' path, recursively add everything that matches the given GLOB pattern
|
|
328
|
+
|
|
329
|
+
scan('src/test/java', '**/*.java')
|
|
330
|
+
|
|
331
|
+
'path_builder' is a function used to map file name into a custom test path.
|
|
332
|
+
It takes a single string argument that represents the portion matched to the glob pattern,
|
|
333
|
+
and its return value controls what happens to that file:
|
|
334
|
+
- skip a file by returning a False-like object
|
|
335
|
+
- if a str is returned, that's interpreted as a path name and
|
|
336
|
+
converted to the default test path representation. Typically, `os.path.join(base,file_name)
|
|
337
|
+
- if a TestPath is returned, that's added as is
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
self.input_given = True
|
|
341
|
+
|
|
342
|
+
if path_builder is None:
|
|
343
|
+
# default implementation of path_builder creates a file name relative to `source` so as not
|
|
344
|
+
# to be affected by the path
|
|
345
|
+
def default_path_builder(file_name):
|
|
346
|
+
return pathlib.Path(self.file_path_normalizer.relativize(join(base, file_name))).as_posix()
|
|
347
|
+
|
|
348
|
+
path_builder = default_path_builder
|
|
349
|
+
|
|
350
|
+
for b in glob.iglob(base):
|
|
351
|
+
for t in glob.iglob(join(b, pattern), recursive=True):
|
|
352
|
+
path = path_builder(os.path.relpath(t, b))
|
|
353
|
+
if path:
|
|
354
|
+
self.test_paths.append(self.to_test_path(path))
|
|
355
|
+
|
|
356
|
+
def get_payload(self) -> dict[str, Any]:
|
|
357
|
+
payload: dict[str, Any] = {
|
|
358
|
+
"testPaths": self.test_paths,
|
|
359
|
+
"testRunner": self.app.test_runner,
|
|
360
|
+
"session": {
|
|
361
|
+
# expecting just the last component, not the whole path
|
|
362
|
+
"id": os.path.basename(str(self.session_id))
|
|
363
|
+
},
|
|
364
|
+
"ignoreNewTests": self.ignore_new_tests,
|
|
365
|
+
"getTestsFromPreviousSessions": self.is_get_tests_from_previous_sessions,
|
|
366
|
+
"getTestsFromGuess": self.is_get_tests_from_guess,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if self.target is not None:
|
|
370
|
+
payload["goal"] = {
|
|
371
|
+
"type": "subset-by-percentage",
|
|
372
|
+
"percentage": float(self.target),
|
|
373
|
+
}
|
|
374
|
+
elif self.time is not None:
|
|
375
|
+
payload["goal"] = {
|
|
376
|
+
"type": "subset-by-absolute-time",
|
|
377
|
+
"duration": float(self.time),
|
|
378
|
+
}
|
|
379
|
+
elif self.confidence is not None:
|
|
380
|
+
payload["goal"] = {
|
|
381
|
+
"type": "subset-by-confidence",
|
|
382
|
+
"percentage": float(self.confidence)
|
|
383
|
+
}
|
|
384
|
+
elif self.goal_spec is not None:
|
|
385
|
+
payload["goal"] = {
|
|
386
|
+
"type": "subset-by-goal-spec",
|
|
387
|
+
"goal": self.goal_spec
|
|
388
|
+
}
|
|
389
|
+
else:
|
|
390
|
+
payload['useServerSideOptimizationTarget'] = True
|
|
391
|
+
|
|
392
|
+
if self.ignore_flaky_tests_above:
|
|
393
|
+
payload["dropFlakinessThreshold"] = self.ignore_flaky_tests_above
|
|
394
|
+
|
|
395
|
+
if self.prioritize_tests_failed_within_hours:
|
|
396
|
+
payload["hoursToPrioritizeFailedTest"] = self.prioritize_tests_failed_within_hours
|
|
397
|
+
|
|
398
|
+
if self.prioritized_tests_mapping_file:
|
|
399
|
+
payload['prioritizedTestsMapping'] = json.load(self.prioritized_tests_mapping_file)
|
|
400
|
+
|
|
401
|
+
if self.use_case:
|
|
402
|
+
payload['changesUnderTest'] = self.use_case.value
|
|
403
|
+
|
|
404
|
+
return payload
|
|
405
|
+
|
|
406
|
+
def _collect_potential_test_files(self):
|
|
407
|
+
LOOSE_TEST_FILE_PATTERN = r'(\.(test|spec)\.|_test\.|Test\.|Spec\.|test/|tests/|__tests__/|src/test/)'
|
|
408
|
+
EXCLUDE_PATTERN = r'\.(xml|json|txt|yml|yaml|md)$'
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
git_managed_files = subprocess.run(['git', 'ls-files'], stdout=subprocess.PIPE,
|
|
412
|
+
universal_newlines=True, check=True).stdout.strip().split('\n')
|
|
413
|
+
except subprocess.CalledProcessError as e:
|
|
414
|
+
warn_and_exit_if_fail_fast_mode(f"git ls-files failed (exit code={e.returncode})")
|
|
415
|
+
return
|
|
416
|
+
except OSError as e:
|
|
417
|
+
warn_and_exit_if_fail_fast_mode(f"git ls-files failed: {e}")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
found = False
|
|
421
|
+
for f in git_managed_files:
|
|
422
|
+
if re.search(LOOSE_TEST_FILE_PATTERN, f) and not re.search(EXCLUDE_PATTERN, f):
|
|
423
|
+
self.test_paths.append(self.to_test_path(f))
|
|
424
|
+
found = True
|
|
425
|
+
|
|
426
|
+
if not found:
|
|
427
|
+
warn_and_exit_if_fail_fast_mode("Nothing that looks like a test file in the current git repository.")
|
|
428
|
+
|
|
429
|
+
def request_subset(self) -> SubsetResult:
|
|
430
|
+
# temporarily extend the timeout because subset API response has become slow
|
|
431
|
+
# TODO: remove this line when API response return response
|
|
432
|
+
# within 300 sec
|
|
433
|
+
timeout = (5, 300)
|
|
434
|
+
payload = self.get_payload()
|
|
435
|
+
|
|
436
|
+
if self.is_non_blocking:
|
|
437
|
+
# Create a new process for requesting a subset.
|
|
438
|
+
process = Process(target=subset_request, args=(self.client, timeout, payload))
|
|
439
|
+
process.start()
|
|
440
|
+
click.echo("The subset was requested in non-blocking mode.", err=True)
|
|
441
|
+
self.output_handler(self.test_paths, [])
|
|
442
|
+
# With non-blocking mode, we don't need to wait for the response
|
|
443
|
+
sys.exit(0)
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
res = subset_request(client=self.client, timeout=timeout, payload=payload)
|
|
447
|
+
# The status code 422 is returned when validation error of the test mapping file occurs.
|
|
448
|
+
if res.status_code == 422:
|
|
449
|
+
print_error_and_die("Error: {}".format(res.reason), self.tracking_client, Tracking.ErrorEvent.USER_ERROR)
|
|
450
|
+
|
|
451
|
+
res.raise_for_status()
|
|
452
|
+
|
|
453
|
+
return SubsetResult.from_response(res.json())
|
|
454
|
+
except Exception as e:
|
|
455
|
+
self.tracking_client.send_error_event(
|
|
456
|
+
event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
|
|
457
|
+
stack_trace=str(e),
|
|
458
|
+
)
|
|
459
|
+
self.client.print_exception_and_recover(
|
|
460
|
+
e, "Warning: the service failed to subset. Falling back to running all tests")
|
|
461
|
+
return SubsetResult.from_test_paths(self.test_paths)
|
|
462
|
+
|
|
463
|
+
def run(self):
|
|
464
|
+
"""called after tests are scanned to compute the optimized order"""
|
|
465
|
+
|
|
466
|
+
if self.is_get_tests_from_guess:
|
|
467
|
+
self._collect_potential_test_files()
|
|
468
|
+
|
|
469
|
+
if not self.is_get_tests_from_previous_sessions and len(self.test_paths) == 0:
|
|
470
|
+
if self.input_given:
|
|
471
|
+
print_error_and_die("ERROR: Given arguments did not match any tests. They appear to be incorrect/non-existent.", tracking_client, Tracking.ErrorEvent.USER_ERROR) # noqa E501
|
|
472
|
+
else:
|
|
473
|
+
print_error_and_die(
|
|
474
|
+
"ERROR: Expecting tests to be given, but none provided. See https://help.launchableinc.com/features/predictive-test-selection/requesting-and-running-a-subset-of-tests/ and provide ones, or use the `--get-tests-from-previous-sessions` option", # noqa E501
|
|
475
|
+
self.tracking_client,
|
|
476
|
+
Tracking.ErrorEvent.USER_ERROR)
|
|
477
|
+
|
|
478
|
+
# When Error occurs, return the test name as it is passed.
|
|
479
|
+
if not self.session_id:
|
|
480
|
+
# Session ID in --session is missing. It might be caused by
|
|
481
|
+
# Launchable API errors.
|
|
482
|
+
subset_result = SubsetResult.from_test_paths(self.test_paths)
|
|
483
|
+
else:
|
|
484
|
+
subset_result = self.request_subset()
|
|
485
|
+
|
|
486
|
+
if len(subset_result.subset) == 0:
|
|
487
|
+
warn_and_exit_if_fail_fast_mode("Error: no tests found matching the path.")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
# TODO(Konboi): split subset isn't provided for smart-tests initial release
|
|
491
|
+
# if split:
|
|
492
|
+
# click.echo("subset/{}".format(subset_result.subset_id))
|
|
493
|
+
output_subset, output_rests = subset_result.subset, subset_result.rest
|
|
494
|
+
|
|
495
|
+
if subset_result.is_observation:
|
|
496
|
+
output_subset = output_subset + output_rests
|
|
497
|
+
output_rests = []
|
|
498
|
+
|
|
499
|
+
if self.is_output_exclusion_rules:
|
|
500
|
+
self.exclusion_output_handler(output_subset, output_rests)
|
|
501
|
+
else:
|
|
502
|
+
self.output_handler(output_subset, output_rests)
|
|
503
|
+
|
|
504
|
+
# When Launchable returns an error, the cli skips showing summary
|
|
505
|
+
# report
|
|
506
|
+
original_subset = subset_result.subset
|
|
507
|
+
original_rest = subset_result.rest
|
|
508
|
+
summary = subset_result.summary
|
|
509
|
+
if "subset" not in summary.keys() or "rest" not in summary.keys():
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
org, workspace = get_org_workspace()
|
|
513
|
+
|
|
514
|
+
header = ["", "Candidates",
|
|
515
|
+
"Estimated duration (%)", "Estimated duration (min)"]
|
|
516
|
+
rows = [
|
|
517
|
+
[
|
|
518
|
+
"Subset",
|
|
519
|
+
len(original_subset),
|
|
520
|
+
summary["subset"].get("rate", 0.0),
|
|
521
|
+
summary["subset"].get("duration", 0.0),
|
|
522
|
+
],
|
|
523
|
+
[
|
|
524
|
+
"Remainder",
|
|
525
|
+
len(original_rest),
|
|
526
|
+
summary["rest"].get("rate", 0.0),
|
|
527
|
+
summary["rest"].get("duration", 0.0),
|
|
528
|
+
],
|
|
529
|
+
[],
|
|
530
|
+
[
|
|
531
|
+
"Total",
|
|
532
|
+
len(original_subset) + len(original_rest),
|
|
533
|
+
summary["subset"].get("rate", 0.0) + summary["rest"].get("rate", 0.0),
|
|
534
|
+
summary["subset"].get("duration", 0.0) + summary["rest"].get("duration", 0.0),
|
|
535
|
+
],
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
if subset_result.is_brainless:
|
|
539
|
+
click.echo(
|
|
540
|
+
"Your model is currently in training", err=True)
|
|
541
|
+
|
|
542
|
+
click.echo(
|
|
543
|
+
"Smart Tests created subset {} for build {} (test session {}) in workspace {}/{}".format(
|
|
544
|
+
subset_result.subset_id,
|
|
545
|
+
self.build_name,
|
|
546
|
+
self.session_id,
|
|
547
|
+
org, workspace,
|
|
548
|
+
), err=True,
|
|
549
|
+
)
|
|
550
|
+
if subset_result.is_observation:
|
|
551
|
+
click.echo(
|
|
552
|
+
"(This test session is under observation mode)",
|
|
553
|
+
err=True)
|
|
554
|
+
|
|
555
|
+
click.echo("", err=True)
|
|
556
|
+
click.echo(tabulate(rows, header, tablefmt="github", floatfmt=".2f"), err=True)
|
|
557
|
+
|
|
558
|
+
click.echo(
|
|
559
|
+
"\nRun `smart-tests inspect subset --subset-id {}` to view full subset details".format(subset_result.subset_id),
|
|
560
|
+
err=True)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
subset = Group(callback=Subset, help="Subsetting tests")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def subset_request(client: SmartTestsClient, timeout: tuple[int, int], payload: dict[str, Any]):
|
|
567
|
+
return client.request("post", "subset", timeout=timeout, payload=payload, compress=True)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from os.path import join
|
|
2
|
+
from typing import Callable, Dict, List
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ..app import Application
|
|
7
|
+
from ..testpath import TestPath
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestPathWriter(object):
|
|
11
|
+
base_path: str | None = None
|
|
12
|
+
base_path_explicitly_set: bool = False # Track if base_path was explicitly provided
|
|
13
|
+
|
|
14
|
+
# string to write between different test paths
|
|
15
|
+
separator: str = "\n"
|
|
16
|
+
|
|
17
|
+
# pluggable logic to convert TestPath to a printable form
|
|
18
|
+
formatter: Callable[[TestPath], str]
|
|
19
|
+
|
|
20
|
+
def __init__(self, app: Application):
|
|
21
|
+
self.formatter = self.default_formatter
|
|
22
|
+
self._same_bin_formatter: Callable[[str], Dict[str, str]] | None = None
|
|
23
|
+
self.separator = "\n"
|
|
24
|
+
self.app = app
|
|
25
|
+
|
|
26
|
+
def default_formatter(self, x: TestPath):
|
|
27
|
+
"""default formatter that's in line with to_test_path(str)"""
|
|
28
|
+
file_name = x[0]['name']
|
|
29
|
+
# Only prepend base_path if it was explicitly set via --base option
|
|
30
|
+
# Auto-inferred base paths should not affect output formatting
|
|
31
|
+
if self.base_path and self.base_path_explicitly_set:
|
|
32
|
+
# default behavior consistent with default_path_builder's relative
|
|
33
|
+
# path handling
|
|
34
|
+
file_name = join(str(self.base_path), file_name)
|
|
35
|
+
return file_name
|
|
36
|
+
|
|
37
|
+
def write_file(self, file: str, test_paths: List[TestPath]):
|
|
38
|
+
open(file, "w+", encoding="utf-8").write(
|
|
39
|
+
self.separator.join(self.formatter(t) for t in test_paths))
|
|
40
|
+
|
|
41
|
+
def print(self, test_paths: List[TestPath]):
|
|
42
|
+
click.echo(self.separator.join(self.formatter(t)
|
|
43
|
+
for t in test_paths))
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def same_bin_formatter(self) -> Callable[[str], Dict[str, str]] | None:
|
|
47
|
+
return self._same_bin_formatter
|
|
48
|
+
|
|
49
|
+
@same_bin_formatter.setter
|
|
50
|
+
def same_bin_formatter(self, v: Callable[[str], Dict[str, str]]):
|
|
51
|
+
self._same_bin_formatter = v
|