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,189 @@
|
|
|
1
|
+
from typing import Annotated, List
|
|
2
|
+
from xml.etree import ElementTree as ET
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
import smart_tests.args4p.typer as typer
|
|
7
|
+
from smart_tests.commands.record.case_event import CaseEvent
|
|
8
|
+
|
|
9
|
+
from ..commands.record.tests import RecordTests
|
|
10
|
+
from ..commands.subset import Subset
|
|
11
|
+
from . import smart_tests
|
|
12
|
+
|
|
13
|
+
# https://source.android.com/docs/compatibility/cts/command-console-v2
|
|
14
|
+
include_option = "--include-filter"
|
|
15
|
+
exclude_option = "--exclude-filter"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_func(p: str):
|
|
19
|
+
""" # noqa: E501
|
|
20
|
+
# sample report format
|
|
21
|
+
success case:
|
|
22
|
+
<Module name="CtsAbiOverrideHostTestCases" abi="arm64-v8a" runtime="1171" done="true" pass="1" total_tests="1">
|
|
23
|
+
<TestCase name="android.abioverride.cts.AbiOverrideTest">
|
|
24
|
+
<Test result="pass" name="testAbiIs32bit" />
|
|
25
|
+
</TestCase>
|
|
26
|
+
</Module>
|
|
27
|
+
|
|
28
|
+
failure case:
|
|
29
|
+
<Module name="CtsAccountManagerTestCases" abi="arm64-v8a" runtime="13414" done="false" pass="2" total_tests="151">
|
|
30
|
+
<Reason message="Instrumentation run failed due to 'Process crashed.'" error_name="INSTRUMENTATION_CRASH" error_code="520200" />
|
|
31
|
+
<TestCase name="android.accounts.cts.AbstractAuthenticatorTests">
|
|
32
|
+
<Test result="fail" name="testFinishSessionAndStartAddAccountSessionDefaultImpl">
|
|
33
|
+
<Failure message="android.accounts.OperationCanceledException ">
|
|
34
|
+
<StackTrace>android.accounts.OperationCanceledException
|
|
35
|
+
at android.accounts.AccountManager$AmsTask.internalGetResult(AccountManager.java:2393)
|
|
36
|
+
at android.accounts.AccountManager$AmsTask.getResult(AccountManager.java:2422)
|
|
37
|
+
at android.accounts.AccountManager$AmsTask.getResult(AccountManager.java:2337)
|
|
38
|
+
at android.accounts.cts.AbstractAuthenticatorTests.testFinishSessionAndStartAddAccountSessionDefaultImpl(AbstractAuthenticatorTests.java:165)
|
|
39
|
+
</StackTrace>
|
|
40
|
+
</Failure>
|
|
41
|
+
</Test>
|
|
42
|
+
</TestCase>
|
|
43
|
+
</Module>
|
|
44
|
+
"""
|
|
45
|
+
class TestResult:
|
|
46
|
+
def __init__(self, test_case_name: str, test_name: str, result: str, stdout: str, stderr: str):
|
|
47
|
+
self.test_case_name = test_case_name
|
|
48
|
+
self.test_name = test_name
|
|
49
|
+
self.result = result
|
|
50
|
+
self.stdout = stdout
|
|
51
|
+
self.stderr = stderr
|
|
52
|
+
|
|
53
|
+
def case_event_status(self):
|
|
54
|
+
if self.result == "fail":
|
|
55
|
+
return CaseEvent.TEST_FAILED
|
|
56
|
+
elif self.result == "ASSUMPTION_FAILURE" or self.result == "IGNORED":
|
|
57
|
+
return CaseEvent.TEST_SKIPPED
|
|
58
|
+
|
|
59
|
+
return CaseEvent.TEST_PASSED
|
|
60
|
+
|
|
61
|
+
def build_record_test_path(module: str, test_case: str, test: str):
|
|
62
|
+
return [
|
|
63
|
+
{"type": "Module", "name": module},
|
|
64
|
+
{"type": "TestCase", "name": test_case},
|
|
65
|
+
{"type": "Test", "name": test},
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
events = []
|
|
69
|
+
tree = ET.parse(p)
|
|
70
|
+
|
|
71
|
+
for module in tree.iter('Module'):
|
|
72
|
+
test_results = []
|
|
73
|
+
total_duration = module.get("runtime", "0")
|
|
74
|
+
module_name = module.get("name", "")
|
|
75
|
+
|
|
76
|
+
for test_case in module.iter("TestCase"):
|
|
77
|
+
test_case_name = test_case.get('name', "")
|
|
78
|
+
|
|
79
|
+
for test in test_case.iter("Test"):
|
|
80
|
+
result = test.get('result', "")
|
|
81
|
+
test_name = test.get("name", "")
|
|
82
|
+
stdout = ""
|
|
83
|
+
stderr = ""
|
|
84
|
+
|
|
85
|
+
failure = test.find('Failure')
|
|
86
|
+
if failure:
|
|
87
|
+
stack_trace = ""
|
|
88
|
+
stack_trace_element = failure.find("StackTrace")
|
|
89
|
+
if stack_trace_element is not None:
|
|
90
|
+
stack_trace = str(stack_trace_element.text)
|
|
91
|
+
|
|
92
|
+
stdout = failure.get("message", "")
|
|
93
|
+
stderr = stack_trace
|
|
94
|
+
|
|
95
|
+
test_results.append(TestResult(
|
|
96
|
+
test_case_name=test_case_name,
|
|
97
|
+
test_name=test_name,
|
|
98
|
+
result=result,
|
|
99
|
+
stdout=stdout,
|
|
100
|
+
stderr=stderr))
|
|
101
|
+
|
|
102
|
+
if len(test_results) == 0:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
test_duration_msec_per_test = int(total_duration) / len(test_results)
|
|
106
|
+
|
|
107
|
+
for test_result in test_results: # type: TestResult
|
|
108
|
+
if module_name == "" or test_result.test_case_name == "" or test_result.test_name == "":
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
events.append(CaseEvent.create(
|
|
112
|
+
test_path=build_record_test_path(module_name, test_result.test_case_name, test_result.test_name),
|
|
113
|
+
duration_secs=float(test_duration_msec_per_test / 1000),
|
|
114
|
+
status=test_result.case_event_status(),
|
|
115
|
+
stdout=test_result.stdout,
|
|
116
|
+
stderr=test_result.stderr,
|
|
117
|
+
))
|
|
118
|
+
|
|
119
|
+
return (x for x in events)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@smart_tests.record.tests
|
|
123
|
+
def record_tests(
|
|
124
|
+
client: RecordTests,
|
|
125
|
+
reports: Annotated[List[str], typer.Argument(
|
|
126
|
+
multiple=True,
|
|
127
|
+
help="Test report files to process"
|
|
128
|
+
)],
|
|
129
|
+
):
|
|
130
|
+
"""
|
|
131
|
+
Beta: Report test result that Compatibility Test Suite (CTS) produced. Supports only CTS v2
|
|
132
|
+
"""
|
|
133
|
+
for r in reports:
|
|
134
|
+
client.report(r)
|
|
135
|
+
|
|
136
|
+
client.parse_func = parse_func
|
|
137
|
+
client.run()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@smart_tests.subset
|
|
141
|
+
def subset(client: Subset):
|
|
142
|
+
"""
|
|
143
|
+
Beta: Produces test list from previous test sessions for Compatibility Test Suite (CTS). Supports only CTS v2
|
|
144
|
+
"""
|
|
145
|
+
start_module = False
|
|
146
|
+
|
|
147
|
+
""" # noqa: E501
|
|
148
|
+
# This is sample output of `cts-tradefed list modules`
|
|
149
|
+
==================
|
|
150
|
+
Notice:
|
|
151
|
+
We collect anonymous usage statistics in accordance with our Content Licenses (https://source.android.com/setup/start/licenses), Contributor License Agreement (https://opensource.google.com/docs/cla/), Privacy Policy (https://policies.google.com/privacy) and Terms of Service (https://policies.google.com/terms).
|
|
152
|
+
==================
|
|
153
|
+
Android Compatibility Test Suite 12.1_r5 (9566553)
|
|
154
|
+
Use "help" or "help all" to get more information on running commands.
|
|
155
|
+
Non-interactive mode: Running initial command then exiting.
|
|
156
|
+
Using commandline arguments as starting command: [list, modules]
|
|
157
|
+
arm64-v8a CtsAbiOverrideHostTestCases[instant]
|
|
158
|
+
arm64-v8a CtsAbiOverrideHostTestCases[secondary_user]
|
|
159
|
+
arm64-v8a CtsAbiOverrideHostTestCases
|
|
160
|
+
armeabi-v7a CtsAbiOverrideHostTestCases
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
for t in client.stdin():
|
|
164
|
+
line = t.rstrip("\n") if isinstance(t, str) else str(t).rstrip("\n")
|
|
165
|
+
|
|
166
|
+
if "starting command" in line:
|
|
167
|
+
start_module = True
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if not start_module:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# e.g) armeabi-v7a CtsAbiOverrideHostTestCases
|
|
174
|
+
device_and_module = line.split()
|
|
175
|
+
if len(device_and_module) != 2:
|
|
176
|
+
click.secho(
|
|
177
|
+
f"Warning: {line} is not expected Module format and skipped",
|
|
178
|
+
fg='yellow',
|
|
179
|
+
err=True)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
client.test_path([{"type": "Module", "name": device_and_module[1]}])
|
|
183
|
+
|
|
184
|
+
option = include_option
|
|
185
|
+
if client.is_output_exclusion_rules:
|
|
186
|
+
option = exclude_option
|
|
187
|
+
|
|
188
|
+
client.formatter = lambda x: "{option} \"{module}\"".format(option=option, module=x[0]['name'])
|
|
189
|
+
client.run()
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
import re
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Dict, Generator, List, cast
|
|
9
|
+
from xml.etree import ElementTree as ET
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
import smart_tests.args4p.typer as typer
|
|
14
|
+
from smart_tests.testpath import FilePathNormalizer, TestPath
|
|
15
|
+
|
|
16
|
+
from ..commands.record.case_event import CaseEvent, CaseEventType
|
|
17
|
+
from ..commands.record.tests import RecordTests
|
|
18
|
+
from . import smart_tests
|
|
19
|
+
|
|
20
|
+
subset = smart_tests.CommonSubsetImpls(__name__).scan_files('*_feature')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
REPORT_FILE_PREFIX = "TEST-"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@smart_tests.record.tests
|
|
27
|
+
def record_tests(
|
|
28
|
+
client: RecordTests,
|
|
29
|
+
reports: Annotated[List[str], typer.Argument(
|
|
30
|
+
multiple=True,
|
|
31
|
+
help="Test report files to process"
|
|
32
|
+
)],
|
|
33
|
+
json_format: Annotated[bool, typer.Option(
|
|
34
|
+
"--json",
|
|
35
|
+
help="use JSON report format"
|
|
36
|
+
)] = False,
|
|
37
|
+
):
|
|
38
|
+
if json_format:
|
|
39
|
+
for r in reports:
|
|
40
|
+
client.report(r)
|
|
41
|
+
client.parse_func = JSONReportParser(client).parse_func
|
|
42
|
+
else:
|
|
43
|
+
report_file_and_test_file_map: dict[str, str] = {}
|
|
44
|
+
_record_tests_from_xml(client, reports, report_file_and_test_file_map)
|
|
45
|
+
|
|
46
|
+
def parse_func(report: str) -> ET.ElementTree:
|
|
47
|
+
tree = cast(ET.ElementTree, ET.parse(report))
|
|
48
|
+
for case in tree.findall("testcase"):
|
|
49
|
+
case.attrib["file"] = str(report_file_and_test_file_map[report])
|
|
50
|
+
|
|
51
|
+
return tree
|
|
52
|
+
|
|
53
|
+
client.junitxml_parse_func = parse_func
|
|
54
|
+
|
|
55
|
+
client.run()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _record_tests_from_xml(client, reports, report_file_and_test_file_map: Dict[str, str]):
|
|
59
|
+
base_path = client.base_path if client.base_path else os.getcwd()
|
|
60
|
+
for report in reports:
|
|
61
|
+
if REPORT_FILE_PREFIX not in report:
|
|
62
|
+
click.echo(f"{report} was load skipped because it doesn't look like a report file.", err=True)
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
test_file = _find_test_file_from_report_file(base_path, report)
|
|
66
|
+
if test_file:
|
|
67
|
+
report_file_and_test_file_map[report] = str(test_file)
|
|
68
|
+
client.report(report)
|
|
69
|
+
else:
|
|
70
|
+
click.echo(f"Cannot find test file of {report}", err=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class JSONReportParser:
|
|
74
|
+
"""
|
|
75
|
+
client: smart_tests.RecordTests
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, client):
|
|
79
|
+
self.client = client
|
|
80
|
+
self.file_path_normalizer = FilePathNormalizer(base_path=client.base_path,
|
|
81
|
+
no_base_path_inference=client.no_base_path_inference)
|
|
82
|
+
|
|
83
|
+
def parse_func(self, report_file: str) -> Generator[CaseEventType, None, None]:
|
|
84
|
+
"""
|
|
85
|
+
example of JSON format report
|
|
86
|
+
[
|
|
87
|
+
{
|
|
88
|
+
"uri": "features/foo/is_it_friday_yet.feature",
|
|
89
|
+
"id": "is-it-friday-yet?",
|
|
90
|
+
"keyword": "Feature",
|
|
91
|
+
"name": "Is it Friday yet?",
|
|
92
|
+
"description": " Everybody wants to know when it's Friday",
|
|
93
|
+
"line": 1,
|
|
94
|
+
"elements": [
|
|
95
|
+
{
|
|
96
|
+
"keyword": "Background",
|
|
97
|
+
"name": "",
|
|
98
|
+
"description": "",
|
|
99
|
+
"line": 4,
|
|
100
|
+
"type": "background",
|
|
101
|
+
"steps": [
|
|
102
|
+
{
|
|
103
|
+
"keyword": "Given ",
|
|
104
|
+
"name": "this year is 2023",
|
|
105
|
+
"line": 5,
|
|
106
|
+
"match": {
|
|
107
|
+
"location": "features/step_definitions/stepdefs.rb:12"
|
|
108
|
+
},
|
|
109
|
+
"result": {
|
|
110
|
+
"status": "passed",
|
|
111
|
+
"duration": 30000
|
|
112
|
+
},
|
|
113
|
+
"after": [
|
|
114
|
+
{
|
|
115
|
+
"match": {
|
|
116
|
+
"location": "features/support/env.rb:22"
|
|
117
|
+
},
|
|
118
|
+
"result": {
|
|
119
|
+
"status": "passed",
|
|
120
|
+
"duration": 10181000
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"keyword": "And ",
|
|
127
|
+
"name": "this month is January",
|
|
128
|
+
"line": 6,
|
|
129
|
+
"match": {
|
|
130
|
+
"location": "features/step_definitions/stepdefs.rb:17"
|
|
131
|
+
},
|
|
132
|
+
"result": {
|
|
133
|
+
"status": "passed",
|
|
134
|
+
"duration": 9000
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"id": "is-it-friday-yet?;today-is-or-is-not-friday;;2",
|
|
141
|
+
"keyword": "Scenario Outline",
|
|
142
|
+
"name": "Today is or is not Friday",
|
|
143
|
+
"description": "",
|
|
144
|
+
"line": 11,
|
|
145
|
+
"type": "scenario",
|
|
146
|
+
"steps": [
|
|
147
|
+
{
|
|
148
|
+
"keyword": "Given ",
|
|
149
|
+
"name": "today is \"Friday\"",
|
|
150
|
+
"line": 5,
|
|
151
|
+
"match": {
|
|
152
|
+
"location": "features/step_definitions/stepdefs.rb:12"
|
|
153
|
+
},
|
|
154
|
+
"result": {
|
|
155
|
+
"status": "passed",
|
|
156
|
+
"duration": 101000
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"keyword": "When ",
|
|
161
|
+
"name": "I ask whether it's Friday yet",
|
|
162
|
+
"line": 6,
|
|
163
|
+
"match": {
|
|
164
|
+
"location": "features/step_definitions/stepdefs.rb:24"
|
|
165
|
+
},
|
|
166
|
+
"result": {
|
|
167
|
+
"status": "passed",
|
|
168
|
+
"duration": 11000
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"keyword": "Then ",
|
|
173
|
+
"name": "I should be told \"TGIF\"",
|
|
174
|
+
"line": 7,
|
|
175
|
+
"match": {
|
|
176
|
+
"location": "features/step_definitions/stepdefs.rb:28"
|
|
177
|
+
},
|
|
178
|
+
"result": {
|
|
179
|
+
"status": "passed",
|
|
180
|
+
"duration": 481000
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
"before": [
|
|
185
|
+
{
|
|
186
|
+
"match": {
|
|
187
|
+
"location": "features/support/env.rb:10"
|
|
188
|
+
},
|
|
189
|
+
"result": {
|
|
190
|
+
"status": "passed",
|
|
191
|
+
"duration": 11092000
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"match": {
|
|
196
|
+
"location": "features/support/env.rb:14"
|
|
197
|
+
},
|
|
198
|
+
"result": {
|
|
199
|
+
"status": "passed",
|
|
200
|
+
"duration": 11081000
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
"after": [
|
|
205
|
+
{
|
|
206
|
+
"match": {
|
|
207
|
+
"location": "features/support/env.rb:18"
|
|
208
|
+
},
|
|
209
|
+
"result": {
|
|
210
|
+
"status": "passed",
|
|
211
|
+
"duration": 11072000
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
"""
|
|
218
|
+
with open(report_file, 'r') as json_file:
|
|
219
|
+
try:
|
|
220
|
+
data = json.load(json_file)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
raise Exception(f"Can't read JSON format report file {report_file}. Make sure to confirm report file.") from e
|
|
223
|
+
|
|
224
|
+
if len(data) == 0:
|
|
225
|
+
click.echo(f"Can't find test reports from {report_file}. Make sure to confirm report file.", err=True)
|
|
226
|
+
|
|
227
|
+
for d in data:
|
|
228
|
+
file_name = clean_uri(d.get("uri", ""))
|
|
229
|
+
class_name = d.get("name", "")
|
|
230
|
+
|
|
231
|
+
# Cucumber can define repeating the same `Given` steps as a `Background`
|
|
232
|
+
# https://cucumber.io/docs/gherkin/reference/#background
|
|
233
|
+
background: TestCaseInfo | None = None
|
|
234
|
+
|
|
235
|
+
for element in d.get("elements", []):
|
|
236
|
+
test_case = element.get("name", "")
|
|
237
|
+
# Scenario hooks run for every scenario.
|
|
238
|
+
# https://cucumber.io/docs/cucumber/api/?lang=java#hooks
|
|
239
|
+
scenario_hook_information = _parse_hook_from_element(element)
|
|
240
|
+
|
|
241
|
+
if element.get("type", "") == CucumberElementType.BACKGROUND.value:
|
|
242
|
+
# `Background` can be defined once per scenario so won't available multiple times.
|
|
243
|
+
background = _parse_test_case_info_from_element(element=element)
|
|
244
|
+
background.append_hook_info(scenario_hook_information)
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
test_case_info = _parse_test_case_info_from_element(element=element)
|
|
248
|
+
if background:
|
|
249
|
+
test_case_info.append_background_results(background)
|
|
250
|
+
# Initialize background for next scenario
|
|
251
|
+
background = None
|
|
252
|
+
|
|
253
|
+
test_case_info.append_hook_info(scenario_hook_information)
|
|
254
|
+
|
|
255
|
+
if test_case_info.is_failed():
|
|
256
|
+
status = CaseEvent.TEST_FAILED
|
|
257
|
+
elif test_case_info.is_skipped():
|
|
258
|
+
status = CaseEvent.TEST_SKIPPED
|
|
259
|
+
else:
|
|
260
|
+
status = CaseEvent.TEST_PASSED
|
|
261
|
+
|
|
262
|
+
test_path: TestPath = [
|
|
263
|
+
{"type": "file", "name": pathlib.Path(self.file_path_normalizer.relativize(file_name)).as_posix()},
|
|
264
|
+
{"type": "class", "name": class_name},
|
|
265
|
+
{"type": "testcase", "name": test_case},
|
|
266
|
+
]
|
|
267
|
+
test_path.extend(test_case_info.test_path())
|
|
268
|
+
|
|
269
|
+
yield CaseEvent.create(
|
|
270
|
+
test_path=test_path,
|
|
271
|
+
duration_secs=test_case_info.duration_sec(),
|
|
272
|
+
status=status,
|
|
273
|
+
stderr="\n".join(test_case_info.stderr()))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _find_test_file_from_report_file(base_path: str, report: str) -> Path | None:
|
|
277
|
+
"""
|
|
278
|
+
Find test file from cucumber report file path format
|
|
279
|
+
e.g) Test-features-foo-hoge.xml -> features/foo/hoge.feature or features/foo-hoge.feature
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
report_file = os.path.basename(report)
|
|
283
|
+
report_file = report_file.lstrip(REPORT_FILE_PREFIX)
|
|
284
|
+
report_file = os.path.splitext(report_file)[0]
|
|
285
|
+
|
|
286
|
+
file_candidates = _create_file_candidate_list(report_file)
|
|
287
|
+
for candidate in file_candidates:
|
|
288
|
+
f = Path(base_path, candidate + ".feature")
|
|
289
|
+
if f.exists():
|
|
290
|
+
return f
|
|
291
|
+
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _create_file_candidate_list(file: str) -> List[str]:
|
|
296
|
+
candidates = [""]
|
|
297
|
+
for c in file:
|
|
298
|
+
if c == "-":
|
|
299
|
+
list_length = len(candidates)
|
|
300
|
+
candidates += deepcopy(candidates)
|
|
301
|
+
for i in range(list_length):
|
|
302
|
+
candidates[i] += '-'
|
|
303
|
+
for i in range(list_length, len(candidates)):
|
|
304
|
+
candidates[i] += '/'
|
|
305
|
+
else:
|
|
306
|
+
for i in range(len(candidates)):
|
|
307
|
+
candidates[i] += c
|
|
308
|
+
|
|
309
|
+
return candidates
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Result:
|
|
313
|
+
def __init__(self, statuses: List[str], duration_nano_sec: int, error_message: List[str]) -> None:
|
|
314
|
+
self._statuses = statuses
|
|
315
|
+
self._duration_nano_sec = duration_nano_sec
|
|
316
|
+
self._error_message = error_message
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class TestCaseHookInfo(Result):
|
|
320
|
+
def __init__(self, duration_nano_sec: int, statuses: List[str], stderr: List[str]) -> None:
|
|
321
|
+
super().__init__(statuses=statuses, duration_nano_sec=duration_nano_sec, error_message=stderr)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_hook_from_element(element: Dict[str, List]) -> TestCaseHookInfo:
|
|
325
|
+
duration_nano_sec: int = 0
|
|
326
|
+
statuses: List[str] = []
|
|
327
|
+
stderr: List[str] = []
|
|
328
|
+
|
|
329
|
+
def parse_steps(step: Dict[str, Dict]):
|
|
330
|
+
result = step.get("result", None)
|
|
331
|
+
if result:
|
|
332
|
+
nonlocal duration_nano_sec
|
|
333
|
+
duration_nano_sec += result.get("duration", 0)
|
|
334
|
+
if result.get("status", None):
|
|
335
|
+
statuses.append(result["status"])
|
|
336
|
+
if result.get("error_message", None):
|
|
337
|
+
stderr.append(result["error_message"])
|
|
338
|
+
|
|
339
|
+
for step in element.get("before", []):
|
|
340
|
+
parse_steps(step)
|
|
341
|
+
|
|
342
|
+
for step in element.get("after", []):
|
|
343
|
+
parse_steps(step)
|
|
344
|
+
|
|
345
|
+
return TestCaseHookInfo(
|
|
346
|
+
duration_nano_sec=duration_nano_sec,
|
|
347
|
+
statuses=statuses,
|
|
348
|
+
stderr=stderr
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class TestCaseInfo(Result):
|
|
353
|
+
def __init__(
|
|
354
|
+
self, steps: List[List[str]],
|
|
355
|
+
duration_nano_sec: int, statuses: List[str],
|
|
356
|
+
stderr: List[str],
|
|
357
|
+
hooks: List[TestCaseHookInfo] = []) -> None:
|
|
358
|
+
super().__init__(statuses=statuses, duration_nano_sec=duration_nano_sec, error_message=stderr)
|
|
359
|
+
self._steps = steps
|
|
360
|
+
self._hooks = hooks
|
|
361
|
+
|
|
362
|
+
def steps(self) -> List[List[str]]:
|
|
363
|
+
return self._steps
|
|
364
|
+
|
|
365
|
+
def duration_nano(self) -> int:
|
|
366
|
+
return self._duration_nano_sec + sum(h._duration_nano_sec for h in self._hooks)
|
|
367
|
+
|
|
368
|
+
def duration_sec(self) -> float:
|
|
369
|
+
return self.duration_nano() / 1000 / 1000 / 1000
|
|
370
|
+
|
|
371
|
+
def statuses(self) -> List[str]:
|
|
372
|
+
return self._statuses + sum([h._statuses for h in self._hooks], [])
|
|
373
|
+
|
|
374
|
+
def stderr(self) -> List[str]:
|
|
375
|
+
return self._error_message + sum([h._error_message for h in self._hooks], [])
|
|
376
|
+
|
|
377
|
+
def append_hook_info(self, other: TestCaseHookInfo) -> None:
|
|
378
|
+
self._hooks.append(other)
|
|
379
|
+
|
|
380
|
+
def is_failed(self) -> bool:
|
|
381
|
+
return "failed" in self.statuses()
|
|
382
|
+
|
|
383
|
+
def is_skipped(self) -> bool:
|
|
384
|
+
return "undefined" in self.statuses()
|
|
385
|
+
|
|
386
|
+
def test_path(self) -> TestPath:
|
|
387
|
+
test_path: TestPath = []
|
|
388
|
+
for step in self._steps:
|
|
389
|
+
if len(step) == 2:
|
|
390
|
+
# While there isn't any cases that the size of step is not 2, we check the size just in case.
|
|
391
|
+
test_path.append({"type": step[0], "name": step[1]})
|
|
392
|
+
return test_path
|
|
393
|
+
|
|
394
|
+
# This method only for append_background_results method
|
|
395
|
+
def _to_hook(self) -> TestCaseHookInfo:
|
|
396
|
+
return TestCaseHookInfo(duration_nano_sec=self.duration_nano(), statuses=self.statuses(), stderr=self.stderr())
|
|
397
|
+
|
|
398
|
+
# Type of other TestCaseInfo (Self).. Python3.6 cannot support `Self`` type even if used typing_extensions module
|
|
399
|
+
def append_background_results(self, other) -> None:
|
|
400
|
+
# Need to merge Background steps to main test scenario to calculate correct test duration,
|
|
401
|
+
# then, we don't need step information of Background. So append it as hooks
|
|
402
|
+
self.append_hook_info(other._to_hook())
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _parse_test_case_info_from_element(element: Dict[str, List]) -> TestCaseInfo:
|
|
406
|
+
steps: List[List[str]] = []
|
|
407
|
+
duration = 0 # nano sec
|
|
408
|
+
statuses = []
|
|
409
|
+
stderr = []
|
|
410
|
+
hooks: List[TestCaseHookInfo] = []
|
|
411
|
+
|
|
412
|
+
for step in element.get("steps", []):
|
|
413
|
+
steps.append([step.get("keyword", "").strip(), step.get("name", "").strip()])
|
|
414
|
+
result = step.get("result", None)
|
|
415
|
+
|
|
416
|
+
if result:
|
|
417
|
+
# duration's unit is nano sec
|
|
418
|
+
# ref:
|
|
419
|
+
# https://github.com/cucumber/cucumber-ruby/blob/main/lib/cucumber/formatter/json.rb#L222
|
|
420
|
+
duration = duration + result.get("duration", 0)
|
|
421
|
+
statuses.append(result.get("status"))
|
|
422
|
+
if result.get("error_message", None):
|
|
423
|
+
stderr.append(result["error_message"])
|
|
424
|
+
|
|
425
|
+
# Step hooks are invoked before and after a step.
|
|
426
|
+
# https://cucumber.io/docs/cucumber/api/?lang=java#hooks
|
|
427
|
+
# When Step hooks are executed, the information about each step is registered in each element.
|
|
428
|
+
hooks.append(_parse_hook_from_element(step))
|
|
429
|
+
|
|
430
|
+
return TestCaseInfo(
|
|
431
|
+
steps=steps,
|
|
432
|
+
duration_nano_sec=duration,
|
|
433
|
+
statuses=statuses,
|
|
434
|
+
stderr=stderr,
|
|
435
|
+
hooks=hooks
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# This type refer to https://github.com/cucumber/json-formatter/blob/v19.0.0/go/json_elements.go#L23.
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class CucumberElementType(Enum):
|
|
442
|
+
BACKGROUND = 'background'
|
|
443
|
+
SCENARIO = 'scenario'
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def clean_uri(uri: str) -> str:
|
|
447
|
+
"""
|
|
448
|
+
Trim unused prefix from the URI string.
|
|
449
|
+
For example, if the URI is "file:features/foo.feature", it will return "features/foo.feature".
|
|
450
|
+
"""
|
|
451
|
+
return re.sub(r'^[a-zA-Z]+:', '', uri)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Annotated, List, cast
|
|
2
|
+
from xml.etree import ElementTree as ET
|
|
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
|
+
for r in reports:
|
|
20
|
+
client.report(r)
|
|
21
|
+
|
|
22
|
+
def parse_func(p: str) -> ET.ElementTree:
|
|
23
|
+
tree = cast(ET.ElementTree, ET.parse(p))
|
|
24
|
+
for suites in tree.iter("testsuites"):
|
|
25
|
+
if len(suites) == 0:
|
|
26
|
+
continue
|
|
27
|
+
root_suite = suites.find('./testsuite[@name="Root Suite"]')
|
|
28
|
+
if root_suite is not None:
|
|
29
|
+
filepath = root_suite.get("file")
|
|
30
|
+
if filepath is not None:
|
|
31
|
+
for suite in suites:
|
|
32
|
+
suite.attrib.update({"filepath": filepath})
|
|
33
|
+
return tree
|
|
34
|
+
|
|
35
|
+
client.junitxml_parse_func = parse_func
|
|
36
|
+
client.run()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@smart_tests.subset
|
|
40
|
+
def subset(client: Subset):
|
|
41
|
+
# read lines as test file names
|
|
42
|
+
for t in client.stdin():
|
|
43
|
+
client.test_path(t.rstrip("\n"))
|
|
44
|
+
|
|
45
|
+
client.separator = ','
|
|
46
|
+
client.run()
|