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,498 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import glob
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from time import time_ns
|
|
8
|
+
from typing import Annotated, Callable, Dict, Generator, List, Tuple, Union
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from dateutil.parser import ParserError, parse
|
|
12
|
+
from junitparser import JUnitXml, TestCase, TestSuite # type: ignore # noqa: F401
|
|
13
|
+
from more_itertools import ichunked
|
|
14
|
+
from tabulate import tabulate
|
|
15
|
+
|
|
16
|
+
import smart_tests.args4p.converters as converters
|
|
17
|
+
import smart_tests.args4p.typer as typer
|
|
18
|
+
from smart_tests.utils.authentication import ensure_org_workspace
|
|
19
|
+
from smart_tests.utils.env_keys import REPORT_ERROR_KEY
|
|
20
|
+
from smart_tests.utils.session import get_session, parse_session
|
|
21
|
+
from smart_tests.utils.tracking import Tracking, TrackingClient
|
|
22
|
+
|
|
23
|
+
from ...app import Application
|
|
24
|
+
from ...args4p.command import Group
|
|
25
|
+
from ...args4p.exceptions import BadCmdLineException
|
|
26
|
+
from ...testpath import FilePathNormalizer, TestPathComponent, unparse_test_path
|
|
27
|
+
from ...utils.commands import Command
|
|
28
|
+
from ...utils.exceptions import InvalidJUnitXMLException, print_error_and_die
|
|
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.logger import Logger
|
|
32
|
+
from ...utils.smart_tests_client import SmartTestsClient
|
|
33
|
+
from .case_event import CaseEvent, CaseEventGenerator, CaseEventType, DataBuilder, TestPathBuilder
|
|
34
|
+
|
|
35
|
+
GROUP_NAME_RULE = re.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
36
|
+
RESERVED_GROUP_NAMES = ["group", "groups", "nogroup", "nogroups"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_group(value):
|
|
40
|
+
if value is None:
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
if str(value).lower() in RESERVED_GROUP_NAMES:
|
|
44
|
+
raise BadCmdLineException(f"{value} is reserved name.")
|
|
45
|
+
|
|
46
|
+
if GROUP_NAME_RULE.match(value):
|
|
47
|
+
return value
|
|
48
|
+
else:
|
|
49
|
+
raise BadCmdLineException("group option supports only alphabet(a-z, A-Z), number(0-9), '-', and '_'")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ParseFunc = Callable[[str], CaseEventGenerator]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RecordTests:
|
|
56
|
+
# The most generic form of parsing, where a path to a test report
|
|
57
|
+
# is turned into a generator by using CaseEvent.create()
|
|
58
|
+
|
|
59
|
+
# A common mechanism to build ParseFunc by building JUnit XML report in-memory (or build it the usual way
|
|
60
|
+
# and patch it to fix things up). This is handy as some libraries
|
|
61
|
+
# produce invalid / broken JUnit reports
|
|
62
|
+
JUnitXmlParseFunc = Callable[[str], ET.Element | ET.ElementTree]
|
|
63
|
+
|
|
64
|
+
path_builder: TestPathBuilder
|
|
65
|
+
|
|
66
|
+
parse_func: ParseFunc
|
|
67
|
+
|
|
68
|
+
build_name: str
|
|
69
|
+
|
|
70
|
+
test_session_id: int
|
|
71
|
+
|
|
72
|
+
# session is generated by `smart-tests record session` command
|
|
73
|
+
# the session format is `builds/<BUILD_NUMBER>/test_sessions/<TEST_SESSION_ID>`
|
|
74
|
+
session: str
|
|
75
|
+
|
|
76
|
+
# This function, if supplied, is used to build a test path
|
|
77
|
+
# that uniquely identifies a test case
|
|
78
|
+
metadata_builder: DataBuilder
|
|
79
|
+
|
|
80
|
+
# setter only property that sits on top of the parse_func property
|
|
81
|
+
def set_junitxml_parse_func(self, f: JUnitXmlParseFunc):
|
|
82
|
+
"""
|
|
83
|
+
Parse XML report file with the JUnit report file, possibly with the custom parser function 'f'
|
|
84
|
+
that can be used to build JUnit ET.Element tree from scratch or do some patch up.
|
|
85
|
+
|
|
86
|
+
If f=None, the default parse code from JUnitParser module is used.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def parse(report: str) -> Generator[CaseEventType, None, None]:
|
|
90
|
+
# To understand JUnit XML format, https://llg.cubic.org/docs/junit/ is helpful
|
|
91
|
+
# TODO: robustness: what's the best way to deal with broken XML
|
|
92
|
+
# file, if any?
|
|
93
|
+
try:
|
|
94
|
+
xml = JUnitXml.fromfile(report, f)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
# `JUnitXml.fromfile()` will raise `JUnitXmlError` and other lxml related errors
|
|
97
|
+
# if the file has wrong format.
|
|
98
|
+
# https://github.com/weiwei/junitparser/blob/master/junitparser/junitparser.py#L321
|
|
99
|
+
warn_and_exit_if_fail_fast_mode(
|
|
100
|
+
"Warning: error reading JUnitXml file {filename}: {error}".format(
|
|
101
|
+
filename=report, error=e))
|
|
102
|
+
return
|
|
103
|
+
if isinstance(xml, JUnitXml):
|
|
104
|
+
testsuites = [suite for suite in xml]
|
|
105
|
+
elif isinstance(xml, TestSuite):
|
|
106
|
+
testsuites = [xml]
|
|
107
|
+
else:
|
|
108
|
+
raise InvalidJUnitXMLException(filename=report)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
for suite in testsuites:
|
|
112
|
+
for case in suite:
|
|
113
|
+
yield CaseEvent.from_case_and_suite(self.path_builder, case, suite, report, self.metadata_builder)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
warn_and_exit_if_fail_fast_mode(
|
|
116
|
+
"Warning: error parsing JUnitXml file {filename}: {error}".format(
|
|
117
|
+
filename=report, error=e))
|
|
118
|
+
|
|
119
|
+
self.parse_func = parse
|
|
120
|
+
|
|
121
|
+
junitxml_parse_func = property(None, set_junitxml_parse_func)
|
|
122
|
+
|
|
123
|
+
check_timestamp: bool
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
app: Application,
|
|
128
|
+
session: Annotated[str, typer.Option(
|
|
129
|
+
"--session",
|
|
130
|
+
help="In the format builds/<build-name>/test_sessions/<test-session-id>",
|
|
131
|
+
required=True
|
|
132
|
+
)],
|
|
133
|
+
base_path: Annotated[Path | None, typer.Option(
|
|
134
|
+
"--base",
|
|
135
|
+
help="(Advanced) base directory to make test names portable",
|
|
136
|
+
type=converters.path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
|
|
137
|
+
)] = None,
|
|
138
|
+
post_chunk: Annotated[int, typer.Option(
|
|
139
|
+
"--post-chunk",
|
|
140
|
+
help="Post chunk"
|
|
141
|
+
)] = 1000,
|
|
142
|
+
no_base_path_inference: Annotated[bool, typer.Option(
|
|
143
|
+
"--no-base-path-inference",
|
|
144
|
+
help="Do not guess the base path to relativize the test file paths. By default, if the test file paths are "
|
|
145
|
+
"absolute file paths, it automatically guesses the repository root directory and relativize the paths. "
|
|
146
|
+
"With this option, the command doesn't do this guess work. If --base-path is specified, the absolute "
|
|
147
|
+
"file paths are relativized to the specified path irrelevant to this option. Use it if the guessed base "
|
|
148
|
+
"path is incorrect."
|
|
149
|
+
)] = False,
|
|
150
|
+
report_paths: Annotated[bool, typer.Option(
|
|
151
|
+
"--report-paths",
|
|
152
|
+
help="Instead of POSTing test results, just report test paths in the report file then quit. For diagnostics. "
|
|
153
|
+
"Use with --dry-run",
|
|
154
|
+
hidden=True
|
|
155
|
+
)] = False,
|
|
156
|
+
group: Annotated[str | None, typer.Option(
|
|
157
|
+
help="Grouping name for test results"
|
|
158
|
+
)] = "",
|
|
159
|
+
is_allow_test_before_build: Annotated[bool, typer.Option(
|
|
160
|
+
"--allow-test-before-build",
|
|
161
|
+
help="",
|
|
162
|
+
hidden=True
|
|
163
|
+
)] = False,
|
|
164
|
+
test_runner: Annotated[str | None, typer.Argument()] = None,
|
|
165
|
+
# TODO(Konboi): restore timestamp option
|
|
166
|
+
):
|
|
167
|
+
self.logger = Logger()
|
|
168
|
+
|
|
169
|
+
self.org, self.workspace = ensure_org_workspace()
|
|
170
|
+
|
|
171
|
+
app.test_runner = test_runner
|
|
172
|
+
self.app = app
|
|
173
|
+
|
|
174
|
+
self.tracking_client = TrackingClient(Command.RECORD_TESTS, app=app)
|
|
175
|
+
self.client = SmartTestsClient(app=app, tracking_client=self.tracking_client)
|
|
176
|
+
set_fail_fast_mode(self.client.is_fail_fast_mode())
|
|
177
|
+
|
|
178
|
+
fail_fast_mode_validate(FailFastModeValidateParams(
|
|
179
|
+
command=Command.RECORD_TESTS,
|
|
180
|
+
session=session,
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
self.post_chunk = post_chunk
|
|
184
|
+
self.report_paths = report_paths
|
|
185
|
+
|
|
186
|
+
# Validate group if provided and ensure it's never None
|
|
187
|
+
if group is None:
|
|
188
|
+
group = ""
|
|
189
|
+
elif group:
|
|
190
|
+
group = _validate_group(group)
|
|
191
|
+
self.group = group
|
|
192
|
+
|
|
193
|
+
self.file_path_normalizer = FilePathNormalizer(
|
|
194
|
+
str(base_path) if base_path else None,
|
|
195
|
+
no_base_path_inference=no_base_path_inference)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
test_session = get_session(session, self.client)
|
|
199
|
+
self.record_start_at = get_record_start_at(session, self.client)
|
|
200
|
+
|
|
201
|
+
test_session_id = test_session.id
|
|
202
|
+
build_name = test_session.build_name
|
|
203
|
+
except ValueError as e:
|
|
204
|
+
print_error_and_die(msg=str(e), event=Tracking.ErrorEvent.USER_ERROR, tracking_client=self.tracking_client)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
if os.getenv(REPORT_ERROR_KEY):
|
|
207
|
+
raise e
|
|
208
|
+
|
|
209
|
+
self.tracking_client.send_error_event(
|
|
210
|
+
event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
|
|
211
|
+
stack_trace=str(e),
|
|
212
|
+
)
|
|
213
|
+
self.client.print_exception_and_recover(e)
|
|
214
|
+
# To prevent users from stopping the CI pipeline, the cli exits with a
|
|
215
|
+
# status code of 0, indicating that the program terminated successfully.
|
|
216
|
+
build_name, test_session_id = parse_session(session)
|
|
217
|
+
exit(0)
|
|
218
|
+
|
|
219
|
+
self.reports: List[str] = []
|
|
220
|
+
self.skipped_reports: List[str] = []
|
|
221
|
+
self.path_builder = CaseEvent.default_path_builder(self.file_path_normalizer)
|
|
222
|
+
self.junitxml_parse_func = None
|
|
223
|
+
self.check_timestamp = True
|
|
224
|
+
self.base_path = str(base_path) if base_path else None
|
|
225
|
+
self.dry_run = app.dry_run # TODO: remove
|
|
226
|
+
self.no_base_path_inference = no_base_path_inference
|
|
227
|
+
self.is_allow_test_before_build = is_allow_test_before_build
|
|
228
|
+
self.build_name = build_name
|
|
229
|
+
self.test_session_id = test_session_id
|
|
230
|
+
self.session = session
|
|
231
|
+
self.metadata_builder = CaseEvent.default_data_builder()
|
|
232
|
+
|
|
233
|
+
def make_file_path_component(self, filepath) -> TestPathComponent:
|
|
234
|
+
"""Create a single TestPathComponent from the given file path"""
|
|
235
|
+
if self.base_path:
|
|
236
|
+
filepath = os.path.relpath(filepath, start=self.base_path)
|
|
237
|
+
return {"type": "file", "name": filepath}
|
|
238
|
+
|
|
239
|
+
def report(self, junit_report_file: str):
|
|
240
|
+
ctime = datetime.datetime.fromtimestamp(
|
|
241
|
+
os.path.getctime(junit_report_file))
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
not self.is_allow_test_before_build # nlqa: W503
|
|
245
|
+
and self.check_timestamp # noqa: W503
|
|
246
|
+
and ctime.timestamp() < self.record_start_at.timestamp() # noqa: W503
|
|
247
|
+
):
|
|
248
|
+
format = "%Y-%m-%d %H:%M:%S"
|
|
249
|
+
self.logger.warning(
|
|
250
|
+
f"skip: {junit_report_file} is too old to report. start_record_at:"
|
|
251
|
+
f"{self.record_start_at.strftime(format)} file_created_at: {ctime.strftime(format)}")
|
|
252
|
+
self.skipped_reports.append(junit_report_file)
|
|
253
|
+
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
self.reports.append(junit_report_file)
|
|
257
|
+
|
|
258
|
+
def scan(self, base: str, pattern: str):
|
|
259
|
+
"""
|
|
260
|
+
Starting at the 'base' path, recursively add everything that matches the given GLOB pattern
|
|
261
|
+
|
|
262
|
+
scan('build/test-reports', '**/*.xml')
|
|
263
|
+
"""
|
|
264
|
+
for t in glob.iglob(os.path.join(base, pattern), recursive=True):
|
|
265
|
+
self.report(t)
|
|
266
|
+
|
|
267
|
+
def run(self):
|
|
268
|
+
count = 0 # count number of test cases sent
|
|
269
|
+
is_observation = False
|
|
270
|
+
|
|
271
|
+
def testcases(reports: List[str]) -> Generator[CaseEventType, None, None]:
|
|
272
|
+
exceptions = []
|
|
273
|
+
for report in reports:
|
|
274
|
+
try:
|
|
275
|
+
for tc in self.parse_func(report):
|
|
276
|
+
# trim empty test path
|
|
277
|
+
if len(tc.get('testPath', [])) == 0:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# Timestamp option has been removed
|
|
281
|
+
|
|
282
|
+
yield tc
|
|
283
|
+
|
|
284
|
+
except Exception as e:
|
|
285
|
+
exceptions.append(Exception(f"Failed to process a report file: {report}", e))
|
|
286
|
+
|
|
287
|
+
if len(exceptions) > 0:
|
|
288
|
+
# defer XML parsing exceptions so that we can send what we
|
|
289
|
+
# can send before we bail out
|
|
290
|
+
raise Exception(exceptions)
|
|
291
|
+
|
|
292
|
+
# generator that creates the payload incrementally
|
|
293
|
+
def payload(
|
|
294
|
+
cases: Generator[TestCase, None, None],
|
|
295
|
+
test_runner, group: str,
|
|
296
|
+
test_suite_name: str,
|
|
297
|
+
flavors: Dict[str, str]) -> Tuple[Dict[str, Union[str, List, dict, bool]], List[Exception]]:
|
|
298
|
+
nonlocal count
|
|
299
|
+
cs = []
|
|
300
|
+
exs = []
|
|
301
|
+
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
cs.append(next(cases))
|
|
305
|
+
except StopIteration:
|
|
306
|
+
break
|
|
307
|
+
except Exception as ex:
|
|
308
|
+
exs.append(ex)
|
|
309
|
+
|
|
310
|
+
count += len(cs)
|
|
311
|
+
return {
|
|
312
|
+
"events": cs,
|
|
313
|
+
"testRunner": test_runner,
|
|
314
|
+
"group": group,
|
|
315
|
+
"metadata": get_env_values(self.client),
|
|
316
|
+
"noBuild": False, # deprecated to set no-build from the record tests command
|
|
317
|
+
# NOTE:
|
|
318
|
+
# testSuite and flavors are applied only when the no-build option is enabled
|
|
319
|
+
"testSuite": test_suite_name,
|
|
320
|
+
"flavors": flavors,
|
|
321
|
+
}, exs
|
|
322
|
+
|
|
323
|
+
def send(payload: Dict[str, Union[str, List]]) -> None:
|
|
324
|
+
res = self.client.request(
|
|
325
|
+
"post", f"{self.session}/events", payload=payload, compress=True)
|
|
326
|
+
res.raise_for_status()
|
|
327
|
+
|
|
328
|
+
nonlocal is_observation
|
|
329
|
+
is_observation = res.json().get("testSession", {}).get("isObservation", False)
|
|
330
|
+
|
|
331
|
+
def recorded_result() -> Tuple[int, int, int, float]:
|
|
332
|
+
test_count = 0
|
|
333
|
+
success_count = 0
|
|
334
|
+
fail_count = 0
|
|
335
|
+
duration = float(0)
|
|
336
|
+
|
|
337
|
+
for tc in testcases(self.reports):
|
|
338
|
+
test_count += 1
|
|
339
|
+
status = tc.get("status")
|
|
340
|
+
if status == 0:
|
|
341
|
+
fail_count += 1
|
|
342
|
+
elif status == 1:
|
|
343
|
+
success_count += 1
|
|
344
|
+
duration += float(tc.get("duration") or 0) # sec
|
|
345
|
+
|
|
346
|
+
return test_count, success_count, fail_count, duration / 60 # sec to min
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
start = time_ns()
|
|
350
|
+
tc = testcases(self.reports)
|
|
351
|
+
end = time_ns()
|
|
352
|
+
self.tracking_client.send_event(
|
|
353
|
+
event_name=Tracking.Event.PERFORMANCE,
|
|
354
|
+
metadata={
|
|
355
|
+
"elapsedTime": end - start,
|
|
356
|
+
"measurementTarget": "testcases method(parsing report file)"
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if self.report_paths:
|
|
361
|
+
# diagnostics mode to just report test paths
|
|
362
|
+
for t in tc:
|
|
363
|
+
print(unparse_test_path(t['testPath']))
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
start = time_ns()
|
|
367
|
+
exceptions = []
|
|
368
|
+
for chunk in ichunked(tc, self.post_chunk):
|
|
369
|
+
p, es = payload(
|
|
370
|
+
cases=chunk,
|
|
371
|
+
test_runner=self.app.test_runner,
|
|
372
|
+
group=self.group,
|
|
373
|
+
test_suite_name="", # test_suite option was removed
|
|
374
|
+
flavors={}, # flavor option was removed
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
send(p)
|
|
378
|
+
exceptions.extend(es)
|
|
379
|
+
end = time_ns()
|
|
380
|
+
self.tracking_client.send_event(
|
|
381
|
+
event_name=Tracking.Event.PERFORMANCE,
|
|
382
|
+
metadata={
|
|
383
|
+
"elapsedTime": end - start,
|
|
384
|
+
"measurementTarget": "events API"
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if len(exceptions) > 0:
|
|
389
|
+
raise Exception(exceptions)
|
|
390
|
+
|
|
391
|
+
except Exception as e:
|
|
392
|
+
self.tracking_client.send_error_event(
|
|
393
|
+
event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
|
|
394
|
+
stack_trace=str(e),
|
|
395
|
+
)
|
|
396
|
+
self.client.print_exception_and_recover(e)
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
if count == 0:
|
|
400
|
+
if len(self.skipped_reports) != 0:
|
|
401
|
+
warn_and_exit_if_fail_fast_mode(
|
|
402
|
+
"{} test report(s) were skipped because they were created before this build was recorded.\n"
|
|
403
|
+
"Make sure to run your tests after you run `smart-tests record build`.\n"
|
|
404
|
+
"Otherwise, if these are really correct test reports, use the `--allow-test-before-build` option.".
|
|
405
|
+
format(len(self.skipped_reports)))
|
|
406
|
+
return
|
|
407
|
+
else:
|
|
408
|
+
warn_and_exit_if_fail_fast_mode(
|
|
409
|
+
"Looks like tests didn't run? If not, make sure the right files/directories were passed into `smart-tests record tests`") # noqa: E501
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
file_count = len(self.reports)
|
|
413
|
+
test_count, success_count, fail_count, duration = recorded_result()
|
|
414
|
+
|
|
415
|
+
click.echo(
|
|
416
|
+
f"Smart Tests recorded tests for build "
|
|
417
|
+
f"{self.build_name}(test session {self.test_session_id}) to "
|
|
418
|
+
f"workspace {self.org} / {self.workspace} from {file_count} files: ")
|
|
419
|
+
|
|
420
|
+
if is_observation:
|
|
421
|
+
click.echo("(This test session is under observation mode)")
|
|
422
|
+
|
|
423
|
+
click.echo("")
|
|
424
|
+
|
|
425
|
+
header = ["Files found", "Tests found", "Tests passed", "Tests failed", "Total duration (min)"]
|
|
426
|
+
|
|
427
|
+
rows = [[file_count, test_count, success_count, fail_count, duration]]
|
|
428
|
+
click.echo(tabulate(rows, header, tablefmt="github", floatfmt=".2f"))
|
|
429
|
+
|
|
430
|
+
if duration == 0:
|
|
431
|
+
click.secho("\nTotal test duration is 0."
|
|
432
|
+
"\nPlease check whether the test duration times in report files are correct.", fg="yellow")
|
|
433
|
+
click.echo(
|
|
434
|
+
f"\nVisit https://app.launchableinc.com/organizations/{self.org}/workspaces/"
|
|
435
|
+
f"{self.workspace}/test-sessions/{self.test_session_id} to view uploaded test results "
|
|
436
|
+
f"(or run `launchable inspect tests --test-session-id {self.test_session_id}`)")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
tests = Group(name="tests", callback=RecordTests, help="Record test results")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# if we fail to determine the timestamp of the build, we err on the side of collecting more test reports
|
|
443
|
+
# than no test reports, so we use the 'epoch' timestamp
|
|
444
|
+
INVALID_TIMESTAMP = datetime.datetime.fromtimestamp(0)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def get_record_start_at(session: str, client: SmartTestsClient):
|
|
448
|
+
"""
|
|
449
|
+
Determine the baseline timestamp to be used for up-to-date checks of report files.
|
|
450
|
+
Only files newer than this timestamp will be collected.
|
|
451
|
+
|
|
452
|
+
Based on the thinking that if a build doesn't exist tests couldn't have possibly run, we attempt
|
|
453
|
+
to use the timestamp of a build, with appropriate fallback.
|
|
454
|
+
"""
|
|
455
|
+
build_name, _ = parse_session(session)
|
|
456
|
+
|
|
457
|
+
sub_path = f"builds/{build_name}"
|
|
458
|
+
|
|
459
|
+
res = client.request("get", sub_path)
|
|
460
|
+
if res.status_code != 200:
|
|
461
|
+
if res.status_code == 404:
|
|
462
|
+
msg = "Build {} was not found. " \
|
|
463
|
+
f"Make sure to run `smart-tests record build --name {build_name}` before `smart-tests record tests`"
|
|
464
|
+
else:
|
|
465
|
+
msg = f"Unable to determine the timestamp of the build {build_name}. HTTP response code was {res.status_code}"
|
|
466
|
+
click.secho(msg, fg='yellow', err=True)
|
|
467
|
+
|
|
468
|
+
# to avoid stop report command
|
|
469
|
+
return INVALID_TIMESTAMP
|
|
470
|
+
|
|
471
|
+
created_at = res.json()["createdAt"]
|
|
472
|
+
Logger().debug(f"Build {build_name} timestamp = {created_at}")
|
|
473
|
+
t = parse_launchable_timeformat(created_at)
|
|
474
|
+
return t
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def parse_launchable_timeformat(t: str) -> datetime.datetime:
|
|
478
|
+
# e.g) "2021-04-01T09:35:47.934+00:00"
|
|
479
|
+
try:
|
|
480
|
+
return parse(t)
|
|
481
|
+
except ParserError:
|
|
482
|
+
return INVALID_TIMESTAMP
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def get_env_values(client: SmartTestsClient) -> Dict[str, str]:
|
|
486
|
+
sub_path = "slack/notification/key/list"
|
|
487
|
+
res = client.request("get", sub_path=sub_path)
|
|
488
|
+
|
|
489
|
+
metadata: Dict[str, str] = {}
|
|
490
|
+
if res.status_code != 200:
|
|
491
|
+
return metadata
|
|
492
|
+
|
|
493
|
+
keys = res.json().get("keys", [])
|
|
494
|
+
for key in keys:
|
|
495
|
+
val = os.getenv(key, "")
|
|
496
|
+
metadata[key] = val
|
|
497
|
+
|
|
498
|
+
return metadata
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Annotated, Any, Dict, List
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
import smart_tests.args4p.typer as typer
|
|
6
|
+
|
|
7
|
+
from ... import args4p
|
|
8
|
+
from ...app import Application
|
|
9
|
+
from ...utils.smart_tests_client import SmartTestsClient
|
|
10
|
+
from ...utils.typer_types import validate_key_value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@args4p.command(help="View test session statistics")
|
|
14
|
+
def test_sessions(
|
|
15
|
+
app: Application,
|
|
16
|
+
days: Annotated[int, typer.Option(
|
|
17
|
+
help="How many days of test sessions in the past to be stat"
|
|
18
|
+
)] = 7,
|
|
19
|
+
flavor: Annotated[List[str], typer.Option(
|
|
20
|
+
multiple=True,
|
|
21
|
+
help="flavors",
|
|
22
|
+
metavar="KEY=VALUE"
|
|
23
|
+
)] = [],
|
|
24
|
+
):
|
|
25
|
+
# Parse flavors
|
|
26
|
+
parsed_flavors = [validate_key_value(f) for f in flavor]
|
|
27
|
+
|
|
28
|
+
params: Dict[str, Any] = {'days': days, 'flavor': []}
|
|
29
|
+
flavors = []
|
|
30
|
+
for f in parsed_flavors:
|
|
31
|
+
flavors.append('%s=%s' % (f[0], f[1]))
|
|
32
|
+
|
|
33
|
+
if flavors:
|
|
34
|
+
params['flavor'] = flavors
|
|
35
|
+
else:
|
|
36
|
+
params.pop('flavor', None)
|
|
37
|
+
|
|
38
|
+
client = SmartTestsClient(app=app)
|
|
39
|
+
try:
|
|
40
|
+
res = client.request('get', '/stats/test-sessions', params=params)
|
|
41
|
+
res.raise_for_status()
|
|
42
|
+
click.echo(res.text)
|
|
43
|
+
|
|
44
|
+
except Exception as e:
|
|
45
|
+
client.print_exception_and_recover(e, "Warning: the service failed to get stat.")
|