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.
Files changed (56) hide show
  1. apirun/__init__.py +59 -0
  2. apirun/cli.py +1345 -0
  3. apirun/cli_help_i18n.py +173 -0
  4. apirun/core/__init__.py +21 -0
  5. apirun/core/models.py +411 -0
  6. apirun/core/retry.py +287 -0
  7. apirun/core/template_functions.py +505 -0
  8. apirun/core/variable_manager.py +571 -0
  9. apirun/data_driven/__init__.py +22 -0
  10. apirun/data_driven/data_source.py +291 -0
  11. apirun/data_driven/iterator.py +195 -0
  12. apirun/executor/__init__.py +21 -0
  13. apirun/executor/api_executor.py +252 -0
  14. apirun/executor/concurrent_executor.py +366 -0
  15. apirun/executor/database_executor.py +415 -0
  16. apirun/executor/loop_executor.py +350 -0
  17. apirun/executor/script_executor.py +459 -0
  18. apirun/executor/step_executor.py +548 -0
  19. apirun/executor/test_case_executor.py +293 -0
  20. apirun/executor/wait_executor.py +530 -0
  21. apirun/extractor/__init__.py +15 -0
  22. apirun/extractor/cookie_extractor.py +37 -0
  23. apirun/extractor/extractor_factory.py +60 -0
  24. apirun/extractor/header_extractor.py +42 -0
  25. apirun/extractor/jsonpath_extractor.py +56 -0
  26. apirun/extractor/regex_extractor.py +51 -0
  27. apirun/mock/__init__.py +16 -0
  28. apirun/mock/models.py +439 -0
  29. apirun/mock/server.py +461 -0
  30. apirun/parser/__init__.py +5 -0
  31. apirun/parser/v2_yaml_parser.py +520 -0
  32. apirun/result/__init__.py +25 -0
  33. apirun/result/allure_exporter.py +916 -0
  34. apirun/result/html_exporter.py +831 -0
  35. apirun/result/json_exporter.py +564 -0
  36. apirun/result/junit_exporter.py +446 -0
  37. apirun/utils/__init__.py +5 -0
  38. apirun/utils/error_utils.py +381 -0
  39. apirun/utils/hooks.py +160 -0
  40. apirun/utils/json_optimized.py +285 -0
  41. apirun/utils/performance.py +375 -0
  42. apirun/utils/template.py +208 -0
  43. apirun/validation/__init__.py +11 -0
  44. apirun/validation/comparators.py +433 -0
  45. apirun/validation/engine.py +323 -0
  46. apirun/websocket/__init__.py +20 -0
  47. apirun/websocket/broadcaster.py +435 -0
  48. apirun/websocket/events.py +334 -0
  49. apirun/websocket/notifier.py +240 -0
  50. apirun/websocket/progress.py +175 -0
  51. apirun/websocket/server.py +209 -0
  52. sisyphus_api_engine-1.0.1.dist-info/METADATA +703 -0
  53. sisyphus_api_engine-1.0.1.dist-info/RECORD +56 -0
  54. sisyphus_api_engine-1.0.1.dist-info/WHEEL +5 -0
  55. sisyphus_api_engine-1.0.1.dist-info/entry_points.txt +3 -0
  56. 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())