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