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,125 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Annotated, List
|
|
3
|
+
from xml.etree import ElementTree as ET
|
|
4
|
+
|
|
5
|
+
from junitparser import JUnitXml # type: ignore
|
|
6
|
+
|
|
7
|
+
import smart_tests.args4p.typer as typer
|
|
8
|
+
|
|
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
|
+
|
|
15
|
+
def parse_func(p: str) -> ET.ElementTree:
|
|
16
|
+
|
|
17
|
+
def parse_suite(suite: ET.Element):
|
|
18
|
+
DATETIME_FORMAT = '%Y%m%d %H:%M:%S.%f'
|
|
19
|
+
|
|
20
|
+
suite_name = suite.get('name')
|
|
21
|
+
for test in suite.iter("test"):
|
|
22
|
+
test_name = test.get('name')
|
|
23
|
+
|
|
24
|
+
status_node = test.find('status')
|
|
25
|
+
status = status_node.get(
|
|
26
|
+
'status') if status_node is not None else None
|
|
27
|
+
|
|
28
|
+
nested_status_node = test.find('./kw/status')
|
|
29
|
+
nested_status = nested_status_node.get('status') if nested_status_node is not None else None
|
|
30
|
+
|
|
31
|
+
if status is not None:
|
|
32
|
+
start_time_str = status_node.get('starttime') if status_node is not None else ''
|
|
33
|
+
end_time_str = status_node.get('endtime') if status_node is not None else ''
|
|
34
|
+
|
|
35
|
+
if start_time_str != '' and end_time_str != '':
|
|
36
|
+
start_time = datetime.strptime(str(start_time_str), DATETIME_FORMAT)
|
|
37
|
+
end_time = datetime.strptime(str(end_time_str), DATETIME_FORMAT)
|
|
38
|
+
|
|
39
|
+
duration = end_time - start_time
|
|
40
|
+
|
|
41
|
+
testcase = ET.SubElement(testsuite, "testcase", {
|
|
42
|
+
"name": str(test_name),
|
|
43
|
+
"classname": str(suite_name),
|
|
44
|
+
"time": str(duration.total_seconds()) if duration is not None else '0',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if status == "FAIL":
|
|
48
|
+
failure = ET.SubElement(testcase, 'failure')
|
|
49
|
+
|
|
50
|
+
msg = test.find('kw/msg')
|
|
51
|
+
failure.text = msg.text if msg is not None else ''
|
|
52
|
+
if status == "NOT_RUN" or nested_status == 'NOT_RUN':
|
|
53
|
+
skipped = ET.SubElement(testcase, "skipped") # noqa: F841
|
|
54
|
+
|
|
55
|
+
original_tree = ET.parse(p)
|
|
56
|
+
testsuite = ET.Element("testsuite", {"name": "robot"})
|
|
57
|
+
|
|
58
|
+
SUITE_TAG_NAME = "suite"
|
|
59
|
+
for suites in original_tree.findall(SUITE_TAG_NAME):
|
|
60
|
+
nested_suites = suites.findall(SUITE_TAG_NAME)
|
|
61
|
+
|
|
62
|
+
if nested_suites:
|
|
63
|
+
# Run tests in a directory
|
|
64
|
+
for suite in nested_suites:
|
|
65
|
+
parse_suite(suite)
|
|
66
|
+
else:
|
|
67
|
+
# Run tests in a single file
|
|
68
|
+
parse_suite(suites)
|
|
69
|
+
|
|
70
|
+
return ET.ElementTree(testsuite)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@smart_tests.record.tests
|
|
74
|
+
def record_tests(
|
|
75
|
+
client: RecordTests,
|
|
76
|
+
reports: Annotated[List[str], typer.Argument(
|
|
77
|
+
multiple=True,
|
|
78
|
+
help="Test report files to process"
|
|
79
|
+
)],
|
|
80
|
+
):
|
|
81
|
+
for r in reports:
|
|
82
|
+
client.report(r)
|
|
83
|
+
|
|
84
|
+
client.junitxml_parse_func = parse_func
|
|
85
|
+
client.run()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@smart_tests.subset
|
|
89
|
+
def subset(
|
|
90
|
+
client: Subset,
|
|
91
|
+
reports: Annotated[List[str], typer.Argument(
|
|
92
|
+
multiple=True,
|
|
93
|
+
required=False,
|
|
94
|
+
help="Test report files to process"
|
|
95
|
+
)] = [],
|
|
96
|
+
):
|
|
97
|
+
for r in reports:
|
|
98
|
+
xml = JUnitXml.fromfile(r, parse_func)
|
|
99
|
+
|
|
100
|
+
for suite in xml:
|
|
101
|
+
for case in suite:
|
|
102
|
+
cls_name = case._elem.attrib.get("classname")
|
|
103
|
+
name = case._elem.attrib.get('name')
|
|
104
|
+
if cls_name != '' and name != '':
|
|
105
|
+
client.test_path([{'type': 'class', 'name': cls_name}, {'type': 'testcase', 'name': name}])
|
|
106
|
+
|
|
107
|
+
client.formatter = robot_formatter
|
|
108
|
+
client.separator = " "
|
|
109
|
+
client.run()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def robot_formatter(x: TestPath):
|
|
113
|
+
cls_name = ''
|
|
114
|
+
case = ''
|
|
115
|
+
for path in x:
|
|
116
|
+
t = path['type']
|
|
117
|
+
if t == 'class':
|
|
118
|
+
cls_name = path['name']
|
|
119
|
+
if t == 'testcase':
|
|
120
|
+
case = path['name']
|
|
121
|
+
|
|
122
|
+
if cls_name != '' and case != '':
|
|
123
|
+
return f"-s '{cls_name}' -t '{case}'"
|
|
124
|
+
|
|
125
|
+
return ''
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import types
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from junitparser import TestCase, TestSuite # type: ignore
|
|
9
|
+
|
|
10
|
+
import smart_tests.args4p.typer as typer
|
|
11
|
+
from smart_tests import args4p
|
|
12
|
+
from smart_tests.args4p import decorator
|
|
13
|
+
from smart_tests.args4p.command import Group
|
|
14
|
+
from smart_tests.commands.detect_flakes import DetectFlakes
|
|
15
|
+
from smart_tests.commands.detect_flakes import detect_flakes as detect_flakes_cmd
|
|
16
|
+
from smart_tests.commands.record.tests import RecordTests
|
|
17
|
+
from smart_tests.commands.record.tests import tests as record_tests_cmd
|
|
18
|
+
from smart_tests.commands.subset import Subset
|
|
19
|
+
from smart_tests.commands.subset import subset as subset_cmd
|
|
20
|
+
|
|
21
|
+
from ..testpath import TestPath
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmdname(m):
|
|
25
|
+
"""figure out the sub-command name from a test runner function"""
|
|
26
|
+
|
|
27
|
+
# a.b.cde -> cde
|
|
28
|
+
# xyz -> xyz
|
|
29
|
+
#
|
|
30
|
+
# In python module name the conventional separator is '_' but in command name,
|
|
31
|
+
# it is '-', so we do replace that
|
|
32
|
+
return m[m.rfind('.') + 1:].replace('_', '-')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def wrap(f, group: Group, name=None):
|
|
36
|
+
"""
|
|
37
|
+
Wraps a 'plugin' function into a click command and registers it to the given group.
|
|
38
|
+
|
|
39
|
+
a plugin function receives the scanner object in its first argument
|
|
40
|
+
"""
|
|
41
|
+
if not name:
|
|
42
|
+
name = cmdname(f.__module__)
|
|
43
|
+
d = args4p.command(name=name)
|
|
44
|
+
cmd = d(f)
|
|
45
|
+
group.add_command(cmd)
|
|
46
|
+
return cmd
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@decorator
|
|
50
|
+
def subset(f):
|
|
51
|
+
return wrap(f, subset_cmd)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
record = types.SimpleNamespace()
|
|
55
|
+
# this is also meant to be used as a decorator, e.g., @record.tests
|
|
56
|
+
record.tests = lambda f: wrap(f, record_tests_cmd)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@decorator
|
|
60
|
+
def flake_detection(f):
|
|
61
|
+
return wrap(f, detect_flakes_cmd)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# TODO
|
|
65
|
+
# @decorator
|
|
66
|
+
# def split_subset(f):
|
|
67
|
+
# return wrap(f, split_subset_cmd)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CommonSubsetImpls:
|
|
71
|
+
"""
|
|
72
|
+
Typical 'subset' implementations that are reusable.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, module_name):
|
|
76
|
+
self.cmdname = cmdname(module_name)
|
|
77
|
+
|
|
78
|
+
def scan_files(self, pattern):
|
|
79
|
+
"""
|
|
80
|
+
Suitable for test runners that use files as unit of tests where file names follow a naming pattern.
|
|
81
|
+
|
|
82
|
+
:param pattern: file masks that identify test files, such as '*_spec.rb'
|
|
83
|
+
"""
|
|
84
|
+
def subset(
|
|
85
|
+
client: Subset,
|
|
86
|
+
files: Annotated[list[str], typer.Argument(
|
|
87
|
+
multiple=True,
|
|
88
|
+
required=False,
|
|
89
|
+
help="Test files or directories to include in the subset"
|
|
90
|
+
)] = []
|
|
91
|
+
):
|
|
92
|
+
# client type: Optimize in def lauchable.commands.subset.subset
|
|
93
|
+
def parse(fname: str):
|
|
94
|
+
if os.path.isdir(fname):
|
|
95
|
+
client.scan(fname, '**/' + pattern)
|
|
96
|
+
elif fname == '@-':
|
|
97
|
+
# read stdin
|
|
98
|
+
for line in sys.stdin:
|
|
99
|
+
parse(line.rstrip())
|
|
100
|
+
elif fname.startswith('@'):
|
|
101
|
+
# read response file
|
|
102
|
+
with open(fname[1:]) as f:
|
|
103
|
+
for line in f:
|
|
104
|
+
parse(line.rstrip())
|
|
105
|
+
else:
|
|
106
|
+
# assume it's a file
|
|
107
|
+
client.test_path(fname)
|
|
108
|
+
|
|
109
|
+
for f in files:
|
|
110
|
+
parse(f)
|
|
111
|
+
|
|
112
|
+
client.run()
|
|
113
|
+
|
|
114
|
+
return wrap(subset, subset_cmd, self.cmdname)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CommonRecordTestImpls:
|
|
118
|
+
"""
|
|
119
|
+
Typical 'record tests' implementations that are reusable.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, module_name):
|
|
123
|
+
self.cmdname = cmdname(module_name)
|
|
124
|
+
|
|
125
|
+
def report_files(self, file_mask="*.xml"):
|
|
126
|
+
"""
|
|
127
|
+
Suitable for test runners that create a directory full of JUnit report files.
|
|
128
|
+
|
|
129
|
+
'record tests' expect JUnit report/XML file names.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def record_tests(
|
|
133
|
+
client: RecordTests,
|
|
134
|
+
source_roots: Annotated[list[str], typer.Argument(
|
|
135
|
+
multiple=True,
|
|
136
|
+
help="Source directories containing test report files"
|
|
137
|
+
)]
|
|
138
|
+
):
|
|
139
|
+
CommonRecordTestImpls.load_report_files(client=client, source_roots=source_roots, file_mask=file_mask)
|
|
140
|
+
|
|
141
|
+
return wrap(record_tests, record_tests_cmd, self.cmdname)
|
|
142
|
+
|
|
143
|
+
def file_profile_report_files(self):
|
|
144
|
+
"""
|
|
145
|
+
Suitable for test runners that create a directory full of JUnit report files.
|
|
146
|
+
|
|
147
|
+
'record tests' expect JUnit report/XML file names.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def record_tests(client: RecordTests,
|
|
151
|
+
source_roots: Annotated[list[str], typer.Argument(
|
|
152
|
+
multiple=True,
|
|
153
|
+
help="Source directories containing test report files"
|
|
154
|
+
)]):
|
|
155
|
+
def path_builder(
|
|
156
|
+
case: TestCase, suite: TestSuite, report_file: str
|
|
157
|
+
) -> TestPath:
|
|
158
|
+
def find_filename():
|
|
159
|
+
"""look for what looks like file names from test reports"""
|
|
160
|
+
for e in [case, suite]:
|
|
161
|
+
for a in ["file", "filepath"]:
|
|
162
|
+
filepath = e._elem.attrib.get(a)
|
|
163
|
+
if filepath:
|
|
164
|
+
return filepath
|
|
165
|
+
return None # failing to find a test name
|
|
166
|
+
|
|
167
|
+
filepath = find_filename()
|
|
168
|
+
if not filepath:
|
|
169
|
+
raise click.ClickException("No file name found in %s" % report_file)
|
|
170
|
+
|
|
171
|
+
# default test path in `subset` expects to have this file name
|
|
172
|
+
test_path = [client.make_file_path_component(filepath)]
|
|
173
|
+
if suite.name:
|
|
174
|
+
test_path.append({"type": "testsuite", "name": suite.name})
|
|
175
|
+
if case.name:
|
|
176
|
+
test_path.append({"type": "testcase", "name": case.name})
|
|
177
|
+
return test_path
|
|
178
|
+
|
|
179
|
+
client.path_builder = path_builder
|
|
180
|
+
|
|
181
|
+
for r in source_roots:
|
|
182
|
+
client.report(r)
|
|
183
|
+
client.run()
|
|
184
|
+
|
|
185
|
+
return wrap(record_tests, record_tests_cmd, self.cmdname)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def load_report_files(cls, client: RecordTests, source_roots, file_mask="*.xml"):
|
|
189
|
+
# client type: RecordTests in def launchable.commands.record.tests.tests
|
|
190
|
+
# Accept both file names and GLOB patterns
|
|
191
|
+
# Simple globs like '*.xml' can be dealt with by shell, but
|
|
192
|
+
# not all shells consistently deal with advanced GLOBS like '**'
|
|
193
|
+
# so it's worth supporting it here.
|
|
194
|
+
for root in source_roots:
|
|
195
|
+
match = False
|
|
196
|
+
for t in glob.iglob(root, recursive=True):
|
|
197
|
+
match = True
|
|
198
|
+
if os.path.isdir(t):
|
|
199
|
+
client.scan(t, file_mask)
|
|
200
|
+
else:
|
|
201
|
+
client.report(t)
|
|
202
|
+
|
|
203
|
+
if not match:
|
|
204
|
+
# By following the shell convention, if the file doesn't exist or GLOB doesn't match anything,
|
|
205
|
+
# raise it as an error. Note this can happen for reasons other than a configuration error.
|
|
206
|
+
# For example, if a build catastrophically failed and no
|
|
207
|
+
# tests got run.
|
|
208
|
+
click.echo(f"No matches found: {root}", err=True)
|
|
209
|
+
# intentionally exiting with zero
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
client.run()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class CommonDetectFlakesImpls:
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
module_name,
|
|
219
|
+
formatter=None,
|
|
220
|
+
separator="\n",
|
|
221
|
+
):
|
|
222
|
+
self.cmdname = cmdname(module_name)
|
|
223
|
+
self._formatter = formatter
|
|
224
|
+
self._separator = separator
|
|
225
|
+
|
|
226
|
+
def detect_flakes(self):
|
|
227
|
+
def detect_flakes(client: DetectFlakes):
|
|
228
|
+
if self._formatter:
|
|
229
|
+
client.formatter = self._formatter
|
|
230
|
+
if self._separator:
|
|
231
|
+
client.separator = self._separator
|
|
232
|
+
|
|
233
|
+
client.run()
|
|
234
|
+
|
|
235
|
+
return wrap(detect_flakes, detect_flakes_cmd, self.cmdname)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ET
|
|
2
|
+
from typing import Annotated, List, cast
|
|
3
|
+
|
|
4
|
+
import smart_tests.args4p.typer as typer
|
|
5
|
+
|
|
6
|
+
from ..commands.record.tests import RecordTests
|
|
7
|
+
from ..commands.subset import Subset
|
|
8
|
+
from . import smart_tests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@smart_tests.record.tests
|
|
12
|
+
def record_tests(
|
|
13
|
+
client: RecordTests,
|
|
14
|
+
reports: Annotated[List[str], typer.Argument(
|
|
15
|
+
multiple=True,
|
|
16
|
+
help="Test report files to process"
|
|
17
|
+
)],
|
|
18
|
+
):
|
|
19
|
+
def parse_func(report: str) -> ET.ElementTree:
|
|
20
|
+
"""
|
|
21
|
+
Vitest junit report doesn't set file/filepath attributes on test cases, and it's set as a classname attribute instead.
|
|
22
|
+
So, set the classname value as the file name in this function.
|
|
23
|
+
e.g.) <testcase classname="src/components/Hello.test.tsx" name="renders hello message" time="0.008676833">
|
|
24
|
+
"""
|
|
25
|
+
tree = cast(ET.ElementTree, ET.parse(report))
|
|
26
|
+
root = tree.getroot()
|
|
27
|
+
|
|
28
|
+
if root is None:
|
|
29
|
+
return tree
|
|
30
|
+
|
|
31
|
+
for test_suite in root.findall('testsuite'):
|
|
32
|
+
for test_case in test_suite.findall('testcase'):
|
|
33
|
+
classname = test_case.get('classname', '')
|
|
34
|
+
test_case.set('file', classname)
|
|
35
|
+
test_case.attrib.pop('classname', None)
|
|
36
|
+
|
|
37
|
+
return tree
|
|
38
|
+
|
|
39
|
+
client.junitxml_parse_func = parse_func
|
|
40
|
+
smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=reports)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@smart_tests.subset
|
|
44
|
+
def subset(client: Subset):
|
|
45
|
+
# read lines as test file names
|
|
46
|
+
for t in client.stdin():
|
|
47
|
+
client.test_path(t.rstrip("\n"))
|
|
48
|
+
|
|
49
|
+
client.run()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import xml.etree.ElementTree as ET # type: ignore
|
|
3
|
+
from typing import Annotated, List, cast
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from junitparser import TestCase, TestSuite
|
|
7
|
+
|
|
8
|
+
import smart_tests.args4p.typer as typer
|
|
9
|
+
|
|
10
|
+
from ..commands.record.tests import RecordTests
|
|
11
|
+
from ..commands.subset import Subset
|
|
12
|
+
from ..testpath import TestPath
|
|
13
|
+
from . import smart_tests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@smart_tests.record.tests
|
|
17
|
+
def record_tests(
|
|
18
|
+
client: RecordTests,
|
|
19
|
+
reports: Annotated[List[str], typer.Argument(
|
|
20
|
+
multiple=True,
|
|
21
|
+
help="Test report files to process"
|
|
22
|
+
)],
|
|
23
|
+
):
|
|
24
|
+
|
|
25
|
+
def parse_func(p: str) -> ET.ElementTree:
|
|
26
|
+
tree = cast(ET.ElementTree, ET.parse(p))
|
|
27
|
+
root = tree.getroot()
|
|
28
|
+
if root is None:
|
|
29
|
+
return tree
|
|
30
|
+
|
|
31
|
+
for testsuite in root.findall('testsuite'):
|
|
32
|
+
for testcase in testsuite.findall('testcase'):
|
|
33
|
+
failure = testcase.find('failure')
|
|
34
|
+
if failure is not None:
|
|
35
|
+
# Note(Konboi): XCTest escape `"` to `"` in the message, so save as unescaped text
|
|
36
|
+
message = html.unescape(failure.get('message', ''))
|
|
37
|
+
body = failure.text or ''
|
|
38
|
+
failure.text = f"{message}\n{body}"
|
|
39
|
+
|
|
40
|
+
return tree
|
|
41
|
+
|
|
42
|
+
def path_builder(case: TestCase, suite: TestSuite, report_path: str) -> TestPath:
|
|
43
|
+
class_attr = case.classname
|
|
44
|
+
[target, *rest] = class_attr.split('.')
|
|
45
|
+
class_name = '/'.join(rest)
|
|
46
|
+
return [
|
|
47
|
+
{"type": "target", "name": target},
|
|
48
|
+
{"type": "class", "name": class_name},
|
|
49
|
+
{"type": "testcase", "name": case.name},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
client.junitxml_parse_func = parse_func
|
|
53
|
+
client.path_builder = path_builder
|
|
54
|
+
smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=reports)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@smart_tests.subset
|
|
58
|
+
def subset(client: Subset):
|
|
59
|
+
if not client.is_get_tests_from_previous_sessions or not client.is_output_exclusion_rules:
|
|
60
|
+
click.secho(
|
|
61
|
+
"XCTest profile only supports the subset with `--get-tests-from-previous-sessions` and `--output-exclusion-rules` options", # noqa: E501
|
|
62
|
+
fg='red',
|
|
63
|
+
err=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def formatter(test_path: TestPath) -> str:
|
|
67
|
+
if len(test_path) == 0:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
# only target case
|
|
71
|
+
if len(test_path) == 1:
|
|
72
|
+
return f"-skip-testing:{test_path[0]['name']}"
|
|
73
|
+
|
|
74
|
+
# default target/class format
|
|
75
|
+
return f"-skip-testing:{test_path[0]['name']}/{test_path[1]['name']}"
|
|
76
|
+
|
|
77
|
+
client.formatter = formatter
|
|
78
|
+
client.separator = "\n"
|
|
79
|
+
client.run()
|
smart_tests/testpath.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import subprocess
|
|
4
|
+
import urllib
|
|
5
|
+
|
|
6
|
+
# No additional typing imports needed
|
|
7
|
+
|
|
8
|
+
# Path component is a node in a tree.
|
|
9
|
+
# It's the equivalent of a short file/directory name in a file system.
|
|
10
|
+
# In our abstraction, it's represented as arbitrary bag of attributes
|
|
11
|
+
TestPathComponent = dict[str, str]
|
|
12
|
+
|
|
13
|
+
# TestPath is a full path to a node in a tree from the root
|
|
14
|
+
# It's the equivalent of an absolute file name in a file system
|
|
15
|
+
TestPath = list[TestPathComponent]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_test_path(tp_str: str) -> TestPath:
|
|
19
|
+
"""Parse a string representation of TestPath."""
|
|
20
|
+
if tp_str == '':
|
|
21
|
+
return []
|
|
22
|
+
ret: TestPath = []
|
|
23
|
+
for component_str in tp_str.split('#'):
|
|
24
|
+
if component_str == '&':
|
|
25
|
+
# Technically, this should be mapped to {None:None}. But because the
|
|
26
|
+
# TestPath definition is now dict[str, str], not dict[str | None,
|
|
27
|
+
# Optinal[str]], we cannot add it. Fixing this definition needs to
|
|
28
|
+
# fix callers not to assume they are always str. In practice, this
|
|
29
|
+
# is a rare case. Do not appent {None: None} now...
|
|
30
|
+
# ret.append({None: None})
|
|
31
|
+
continue
|
|
32
|
+
first = True
|
|
33
|
+
component = {}
|
|
34
|
+
for kv in component_str.split('&'):
|
|
35
|
+
if first:
|
|
36
|
+
first = False
|
|
37
|
+
if kv:
|
|
38
|
+
(component['type'], component['name']) = _parse_kv(kv)
|
|
39
|
+
else:
|
|
40
|
+
(k, v) = _parse_kv(kv)
|
|
41
|
+
component[k] = v
|
|
42
|
+
ret.append(component)
|
|
43
|
+
return ret
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_kv(kv: str) -> tuple[str, str]:
|
|
47
|
+
kvs = kv.split('=')
|
|
48
|
+
if len(kvs) != 2:
|
|
49
|
+
raise ValueError('Malformed TestPath component: ' + kv)
|
|
50
|
+
return (_decode_str(kvs[0]), _decode_str(kvs[1]))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unparse_test_path(tp: TestPath) -> str:
|
|
54
|
+
"""Create a string representation of TestPath."""
|
|
55
|
+
ret = []
|
|
56
|
+
for component in tp:
|
|
57
|
+
s = ''
|
|
58
|
+
pairs = []
|
|
59
|
+
if component.get('type', None) and component.get('name', None):
|
|
60
|
+
s += _encode_str(component['type']) + '=' + _encode_str(component['name'])
|
|
61
|
+
for k, v in component.items():
|
|
62
|
+
if k not in ('type', 'name'):
|
|
63
|
+
pairs.append((k, v))
|
|
64
|
+
else:
|
|
65
|
+
for k, v in component.items():
|
|
66
|
+
if not k or not v:
|
|
67
|
+
continue
|
|
68
|
+
pairs.append((k, v))
|
|
69
|
+
if len(pairs) == 0:
|
|
70
|
+
s = '&'
|
|
71
|
+
pairs = sorted(pairs, key=lambda p: p[0])
|
|
72
|
+
for (k, v) in pairs:
|
|
73
|
+
s += '&'
|
|
74
|
+
s += _encode_str(k) + '=' + _encode_str(v)
|
|
75
|
+
ret.append(s)
|
|
76
|
+
return '#'.join(ret)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decode_str(s: str) -> str:
|
|
80
|
+
return urllib.parse.unquote(s)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _encode_str(s: str) -> str:
|
|
84
|
+
return s.replace('%', '%25').replace('=', '%3D').replace('#', '%23').replace('&', '%26')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _relative_to(p: pathlib.Path, base: str) -> pathlib.Path:
|
|
88
|
+
return p.resolve(strict=False).relative_to(base)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FilePathNormalizer:
|
|
92
|
+
"""Normalize file paths based on the Git repository root
|
|
93
|
+
|
|
94
|
+
Some test runners output absolute file paths. This is not preferrable when
|
|
95
|
+
making statistical data on tests as the absolute paths can vary per machine
|
|
96
|
+
or per run. FilePathNormalizer guesses the relative paths based on the Git
|
|
97
|
+
repository root.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, base_path: str | None = None, no_base_path_inference: bool = False):
|
|
101
|
+
self._base_path = base_path
|
|
102
|
+
self._no_base_path_inference = no_base_path_inference
|
|
103
|
+
self._inferred_base_path = None # type: str | None
|
|
104
|
+
|
|
105
|
+
def relativize(self, p: str) -> str:
|
|
106
|
+
return str(self._relativize(pathlib.Path(os.path.normpath(p))))
|
|
107
|
+
|
|
108
|
+
def _relativize(self, p: pathlib.Path) -> pathlib.Path:
|
|
109
|
+
if not p.is_absolute():
|
|
110
|
+
return p
|
|
111
|
+
|
|
112
|
+
if self._base_path:
|
|
113
|
+
return _relative_to(p, self._base_path)
|
|
114
|
+
|
|
115
|
+
if self._no_base_path_inference:
|
|
116
|
+
return p
|
|
117
|
+
|
|
118
|
+
if not self._inferred_base_path:
|
|
119
|
+
self._inferred_base_path = self._auto_infer_base_path(p)
|
|
120
|
+
|
|
121
|
+
if self._inferred_base_path:
|
|
122
|
+
return _relative_to(p, self._inferred_base_path)
|
|
123
|
+
|
|
124
|
+
return p
|
|
125
|
+
|
|
126
|
+
def get_effective_base_path(self) -> str | None:
|
|
127
|
+
"""Get the effective base path, either explicitly set or inferred."""
|
|
128
|
+
if self._base_path:
|
|
129
|
+
return self._base_path
|
|
130
|
+
return self._inferred_base_path
|
|
131
|
+
|
|
132
|
+
def _auto_infer_base_path(self, p: pathlib.Path) -> str | None:
|
|
133
|
+
# If p is a file, start from its parent directory
|
|
134
|
+
if p.is_file():
|
|
135
|
+
p = p.parent
|
|
136
|
+
|
|
137
|
+
while p != p.root and not p.exists():
|
|
138
|
+
p = p.parent
|
|
139
|
+
try:
|
|
140
|
+
toplevel = subprocess.check_output(
|
|
141
|
+
['git', 'rev-parse', '--show-superproject-working-tree'],
|
|
142
|
+
cwd=str(p),
|
|
143
|
+
stderr=subprocess.DEVNULL,
|
|
144
|
+
universal_newlines=True).strip()
|
|
145
|
+
if toplevel:
|
|
146
|
+
return toplevel
|
|
147
|
+
return subprocess.check_output(
|
|
148
|
+
['git', 'rev-parse', '--show-toplevel'],
|
|
149
|
+
cwd=str(p),
|
|
150
|
+
stderr=subprocess.DEVNULL,
|
|
151
|
+
universal_newlines=True).strip()
|
|
152
|
+
except subprocess.CalledProcessError:
|
|
153
|
+
# Cannot infer the Git repo. Continue with the abs path...
|
|
154
|
+
return None
|
|
File without changes
|