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,74 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Annotated, List
|
|
3
|
+
|
|
4
|
+
from junitparser import TestCase, TestSuite # type: ignore
|
|
5
|
+
|
|
6
|
+
import smart_tests.args4p.typer as typer
|
|
7
|
+
|
|
8
|
+
from ..args4p.exceptions import BadCmdLineException
|
|
9
|
+
from ..commands.record.tests import RecordTests
|
|
10
|
+
from ..commands.subset import Subset
|
|
11
|
+
from ..testpath import TestPath
|
|
12
|
+
from . import smart_tests
|
|
13
|
+
|
|
14
|
+
TEARDOWN = "(teardown)"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def remove_leading_number_and_dash(input_string: str) -> str:
|
|
18
|
+
result = re.sub(r'^\d+ - ', '', input_string)
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@smart_tests.subset
|
|
23
|
+
def subset(client: Subset):
|
|
24
|
+
# read lines as test file names
|
|
25
|
+
for t in client.stdin():
|
|
26
|
+
client.test_path(t.rstrip("\n"))
|
|
27
|
+
|
|
28
|
+
client.run()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@smart_tests.record.tests
|
|
32
|
+
def record_tests(
|
|
33
|
+
client: RecordTests,
|
|
34
|
+
reports: Annotated[List[str], typer.Argument(
|
|
35
|
+
multiple=True,
|
|
36
|
+
help="Test report files to process"
|
|
37
|
+
)],
|
|
38
|
+
):
|
|
39
|
+
def path_builder(case: TestCase, suite: TestSuite,
|
|
40
|
+
report_file: str) -> TestPath:
|
|
41
|
+
def find_filename():
|
|
42
|
+
# find file path from test suite attribute first.
|
|
43
|
+
filepath = suite._elem.attrib.get("name")
|
|
44
|
+
if filepath:
|
|
45
|
+
return filepath
|
|
46
|
+
# find file path from test case attribute.
|
|
47
|
+
filepath = case._elem.attrib.get("classname")
|
|
48
|
+
if filepath:
|
|
49
|
+
return filepath
|
|
50
|
+
return None # failing to find a test name
|
|
51
|
+
|
|
52
|
+
filepath = find_filename()
|
|
53
|
+
if not filepath:
|
|
54
|
+
raise BadCmdLineException(
|
|
55
|
+
"No file name found in %s."
|
|
56
|
+
"Perl prove profile is made to take Junit report produced by "
|
|
57
|
+
"TAP::Formatter::JUnit (https://github.com/bleargh45/TAP-Formatter-JUnit), "
|
|
58
|
+
"which exports the report in stdout."
|
|
59
|
+
"Please export the stdout result to XML report file."
|
|
60
|
+
"If you are not using TAP::Formatter::JUnit, "
|
|
61
|
+
"please change your reporting to TAP::Formatter::JUnit." % report_file)
|
|
62
|
+
|
|
63
|
+
# default test path in `subset` expects to have this file name
|
|
64
|
+
test_path = [client.make_file_path_component(filepath)]
|
|
65
|
+
if case.name:
|
|
66
|
+
case_name = remove_leading_number_and_dash(input_string=case.name)
|
|
67
|
+
test_path.append({"type": "testcase", "name": case_name})
|
|
68
|
+
return test_path
|
|
69
|
+
|
|
70
|
+
client.path_builder = path_builder
|
|
71
|
+
|
|
72
|
+
for r in reports:
|
|
73
|
+
client.report(r)
|
|
74
|
+
client.run()
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Annotated, Generator, Iterable, List
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from junitparser import Properties, TestCase # type: ignore
|
|
10
|
+
|
|
11
|
+
import smart_tests.args4p.typer as typer
|
|
12
|
+
from smart_tests.commands.record.case_event import CaseEvent, CaseEventType, MetadataTestCase
|
|
13
|
+
from smart_tests.testpath import TestPath
|
|
14
|
+
|
|
15
|
+
from ..args4p.exceptions import BadCmdLineException
|
|
16
|
+
from ..commands.record.tests import RecordTests
|
|
17
|
+
from ..commands.subset import Subset
|
|
18
|
+
from . import smart_tests
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Please specify junit_family=legacy for pytest report format. if using pytest version 6 or higher.
|
|
22
|
+
# - pytest has changed its default test report format from xunit1 to xunit2 since version 6.
|
|
23
|
+
# - https://docs.pytest.org/en/latest/deprecations.html#junit-family-default-value-change-to-xunit2
|
|
24
|
+
# - The xunit2 format no longer includes file names.
|
|
25
|
+
# - It is possible to output in xunit1 format by specifying junit_family=legacy.
|
|
26
|
+
# - The xunit1 format includes the file name.
|
|
27
|
+
# The format of pytest changes depending on the existence of the class. They are also incompatible with
|
|
28
|
+
# the junit format.
|
|
29
|
+
# Therefore, it converts to junit format at the timing of sending the subset, and converts the returned
|
|
30
|
+
# value to pytest format.
|
|
31
|
+
# for Example
|
|
32
|
+
# $ pytest --collect-only -q
|
|
33
|
+
# > tests/test_mod.py::TestClass::test__can_print_aaa
|
|
34
|
+
# > tests/fooo/func4_test.py::test_func6
|
|
35
|
+
# >
|
|
36
|
+
# > 2 tests collected in 0.02s
|
|
37
|
+
# result.xml(junit)
|
|
38
|
+
# <testcase classname="tests.fooo.func4_test" name="test_func6" file="tests/fooo/func4_test.py"
|
|
39
|
+
# line="0" time="0.000" />
|
|
40
|
+
# <testcase classname="tests.test_mod.TestClass" name="test__can_print_aaa" file="tests/test_mod.py"
|
|
41
|
+
# line="3" time="0.001" />
|
|
42
|
+
#
|
|
43
|
+
@smart_tests.subset
|
|
44
|
+
def subset(
|
|
45
|
+
client: Subset,
|
|
46
|
+
source_roots: Annotated[List[str] | None, typer.Argument(
|
|
47
|
+
help="Source root directories for pytest test collection",
|
|
48
|
+
multiple=True,
|
|
49
|
+
required=False,
|
|
50
|
+
)] = None,
|
|
51
|
+
):
|
|
52
|
+
def _add_testpaths(lines: Iterable[str]):
|
|
53
|
+
for line in lines:
|
|
54
|
+
line = line.rstrip()
|
|
55
|
+
# When an empty line comes, it's done.
|
|
56
|
+
if not line:
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
test_path = _parse_pytest_nodeid(line)
|
|
60
|
+
client.test_path(test_path)
|
|
61
|
+
|
|
62
|
+
if not source_roots:
|
|
63
|
+
_add_testpaths(client.stdin())
|
|
64
|
+
else:
|
|
65
|
+
command = ["pytest", "--collect-only", "-q"]
|
|
66
|
+
command.extend(source_roots)
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(command, stdout=subprocess.PIPE, universal_newlines=True)
|
|
69
|
+
_add_testpaths(result.stdout.split(os.linesep))
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
raise BadCmdLineException("pytest command not found. Please check the path.")
|
|
72
|
+
|
|
73
|
+
client.formatter = _pytest_formatter
|
|
74
|
+
client.run()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_pytest_nodeid(nodeid: str) -> TestPath:
|
|
78
|
+
data = nodeid.split("::")
|
|
79
|
+
file = data[0]
|
|
80
|
+
class_name = _path_to_class_name(file)
|
|
81
|
+
normalized_file = os.path.normpath(file)
|
|
82
|
+
|
|
83
|
+
# file name only
|
|
84
|
+
if len(data) == 1:
|
|
85
|
+
return [
|
|
86
|
+
{"type": "file", "name": normalized_file},
|
|
87
|
+
{"type": "class", "name": class_name},
|
|
88
|
+
]
|
|
89
|
+
# file + testcase, or file + class + testcase
|
|
90
|
+
else:
|
|
91
|
+
testcase = data[-1]
|
|
92
|
+
if len(data) == 3:
|
|
93
|
+
class_name += "." + data[1]
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
{"type": "file", "name": normalized_file},
|
|
97
|
+
{"type": "class", "name": class_name},
|
|
98
|
+
{"type": "testcase", "name": testcase},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _path_to_class_name(path):
|
|
103
|
+
'''
|
|
104
|
+
tests/fooo/func4_test.py -> tests.fooo.func4_test
|
|
105
|
+
'''
|
|
106
|
+
return os.path.splitext(os.path.normpath(path))[0].replace(os.sep, ".")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pytest_formatter(test_path):
|
|
110
|
+
for path in test_path:
|
|
111
|
+
t = path.get('type', '')
|
|
112
|
+
n = path.get('name', '')
|
|
113
|
+
if t == 'class':
|
|
114
|
+
cls_name = n
|
|
115
|
+
elif t == 'testcase':
|
|
116
|
+
case = n
|
|
117
|
+
elif t == 'file':
|
|
118
|
+
file = n
|
|
119
|
+
# If there is no class, junitformat use package name, but pytest will be omitted
|
|
120
|
+
# pytest -> tests/fooo/func4_test.py::test_func6
|
|
121
|
+
# junitformat -> <testcase classname="tests.fooo.func4_test"
|
|
122
|
+
# name="test_func6" file="tests/fooo/func4_test.py" line="0" time="0.000"
|
|
123
|
+
# />
|
|
124
|
+
|
|
125
|
+
if cls_name == _path_to_class_name(file):
|
|
126
|
+
return f"{file}::{case}"
|
|
127
|
+
|
|
128
|
+
else:
|
|
129
|
+
# junitformat's class name includes package, but pytest does not
|
|
130
|
+
# pytest -> tests/test_mod.py::TestClass::test__can_print_aaa
|
|
131
|
+
# junitformat -> <testcase classname="tests.test_mod.TestClass"
|
|
132
|
+
# name="test__can_print_aaa" file="tests/test_mod.py" line="3"
|
|
133
|
+
# time="0.001" />
|
|
134
|
+
if cls_name:
|
|
135
|
+
return f"{file}::{cls_name.split('.')[-1]}::{case}"
|
|
136
|
+
else:
|
|
137
|
+
return f"{file}::{case}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@smart_tests.record.tests
|
|
141
|
+
def record_tests(
|
|
142
|
+
client: RecordTests,
|
|
143
|
+
source_roots: Annotated[List[str], typer.Argument(
|
|
144
|
+
multiple=True,
|
|
145
|
+
help="Source directories containing test report files"
|
|
146
|
+
)],
|
|
147
|
+
json_report: Annotated[bool, typer.Option(
|
|
148
|
+
"--json",
|
|
149
|
+
help="use JSON report files produced by pytest-dev/pytest-reportlog"
|
|
150
|
+
)] = False,
|
|
151
|
+
):
|
|
152
|
+
|
|
153
|
+
def data_builder(case: TestCase):
|
|
154
|
+
props = case.child(Properties)
|
|
155
|
+
result = {}
|
|
156
|
+
if props is not None:
|
|
157
|
+
"""
|
|
158
|
+
Here is an example of an XML file with markers.
|
|
159
|
+
```
|
|
160
|
+
<properties>
|
|
161
|
+
<property name="name" value="parametrize" />
|
|
162
|
+
<property name="args" value="('y', [2, 3])" />
|
|
163
|
+
<property name="kwargs" value="{}" />
|
|
164
|
+
<property name="name" value="parametrize" />
|
|
165
|
+
<property name="args" value="('x', [0, 1])" />
|
|
166
|
+
<property name="kwargs" value="{}" />
|
|
167
|
+
</properties>
|
|
168
|
+
```
|
|
169
|
+
"""
|
|
170
|
+
markers = [{"name": prop.name, "value": prop.value} for prop in props]
|
|
171
|
+
result["markers"] = markers if markers else []
|
|
172
|
+
|
|
173
|
+
metadata = MetadataTestCase.fromelem(case)
|
|
174
|
+
if metadata and metadata.line is not None:
|
|
175
|
+
# Please note that line numbers start from 0.
|
|
176
|
+
# https://github.com/pytest-dev/pytest/blob/8.1.1/src/_pytest/_code/source.py#L93
|
|
177
|
+
result["lineNumber"] = metadata.line + 1
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
ext = "json" if json_report else "xml"
|
|
181
|
+
for root in source_roots:
|
|
182
|
+
match = False
|
|
183
|
+
for t in glob.iglob(root, recursive=True):
|
|
184
|
+
match = True
|
|
185
|
+
if os.path.isdir(t):
|
|
186
|
+
client.scan(t, f"*.{ext}")
|
|
187
|
+
else:
|
|
188
|
+
client.report(t)
|
|
189
|
+
|
|
190
|
+
if not match:
|
|
191
|
+
click.echo(f"No matches found: {root}", err=True)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if json_report:
|
|
195
|
+
client.parse_func = PytestJSONReportParser(client).parse_func
|
|
196
|
+
|
|
197
|
+
client.metadata_builder = data_builder
|
|
198
|
+
|
|
199
|
+
client.run()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
If you want to use --json option, please install pytest-dev/pytest-reportlog.
|
|
204
|
+
(https://github.com/pytest-dev/pytest-reportlog)
|
|
205
|
+
|
|
206
|
+
Usage
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
$ pip install -U pytest-reportlog
|
|
210
|
+
$ pytest --report-log report.json
|
|
211
|
+
$ smart-tests record tests --json report.json
|
|
212
|
+
```
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class PytestJSONReportParser:
|
|
217
|
+
def __init__(self, client):
|
|
218
|
+
self.client = client
|
|
219
|
+
|
|
220
|
+
def parse_func(
|
|
221
|
+
self, report_file: str) -> Generator[CaseEventType, None, None]:
|
|
222
|
+
with open(report_file, 'r') as json_file:
|
|
223
|
+
for line in json_file:
|
|
224
|
+
try:
|
|
225
|
+
data = json.loads(line)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
raise Exception(f"Can't read JSON format report file {report_file}. Make sure to confirm report file.") from e
|
|
228
|
+
|
|
229
|
+
nodeid = data.get("nodeid", "")
|
|
230
|
+
if nodeid == "":
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
when = data.get("when", "")
|
|
234
|
+
outcome = data.get("outcome", "")
|
|
235
|
+
|
|
236
|
+
if not (when == "call" or (when == "setup" and outcome == "skipped")):
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
status = CaseEvent.TEST_FAILED
|
|
240
|
+
if outcome == "passed":
|
|
241
|
+
status = CaseEvent.TEST_PASSED
|
|
242
|
+
elif outcome == "skipped":
|
|
243
|
+
status = CaseEvent.TEST_SKIPPED
|
|
244
|
+
|
|
245
|
+
"""example json
|
|
246
|
+
"longrepr": {
|
|
247
|
+
"reprcrash": {
|
|
248
|
+
"lineno": 6,
|
|
249
|
+
"message": "assert 1 == False",
|
|
250
|
+
"path": "/Users/yabuki-ryosuke/src/github.com/launchableinc/cli/tests/data/pytest/tests/test_funcs1.py"
|
|
251
|
+
},
|
|
252
|
+
"reprtraceback": {
|
|
253
|
+
"extraline": null,
|
|
254
|
+
"reprentries": [
|
|
255
|
+
{
|
|
256
|
+
"data": {
|
|
257
|
+
"lines": [
|
|
258
|
+
" def test_func2():",
|
|
259
|
+
"> assert 1 == False",
|
|
260
|
+
"E assert 1 == False"
|
|
261
|
+
],
|
|
262
|
+
"reprfileloc": {
|
|
263
|
+
"lineno": 6,
|
|
264
|
+
"message": "AssertionError",
|
|
265
|
+
"path": "tests/test_funcs1.py"
|
|
266
|
+
},
|
|
267
|
+
"reprfuncargs": {
|
|
268
|
+
"args": []
|
|
269
|
+
},
|
|
270
|
+
"reprlocals": null,
|
|
271
|
+
"style": "long"
|
|
272
|
+
},
|
|
273
|
+
"type": "ReprEntry"
|
|
274
|
+
}
|
|
275
|
+
],
|
|
276
|
+
"style": "long"
|
|
277
|
+
},
|
|
278
|
+
"sections": []
|
|
279
|
+
}
|
|
280
|
+
"""
|
|
281
|
+
stdout = ""
|
|
282
|
+
stderr = ""
|
|
283
|
+
longrepr = data.get("longrepr", None)
|
|
284
|
+
if longrepr:
|
|
285
|
+
# https://github.com/pytest-dev/pytest/blob/1d7d63555e431d4562bcacbdc97038b0613d20ba/src/_pytest/reports.py#L60
|
|
286
|
+
if isinstance(longrepr, dict):
|
|
287
|
+
# https://github.com/pytest-dev/pytest/blob/1d7d63555e431d4562bcacbdc97038b0613d20ba/src/_pytest/reports.py#L361
|
|
288
|
+
message = None
|
|
289
|
+
reprcrash = longrepr.get("reprcrash", None)
|
|
290
|
+
if reprcrash:
|
|
291
|
+
message = reprcrash.get("message", None)
|
|
292
|
+
|
|
293
|
+
text = None
|
|
294
|
+
reprtraceback = longrepr.get("reprtraceback", None)
|
|
295
|
+
if reprtraceback:
|
|
296
|
+
reprentries = reprtraceback.get("reprentries", None)
|
|
297
|
+
if reprentries:
|
|
298
|
+
for r in reprentries:
|
|
299
|
+
d = r.get("data", None)
|
|
300
|
+
if d:
|
|
301
|
+
text = "\n".join(d.get("lines", []))
|
|
302
|
+
|
|
303
|
+
if message and text:
|
|
304
|
+
stderr = message + "\n" + text
|
|
305
|
+
elif message:
|
|
306
|
+
stderr = stderr + message
|
|
307
|
+
elif text:
|
|
308
|
+
stderr = stderr + text
|
|
309
|
+
elif isinstance(longrepr, list):
|
|
310
|
+
# [path, lineno, messge]
|
|
311
|
+
# https://github.com/pytest-dev/pytest/blob/1d7d63555e431d4562bcacbdc97038b0613d20ba/src/_pytest/reports.py#L371
|
|
312
|
+
if len(longrepr) == 3:
|
|
313
|
+
stderr = longrepr[2]
|
|
314
|
+
|
|
315
|
+
elif isinstance(longrepr, str):
|
|
316
|
+
# When longrepr is a string, it is the same as the stderr.
|
|
317
|
+
# https://github.com/pytest-dev/pytest/blob/1d7d63555e431d4562bcacbdc97038b0613d20ba/src/_pytest/reports.py#L377
|
|
318
|
+
# https://github.com/pytest-dev/pytest/blob/1d7d63555e431d4562bcacbdc97038b0613d20ba/src/_pytest/nodes.py#L470
|
|
319
|
+
stderr = longrepr
|
|
320
|
+
|
|
321
|
+
"""example json
|
|
322
|
+
"user_properties": [
|
|
323
|
+
["name", "dependency"],
|
|
324
|
+
["args", []],
|
|
325
|
+
["kwargs", { "name": "c", "depends": ["a"] }],
|
|
326
|
+
["name", "order"],
|
|
327
|
+
["args", [2]],
|
|
328
|
+
["kwargs", {}]
|
|
329
|
+
]
|
|
330
|
+
"""
|
|
331
|
+
props = data.get('user_properties')
|
|
332
|
+
if isinstance(props, list):
|
|
333
|
+
markers = []
|
|
334
|
+
for prop in props:
|
|
335
|
+
if isinstance(prop, list) and len(prop) == 2:
|
|
336
|
+
# prop is like ["name", "value"]
|
|
337
|
+
# prop[0] is name, prop[1] is value
|
|
338
|
+
if isinstance(prop[1], str):
|
|
339
|
+
markers.append({"name": prop[0], "value": prop[1]})
|
|
340
|
+
else:
|
|
341
|
+
markers.append({"name": prop[0], "value": json.dumps(prop[1])})
|
|
342
|
+
if len(props) > 0:
|
|
343
|
+
props = {'markers': markers}
|
|
344
|
+
else:
|
|
345
|
+
props = None
|
|
346
|
+
|
|
347
|
+
test_path = _parse_pytest_nodeid(nodeid)
|
|
348
|
+
for path in test_path:
|
|
349
|
+
if path.get("type") == "file":
|
|
350
|
+
path["name"] = pathlib.Path(path["name"]).as_posix()
|
|
351
|
+
|
|
352
|
+
yield CaseEvent.create(
|
|
353
|
+
test_path=test_path,
|
|
354
|
+
duration_secs=data.get("duration", 0),
|
|
355
|
+
status=status,
|
|
356
|
+
stdout=stdout,
|
|
357
|
+
stderr=stderr,
|
|
358
|
+
data=props)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Annotated, Generator, List
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import dateutil.parser
|
|
8
|
+
|
|
9
|
+
import smart_tests.args4p.typer as typer
|
|
10
|
+
|
|
11
|
+
from ..args4p.exceptions import BadCmdLineException
|
|
12
|
+
from ..commands.record.case_event import CaseEvent, CaseEventType
|
|
13
|
+
from ..commands.record.tests import RecordTests
|
|
14
|
+
from ..commands.subset import Subset
|
|
15
|
+
from ..testpath import TestPath, parse_test_path, unparse_test_path
|
|
16
|
+
from . import smart_tests
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@smart_tests.subset
|
|
20
|
+
def subset(
|
|
21
|
+
client: Subset,
|
|
22
|
+
test_path_file: Annotated[str | None, typer.Argument(
|
|
23
|
+
required=False,
|
|
24
|
+
help="File containing test paths, one per line"
|
|
25
|
+
)] = None,
|
|
26
|
+
):
|
|
27
|
+
"""Subset tests
|
|
28
|
+
|
|
29
|
+
TEST_PATH_FILE is a file that contains test paths (e.g.
|
|
30
|
+
"file=a.py#class=classA") one per line. Lines start with a hash ('#') are
|
|
31
|
+
considered as a comment and ignored.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
if not client.is_get_tests_from_previous_sessions and test_path_file is None:
|
|
35
|
+
raise BadCmdLineException("Missing argument 'TEST_PATH_FILE'.")
|
|
36
|
+
|
|
37
|
+
if client.is_output_exclusion_rules:
|
|
38
|
+
raise BadCmdLineException(
|
|
39
|
+
"Don't need to use `--output-exclusion-rules` option. Please use `--rest` option and use it for exclusion"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not client.is_get_tests_from_previous_sessions:
|
|
43
|
+
assert test_path_file is not None # Guaranteed by earlier check
|
|
44
|
+
with open(test_path_file, 'r') as f:
|
|
45
|
+
tps = [s.strip() for s in f.readlines()]
|
|
46
|
+
for tp_str in tps:
|
|
47
|
+
if not tp_str or tp_str.startswith('#'):
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
tp = parse_test_path(tp_str)
|
|
51
|
+
except ValueError as e:
|
|
52
|
+
sys.exit(e.args[0])
|
|
53
|
+
client.test_path(tp)
|
|
54
|
+
|
|
55
|
+
client.formatter = unparse_test_path
|
|
56
|
+
client.separator = '\n'
|
|
57
|
+
client.run()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@smart_tests.record.tests
|
|
61
|
+
def record_tests(
|
|
62
|
+
client: RecordTests,
|
|
63
|
+
test_result_files: Annotated[List[str], typer.Argument(
|
|
64
|
+
multiple=True,
|
|
65
|
+
help="Test result files (JSON or JUnit XML)"
|
|
66
|
+
)],
|
|
67
|
+
):
|
|
68
|
+
"""Record test results
|
|
69
|
+
|
|
70
|
+
TEST_RESULT_FILE is a file that contains a JSON document or JUnit XML file
|
|
71
|
+
that describes the test results.
|
|
72
|
+
|
|
73
|
+
## Example JSON document
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
"testCases": [
|
|
77
|
+
{
|
|
78
|
+
"testPath": "file=a.py#class=classA",
|
|
79
|
+
"duration": 42,
|
|
80
|
+
"status": "TEST_PASSED",
|
|
81
|
+
"stdout": "This is stdout",
|
|
82
|
+
"stderr": "This is stderr",
|
|
83
|
+
"createdAt": "2021-10-05T12:34:00"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
## JSON schema
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
"$id": "https://launchableinc.com/schema/RecordTestInput",
|
|
92
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
93
|
+
"title": "RecordTestInput",
|
|
94
|
+
"description": "The input to record test",
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"testCases": {
|
|
98
|
+
"description": "Result of test cases",
|
|
99
|
+
"type": "array",
|
|
100
|
+
"items": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"testPath": {
|
|
104
|
+
"description": "TestPath for the test",
|
|
105
|
+
"type": "string"
|
|
106
|
+
},
|
|
107
|
+
"testPathComponents": {
|
|
108
|
+
"description": "TestPath for the test",
|
|
109
|
+
"type": "array",
|
|
110
|
+
"items": {
|
|
111
|
+
"type": "object"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"duration": {
|
|
115
|
+
"description": "Time taken to finish the test in seconds. If unspecified, assume 0 sec.",
|
|
116
|
+
"type": "number",
|
|
117
|
+
"minimum": 0
|
|
118
|
+
},
|
|
119
|
+
"status": {
|
|
120
|
+
"description": "Test result",
|
|
121
|
+
"type": "string",
|
|
122
|
+
"enum": [
|
|
123
|
+
"TEST_PASSED",
|
|
124
|
+
"TEST_FAILED",
|
|
125
|
+
"TEST_SKIPPED"
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
"stdout": {
|
|
129
|
+
"description": "Standard output of the test. If unspecified, assume empty.",
|
|
130
|
+
"type": "string"
|
|
131
|
+
},
|
|
132
|
+
"stderr": {
|
|
133
|
+
"description": "Standard error of the test. If unspecified, assume empty",
|
|
134
|
+
"type": "string"
|
|
135
|
+
},
|
|
136
|
+
"createdAt": {
|
|
137
|
+
"description": "The timestamp that the test started at. If unspecified, assume the current timestamp
|
|
138
|
+
that the CLI is invoked.",
|
|
139
|
+
"type": "string",
|
|
140
|
+
"format": "date-time"
|
|
141
|
+
},
|
|
142
|
+
"data": {
|
|
143
|
+
"description": "Metadata of the test, e.g. line number.",
|
|
144
|
+
"type": "object",
|
|
145
|
+
"properties": {
|
|
146
|
+
"lineNumber": {
|
|
147
|
+
"description": "Line number of the test (1-based).",
|
|
148
|
+
"type": "number"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"required": [
|
|
154
|
+
"status"
|
|
155
|
+
],
|
|
156
|
+
"oneOf": [
|
|
157
|
+
{
|
|
158
|
+
"required": [
|
|
159
|
+
"testPath"
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"required": [
|
|
164
|
+
"testPathComponents"
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"required": [
|
|
172
|
+
"testCases"
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
## JUnit XML TestPath mapping
|
|
177
|
+
|
|
178
|
+
If the file path ends with '.xml', the command parses the file as a JUnit XML file. When this mode is used the subset input
|
|
179
|
+
TestPath should look like 'class={classname}#testcase={testcase}'.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def fail(msg):
|
|
183
|
+
click.secho(msg, fg='red', err=True)
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
def parse_json(test_result_file: str) -> Generator[CaseEventType, None, None]:
|
|
187
|
+
with open(test_result_file, 'r') as f:
|
|
188
|
+
doc = json.load(f)
|
|
189
|
+
default_created_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
190
|
+
for case in doc['testCases']:
|
|
191
|
+
test_path_components: TestPath = case.get('testPathComponents', None)
|
|
192
|
+
test_path: str = case.get('testPath', None)
|
|
193
|
+
if test_path_components is None and test_path is None:
|
|
194
|
+
fail("Missing testPath or testPathComponents field in the test case.")
|
|
195
|
+
if test_path_components and test_path:
|
|
196
|
+
fail("Specifying both testPath and testPathComponents fields is invalid.")
|
|
197
|
+
if test_path:
|
|
198
|
+
test_path_components = parse_test_path(test_path)
|
|
199
|
+
status = case['status']
|
|
200
|
+
duration_secs = case.get('duration', 0)
|
|
201
|
+
if isinstance(duration_secs, str):
|
|
202
|
+
try:
|
|
203
|
+
duration_secs = float(duration_secs)
|
|
204
|
+
except ValueError:
|
|
205
|
+
fail(f"The duration of {test_path_components} in {test_result_file} isn't a valid format (was {duration_secs}). Make sure set a valid duration") # noqa
|
|
206
|
+
|
|
207
|
+
created_at = case.get('createdAt', default_created_at)
|
|
208
|
+
|
|
209
|
+
if status not in CaseEvent.STATUS_MAP:
|
|
210
|
+
fail(
|
|
211
|
+
f"The status of {test_path_components} should be one of {list(CaseEvent.STATUS_MAP.keys())} (was {status})")
|
|
212
|
+
|
|
213
|
+
if duration_secs < 0:
|
|
214
|
+
fail(f"The duration of {test_path_components} should be positive (was {duration_secs})")
|
|
215
|
+
dateutil.parser.parse(created_at)
|
|
216
|
+
metadata = case.get('data', None)
|
|
217
|
+
|
|
218
|
+
yield CaseEvent.create(
|
|
219
|
+
test_path=test_path_components,
|
|
220
|
+
duration_secs=duration_secs,
|
|
221
|
+
status=CaseEvent.STATUS_MAP[status],
|
|
222
|
+
stdout=case.get('stdout', ''),
|
|
223
|
+
stderr=case.get('stderr', ''),
|
|
224
|
+
timestamp=created_at,
|
|
225
|
+
data=metadata)
|
|
226
|
+
|
|
227
|
+
for test_result_file in test_result_files:
|
|
228
|
+
if not test_result_file.endswith('.xml'):
|
|
229
|
+
client.parse_func = parse_json
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
for test_result_file in test_result_files:
|
|
233
|
+
client.report(test_result_file)
|
|
234
|
+
|
|
235
|
+
client.run()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
smart_tests.CommonDetectFlakesImpls(__name__).detect_flakes()
|