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,149 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
from typing import Annotated, Dict, List
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
import smart_tests.args4p.typer as typer
|
|
8
|
+
from smart_tests.utils import glob as uglob
|
|
9
|
+
from smart_tests.utils.java import junit5_nested_class_path_builder
|
|
10
|
+
|
|
11
|
+
from ..commands.record.tests import RecordTests
|
|
12
|
+
from ..commands.subset import Subset
|
|
13
|
+
from . import smart_tests
|
|
14
|
+
|
|
15
|
+
# Surefire has the default inclusion pattern
|
|
16
|
+
# https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html#includes
|
|
17
|
+
# and the default exclusion pattern
|
|
18
|
+
# https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html#excludes
|
|
19
|
+
# these variables emulates those effects.
|
|
20
|
+
# TODO: inclusion/exclusion are user configurable patterns, so it should be user configurable
|
|
21
|
+
# beyond that and to fully generalize this, there's internal discussion of
|
|
22
|
+
# this at https://launchableinc.atlassian.net/l/c/TXDJnn09
|
|
23
|
+
includes = [uglob.compile(x) for x in [
|
|
24
|
+
# HACK: we check extensions outside the glob. We seem to allow both source
|
|
25
|
+
# file enumeration and class file enumeration
|
|
26
|
+
'**/Test*.*',
|
|
27
|
+
'**/*Test.*',
|
|
28
|
+
'**/*Spec.*',
|
|
29
|
+
'**/*Tests.*',
|
|
30
|
+
'**/*TestCase.*'
|
|
31
|
+
]]
|
|
32
|
+
excludes = [uglob.compile(x) for x in [
|
|
33
|
+
'**/*$*'
|
|
34
|
+
]]
|
|
35
|
+
|
|
36
|
+
# Test if a given path name is a test that Surefire recognizes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_file(f: str) -> bool:
|
|
40
|
+
if not (f.endswith('.java') or f.endswith(".scala") or f.endswith(".kt") or f.endswith(".class") or f.endswith(".groovy")):
|
|
41
|
+
return False
|
|
42
|
+
for p in excludes:
|
|
43
|
+
if p.fullmatch(f):
|
|
44
|
+
return False
|
|
45
|
+
for p in includes:
|
|
46
|
+
if p.fullmatch(f):
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@smart_tests.subset
|
|
52
|
+
def subset(
|
|
53
|
+
client: Subset,
|
|
54
|
+
source_roots: Annotated[List[str] | None, typer.Argument(
|
|
55
|
+
multiple=True,
|
|
56
|
+
required=False,
|
|
57
|
+
help="Source root directories to scan for tests"
|
|
58
|
+
)] = None,
|
|
59
|
+
test_compile_created_file: Annotated[List[str] | None, typer.Option(
|
|
60
|
+
"--test-compile-created-file",
|
|
61
|
+
multiple=True,
|
|
62
|
+
help="Please run `mvn test-compile` command to create input file for this option"
|
|
63
|
+
)] = None,
|
|
64
|
+
is_scan_test_compile_lst: Annotated[bool, typer.Option(
|
|
65
|
+
"--scan-test-compile-lst",
|
|
66
|
+
help="Scan testCompile/default-testCompile/createdFiles.lst for *.lst files generated by `mvn compile` and "
|
|
67
|
+
"use them as test inputs."
|
|
68
|
+
)] = False,
|
|
69
|
+
):
|
|
70
|
+
|
|
71
|
+
def file2class_test_path(f: str) -> List[Dict[str, str]]:
|
|
72
|
+
# remove extension
|
|
73
|
+
f, _ = os.path.splitext(f)
|
|
74
|
+
|
|
75
|
+
# directory -> package name conversion
|
|
76
|
+
cls_name = f.replace(os.path.sep, '.')
|
|
77
|
+
return [{"type": "class", "name": cls_name}]
|
|
78
|
+
|
|
79
|
+
def file2test(f: str) -> List | None:
|
|
80
|
+
if is_file(f):
|
|
81
|
+
return file2class_test_path(f)
|
|
82
|
+
else:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Handle None values
|
|
86
|
+
if test_compile_created_file is None:
|
|
87
|
+
test_compile_created_file = []
|
|
88
|
+
if source_roots is None:
|
|
89
|
+
source_roots = []
|
|
90
|
+
|
|
91
|
+
files_to_read = list(test_compile_created_file)
|
|
92
|
+
if is_scan_test_compile_lst:
|
|
93
|
+
if len(test_compile_created_file) > 0:
|
|
94
|
+
click.secho(
|
|
95
|
+
"Warning: --test-compile-created-file is overridden by --scan-test-compile-lst",
|
|
96
|
+
fg='yellow', err=True)
|
|
97
|
+
|
|
98
|
+
pattern = os.path.join('**', 'createdFiles.lst')
|
|
99
|
+
files_to_read = glob.glob(pattern, recursive=True)
|
|
100
|
+
|
|
101
|
+
if not files_to_read:
|
|
102
|
+
click.secho(
|
|
103
|
+
"Warning: No .lst files. Please run after executing `mvn test-compile`",
|
|
104
|
+
fg='yellow',
|
|
105
|
+
err=True)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if files_to_read:
|
|
109
|
+
for file in files_to_read:
|
|
110
|
+
with open(file, 'r') as f:
|
|
111
|
+
lines = f.readlines()
|
|
112
|
+
if len(lines) == 0:
|
|
113
|
+
click.secho(
|
|
114
|
+
f"Warning: --test-compile-created-file {file} is empty",
|
|
115
|
+
fg='yellow',
|
|
116
|
+
err=True)
|
|
117
|
+
|
|
118
|
+
for line in lines:
|
|
119
|
+
# trim trailing newline
|
|
120
|
+
line = line.strip()
|
|
121
|
+
|
|
122
|
+
path = file2test(line)
|
|
123
|
+
if path:
|
|
124
|
+
client.test_paths.append(path)
|
|
125
|
+
else:
|
|
126
|
+
for root in source_roots:
|
|
127
|
+
client.scan(root, '**/*', file2test)
|
|
128
|
+
|
|
129
|
+
client.run()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# TestNG produces surefire-reports/testng-results.xml in TestNG's native format.
|
|
133
|
+
# Surefire produces TEST-*.xml in JUnit format (see Surefire's StatelessXmlReporter.getReportFile)
|
|
134
|
+
# In addition, TestNG also produces surefire-reports/junitreports/TEST-*.xml
|
|
135
|
+
# (see TestNG's JUnitReportReporter.getFileName)
|
|
136
|
+
# And there are more test reports in play.
|
|
137
|
+
#
|
|
138
|
+
# So to collectly find tests without duplications, we need to find surefire-reports/TEST-*.xml
|
|
139
|
+
# not surefire-reports/**/TEST-*.xml nor surefire-reports/*.xml
|
|
140
|
+
@smart_tests.record.tests
|
|
141
|
+
def record_tests(
|
|
142
|
+
client: RecordTests,
|
|
143
|
+
reports: Annotated[List[str], typer.Argument(
|
|
144
|
+
multiple=True,
|
|
145
|
+
help="Test report files to process"
|
|
146
|
+
)],
|
|
147
|
+
):
|
|
148
|
+
client.path_builder = junit5_nested_class_path_builder(client.path_builder)
|
|
149
|
+
smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=reports)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Annotated, List
|
|
2
|
+
|
|
3
|
+
from junitparser import TestCase, TestSuite # type: ignore
|
|
4
|
+
|
|
5
|
+
import smart_tests.args4p.typer as typer
|
|
6
|
+
|
|
7
|
+
from ..commands.record.tests import RecordTests
|
|
8
|
+
from ..testpath import TestPath
|
|
9
|
+
from . import smart_tests
|
|
10
|
+
|
|
11
|
+
subset = smart_tests.CommonSubsetImpls(__name__).scan_files('*_test.rb')
|
|
12
|
+
|
|
13
|
+
TEST_PATH_ORDER = {"file": 1, "class": 2, "testcase": 3}
|
|
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
|
+
default_path_builder = client.path_builder
|
|
25
|
+
|
|
26
|
+
def path_builder(case: TestCase, suite: TestSuite, report_file: str) -> TestPath:
|
|
27
|
+
test_path = default_path_builder(case, suite, report_file)
|
|
28
|
+
if not any(item.get("type") == "class" for item in test_path):
|
|
29
|
+
# mintiest-ci sets a class name in name attribute in testsuite tag.
|
|
30
|
+
# https://github.com/CircleCI-Public/minitest-ci/blob/v3.4.0/lib/minitest/ci_plugin.rb#L86-L87
|
|
31
|
+
# https://github.com/CircleCI-Public/minitest-ci/blob/v3.4.0/lib/minitest/ci_plugin.rb#L132
|
|
32
|
+
classname = suite._elem.attrib.get("name")
|
|
33
|
+
if classname:
|
|
34
|
+
test_path.append({"type": "class", "name": classname})
|
|
35
|
+
test_path = sorted(test_path, key=lambda x: TEST_PATH_ORDER[x["type"]])
|
|
36
|
+
return test_path
|
|
37
|
+
|
|
38
|
+
client.path_builder = path_builder
|
|
39
|
+
|
|
40
|
+
smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=reports)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from typing import Annotated, Callable, Dict, List
|
|
2
|
+
from xml.etree import ElementTree as ET
|
|
3
|
+
|
|
4
|
+
import smart_tests.args4p.typer as typer
|
|
5
|
+
from smart_tests.commands.record.case_event import CaseEvent
|
|
6
|
+
from smart_tests.testpath import TestPath, parse_test_path, unparse_test_path
|
|
7
|
+
|
|
8
|
+
from ..commands.record.tests import RecordTests
|
|
9
|
+
from ..commands.subset import Subset
|
|
10
|
+
from . import smart_tests
|
|
11
|
+
|
|
12
|
+
# common code between 'subset' & 'record tests' to build up test path from
|
|
13
|
+
# nested <test-suite>s
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Nested class name handling in .NET
|
|
17
|
+
---------------------------------
|
|
18
|
+
|
|
19
|
+
Nested class 'Zot' in the following example gets the full name "Foo.Bar+Zot":
|
|
20
|
+
|
|
21
|
+
namespace Foo {
|
|
22
|
+
class Bar {
|
|
23
|
+
class Zot {
|
|
24
|
+
}}}
|
|
25
|
+
|
|
26
|
+
This is incontrast to how you refer to this class from the source code. For example,
|
|
27
|
+
"new Foo.Bar.Zot()"
|
|
28
|
+
|
|
29
|
+
The subset command expects the list of tests to be passed to "nunit --testlist" option,
|
|
30
|
+
and this option expects test names to be in "Foo.Bar+Zot" format.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_path(e: ET.Element, parent: ET.Element):
|
|
36
|
+
pp: TestPath = []
|
|
37
|
+
parent_start_time = parent.attrib.get('start-time')
|
|
38
|
+
if parent_start_time is not None and e.attrib.get('start-time') is None:
|
|
39
|
+
# the 'start-time' attribute is normally on <test-case> but apparently not always,
|
|
40
|
+
# so we try to use the nearest ancestor as an approximate
|
|
41
|
+
e.set('start-time', parent_start_time)
|
|
42
|
+
parent_path = parent.attrib.get('path')
|
|
43
|
+
if parent_path is not None:
|
|
44
|
+
pp = parse_test_path(parent_path)
|
|
45
|
+
if e.tag == "test-suite":
|
|
46
|
+
# <test-suite>s form a nested tree structure so capture those in path
|
|
47
|
+
pp = pp + [{'type': e.attrib['type'], 'name': e.attrib['name']}]
|
|
48
|
+
if e.tag == "test-case":
|
|
49
|
+
# work around a bug in NUnitXML.Logger.
|
|
50
|
+
# see nunit-reporter-bug-with-nested-type.xml test case
|
|
51
|
+
methodname = e.attrib['methodname']
|
|
52
|
+
bra = methodname.find("(")
|
|
53
|
+
idx = methodname.rfind(".", 0, bra)
|
|
54
|
+
if idx >= 0:
|
|
55
|
+
# when things are going well, method name cannot contain '.' since it's not a valid character in a symbol.
|
|
56
|
+
# but when NUnitXML.Logger messes up, it ends up putting the class name and the method name, like
|
|
57
|
+
# <test-case name="TheTest" fullname="Launchable.NUnit.Test.Outer+Inner.TheTest"
|
|
58
|
+
# methodname="Outer+Inner.TheTest" classname="Test"
|
|
59
|
+
|
|
60
|
+
pp = pp[0:-1] + [
|
|
61
|
+
# NUnitXML.Logger mistreats the last portion of the namespace as a test fixture when
|
|
62
|
+
# it really should be test suite. So we patch that up too. This is going beyond what's minimally required
|
|
63
|
+
# to make subset work, because type information won't impact how the test path is printed, but
|
|
64
|
+
# when NUnitXML.Logger eventually fixes this bug, we don't want that to produce different test paths.
|
|
65
|
+
{'type': 'TestSuite', 'name': pp[-1]['name']},
|
|
66
|
+
# Here, we need to insert the missing TestFixture=Outer+Inner.
|
|
67
|
+
# I chose TestFixture because that's what nunit console runner (which we believe is handling it correctly)
|
|
68
|
+
# chooses as its type.
|
|
69
|
+
{'type': 'TestFixture', 'name': methodname[0:idx]}
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
pp = pp + [{'type': 'TestCase', 'name': e.attrib['name']}]
|
|
73
|
+
|
|
74
|
+
if len(pp) > 0:
|
|
75
|
+
def split_filepath(path: str) -> List[str]:
|
|
76
|
+
# Supports Linux and Windows
|
|
77
|
+
if '/' in path:
|
|
78
|
+
return path.split('/')
|
|
79
|
+
else:
|
|
80
|
+
return path.split('\\')
|
|
81
|
+
|
|
82
|
+
# "Assembly" type contains full path at a customer's environment
|
|
83
|
+
# remove file path prefix in Assembly
|
|
84
|
+
e.set('path', unparse_test_path([
|
|
85
|
+
{**path, 'name': split_filepath(path['name'])[-1]}
|
|
86
|
+
if path['type'] == 'Assembly'
|
|
87
|
+
else path
|
|
88
|
+
for path in pp
|
|
89
|
+
]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def nunit_parse_func(report: str):
|
|
93
|
+
events = []
|
|
94
|
+
|
|
95
|
+
# parse <test-case> element into CaseEvent
|
|
96
|
+
def on_element(e: ET.Element, parent: ET.Element):
|
|
97
|
+
build_path(e, parent)
|
|
98
|
+
if e.tag == "test-case":
|
|
99
|
+
result = e.attrib.get('result')
|
|
100
|
+
status = CaseEvent.TEST_FAILED
|
|
101
|
+
stderr: List[str] = []
|
|
102
|
+
if result == 'Passed':
|
|
103
|
+
status = CaseEvent.TEST_PASSED
|
|
104
|
+
elif result == 'Skipped':
|
|
105
|
+
status = CaseEvent.TEST_SKIPPED
|
|
106
|
+
else:
|
|
107
|
+
failure = e.find('failure')
|
|
108
|
+
if failure is not None:
|
|
109
|
+
message = failure.find('message')
|
|
110
|
+
if message is not None and message.text is not None:
|
|
111
|
+
stderr.append(message.text)
|
|
112
|
+
stack_trace = failure.find('stack-trace')
|
|
113
|
+
if stack_trace is not None and stack_trace.text is not None:
|
|
114
|
+
stderr.append(stack_trace.text)
|
|
115
|
+
|
|
116
|
+
events.append(CaseEvent.create(
|
|
117
|
+
test_path=_replace_fixture_to_suite(e.attrib['path']), # type: ignore
|
|
118
|
+
duration_secs=float(e.attrib['duration']),
|
|
119
|
+
status=status,
|
|
120
|
+
timestamp=str(e.attrib.get('start-time')),
|
|
121
|
+
stderr='\n'.join(stderr))) # timestamp is already iso-8601 formatted
|
|
122
|
+
|
|
123
|
+
_parse_dfs_element(report=report, on_element=on_element)
|
|
124
|
+
# return the obtained events as a generator
|
|
125
|
+
return (x for x in events)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@smart_tests.subset
|
|
129
|
+
def subset(
|
|
130
|
+
client: Subset,
|
|
131
|
+
report_xmls: Annotated[List[str], typer.Argument(
|
|
132
|
+
multiple=True,
|
|
133
|
+
required=False,
|
|
134
|
+
help="Test report XML files to process"
|
|
135
|
+
)] = [],
|
|
136
|
+
):
|
|
137
|
+
"""
|
|
138
|
+
Parse an XML file produced from NUnit --explore option to list up all the viable test cases
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def on_element(e: ET.Element, parent: ET.Element):
|
|
142
|
+
build_path(e, parent)
|
|
143
|
+
if e.tag == "test-case":
|
|
144
|
+
client.test_path(_replace_fixture_to_suite(e.attrib['path']))
|
|
145
|
+
|
|
146
|
+
for report_xml in report_xmls:
|
|
147
|
+
_parse_dfs_element(report=report_xml, on_element=on_element)
|
|
148
|
+
|
|
149
|
+
# join all the names except when the type is ParameterizedMethod, because in that case test cases have
|
|
150
|
+
# the name of the test method in it and ends up creating duplicates
|
|
151
|
+
client.formatter = lambda x: '.'.join([c['name'] for c in x if c['type'] not in ['ParameterizedMethod', 'Assembly']])
|
|
152
|
+
client.run()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@smart_tests.record.tests
|
|
156
|
+
def record_tests(
|
|
157
|
+
client: RecordTests,
|
|
158
|
+
report_xml: Annotated[List[str], typer.Argument(
|
|
159
|
+
multiple=True,
|
|
160
|
+
help="Test report XML files to process"
|
|
161
|
+
)],
|
|
162
|
+
):
|
|
163
|
+
client.parse_func = nunit_parse_func
|
|
164
|
+
smart_tests.CommonRecordTestImpls.load_report_files(client=client, source_roots=report_xml)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
Nunit produces different XML structure report between without --explore option and without it.
|
|
169
|
+
So we replace TestFixture to TestSuite to avid this difference problem.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _replace_fixture_to_suite(test_path_str: str) -> List[Dict[str, str]]:
|
|
174
|
+
paths = parse_test_path(test_path_str)
|
|
175
|
+
for path in paths:
|
|
176
|
+
if path["type"] == "TestFixture":
|
|
177
|
+
path["type"] = "TestSuite"
|
|
178
|
+
|
|
179
|
+
return paths
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_dfs_element(report: str, on_element: Callable[[ET.Element, ET.Element], None]):
|
|
183
|
+
tree = ET.parse(source=report)
|
|
184
|
+
root = tree.getroot()
|
|
185
|
+
stack: List[ET.Element] = [root]
|
|
186
|
+
while len(stack) > 0:
|
|
187
|
+
element = stack.pop()
|
|
188
|
+
for test_suite in element.findall('test-suite') + element.findall('test-case'):
|
|
189
|
+
on_element(test_suite, element)
|
|
190
|
+
stack.append(test_suite)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#
|
|
2
|
+
# The the test runner to support playwright junit and JSON report format.
|
|
3
|
+
# https://playwright.dev/
|
|
4
|
+
#
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated, Dict, List
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from junitparser import TestCase, TestSuite # type: ignore
|
|
10
|
+
|
|
11
|
+
import smart_tests.args4p.typer as typer
|
|
12
|
+
|
|
13
|
+
from ..args4p.exceptions import BadCmdLineException
|
|
14
|
+
from ..commands.record.case_event import CaseEvent, CaseEventGenerator
|
|
15
|
+
from ..commands.record.tests import RecordTests
|
|
16
|
+
from ..commands.subset import Subset
|
|
17
|
+
from ..testpath import TestPath
|
|
18
|
+
from . import smart_tests
|
|
19
|
+
|
|
20
|
+
TEST_CASE_DELIMITER = " › "
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@smart_tests.record.tests
|
|
24
|
+
def record_tests(
|
|
25
|
+
client: RecordTests,
|
|
26
|
+
reports: Annotated[List[str], typer.Argument(
|
|
27
|
+
multiple=True,
|
|
28
|
+
help="Test report files to process"
|
|
29
|
+
)],
|
|
30
|
+
json_format: Annotated[bool, typer.Option(
|
|
31
|
+
"--json",
|
|
32
|
+
help="use JSON report format"
|
|
33
|
+
)] = False,
|
|
34
|
+
):
|
|
35
|
+
def path_builder(case: TestCase, suite: TestSuite,
|
|
36
|
+
report_file: str) -> TestPath:
|
|
37
|
+
"""
|
|
38
|
+
The playwright junit report sets a file name to the name attribute in a testsuite element and the classname attribute in a testcase element. # noqa: E501
|
|
39
|
+
This playwright plugin uses a testsuite attribute value.
|
|
40
|
+
e.g.)
|
|
41
|
+
<testsuite name="tests/demo-todo-app.spec.ts" ...>
|
|
42
|
+
<testcase name="New Todo › should allow me to add todo items" classname="tests/demo-todo-app.spec.ts"></testcase>
|
|
43
|
+
<testcase name="New Todo › should clear text input field when an item is added" classname="tests/demo-todo-app.spec.ts"></testcase>
|
|
44
|
+
</testsuite>
|
|
45
|
+
"""
|
|
46
|
+
filepath = suite.name
|
|
47
|
+
if not filepath:
|
|
48
|
+
raise BadCmdLineException("No file name found in %s" % report_file)
|
|
49
|
+
|
|
50
|
+
test_path = [client.make_file_path_component(filepath)]
|
|
51
|
+
if case.name:
|
|
52
|
+
test_path.append({"type": "testcase", "name": case.name})
|
|
53
|
+
return test_path
|
|
54
|
+
|
|
55
|
+
if json_format:
|
|
56
|
+
client.parse_func = JSONReportParser(client).parse_func
|
|
57
|
+
else:
|
|
58
|
+
client.path_builder = path_builder
|
|
59
|
+
|
|
60
|
+
for r in reports:
|
|
61
|
+
client.report(r)
|
|
62
|
+
|
|
63
|
+
client.run()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@smart_tests.subset
|
|
67
|
+
def subset(client: Subset):
|
|
68
|
+
# read lines as test file names
|
|
69
|
+
for t in client.stdin():
|
|
70
|
+
client.test_path(t.rstrip("\n"))
|
|
71
|
+
|
|
72
|
+
client.run()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class JSONReportParser:
|
|
76
|
+
"""
|
|
77
|
+
example of JSON reporter format:
|
|
78
|
+
{
|
|
79
|
+
"suites": [
|
|
80
|
+
{
|
|
81
|
+
"title": "tests/demo-todo-app.spec.ts",
|
|
82
|
+
"file": "tests/demo-todo-app.spec.ts",
|
|
83
|
+
"column": 0,
|
|
84
|
+
"line": 0,
|
|
85
|
+
"specs": [],
|
|
86
|
+
"suites": [
|
|
87
|
+
{
|
|
88
|
+
"title": "New Todo",
|
|
89
|
+
"file": "tests/demo-todo-app.spec.ts",
|
|
90
|
+
"line": 13,
|
|
91
|
+
"column": 6,
|
|
92
|
+
"specs": [
|
|
93
|
+
{
|
|
94
|
+
"title": "should allow me to add todo items",
|
|
95
|
+
"ok": true,
|
|
96
|
+
"tags": [],
|
|
97
|
+
"tests": [
|
|
98
|
+
{
|
|
99
|
+
"timeout": 30000,
|
|
100
|
+
"annotations": [],
|
|
101
|
+
"expectedStatus": "passed",
|
|
102
|
+
"projectId": "chromium",
|
|
103
|
+
"projectName": "chromium",
|
|
104
|
+
"results": [
|
|
105
|
+
{
|
|
106
|
+
"workerIndex": 0,
|
|
107
|
+
"status": "passed",
|
|
108
|
+
"duration": 1254,
|
|
109
|
+
"errors": [],
|
|
110
|
+
"stdout": [],
|
|
111
|
+
"stderr": [],
|
|
112
|
+
"retry": 0,
|
|
113
|
+
"startTime": "2024-04-08T07:42:17.455Z",
|
|
114
|
+
"attachments": []
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
"status": "expected"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"timeout": 30000,
|
|
121
|
+
"annotations": [],
|
|
122
|
+
"expectedStatus": "passed",
|
|
123
|
+
"projectId": "firefox",
|
|
124
|
+
"projectName": "firefox",
|
|
125
|
+
"results": [
|
|
126
|
+
{
|
|
127
|
+
"workerIndex": 5,
|
|
128
|
+
"status": "passed",
|
|
129
|
+
"duration": 1320,
|
|
130
|
+
"errors": [],
|
|
131
|
+
"stdout": [],
|
|
132
|
+
"stderr": [],
|
|
133
|
+
"retry": 0,
|
|
134
|
+
"startTime": "2024-04-08T07:42:21.122Z",
|
|
135
|
+
"attachments": []
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
"status": "expected"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"timeout": 30000,
|
|
142
|
+
"annotations": [],
|
|
143
|
+
"expectedStatus": "passed",
|
|
144
|
+
"projectId": "webkit",
|
|
145
|
+
"projectName": "webkit",
|
|
146
|
+
"results": [
|
|
147
|
+
{
|
|
148
|
+
"workerIndex": 10,
|
|
149
|
+
"status": "passed",
|
|
150
|
+
"duration": 805,
|
|
151
|
+
"errors": [],
|
|
152
|
+
"stdout": [],
|
|
153
|
+
"stderr": [],
|
|
154
|
+
"retry": 0,
|
|
155
|
+
"startTime": "2024-04-08T07:42:26.319Z",
|
|
156
|
+
"attachments": []
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
"status": "expected"
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"id": "f8b37aaddd8ecd14ecd4-3367671d4d7af1046962",
|
|
163
|
+
"file": "tests/demo-todo-app.spec.ts",
|
|
164
|
+
"line": 14,
|
|
165
|
+
"column": 7
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, client):
|
|
176
|
+
self.client = client
|
|
177
|
+
|
|
178
|
+
def parse_func(self, report_file: str) -> CaseEventGenerator:
|
|
179
|
+
data: Dict[str, Dict]
|
|
180
|
+
with open(report_file, 'r') as json_file:
|
|
181
|
+
try:
|
|
182
|
+
data = json.load(json_file)
|
|
183
|
+
except Exception as e:
|
|
184
|
+
raise Exception(f"Can't read JSON format report file {report_file}. Make sure to confirm report file.") from e
|
|
185
|
+
|
|
186
|
+
suites: List[Dict[str, Dict]] = list(data.get("suites", []))
|
|
187
|
+
if len(suites) == 0:
|
|
188
|
+
click.echo(f"Can't find test results from {report_file}. Make sure to confirm report file.", err=True)
|
|
189
|
+
|
|
190
|
+
for s in suites:
|
|
191
|
+
# The title of the root suite object contains the file name.
|
|
192
|
+
test_file = str(s.get("title", ""))
|
|
193
|
+
|
|
194
|
+
for event in self._parse_suites(test_file, s, []):
|
|
195
|
+
yield event
|
|
196
|
+
|
|
197
|
+
def _parse_suites(self, test_file: str, suite: Dict[str, Dict], test_case_names: List[str] = []) -> List:
|
|
198
|
+
events = []
|
|
199
|
+
|
|
200
|
+
# In some cases, suites are nested.
|
|
201
|
+
for s in suite.get("suites", []):
|
|
202
|
+
for e in self._parse_suites(test_file, s, test_case_names + [s.get("title")]):
|
|
203
|
+
events.append(e)
|
|
204
|
+
|
|
205
|
+
for spec in suite.get("specs", []):
|
|
206
|
+
spec_name = spec.get("title", "")
|
|
207
|
+
line_no = spec.get("line", None)
|
|
208
|
+
for test in spec.get("tests", []):
|
|
209
|
+
for result in test.get("results", []):
|
|
210
|
+
test_path: TestPath = [
|
|
211
|
+
self.client.make_file_path_component(test_file),
|
|
212
|
+
{"type": "testcase", "name": TEST_CASE_DELIMITER.join(test_case_names + [spec_name])}
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
duration_msec = result.get("duration", 0)
|
|
216
|
+
status = self._case_event_status_from_str(result.get("status", ""))
|
|
217
|
+
stdout = self._parse_stdout(result.get("stdout", []))
|
|
218
|
+
stderr = self._parse_stderr(result.get("errors", []))
|
|
219
|
+
|
|
220
|
+
events.append(CaseEvent.create(
|
|
221
|
+
test_path=test_path,
|
|
222
|
+
duration_secs=duration_msec / 1000, # convert msec to sec,
|
|
223
|
+
status=status,
|
|
224
|
+
stdout=stdout,
|
|
225
|
+
stderr=stderr,
|
|
226
|
+
data={"lineNumber": line_no} if line_no else {}
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
return events
|
|
230
|
+
|
|
231
|
+
def _case_event_status_from_str(self, status_str: str) -> int:
|
|
232
|
+
# see: https://playwright.dev/docs/api/class-testresult#test-result-status
|
|
233
|
+
if status_str == "passed":
|
|
234
|
+
return CaseEvent.TEST_PASSED
|
|
235
|
+
elif status_str == "failed" or status_str == "timedOut" or status_str == "interrupted":
|
|
236
|
+
return CaseEvent.TEST_FAILED
|
|
237
|
+
elif status_str == "skipped":
|
|
238
|
+
return CaseEvent.TEST_SKIPPED
|
|
239
|
+
else:
|
|
240
|
+
return CaseEvent.TEST_PASSED
|
|
241
|
+
|
|
242
|
+
def _parse_stdout(self, stdout: List[Dict[str, Dict]]) -> str:
|
|
243
|
+
if len(stdout) == 0:
|
|
244
|
+
return ""
|
|
245
|
+
|
|
246
|
+
return "\n".join([str(out.get("text", "")) for out in stdout])
|
|
247
|
+
|
|
248
|
+
def _parse_stderr(self, stderr: List[Dict[str, Dict]]) -> str:
|
|
249
|
+
if len(stderr) == 0:
|
|
250
|
+
return ""
|
|
251
|
+
|
|
252
|
+
return "\n".join([str(err.get("message", "")) for err in stderr])
|