Sisyphus-api-engine 1.0.1__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.
- apirun/__init__.py +59 -0
- apirun/cli.py +1345 -0
- apirun/cli_help_i18n.py +173 -0
- apirun/core/__init__.py +21 -0
- apirun/core/models.py +411 -0
- apirun/core/retry.py +287 -0
- apirun/core/template_functions.py +505 -0
- apirun/core/variable_manager.py +571 -0
- apirun/data_driven/__init__.py +22 -0
- apirun/data_driven/data_source.py +291 -0
- apirun/data_driven/iterator.py +195 -0
- apirun/executor/__init__.py +21 -0
- apirun/executor/api_executor.py +252 -0
- apirun/executor/concurrent_executor.py +366 -0
- apirun/executor/database_executor.py +415 -0
- apirun/executor/loop_executor.py +350 -0
- apirun/executor/script_executor.py +459 -0
- apirun/executor/step_executor.py +548 -0
- apirun/executor/test_case_executor.py +293 -0
- apirun/executor/wait_executor.py +530 -0
- apirun/extractor/__init__.py +15 -0
- apirun/extractor/cookie_extractor.py +37 -0
- apirun/extractor/extractor_factory.py +60 -0
- apirun/extractor/header_extractor.py +42 -0
- apirun/extractor/jsonpath_extractor.py +56 -0
- apirun/extractor/regex_extractor.py +51 -0
- apirun/mock/__init__.py +16 -0
- apirun/mock/models.py +439 -0
- apirun/mock/server.py +461 -0
- apirun/parser/__init__.py +5 -0
- apirun/parser/v2_yaml_parser.py +520 -0
- apirun/result/__init__.py +25 -0
- apirun/result/allure_exporter.py +916 -0
- apirun/result/html_exporter.py +831 -0
- apirun/result/json_exporter.py +564 -0
- apirun/result/junit_exporter.py +446 -0
- apirun/utils/__init__.py +5 -0
- apirun/utils/error_utils.py +381 -0
- apirun/utils/hooks.py +160 -0
- apirun/utils/json_optimized.py +285 -0
- apirun/utils/performance.py +375 -0
- apirun/utils/template.py +208 -0
- apirun/validation/__init__.py +11 -0
- apirun/validation/comparators.py +433 -0
- apirun/validation/engine.py +323 -0
- apirun/websocket/__init__.py +20 -0
- apirun/websocket/broadcaster.py +435 -0
- apirun/websocket/events.py +334 -0
- apirun/websocket/notifier.py +240 -0
- apirun/websocket/progress.py +175 -0
- apirun/websocket/server.py +209 -0
- sisyphus_api_engine-1.0.1.dist-info/METADATA +703 -0
- sisyphus_api_engine-1.0.1.dist-info/RECORD +56 -0
- sisyphus_api_engine-1.0.1.dist-info/WHEEL +5 -0
- sisyphus_api_engine-1.0.1.dist-info/entry_points.txt +3 -0
- sisyphus_api_engine-1.0.1.dist-info/top_level.txt +1 -0
apirun/cli.py
ADDED
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
"""Command Line Interface for Sisyphus API Engine.
|
|
2
|
+
|
|
3
|
+
This module provides the CLI entry point for running test cases.
|
|
4
|
+
Following Google Python Style Guide.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from apirun.parser.v2_yaml_parser import V2YamlParser, YamlParseError
|
|
15
|
+
from apirun.executor.test_case_executor import TestCaseExecutor
|
|
16
|
+
from apirun.core.variable_manager import VariableManager
|
|
17
|
+
from apirun.utils.template import render_template
|
|
18
|
+
from apirun.data_driven.iterator import DataDrivenIterator
|
|
19
|
+
from apirun.cli_help_i18n import get_help_messages, get_validate_help_messages, ARGUMENT_MAPPING
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def show_help(parser: argparse.ArgumentParser, lang: str = "en") -> None:
|
|
23
|
+
"""Display help message in specified language.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
parser: Argument parser object
|
|
27
|
+
lang: Language code ('en' for English, 'zh' for Chinese)
|
|
28
|
+
"""
|
|
29
|
+
messages = get_help_messages(lang)
|
|
30
|
+
|
|
31
|
+
print(f"\n{messages['description']}\n")
|
|
32
|
+
if lang == "zh":
|
|
33
|
+
print("用法:")
|
|
34
|
+
print(" sisyphus-api-engine --cases <文件> [选项]\n")
|
|
35
|
+
print("参数:")
|
|
36
|
+
else:
|
|
37
|
+
print("Usage:")
|
|
38
|
+
print(" sisyphus-api-engine --cases <file> [options]\n")
|
|
39
|
+
print("Arguments:")
|
|
40
|
+
|
|
41
|
+
# Format and display arguments
|
|
42
|
+
for action in parser._actions:
|
|
43
|
+
if action.dest in ['help', '中文帮助']:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
# Get option strings
|
|
47
|
+
opts = ", ".join(action.option_strings)
|
|
48
|
+
|
|
49
|
+
# Get help text from messages based on language
|
|
50
|
+
help_text = ""
|
|
51
|
+
if action.dest in ARGUMENT_MAPPING:
|
|
52
|
+
arg_key = ARGUMENT_MAPPING[action.dest]
|
|
53
|
+
help_text = messages["args"].get(arg_key, "")
|
|
54
|
+
else:
|
|
55
|
+
# Fallback to action's help text (should be English only)
|
|
56
|
+
help_text = action.help or ""
|
|
57
|
+
|
|
58
|
+
# Format default values
|
|
59
|
+
if action.default is not None and action.default != "==SUPPRESS==" and action.default != []:
|
|
60
|
+
if action.dest in ['ws_host', 'ws_port', 'allure_dir', 'format']:
|
|
61
|
+
if lang == "zh":
|
|
62
|
+
if isinstance(action.default, str):
|
|
63
|
+
help_text += f" (默认: {action.default})"
|
|
64
|
+
elif isinstance(action.default, int):
|
|
65
|
+
help_text += f" (默认: {action.default})"
|
|
66
|
+
else:
|
|
67
|
+
if isinstance(action.default, str):
|
|
68
|
+
help_text += f" (default: {action.default})"
|
|
69
|
+
elif isinstance(action.default, int):
|
|
70
|
+
help_text += f" (default: {action.default})"
|
|
71
|
+
|
|
72
|
+
# Display the argument
|
|
73
|
+
if opts:
|
|
74
|
+
print(f" {opts.ljust(25)} {help_text}")
|
|
75
|
+
|
|
76
|
+
print(f"\n{messages['epilog']}")
|
|
77
|
+
|
|
78
|
+
# Show additional help options
|
|
79
|
+
if lang == "zh":
|
|
80
|
+
print("帮助选项:")
|
|
81
|
+
print(" -h, --help 显示英文帮助")
|
|
82
|
+
print(" -H, --中文帮助 显示中文帮助")
|
|
83
|
+
else:
|
|
84
|
+
print("Help Options:")
|
|
85
|
+
print(" -h, --help Show help in English")
|
|
86
|
+
print(" -H, --中文帮助 Show help in Chinese")
|
|
87
|
+
print()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def show_validate_help(parser: argparse.ArgumentParser, lang: str = "en") -> None:
|
|
91
|
+
"""Display validation command help message in specified language.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
parser: Argument parser object
|
|
95
|
+
lang: Language code ('en' for English, 'zh' for Chinese)
|
|
96
|
+
"""
|
|
97
|
+
messages = get_validate_help_messages(lang)
|
|
98
|
+
|
|
99
|
+
print(f"\n{messages['description']}\n")
|
|
100
|
+
if lang == "zh":
|
|
101
|
+
print("用法:")
|
|
102
|
+
print(" sisyphus-api-validate <路径>...\n")
|
|
103
|
+
print("参数:")
|
|
104
|
+
else:
|
|
105
|
+
print("Usage:")
|
|
106
|
+
print(" sisyphus-api-validate <paths>...\n")
|
|
107
|
+
print("Arguments:")
|
|
108
|
+
|
|
109
|
+
# Format and display arguments
|
|
110
|
+
for action in parser._actions:
|
|
111
|
+
if action.dest in ['help', '中文帮助']:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Get option strings or positional name
|
|
115
|
+
if action.option_strings:
|
|
116
|
+
opts = ", ".join(action.option_strings)
|
|
117
|
+
else:
|
|
118
|
+
opts = action.dest.upper()
|
|
119
|
+
|
|
120
|
+
# Get help text from messages based on language
|
|
121
|
+
help_text = ""
|
|
122
|
+
if action.dest == "paths":
|
|
123
|
+
help_text = messages["args"].get("paths", "")
|
|
124
|
+
else:
|
|
125
|
+
help_text = action.help or ""
|
|
126
|
+
|
|
127
|
+
# Display the argument
|
|
128
|
+
print(f" {opts.ljust(25)} {help_text}")
|
|
129
|
+
|
|
130
|
+
print(f"\n{messages['epilog']}")
|
|
131
|
+
|
|
132
|
+
# Show additional help options
|
|
133
|
+
if lang == "zh":
|
|
134
|
+
print("帮助选项:")
|
|
135
|
+
print(" -h, --help 显示英文帮助")
|
|
136
|
+
print(" -H, --中文帮助 显示中文帮助")
|
|
137
|
+
else:
|
|
138
|
+
print("Help Options:")
|
|
139
|
+
print(" -h, --help Show help in English")
|
|
140
|
+
print(" -H, --中文帮助 Show help in Chinese")
|
|
141
|
+
print()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main() -> int:
|
|
145
|
+
"""Main CLI entry point.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Exit code (0 for success, non-zero for failure)
|
|
149
|
+
"""
|
|
150
|
+
parser = argparse.ArgumentParser(
|
|
151
|
+
description="Sisyphus API Engine - Enterprise-grade API Testing Tool",
|
|
152
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
153
|
+
epilog=get_help_messages("en")["epilog"],
|
|
154
|
+
add_help=False, # We'll add help manually
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Add standard help
|
|
158
|
+
parser.add_argument(
|
|
159
|
+
"-h", "--help",
|
|
160
|
+
action="store_true",
|
|
161
|
+
help="Show help message",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Add Chinese help
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"-H", "--中文帮助",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help="Show help in Chinese",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--cases",
|
|
173
|
+
type=str,
|
|
174
|
+
required=True,
|
|
175
|
+
help="Path to YAML test case file or directory",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"-o",
|
|
180
|
+
"--output",
|
|
181
|
+
type=str,
|
|
182
|
+
help="Output file path for JSON results",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"-v",
|
|
187
|
+
"--verbose",
|
|
188
|
+
action="store_true",
|
|
189
|
+
help="Enable verbose output",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--validate",
|
|
194
|
+
action="store_true",
|
|
195
|
+
help="Validate YAML syntax without execution",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"--profile",
|
|
200
|
+
type=str,
|
|
201
|
+
help="Active profile name (overrides config)",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
"--ws-server",
|
|
206
|
+
action="store_true",
|
|
207
|
+
help="Enable WebSocket server for real-time updates",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
parser.add_argument(
|
|
211
|
+
"--ws-host",
|
|
212
|
+
type=str,
|
|
213
|
+
default="localhost",
|
|
214
|
+
help="WebSocket server host",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--ws-port",
|
|
219
|
+
type=int,
|
|
220
|
+
default=8765,
|
|
221
|
+
help="WebSocket server port",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--env-prefix",
|
|
226
|
+
type=str,
|
|
227
|
+
help="Environment variable prefix to load",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--override",
|
|
232
|
+
type=str,
|
|
233
|
+
action="append",
|
|
234
|
+
help="Configuration overrides in 'key=value' format",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"--debug",
|
|
239
|
+
action="store_true",
|
|
240
|
+
help="Enable debug mode with variable tracking",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--format",
|
|
245
|
+
type=str,
|
|
246
|
+
choices=["text", "json", "csv", "junit", "html"],
|
|
247
|
+
default="text",
|
|
248
|
+
help="Output format: text/json/csv/junit/html",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
parser.add_argument(
|
|
252
|
+
"--allure",
|
|
253
|
+
action="store_true",
|
|
254
|
+
help="Generate Allure report",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--allure-dir",
|
|
259
|
+
type=str,
|
|
260
|
+
default="allure-results",
|
|
261
|
+
help="Allure results directory",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
parser.add_argument(
|
|
265
|
+
"--allure-clean",
|
|
266
|
+
action="store_true",
|
|
267
|
+
default=True,
|
|
268
|
+
help="Clean Allure results directory before generating (default: True)",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
parser.add_argument(
|
|
272
|
+
"--allure-no-clean",
|
|
273
|
+
action="store_false",
|
|
274
|
+
dest="allure_clean",
|
|
275
|
+
help="Keep previous Allure results (accumulate data)",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Check for help flags first before parsing required arguments
|
|
279
|
+
import sys
|
|
280
|
+
if "-H" in sys.argv or "--中文帮助" in sys.argv:
|
|
281
|
+
show_help(parser, lang="zh")
|
|
282
|
+
return 0
|
|
283
|
+
elif "-h" in sys.argv or "--help" in sys.argv:
|
|
284
|
+
show_help(parser, lang="en")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
# Parse args normally (now --cases is required)
|
|
288
|
+
args = parser.parse_args()
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
if args.validate:
|
|
292
|
+
return validate_yaml(args.cases)
|
|
293
|
+
|
|
294
|
+
# Execute test case
|
|
295
|
+
result = execute_test_case(
|
|
296
|
+
args.cases,
|
|
297
|
+
args.verbose,
|
|
298
|
+
args.profile,
|
|
299
|
+
args.ws_server,
|
|
300
|
+
args.ws_host,
|
|
301
|
+
args.ws_port,
|
|
302
|
+
args.env_prefix,
|
|
303
|
+
args.override,
|
|
304
|
+
args.debug,
|
|
305
|
+
args.output,
|
|
306
|
+
args.format,
|
|
307
|
+
args.allure,
|
|
308
|
+
args.allure_dir,
|
|
309
|
+
args.allure_clean,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Handle output based on format
|
|
313
|
+
if args.format == "json":
|
|
314
|
+
# Determine if we should output compact or full JSON
|
|
315
|
+
# Check verbose flag (from CLI or YAML config)
|
|
316
|
+
use_verbose = args.verbose
|
|
317
|
+
if not use_verbose and result.get("test_case", {}).get("config", {}).get("verbose"):
|
|
318
|
+
use_verbose = True
|
|
319
|
+
|
|
320
|
+
if use_verbose:
|
|
321
|
+
# Full JSON output (all information)
|
|
322
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
323
|
+
else:
|
|
324
|
+
# Ultra-compact JSON output (only response content)
|
|
325
|
+
api_responses = []
|
|
326
|
+
|
|
327
|
+
# Extract only response content from steps
|
|
328
|
+
for step in result.get("steps", []):
|
|
329
|
+
if step.get("response"):
|
|
330
|
+
response_item = {
|
|
331
|
+
"step": step["name"],
|
|
332
|
+
"response": step["response"]
|
|
333
|
+
}
|
|
334
|
+
api_responses.append(response_item)
|
|
335
|
+
|
|
336
|
+
print(json.dumps(api_responses, ensure_ascii=False, indent=2))
|
|
337
|
+
|
|
338
|
+
elif args.format == "csv":
|
|
339
|
+
# CSV output
|
|
340
|
+
from apirun.result.json_exporter import JSONExporter
|
|
341
|
+
from apirun.core.models import TestCaseResult, StepResult, PerformanceMetrics
|
|
342
|
+
from datetime import datetime
|
|
343
|
+
|
|
344
|
+
# Reconstruct TestCaseResult from dict
|
|
345
|
+
start_time = datetime.fromisoformat(result["test_case"]["start_time"]) if result["test_case"].get("start_time") else datetime.now()
|
|
346
|
+
end_time = datetime.fromisoformat(result["test_case"]["end_time"]) if result["test_case"].get("end_time") else datetime.now()
|
|
347
|
+
|
|
348
|
+
# Reconstruct step results for CSV
|
|
349
|
+
step_results = []
|
|
350
|
+
for step_data in result.get("steps", []):
|
|
351
|
+
step_start = datetime.fromisoformat(step_data["start_time"]) if step_data.get("start_time") else None
|
|
352
|
+
step_end = datetime.fromisoformat(step_data["end_time"]) if step_data.get("end_time") else None
|
|
353
|
+
|
|
354
|
+
step_perf = None
|
|
355
|
+
if args.verbose and step_data.get("performance"):
|
|
356
|
+
perf_data = step_data["performance"]
|
|
357
|
+
step_perf = PerformanceMetrics(
|
|
358
|
+
total_time=perf_data.get("total_time", 0),
|
|
359
|
+
dns_time=perf_data.get("dns_time", 0),
|
|
360
|
+
tcp_time=perf_data.get("tcp_time", 0),
|
|
361
|
+
tls_time=perf_data.get("tls_time", 0),
|
|
362
|
+
server_time=perf_data.get("server_time", 0),
|
|
363
|
+
download_time=perf_data.get("download_time", 0),
|
|
364
|
+
size=perf_data.get("size", 0),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
step_result = StepResult(
|
|
368
|
+
name=step_data["name"],
|
|
369
|
+
status=step_data["status"],
|
|
370
|
+
start_time=step_start,
|
|
371
|
+
end_time=step_end,
|
|
372
|
+
response=step_data.get("response"),
|
|
373
|
+
performance=step_perf,
|
|
374
|
+
error_info=None,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
step_results.append(step_result)
|
|
378
|
+
|
|
379
|
+
test_case_result = TestCaseResult(
|
|
380
|
+
name=result["test_case"]["name"],
|
|
381
|
+
status=result["test_case"]["status"],
|
|
382
|
+
start_time=start_time,
|
|
383
|
+
end_time=end_time,
|
|
384
|
+
duration=result["test_case"]["duration"],
|
|
385
|
+
total_steps=result["statistics"]["total_steps"],
|
|
386
|
+
passed_steps=result["statistics"]["passed_steps"],
|
|
387
|
+
failed_steps=result["statistics"]["failed_steps"],
|
|
388
|
+
skipped_steps=result["statistics"]["skipped_steps"],
|
|
389
|
+
step_results=step_results,
|
|
390
|
+
final_variables={},
|
|
391
|
+
error_info=None,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
collector = JSONExporter()
|
|
395
|
+
# Determine if we should use verbose mode
|
|
396
|
+
use_verbose = args.verbose
|
|
397
|
+
if not use_verbose and result.get("test_case", {}).get("config", {}).get("verbose"):
|
|
398
|
+
use_verbose = True
|
|
399
|
+
|
|
400
|
+
csv_output = collector.to_csv(test_case_result, verbose=use_verbose)
|
|
401
|
+
print(csv_output, end="")
|
|
402
|
+
|
|
403
|
+
elif args.format == "junit":
|
|
404
|
+
# JUnit XML output
|
|
405
|
+
from apirun.result.junit_exporter import JUnitExporter
|
|
406
|
+
from apirun.core.models import TestCaseResult, StepResult, PerformanceMetrics
|
|
407
|
+
from datetime import datetime
|
|
408
|
+
|
|
409
|
+
# Reconstruct TestCaseResult with full step results
|
|
410
|
+
start_time = datetime.fromisoformat(result["test_case"]["start_time"]) if result["test_case"].get("start_time") else datetime.now()
|
|
411
|
+
end_time = datetime.fromisoformat(result["test_case"]["end_time"]) if result["test_case"].get("end_time") else datetime.now()
|
|
412
|
+
|
|
413
|
+
# Reconstruct step results
|
|
414
|
+
step_results = []
|
|
415
|
+
for step_data in result.get("steps", []):
|
|
416
|
+
step_start = datetime.fromisoformat(step_data["start_time"]) if step_data.get("start_time") else None
|
|
417
|
+
step_end = datetime.fromisoformat(step_data["end_time"]) if step_data.get("end_time") else None
|
|
418
|
+
step_perf = None
|
|
419
|
+
if step_data.get("performance"):
|
|
420
|
+
perf_data = step_data["performance"]
|
|
421
|
+
step_perf = PerformanceMetrics(
|
|
422
|
+
total_time=perf_data.get("total_time", 0),
|
|
423
|
+
dns_time=perf_data.get("dns_time", 0),
|
|
424
|
+
tcp_time=perf_data.get("tcp_time", 0),
|
|
425
|
+
tls_time=perf_data.get("tls_time", 0),
|
|
426
|
+
server_time=perf_data.get("server_time", 0),
|
|
427
|
+
download_time=perf_data.get("download_time", 0),
|
|
428
|
+
size=perf_data.get("size", 0),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
step_result = StepResult(
|
|
432
|
+
name=step_data["name"],
|
|
433
|
+
status=step_data["status"],
|
|
434
|
+
start_time=step_start,
|
|
435
|
+
end_time=step_end,
|
|
436
|
+
response=step_data.get("response"),
|
|
437
|
+
performance=step_perf,
|
|
438
|
+
error_info=None, # We'll skip error details in non-verbose mode
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Only add detailed error info if verbose
|
|
442
|
+
if args.verbose and step_data.get("error_info"):
|
|
443
|
+
from apirun.core.models import ErrorInfo, ErrorCategory
|
|
444
|
+
error_data = step_data["error_info"]
|
|
445
|
+
step_result.error_info = ErrorInfo(
|
|
446
|
+
type=error_data.get("type", "UNKNOWN"),
|
|
447
|
+
message=error_data.get("message", ""),
|
|
448
|
+
category=ErrorCategory(error_data.get("category", "SYSTEM")),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
step_results.append(step_result)
|
|
452
|
+
|
|
453
|
+
test_case_result = TestCaseResult(
|
|
454
|
+
name=result["test_case"]["name"],
|
|
455
|
+
status=result["test_case"]["status"],
|
|
456
|
+
start_time=start_time,
|
|
457
|
+
end_time=end_time,
|
|
458
|
+
duration=result["test_case"]["duration"],
|
|
459
|
+
total_steps=result["statistics"]["total_steps"],
|
|
460
|
+
passed_steps=result["statistics"]["passed_steps"],
|
|
461
|
+
failed_steps=result["statistics"]["failed_steps"],
|
|
462
|
+
skipped_steps=result["statistics"]["skipped_steps"],
|
|
463
|
+
step_results=step_results,
|
|
464
|
+
final_variables={},
|
|
465
|
+
error_info=None,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
exporter = JUnitExporter()
|
|
469
|
+
junit_xml = exporter.to_junit_xml(test_case_result)
|
|
470
|
+
print(junit_xml, end="")
|
|
471
|
+
|
|
472
|
+
elif args.format == "html":
|
|
473
|
+
# HTML output
|
|
474
|
+
from apirun.result.html_exporter import HTMLExporter
|
|
475
|
+
from apirun.core.models import TestCaseResult, StepResult, PerformanceMetrics
|
|
476
|
+
from datetime import datetime
|
|
477
|
+
|
|
478
|
+
# Reconstruct TestCaseResult with full step results
|
|
479
|
+
start_time = datetime.fromisoformat(result["test_case"]["start_time"]) if result["test_case"].get("start_time") else datetime.now()
|
|
480
|
+
end_time = datetime.fromisoformat(result["test_case"]["end_time"]) if result["test_case"].get("end_time") else datetime.now()
|
|
481
|
+
|
|
482
|
+
# Reconstruct step results based on verbose mode
|
|
483
|
+
step_results = []
|
|
484
|
+
for step_data in result.get("steps", []):
|
|
485
|
+
step_start = datetime.fromisoformat(step_data["start_time"]) if step_data.get("start_time") else None
|
|
486
|
+
step_end = datetime.fromisoformat(step_data["end_time"]) if step_data.get("end_time") else None
|
|
487
|
+
|
|
488
|
+
step_perf = None
|
|
489
|
+
if args.verbose and step_data.get("performance"):
|
|
490
|
+
perf_data = step_data["performance"]
|
|
491
|
+
step_perf = PerformanceMetrics(
|
|
492
|
+
total_time=perf_data.get("total_time", 0),
|
|
493
|
+
dns_time=perf_data.get("dns_time", 0),
|
|
494
|
+
tcp_time=perf_data.get("tcp_time", 0),
|
|
495
|
+
tls_time=perf_data.get("tls_time", 0),
|
|
496
|
+
server_time=perf_data.get("server_time", 0),
|
|
497
|
+
download_time=perf_data.get("download_time", 0),
|
|
498
|
+
size=perf_data.get("size", 0),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
step_result = StepResult(
|
|
502
|
+
name=step_data["name"],
|
|
503
|
+
status=step_data["status"],
|
|
504
|
+
start_time=step_start,
|
|
505
|
+
end_time=step_end,
|
|
506
|
+
response=step_data.get("response") if args.verbose else None,
|
|
507
|
+
performance=step_perf,
|
|
508
|
+
error_info=None,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Only add detailed error info if verbose
|
|
512
|
+
if args.verbose and step_data.get("error_info"):
|
|
513
|
+
from apirun.core.models import ErrorInfo, ErrorCategory
|
|
514
|
+
error_data = step_data["error_info"]
|
|
515
|
+
step_result.error_info = ErrorInfo(
|
|
516
|
+
type=error_data.get("type", "UNKNOWN"),
|
|
517
|
+
message=error_data.get("message", ""),
|
|
518
|
+
category=ErrorCategory(error_data.get("category", "SYSTEM")),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
step_results.append(step_result)
|
|
522
|
+
|
|
523
|
+
test_case_result = TestCaseResult(
|
|
524
|
+
name=result["test_case"]["name"],
|
|
525
|
+
status=result["test_case"]["status"],
|
|
526
|
+
start_time=start_time,
|
|
527
|
+
end_time=end_time,
|
|
528
|
+
duration=result["test_case"]["duration"],
|
|
529
|
+
total_steps=result["statistics"]["total_steps"],
|
|
530
|
+
passed_steps=result["statistics"]["passed_steps"],
|
|
531
|
+
failed_steps=result["statistics"]["failed_steps"],
|
|
532
|
+
skipped_steps=result["statistics"]["skipped_steps"],
|
|
533
|
+
step_results=step_results,
|
|
534
|
+
final_variables={},
|
|
535
|
+
error_info=None,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
exporter = HTMLExporter()
|
|
539
|
+
html_output = exporter.to_html(test_case_result)
|
|
540
|
+
print(html_output, end="")
|
|
541
|
+
|
|
542
|
+
# Save result if output path specified (either in YAML or CLI)
|
|
543
|
+
output_path = args.output
|
|
544
|
+
if not output_path and result.get("test_case", {}).get("config", {}).get("output", {}).get("path"):
|
|
545
|
+
# Use output path from YAML config
|
|
546
|
+
output_path = result["test_case"]["config"]["output"]["path"]
|
|
547
|
+
|
|
548
|
+
if output_path:
|
|
549
|
+
save_result(result, output_path)
|
|
550
|
+
# Only print save message in text mode
|
|
551
|
+
if args.format in ["text"] and (args.verbose or result.get("test_case", {}).get("config", {}).get("verbose")):
|
|
552
|
+
print(f"\nResults saved to: {output_path}")
|
|
553
|
+
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
except FileNotFoundError as e:
|
|
557
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
558
|
+
return 1
|
|
559
|
+
except YamlParseError as e:
|
|
560
|
+
print(f"Parse Error: {e}", file=sys.stderr)
|
|
561
|
+
return 1
|
|
562
|
+
except Exception as e:
|
|
563
|
+
print(f"Unexpected Error: {e}", file=sys.stderr)
|
|
564
|
+
if args.verbose:
|
|
565
|
+
import traceback
|
|
566
|
+
|
|
567
|
+
traceback.print_exc()
|
|
568
|
+
return 1
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def validate_main() -> int:
|
|
572
|
+
"""CLI entry point for validation-only mode.
|
|
573
|
+
|
|
574
|
+
This is a dedicated command for validating YAML syntax without execution.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
Exit code (0 for valid, non-zero for invalid)
|
|
578
|
+
"""
|
|
579
|
+
parser = argparse.ArgumentParser(
|
|
580
|
+
description="Sisyphus API Engine - YAML Validator",
|
|
581
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
582
|
+
epilog=get_validate_help_messages("en")["epilog"],
|
|
583
|
+
add_help=False,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Add help options
|
|
587
|
+
parser.add_argument(
|
|
588
|
+
"-h", "--help",
|
|
589
|
+
action="store_true",
|
|
590
|
+
help="Show help message",
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
parser.add_argument(
|
|
594
|
+
"-H", "--中文帮助",
|
|
595
|
+
action="store_true",
|
|
596
|
+
help="Show help in Chinese",
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
parser.add_argument(
|
|
600
|
+
"paths",
|
|
601
|
+
type=str,
|
|
602
|
+
nargs="+",
|
|
603
|
+
help="Path(s) to YAML file(s) or directory",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# Check for help flags first
|
|
607
|
+
import sys
|
|
608
|
+
if "-H" in sys.argv or "--中文帮助" in sys.argv:
|
|
609
|
+
show_validate_help(parser, lang="zh")
|
|
610
|
+
return 0
|
|
611
|
+
elif "-h" in sys.argv or "--help" in sys.argv:
|
|
612
|
+
show_validate_help(parser, lang="en")
|
|
613
|
+
return 0
|
|
614
|
+
|
|
615
|
+
# Parse args normally
|
|
616
|
+
args = parser.parse_args()
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
all_valid = True
|
|
620
|
+
validator = V2YamlParser()
|
|
621
|
+
|
|
622
|
+
for path_str in args.paths:
|
|
623
|
+
path = Path(path_str)
|
|
624
|
+
|
|
625
|
+
if not path.exists():
|
|
626
|
+
print(f"Error: Path not found: {path_str}", file=sys.stderr)
|
|
627
|
+
all_valid = False
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
if path.is_file():
|
|
631
|
+
yaml_files = [path]
|
|
632
|
+
elif path.is_dir():
|
|
633
|
+
yaml_files = list(path.glob("**/*.yaml"))
|
|
634
|
+
if not yaml_files:
|
|
635
|
+
print(f"Warning: No YAML files found in {path_str}")
|
|
636
|
+
continue
|
|
637
|
+
else:
|
|
638
|
+
print(f"Error: Invalid path: {path_str}", file=sys.stderr)
|
|
639
|
+
all_valid = False
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
for yaml_file in yaml_files:
|
|
643
|
+
print(f"Validating: {yaml_file}")
|
|
644
|
+
errors = validator.validate_yaml(str(yaml_file))
|
|
645
|
+
|
|
646
|
+
if errors:
|
|
647
|
+
all_valid = False
|
|
648
|
+
print(f" ❌ Validation failed:")
|
|
649
|
+
for error in errors:
|
|
650
|
+
print(f" - {error}")
|
|
651
|
+
else:
|
|
652
|
+
print(f" ✓ Valid")
|
|
653
|
+
|
|
654
|
+
if all_valid:
|
|
655
|
+
print("\n✓ All YAML files are valid!")
|
|
656
|
+
return 0
|
|
657
|
+
else:
|
|
658
|
+
print("\n❌ Some YAML files have validation errors.")
|
|
659
|
+
return 1
|
|
660
|
+
|
|
661
|
+
except Exception as e:
|
|
662
|
+
print(f"Unexpected Error: {e}", file=sys.stderr)
|
|
663
|
+
import traceback
|
|
664
|
+
traceback.print_exc()
|
|
665
|
+
return 1
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def validate_yaml(case_path: str) -> int:
|
|
669
|
+
"""Validate YAML file syntax.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
case_path: Path to YAML file or directory
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
Exit code (0 for valid, non-zero for invalid)
|
|
676
|
+
"""
|
|
677
|
+
parser = V2YamlParser()
|
|
678
|
+
|
|
679
|
+
path = Path(case_path)
|
|
680
|
+
yaml_files = []
|
|
681
|
+
|
|
682
|
+
if path.is_file():
|
|
683
|
+
yaml_files = [path]
|
|
684
|
+
elif path.is_dir():
|
|
685
|
+
yaml_files = list(path.glob("**/*.yaml"))
|
|
686
|
+
else:
|
|
687
|
+
print(f"Error: Path not found: {case_path}", file=sys.stderr)
|
|
688
|
+
return 1
|
|
689
|
+
|
|
690
|
+
all_valid = True
|
|
691
|
+
for yaml_file in yaml_files:
|
|
692
|
+
print(f"Validating: {yaml_file}")
|
|
693
|
+
errors = parser.validate_yaml(str(yaml_file))
|
|
694
|
+
|
|
695
|
+
if errors:
|
|
696
|
+
all_valid = False
|
|
697
|
+
print(f" ❌ Validation failed:")
|
|
698
|
+
for error in errors:
|
|
699
|
+
print(f" - {error}")
|
|
700
|
+
else:
|
|
701
|
+
print(f" ✓ Valid")
|
|
702
|
+
|
|
703
|
+
return 0 if all_valid else 1
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def execute_test_case(
|
|
707
|
+
case_path: str,
|
|
708
|
+
verbose: bool = False,
|
|
709
|
+
profile: Optional[str] = None,
|
|
710
|
+
ws_server: bool = False,
|
|
711
|
+
ws_host: str = "localhost",
|
|
712
|
+
ws_port: int = 8765,
|
|
713
|
+
env_prefix: Optional[str] = None,
|
|
714
|
+
overrides: Optional[list] = None,
|
|
715
|
+
debug: bool = False,
|
|
716
|
+
output: Optional[str] = None,
|
|
717
|
+
format_type: str = "text",
|
|
718
|
+
allure: bool = False,
|
|
719
|
+
allure_dir: str = "allure-results",
|
|
720
|
+
allure_clean: bool = True,
|
|
721
|
+
) -> dict:
|
|
722
|
+
"""Execute test case and return results.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
case_path: Path to YAML file
|
|
726
|
+
verbose: Enable verbose output (overrides YAML config)
|
|
727
|
+
profile: Active profile name (overrides config)
|
|
728
|
+
ws_server: Enable WebSocket server for real-time updates
|
|
729
|
+
ws_host: WebSocket server host
|
|
730
|
+
ws_port: WebSocket server port
|
|
731
|
+
env_prefix: Environment variable prefix (overrides YAML config)
|
|
732
|
+
overrides: Configuration overrides (list of "key=value" strings)
|
|
733
|
+
debug: Enable debug mode (overrides YAML config)
|
|
734
|
+
output: Output file path (overrides YAML config)
|
|
735
|
+
format_type: Output format (text/json, overrides YAML config)
|
|
736
|
+
allure: Generate Allure report (overrides YAML config)
|
|
737
|
+
allure_dir: Allure results directory (overrides YAML config)
|
|
738
|
+
allure_clean: Clean Allure results before generating (default: True)
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Execution result as dictionary
|
|
742
|
+
"""
|
|
743
|
+
# Parse YAML
|
|
744
|
+
parser = V2YamlParser()
|
|
745
|
+
test_case = parser.parse(case_path)
|
|
746
|
+
|
|
747
|
+
# Initialize config if not exists
|
|
748
|
+
if not test_case.config:
|
|
749
|
+
from apirun.core.models import GlobalConfig
|
|
750
|
+
test_case.config = GlobalConfig(name=test_case.name)
|
|
751
|
+
|
|
752
|
+
# Apply CLI overrides to config (CLI has higher priority)
|
|
753
|
+
if verbose is not False: # Only override if explicitly set
|
|
754
|
+
test_case.config.verbose = verbose
|
|
755
|
+
|
|
756
|
+
if profile and test_case.config:
|
|
757
|
+
test_case.config.active_profile = profile
|
|
758
|
+
|
|
759
|
+
if debug:
|
|
760
|
+
if test_case.config.debug is None:
|
|
761
|
+
test_case.config.debug = {}
|
|
762
|
+
test_case.config.debug["enabled"] = True
|
|
763
|
+
|
|
764
|
+
if env_prefix:
|
|
765
|
+
if test_case.config.env_vars is None:
|
|
766
|
+
test_case.config.env_vars = {}
|
|
767
|
+
test_case.config.env_vars["prefix"] = env_prefix
|
|
768
|
+
|
|
769
|
+
if output:
|
|
770
|
+
if test_case.config.output is None:
|
|
771
|
+
test_case.config.output = {}
|
|
772
|
+
test_case.config.output["path"] = output
|
|
773
|
+
|
|
774
|
+
# Determine output format configuration
|
|
775
|
+
# Priority: CLI args > YAML config > defaults (text)
|
|
776
|
+
output_format = format_type
|
|
777
|
+
if test_case.config and test_case.config.output:
|
|
778
|
+
yaml_format = test_case.config.output.get("format", "text")
|
|
779
|
+
# Only use YAML config if CLI is default value
|
|
780
|
+
if format_type == "text" and yaml_format in ["text", "json", "csv", "junit", "html"]:
|
|
781
|
+
output_format = yaml_format
|
|
782
|
+
|
|
783
|
+
# Store format in config for later use
|
|
784
|
+
if test_case.config.output is None:
|
|
785
|
+
test_case.config.output = {}
|
|
786
|
+
test_case.config.output["format"] = output_format
|
|
787
|
+
|
|
788
|
+
# Parse key=value overrides
|
|
789
|
+
override_dict = {}
|
|
790
|
+
if overrides:
|
|
791
|
+
for override in overrides:
|
|
792
|
+
if "=" in override:
|
|
793
|
+
key, value = override.split("=", 1)
|
|
794
|
+
override_dict[key] = value
|
|
795
|
+
|
|
796
|
+
# Apply overrides to test case config
|
|
797
|
+
if override_dict and test_case.config:
|
|
798
|
+
for key, value in override_dict.items():
|
|
799
|
+
if hasattr(test_case.config, key):
|
|
800
|
+
setattr(test_case.config, key, value)
|
|
801
|
+
elif test_case.config.active_profile in test_case.config.profiles:
|
|
802
|
+
setattr(test_case.config.profiles[test_case.config.active_profile], key, value)
|
|
803
|
+
|
|
804
|
+
# Determine WebSocket configuration
|
|
805
|
+
# Priority: CLI args > YAML config > defaults
|
|
806
|
+
ws_config_enabled = ws_server
|
|
807
|
+
ws_config_host = ws_host
|
|
808
|
+
ws_config_port = ws_port
|
|
809
|
+
|
|
810
|
+
# Read from YAML config if available
|
|
811
|
+
if test_case.config and test_case.config.websocket:
|
|
812
|
+
yaml_ws = test_case.config.websocket
|
|
813
|
+
# Only use YAML config if CLI args are not explicitly set
|
|
814
|
+
if not ws_server and yaml_ws.get("enabled", False):
|
|
815
|
+
ws_config_enabled = True
|
|
816
|
+
if ws_host == "localhost": # Default value, check if YAML has custom value
|
|
817
|
+
ws_config_host = yaml_ws.get("host", "localhost")
|
|
818
|
+
if ws_port == 8765: # Default value, check if YAML has custom value
|
|
819
|
+
ws_config_port = yaml_ws.get("port", 8765)
|
|
820
|
+
|
|
821
|
+
# Check if WebSocket server mode is enabled
|
|
822
|
+
if ws_config_enabled:
|
|
823
|
+
return _execute_with_websocket(
|
|
824
|
+
test_case, verbose, ws_config_host, ws_config_port, yaml_ws_config=test_case.config.websocket if test_case.config else None
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
# Determine Allure configuration
|
|
828
|
+
# Priority: CLI args > YAML config > defaults (disabled)
|
|
829
|
+
allure_enabled = allure
|
|
830
|
+
allure_output_dir = allure_dir
|
|
831
|
+
|
|
832
|
+
# Read from YAML config if available
|
|
833
|
+
if test_case.config and test_case.config.output:
|
|
834
|
+
yaml_output = test_case.config.output
|
|
835
|
+
# Only use YAML config if CLI args are not explicitly set
|
|
836
|
+
if not allure and yaml_output.get("allure", False):
|
|
837
|
+
allure_enabled = True
|
|
838
|
+
if allure_dir == "allure-results": # Default value, check if YAML has custom value
|
|
839
|
+
custom_dir = yaml_output.get("allure_dir")
|
|
840
|
+
if custom_dir:
|
|
841
|
+
allure_output_dir = custom_dir
|
|
842
|
+
|
|
843
|
+
# Check if data-driven testing is enabled
|
|
844
|
+
if (
|
|
845
|
+
test_case.config
|
|
846
|
+
and test_case.config.data_iterations
|
|
847
|
+
and test_case.config.data_source
|
|
848
|
+
):
|
|
849
|
+
result = _execute_data_driven_test(test_case, verbose)
|
|
850
|
+
else:
|
|
851
|
+
result = _execute_single_test(test_case, verbose)
|
|
852
|
+
|
|
853
|
+
# Generate Allure report if enabled
|
|
854
|
+
if allure_enabled:
|
|
855
|
+
_generate_allure_report(test_case, result, allure_output_dir, allure_clean)
|
|
856
|
+
|
|
857
|
+
return result
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _execute_with_websocket(
|
|
861
|
+
test_case, verbose: bool = False, ws_host: str = "localhost", ws_port: int = 8765, yaml_ws_config: dict = None
|
|
862
|
+
) -> dict:
|
|
863
|
+
"""Execute test case with WebSocket server enabled.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
test_case: Test case to execute
|
|
867
|
+
verbose: Enable verbose output
|
|
868
|
+
ws_host: WebSocket server host
|
|
869
|
+
ws_port: WebSocket server port
|
|
870
|
+
yaml_ws_config: WebSocket configuration from YAML
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Execution result as dictionary
|
|
874
|
+
"""
|
|
875
|
+
from apirun.websocket.server import WebSocketServer
|
|
876
|
+
from apirun.websocket.broadcaster import EventBroadcaster
|
|
877
|
+
from apirun.websocket.notifier import WebSocketNotifier
|
|
878
|
+
|
|
879
|
+
# Merge YAML config with defaults (YAML config takes priority)
|
|
880
|
+
ws_settings = yaml_ws_config or {}
|
|
881
|
+
enable_progress = ws_settings.get("send_progress", True)
|
|
882
|
+
enable_logs = ws_settings.get("send_logs", True)
|
|
883
|
+
enable_variables = ws_settings.get("send_variables", False)
|
|
884
|
+
|
|
885
|
+
async def run_test_with_ws():
|
|
886
|
+
"""Async function to run WebSocket server and test execution."""
|
|
887
|
+
# Create WebSocket server and broadcaster
|
|
888
|
+
server = WebSocketServer(host=ws_host, port=ws_port)
|
|
889
|
+
broadcaster = EventBroadcaster(server=server)
|
|
890
|
+
|
|
891
|
+
# Start server and broadcaster
|
|
892
|
+
await server.start()
|
|
893
|
+
await broadcaster.start()
|
|
894
|
+
|
|
895
|
+
print(f"WebSocket server started at ws://{ws_host}:{ws_port}")
|
|
896
|
+
print("Connect a WebSocket client to receive real-time updates.")
|
|
897
|
+
print("Press Ctrl+C to stop the server.\n")
|
|
898
|
+
|
|
899
|
+
try:
|
|
900
|
+
# Create notifier with config from YAML
|
|
901
|
+
notifier = WebSocketNotifier(
|
|
902
|
+
broadcaster=broadcaster,
|
|
903
|
+
test_case_id=test_case.name,
|
|
904
|
+
enable_progress=enable_progress,
|
|
905
|
+
enable_logs=enable_logs,
|
|
906
|
+
enable_variables=enable_variables,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
# Execute test case with notifier
|
|
910
|
+
result = _execute_single_test(test_case, verbose, notifier=notifier)
|
|
911
|
+
|
|
912
|
+
# Wait a bit for final messages to be sent
|
|
913
|
+
await asyncio.sleep(0.5)
|
|
914
|
+
|
|
915
|
+
return result
|
|
916
|
+
|
|
917
|
+
finally:
|
|
918
|
+
# Stop broadcaster and server
|
|
919
|
+
await broadcaster.stop()
|
|
920
|
+
await server.stop()
|
|
921
|
+
print(f"\nWebSocket server stopped.")
|
|
922
|
+
|
|
923
|
+
# Run the async function
|
|
924
|
+
return asyncio.run(run_test_with_ws())
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _execute_data_driven_test(test_case, verbose: bool = False) -> dict:
|
|
928
|
+
"""Execute data-driven test case.
|
|
929
|
+
|
|
930
|
+
Args:
|
|
931
|
+
test_case: Test case with data source configuration
|
|
932
|
+
verbose: Enable verbose output
|
|
933
|
+
|
|
934
|
+
Returns:
|
|
935
|
+
Aggregated execution results
|
|
936
|
+
"""
|
|
937
|
+
# Create data-driven iterator
|
|
938
|
+
iterator = DataDrivenIterator(
|
|
939
|
+
test_case,
|
|
940
|
+
test_case.config.data_source,
|
|
941
|
+
test_case.config.variable_prefix,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
print(f"Executing: {test_case.name} (Data-Driven)")
|
|
945
|
+
print(f"Description: {test_case.description}")
|
|
946
|
+
print(f"Data iterations: {len(iterator)}")
|
|
947
|
+
print(f"Steps: {len(test_case.steps)}")
|
|
948
|
+
print()
|
|
949
|
+
|
|
950
|
+
# Execute for each data row
|
|
951
|
+
all_results = []
|
|
952
|
+
total_passed = 0
|
|
953
|
+
total_failed = 0
|
|
954
|
+
|
|
955
|
+
for i, (data_row, augmented_test_case) in enumerate(iterator):
|
|
956
|
+
print(f"\n--- Data Iteration #{i + 1} ---")
|
|
957
|
+
if verbose:
|
|
958
|
+
print(f"Data: {data_row}")
|
|
959
|
+
|
|
960
|
+
result = _execute_single_test(augmented_test_case, verbose, notifier=None)
|
|
961
|
+
all_results.append(result)
|
|
962
|
+
|
|
963
|
+
# Update statistics
|
|
964
|
+
if result["test_case"]["status"] == "passed":
|
|
965
|
+
total_passed += 1
|
|
966
|
+
else:
|
|
967
|
+
total_failed += 1
|
|
968
|
+
|
|
969
|
+
# Aggregate results
|
|
970
|
+
aggregated_result = {
|
|
971
|
+
"test_case": {
|
|
972
|
+
"name": test_case.name,
|
|
973
|
+
"status": "passed" if total_failed == 0 else "failed",
|
|
974
|
+
"total_iterations": len(iterator),
|
|
975
|
+
"passed_iterations": total_passed,
|
|
976
|
+
"failed_iterations": total_failed,
|
|
977
|
+
"pass_rate": (total_passed / len(iterator) * 100) if len(iterator) > 0 else 0,
|
|
978
|
+
},
|
|
979
|
+
"iterations": all_results,
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
# Print summary
|
|
983
|
+
print(f"\n{'='*60}")
|
|
984
|
+
print(f"Data-Driven Test Summary")
|
|
985
|
+
print(f"Total Iterations: {len(iterator)}")
|
|
986
|
+
print(f"Passed: {total_passed} ✓")
|
|
987
|
+
print(f"Failed: {total_failed} ✗")
|
|
988
|
+
print(f"Pass Rate: {aggregated_result['test_case']['pass_rate']:.1f}%")
|
|
989
|
+
print(f"{'='*60}")
|
|
990
|
+
|
|
991
|
+
return aggregated_result
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _execute_single_test(test_case, verbose: bool = False, notifier=None) -> dict:
|
|
995
|
+
"""Execute single test case.
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
test_case: Test case to execute
|
|
999
|
+
verbose: Enable verbose output
|
|
1000
|
+
notifier: Optional WebSocket notifier for real-time updates
|
|
1001
|
+
|
|
1002
|
+
Returns:
|
|
1003
|
+
Execution result as dictionary
|
|
1004
|
+
"""
|
|
1005
|
+
# Check if output format is text (JSON/CSV/JUnit/HTML modes suppress text output)
|
|
1006
|
+
is_text_output = not (
|
|
1007
|
+
test_case.config
|
|
1008
|
+
and test_case.config.output
|
|
1009
|
+
and test_case.config.output.get("format") in ["json", "csv", "junit", "html"]
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# Only print in text mode
|
|
1013
|
+
if is_text_output:
|
|
1014
|
+
# Print test case info
|
|
1015
|
+
print(f"Executing: {test_case.name}")
|
|
1016
|
+
print(f"Description: {test_case.description}")
|
|
1017
|
+
print(f"Steps: {len(test_case.steps)}")
|
|
1018
|
+
|
|
1019
|
+
# Execute test case
|
|
1020
|
+
executor = TestCaseExecutor(test_case, notifier=notifier)
|
|
1021
|
+
result = executor.execute()
|
|
1022
|
+
|
|
1023
|
+
# Only print summary in text mode
|
|
1024
|
+
if is_text_output:
|
|
1025
|
+
# Print summary
|
|
1026
|
+
print(f"\n{'='*60}")
|
|
1027
|
+
print(f"Status: {result['test_case']['status'].upper()}")
|
|
1028
|
+
print(f"Duration: {result['test_case']['duration']:.2f}s")
|
|
1029
|
+
print(f"Statistics:")
|
|
1030
|
+
print(f" Total: {result['statistics']['total_steps']}")
|
|
1031
|
+
print(f" Passed: {result['statistics']['passed_steps']} ✓")
|
|
1032
|
+
print(f" Failed: {result['statistics']['failed_steps']} ✗")
|
|
1033
|
+
print(f" Skipped: {result['statistics']['skipped_steps']} ⊘")
|
|
1034
|
+
print(f"Pass Rate: {result['statistics']['pass_rate']:.1f}%")
|
|
1035
|
+
print(f"{'='*60}")
|
|
1036
|
+
|
|
1037
|
+
# Print step results if verbose
|
|
1038
|
+
if verbose:
|
|
1039
|
+
print(f"\nStep Details:")
|
|
1040
|
+
for step in result["steps"]:
|
|
1041
|
+
status_icon = {
|
|
1042
|
+
"success": "✓",
|
|
1043
|
+
"failure": "✗",
|
|
1044
|
+
"skipped": "⊘",
|
|
1045
|
+
"error": "⚠",
|
|
1046
|
+
}.get(step["status"], "?")
|
|
1047
|
+
|
|
1048
|
+
print(f"\n {status_icon} {step['name']}")
|
|
1049
|
+
print(f" Status: {step['status']}")
|
|
1050
|
+
|
|
1051
|
+
if step.get("performance"):
|
|
1052
|
+
perf = step["performance"]
|
|
1053
|
+
total_time = perf.get("total_time", 0)
|
|
1054
|
+
print(f" Total Time: {total_time:.2f}ms")
|
|
1055
|
+
|
|
1056
|
+
# Display detailed timing breakdown if available
|
|
1057
|
+
dns_time = perf.get("dns_time", 0)
|
|
1058
|
+
tcp_time = perf.get("tcp_time", 0)
|
|
1059
|
+
tls_time = perf.get("tls_time", 0)
|
|
1060
|
+
server_time = perf.get("server_time", 0)
|
|
1061
|
+
download_time = perf.get("download_time", 0)
|
|
1062
|
+
upload_time = perf.get("upload_time", 0)
|
|
1063
|
+
|
|
1064
|
+
if any([dns_time, tcp_time, tls_time, server_time, download_time, upload_time]):
|
|
1065
|
+
timing_details = []
|
|
1066
|
+
if dns_time > 0:
|
|
1067
|
+
timing_details.append(f"DNS: {dns_time:.2f}ms")
|
|
1068
|
+
if tcp_time > 0:
|
|
1069
|
+
timing_details.append(f"TCP: {tcp_time:.2f}ms")
|
|
1070
|
+
if tls_time > 0:
|
|
1071
|
+
timing_details.append(f"TLS: {tls_time:.2f}ms")
|
|
1072
|
+
if server_time > 0:
|
|
1073
|
+
timing_details.append(f"Server: {server_time:.2f}ms")
|
|
1074
|
+
if download_time > 0:
|
|
1075
|
+
timing_details.append(f"Download: {download_time:.2f}ms")
|
|
1076
|
+
if upload_time > 0:
|
|
1077
|
+
timing_details.append(f"Upload: {upload_time:.2f}ms")
|
|
1078
|
+
|
|
1079
|
+
print(f" Breakdown: {' | '.join(timing_details)}")
|
|
1080
|
+
|
|
1081
|
+
# Display size information
|
|
1082
|
+
size = perf.get("size", 0)
|
|
1083
|
+
if size > 0:
|
|
1084
|
+
size_kb = size / 1024
|
|
1085
|
+
print(f" Size: {size} bytes ({size_kb:.2f} KB)")
|
|
1086
|
+
|
|
1087
|
+
if step.get("response", {}).get("status_code"):
|
|
1088
|
+
print(f" Status Code: {step['response']['status_code']}")
|
|
1089
|
+
|
|
1090
|
+
if step.get("error_info"):
|
|
1091
|
+
print(f" Error: {step['error_info']['message']}")
|
|
1092
|
+
|
|
1093
|
+
return result
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def save_result(result: dict, output_path: str) -> None:
|
|
1097
|
+
"""Save result to file (format based on extension or config).
|
|
1098
|
+
|
|
1099
|
+
Args:
|
|
1100
|
+
result: Result dictionary
|
|
1101
|
+
output_path: Output file path
|
|
1102
|
+
"""
|
|
1103
|
+
# Determine format from file extension or config
|
|
1104
|
+
format_from_config = "json"
|
|
1105
|
+
if result.get("test_case", {}).get("config", {}).get("output", {}).get("format"):
|
|
1106
|
+
format_from_config = result["test_case"]["config"]["output"]["format"]
|
|
1107
|
+
|
|
1108
|
+
# Check file extension
|
|
1109
|
+
if output_path.endswith(".csv"):
|
|
1110
|
+
output_format = "csv"
|
|
1111
|
+
elif output_path.endswith(".json"):
|
|
1112
|
+
output_format = "json"
|
|
1113
|
+
elif output_path.endswith(".xml"):
|
|
1114
|
+
output_format = "junit"
|
|
1115
|
+
elif output_path.endswith(".html"):
|
|
1116
|
+
output_format = "html"
|
|
1117
|
+
else:
|
|
1118
|
+
# Use format from config
|
|
1119
|
+
output_format = format_from_config
|
|
1120
|
+
|
|
1121
|
+
# Reconstruct TestCaseResult (needed for all formats)
|
|
1122
|
+
from apirun.core.models import TestCaseResult, StepResult, PerformanceMetrics, ErrorInfo
|
|
1123
|
+
from datetime import datetime
|
|
1124
|
+
|
|
1125
|
+
start_time = datetime.fromisoformat(result["test_case"]["start_time"]) if result["test_case"].get("start_time") else datetime.now()
|
|
1126
|
+
end_time = datetime.fromisoformat(result["test_case"]["end_time"]) if result["test_case"].get("end_time") else datetime.now()
|
|
1127
|
+
|
|
1128
|
+
# Reconstruct step results for formats that need them
|
|
1129
|
+
step_results = []
|
|
1130
|
+
for step_data in result.get("steps", []):
|
|
1131
|
+
step_result = StepResult(
|
|
1132
|
+
name=step_data["name"],
|
|
1133
|
+
status=step_data["status"],
|
|
1134
|
+
response=step_data.get("response"),
|
|
1135
|
+
extracted_vars=step_data.get("extracted_vars", {}),
|
|
1136
|
+
validation_results=step_data.get("validations", []),
|
|
1137
|
+
performance=None,
|
|
1138
|
+
error_info=None,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
# Add performance if available
|
|
1142
|
+
if step_data.get("performance"):
|
|
1143
|
+
perf_data = step_data["performance"]
|
|
1144
|
+
step_result.performance = PerformanceMetrics(
|
|
1145
|
+
total_time=perf_data.get("total_time", 0),
|
|
1146
|
+
dns_time=perf_data.get("dns_time", 0),
|
|
1147
|
+
tcp_time=perf_data.get("tcp_time", 0),
|
|
1148
|
+
tls_time=perf_data.get("tls_time", 0),
|
|
1149
|
+
server_time=perf_data.get("server_time", 0),
|
|
1150
|
+
download_time=perf_data.get("download_time", 0),
|
|
1151
|
+
upload_time=perf_data.get("upload_time", 0),
|
|
1152
|
+
size=perf_data.get("size", 0),
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
# Add error info if available
|
|
1156
|
+
if step_data.get("error_info"):
|
|
1157
|
+
err_data = step_data["error_info"]
|
|
1158
|
+
from apirun.core.models import ErrorCategory
|
|
1159
|
+
category_map = {
|
|
1160
|
+
"assertion": ErrorCategory.ASSERTION,
|
|
1161
|
+
"network": ErrorCategory.NETWORK,
|
|
1162
|
+
"timeout": ErrorCategory.TIMEOUT,
|
|
1163
|
+
"parsing": ErrorCategory.PARSING,
|
|
1164
|
+
"business": ErrorCategory.BUSINESS,
|
|
1165
|
+
"system": ErrorCategory.SYSTEM,
|
|
1166
|
+
}
|
|
1167
|
+
category = category_map.get(err_data.get("category", ""), ErrorCategory.SYSTEM)
|
|
1168
|
+
step_result.error_info = ErrorInfo(
|
|
1169
|
+
type=err_data.get("type", "UnknownError"),
|
|
1170
|
+
category=category,
|
|
1171
|
+
message=err_data.get("message", ""),
|
|
1172
|
+
suggestion=err_data.get("suggestion", ""),
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
# Parse timestamps
|
|
1176
|
+
if step_data.get("start_time"):
|
|
1177
|
+
step_result.start_time = datetime.fromisoformat(step_data["start_time"])
|
|
1178
|
+
if step_data.get("end_time"):
|
|
1179
|
+
step_result.end_time = datetime.fromisoformat(step_data["end_time"])
|
|
1180
|
+
|
|
1181
|
+
step_result.retry_count = step_data.get("retry_count", 0)
|
|
1182
|
+
step_results.append(step_result)
|
|
1183
|
+
|
|
1184
|
+
test_case_result = TestCaseResult(
|
|
1185
|
+
name=result["test_case"]["name"],
|
|
1186
|
+
status=result["test_case"]["status"],
|
|
1187
|
+
start_time=start_time,
|
|
1188
|
+
end_time=end_time,
|
|
1189
|
+
duration=result["test_case"]["duration"],
|
|
1190
|
+
total_steps=result["statistics"]["total_steps"],
|
|
1191
|
+
passed_steps=result["statistics"]["passed_steps"],
|
|
1192
|
+
failed_steps=result["statistics"]["failed_steps"],
|
|
1193
|
+
skipped_steps=result["statistics"]["skipped_steps"],
|
|
1194
|
+
step_results=step_results,
|
|
1195
|
+
final_variables=result.get("final_variables", {}),
|
|
1196
|
+
error_info=None,
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
# Save based on format
|
|
1200
|
+
if output_format == "csv":
|
|
1201
|
+
from apirun.result.json_exporter import JSONExporter
|
|
1202
|
+
collector = JSONExporter()
|
|
1203
|
+
collector.save_csv(test_case_result, output_path)
|
|
1204
|
+
|
|
1205
|
+
elif output_format == "junit":
|
|
1206
|
+
from apirun.result.junit_exporter import JUnitExporter
|
|
1207
|
+
exporter = JUnitExporter()
|
|
1208
|
+
exporter.save_junit_xml(test_case_result, output_path)
|
|
1209
|
+
|
|
1210
|
+
elif output_format == "html":
|
|
1211
|
+
from apirun.result.html_exporter import HTMLExporter
|
|
1212
|
+
exporter = HTMLExporter()
|
|
1213
|
+
exporter.save_html(test_case_result, output_path)
|
|
1214
|
+
|
|
1215
|
+
else:
|
|
1216
|
+
# Default to JSON
|
|
1217
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
1218
|
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _generate_allure_report(test_case, result: dict, allure_dir: str, clean: bool = True):
|
|
1222
|
+
"""Generate Allure report from test result.
|
|
1223
|
+
|
|
1224
|
+
Args:
|
|
1225
|
+
test_case: Test case object
|
|
1226
|
+
result: Test execution result dictionary
|
|
1227
|
+
allure_dir: Allure results directory
|
|
1228
|
+
clean: Whether to clean directory before generating (default: True)
|
|
1229
|
+
"""
|
|
1230
|
+
import shutil
|
|
1231
|
+
from pathlib import Path
|
|
1232
|
+
|
|
1233
|
+
from apirun.result.allure_exporter import AllureExporter
|
|
1234
|
+
from apirun.result.json_exporter import JSONExporter
|
|
1235
|
+
from apirun.core.models import TestCaseResult
|
|
1236
|
+
|
|
1237
|
+
# Clear previous Allure results if requested
|
|
1238
|
+
if clean:
|
|
1239
|
+
allure_path = Path(allure_dir)
|
|
1240
|
+
if allure_path.exists():
|
|
1241
|
+
# Remove all files in the directory
|
|
1242
|
+
for item in allure_path.iterdir():
|
|
1243
|
+
if item.is_file():
|
|
1244
|
+
item.unlink()
|
|
1245
|
+
elif item.is_dir():
|
|
1246
|
+
shutil.rmtree(item)
|
|
1247
|
+
|
|
1248
|
+
# Create Allure collector
|
|
1249
|
+
collector = AllureExporter(output_dir=allure_dir)
|
|
1250
|
+
|
|
1251
|
+
# Reconstruct TestCaseResult from dict
|
|
1252
|
+
# This is a simplified reconstruction
|
|
1253
|
+
from datetime import datetime
|
|
1254
|
+
from apirun.core.models import StepResult, PerformanceMetrics, ErrorInfo
|
|
1255
|
+
|
|
1256
|
+
step_results = []
|
|
1257
|
+
for step_data in result.get("steps", []):
|
|
1258
|
+
# Reconstruct StepResult
|
|
1259
|
+
step_result = StepResult(
|
|
1260
|
+
name=step_data["name"],
|
|
1261
|
+
status=step_data["status"],
|
|
1262
|
+
response=step_data.get("response"),
|
|
1263
|
+
extracted_vars=step_data.get("extracted_vars", {}),
|
|
1264
|
+
validation_results=step_data.get("validations", []),
|
|
1265
|
+
performance=None,
|
|
1266
|
+
error_info=None,
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
# Add performance if available
|
|
1270
|
+
if step_data.get("performance"):
|
|
1271
|
+
perf_data = step_data["performance"]
|
|
1272
|
+
step_result.performance = PerformanceMetrics(
|
|
1273
|
+
total_time=perf_data.get("total_time", 0),
|
|
1274
|
+
dns_time=perf_data.get("dns_time", 0),
|
|
1275
|
+
tcp_time=perf_data.get("tcp_time", 0),
|
|
1276
|
+
tls_time=perf_data.get("tls_time", 0),
|
|
1277
|
+
server_time=perf_data.get("server_time", 0),
|
|
1278
|
+
download_time=perf_data.get("download_time", 0),
|
|
1279
|
+
upload_time=perf_data.get("upload_time", 0),
|
|
1280
|
+
size=perf_data.get("size", 0),
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
# Add error info if available
|
|
1284
|
+
if step_data.get("error_info"):
|
|
1285
|
+
err_data = step_data["error_info"]
|
|
1286
|
+
from apirun.core.models import ErrorCategory
|
|
1287
|
+
# Map category string to enum
|
|
1288
|
+
category_map = {
|
|
1289
|
+
"assertion": ErrorCategory.ASSERTION,
|
|
1290
|
+
"network": ErrorCategory.NETWORK,
|
|
1291
|
+
"timeout": ErrorCategory.TIMEOUT,
|
|
1292
|
+
"parsing": ErrorCategory.PARSING,
|
|
1293
|
+
"business": ErrorCategory.BUSINESS,
|
|
1294
|
+
"system": ErrorCategory.SYSTEM,
|
|
1295
|
+
}
|
|
1296
|
+
category = category_map.get(err_data.get("category", ""), ErrorCategory.SYSTEM)
|
|
1297
|
+
|
|
1298
|
+
step_result.error_info = ErrorInfo(
|
|
1299
|
+
type=err_data.get("type", "UnknownError"),
|
|
1300
|
+
category=category,
|
|
1301
|
+
message=err_data.get("message", ""),
|
|
1302
|
+
suggestion=err_data.get("suggestion", ""),
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
# Parse timestamps
|
|
1306
|
+
if step_data.get("start_time"):
|
|
1307
|
+
step_result.start_time = datetime.fromisoformat(step_data["start_time"])
|
|
1308
|
+
if step_data.get("end_time"):
|
|
1309
|
+
step_result.end_time = datetime.fromisoformat(step_data["end_time"])
|
|
1310
|
+
|
|
1311
|
+
step_result.retry_count = step_data.get("retry_count", 0)
|
|
1312
|
+
step_results.append(step_result)
|
|
1313
|
+
|
|
1314
|
+
# Reconstruct TestCaseResult
|
|
1315
|
+
test_result = TestCaseResult(
|
|
1316
|
+
name=result["test_case"]["name"],
|
|
1317
|
+
status=result["test_case"]["status"],
|
|
1318
|
+
start_time=datetime.fromisoformat(result["test_case"]["start_time"]),
|
|
1319
|
+
end_time=datetime.fromisoformat(result["test_case"]["end_time"]),
|
|
1320
|
+
duration=result["test_case"]["duration"],
|
|
1321
|
+
total_steps=result["statistics"]["total_steps"],
|
|
1322
|
+
passed_steps=result["statistics"]["passed_steps"],
|
|
1323
|
+
failed_steps=result["statistics"]["failed_steps"],
|
|
1324
|
+
skipped_steps=result["statistics"]["skipped_steps"],
|
|
1325
|
+
step_results=step_results,
|
|
1326
|
+
final_variables=result.get("final_variables", {}),
|
|
1327
|
+
error_info=None,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
# Generate Allure result file
|
|
1331
|
+
result_file = collector.collect(test_case, test_result)
|
|
1332
|
+
|
|
1333
|
+
# Generate supporting files
|
|
1334
|
+
collector.generate_environment_file()
|
|
1335
|
+
collector.generate_categories_file()
|
|
1336
|
+
|
|
1337
|
+
# Print message
|
|
1338
|
+
print(f"\n✓ Allure report data generated: {result_file}")
|
|
1339
|
+
print(f" Results directory: {allure_dir}")
|
|
1340
|
+
print(f" View report: allure serve {allure_dir}")
|
|
1341
|
+
print(f" Or generate HTML: allure generate {allure_dir} --clean -o allure-report")
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
if __name__ == "__main__":
|
|
1345
|
+
sys.exit(main())
|