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.
Files changed (96) hide show
  1. smart_tests/__init__.py +0 -0
  2. smart_tests/__main__.py +60 -0
  3. smart_tests/app.py +67 -0
  4. smart_tests/args4p/README.md +102 -0
  5. smart_tests/args4p/__init__.py +13 -0
  6. smart_tests/args4p/argument.py +45 -0
  7. smart_tests/args4p/command.py +593 -0
  8. smart_tests/args4p/converters/__init__.py +75 -0
  9. smart_tests/args4p/decorators.py +98 -0
  10. smart_tests/args4p/exceptions.py +12 -0
  11. smart_tests/args4p/option.py +85 -0
  12. smart_tests/args4p/parameter.py +84 -0
  13. smart_tests/args4p/typer/__init__.py +42 -0
  14. smart_tests/commands/__init__.py +0 -0
  15. smart_tests/commands/compare/__init__.py +11 -0
  16. smart_tests/commands/compare/subsets.py +58 -0
  17. smart_tests/commands/detect_flakes.py +105 -0
  18. smart_tests/commands/inspect/__init__.py +13 -0
  19. smart_tests/commands/inspect/model.py +52 -0
  20. smart_tests/commands/inspect/subset.py +138 -0
  21. smart_tests/commands/record/__init__.py +19 -0
  22. smart_tests/commands/record/attachment.py +38 -0
  23. smart_tests/commands/record/build.py +356 -0
  24. smart_tests/commands/record/case_event.py +190 -0
  25. smart_tests/commands/record/commit.py +157 -0
  26. smart_tests/commands/record/session.py +120 -0
  27. smart_tests/commands/record/tests.py +498 -0
  28. smart_tests/commands/stats/__init__.py +11 -0
  29. smart_tests/commands/stats/test_sessions.py +45 -0
  30. smart_tests/commands/subset.py +567 -0
  31. smart_tests/commands/test_path_writer.py +51 -0
  32. smart_tests/commands/verify.py +153 -0
  33. smart_tests/jar/exe_deploy.jar +0 -0
  34. smart_tests/plugins/__init__.py +0 -0
  35. smart_tests/test_runners/__init__.py +0 -0
  36. smart_tests/test_runners/adb.py +24 -0
  37. smart_tests/test_runners/ant.py +35 -0
  38. smart_tests/test_runners/bazel.py +103 -0
  39. smart_tests/test_runners/behave.py +62 -0
  40. smart_tests/test_runners/codeceptjs.py +33 -0
  41. smart_tests/test_runners/ctest.py +164 -0
  42. smart_tests/test_runners/cts.py +189 -0
  43. smart_tests/test_runners/cucumber.py +451 -0
  44. smart_tests/test_runners/cypress.py +46 -0
  45. smart_tests/test_runners/dotnet.py +106 -0
  46. smart_tests/test_runners/file.py +20 -0
  47. smart_tests/test_runners/flutter.py +251 -0
  48. smart_tests/test_runners/go_test.py +99 -0
  49. smart_tests/test_runners/googletest.py +34 -0
  50. smart_tests/test_runners/gradle.py +96 -0
  51. smart_tests/test_runners/jest.py +52 -0
  52. smart_tests/test_runners/maven.py +149 -0
  53. smart_tests/test_runners/minitest.py +40 -0
  54. smart_tests/test_runners/nunit.py +190 -0
  55. smart_tests/test_runners/playwright.py +252 -0
  56. smart_tests/test_runners/prove.py +74 -0
  57. smart_tests/test_runners/pytest.py +358 -0
  58. smart_tests/test_runners/raw.py +238 -0
  59. smart_tests/test_runners/robot.py +125 -0
  60. smart_tests/test_runners/rspec.py +5 -0
  61. smart_tests/test_runners/smart_tests.py +235 -0
  62. smart_tests/test_runners/vitest.py +49 -0
  63. smart_tests/test_runners/xctest.py +79 -0
  64. smart_tests/testpath.py +154 -0
  65. smart_tests/utils/__init__.py +0 -0
  66. smart_tests/utils/authentication.py +78 -0
  67. smart_tests/utils/ci_provider.py +7 -0
  68. smart_tests/utils/commands.py +14 -0
  69. smart_tests/utils/commit_ingester.py +59 -0
  70. smart_tests/utils/common_tz.py +12 -0
  71. smart_tests/utils/edit_distance.py +11 -0
  72. smart_tests/utils/env_keys.py +19 -0
  73. smart_tests/utils/exceptions.py +34 -0
  74. smart_tests/utils/fail_fast_mode.py +99 -0
  75. smart_tests/utils/file_name_pattern.py +4 -0
  76. smart_tests/utils/git_log_parser.py +53 -0
  77. smart_tests/utils/glob.py +44 -0
  78. smart_tests/utils/gzipgen.py +46 -0
  79. smart_tests/utils/http_client.py +169 -0
  80. smart_tests/utils/java.py +61 -0
  81. smart_tests/utils/link.py +149 -0
  82. smart_tests/utils/logger.py +53 -0
  83. smart_tests/utils/no_build.py +2 -0
  84. smart_tests/utils/sax.py +119 -0
  85. smart_tests/utils/session.py +73 -0
  86. smart_tests/utils/smart_tests_client.py +134 -0
  87. smart_tests/utils/subprocess.py +12 -0
  88. smart_tests/utils/tracking.py +95 -0
  89. smart_tests/utils/typer_types.py +241 -0
  90. smart_tests/version.py +7 -0
  91. smart_tests_cli-2.0.0.dist-info/METADATA +168 -0
  92. smart_tests_cli-2.0.0.dist-info/RECORD +96 -0
  93. smart_tests_cli-2.0.0.dist-info/WHEEL +5 -0
  94. smart_tests_cli-2.0.0.dist-info/entry_points.txt +2 -0
  95. smart_tests_cli-2.0.0.dist-info/licenses/LICENSE.txt +202 -0
  96. 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])