cli-test-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_test_framework-0.1.0.dist-info/METADATA +15 -0
- cli_test_framework-0.1.0.dist-info/RECORD +17 -0
- cli_test_framework-0.1.0.dist-info/WHEEL +5 -0
- cli_test_framework-0.1.0.dist-info/top_level.txt +3 -0
- core/__init__.py +4 -0
- core/assertions.py +32 -0
- core/base_runner.py +56 -0
- core/parallel_runner.py +118 -0
- core/process_worker.py +93 -0
- core/test_case.py +21 -0
- runners/__init__.py +3 -0
- runners/json_runner.py +96 -0
- runners/parallel_json_runner.py +115 -0
- runners/yaml_runner.py +93 -0
- utils/__init__.py +3 -0
- utils/path_resolver.py +195 -0
- utils/report_generator.py +68 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-test-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small command line testing framework in Python.
|
|
5
|
+
Author: Xiaotong Wang
|
|
6
|
+
Author-email: xiaotongwang98@gmail.com
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
|
+
Dynamic: author
|
|
12
|
+
Dynamic: author-email
|
|
13
|
+
Dynamic: classifier
|
|
14
|
+
Dynamic: requires-python
|
|
15
|
+
Dynamic: summary
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
core/__init__.py,sha256=hSG3J7CT59-weORalShDrpIs8YT0CdNSTdksbLQpQvY,146
|
|
2
|
+
core/assertions.py,sha256=cX76Hz0NyHjXiZgTdUk1qModGFvzVSiOXV_vMWlnnfI,1225
|
|
3
|
+
core/base_runner.py,sha256=idcFPY2-SEMZmWydBZ93VuNvuVTvSAxkoMqUAVoQb2s,2152
|
|
4
|
+
core/parallel_runner.py,sha256=3HlxKWiv1vooV20IMyKpDSOR4KzcOeKxPV5MyciRDy8,5092
|
|
5
|
+
core/process_worker.py,sha256=-d_mSPI4Izgn2d8G_28TxjmY6TIbHtXzzyoff2z_DUE,2996
|
|
6
|
+
core/test_case.py,sha256=wdcOGwHCwhte6HcaFrWtuq1-2Dg2GYoGJdWGJ7_GQFQ,569
|
|
7
|
+
runners/__init__.py,sha256=tNjEfDgSnc2xmgYqcKm8f8dAbq5IqAPaXs0LfbdnTsc,124
|
|
8
|
+
runners/json_runner.py,sha256=i6h9G7tTIhY9uT-i-Jm50qvnn6myw3cXCdwrwsYCTJE,3911
|
|
9
|
+
runners/parallel_json_runner.py,sha256=XUHOljkpEADuNSCtnrbUcGgZo7sdbLGbUVyRa2GmH5k,4607
|
|
10
|
+
runners/yaml_runner.py,sha256=KhQimcGsq-qnfdTq5EfQp6H4efr5r6iVmM9-LFFSgoY,3622
|
|
11
|
+
utils/__init__.py,sha256=1K8HjFkHzJ8Q9rNNVpIheb2sXQuARtZhkYSJEWHW-_8,116
|
|
12
|
+
utils/path_resolver.py,sha256=gkICP-qP_18f2nsH9ekOg21jP7Yrf3hBcQuJ8cOxf4Y,8670
|
|
13
|
+
utils/report_generator.py,sha256=wREmV9nPODP96-OuKyF_AruZEt4NDpcaQml-_dsOKoo,2857
|
|
14
|
+
cli_test_framework-0.1.0.dist-info/METADATA,sha256=Mhzr6EC2fp5m7wZ1ksj5OdZEBHUn2dDBcYHqBli6kSE,466
|
|
15
|
+
cli_test_framework-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
+
cli_test_framework-0.1.0.dist-info/top_level.txt,sha256=DowRqhMNKADEnL9FTGS6N_r_saE0obbpmwTUUmbtcLI,19
|
|
17
|
+
cli_test_framework-0.1.0.dist-info/RECORD,,
|
core/__init__.py
ADDED
core/assertions.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Pattern
|
|
3
|
+
|
|
4
|
+
class Assertions:
|
|
5
|
+
@staticmethod
|
|
6
|
+
def equals(actual: Any, expected: Any, message: str = "") -> bool:
|
|
7
|
+
if actual != expected:
|
|
8
|
+
raise AssertionError(f"{message} Expected: {expected}, but got: {actual}")
|
|
9
|
+
return True
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def contains(container: str, item: str, message: str = "") -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Check if the item is contained within the container string.
|
|
15
|
+
This method returns True if the item is found anywhere within the container,
|
|
16
|
+
even if the container contains other information.
|
|
17
|
+
"""
|
|
18
|
+
if item not in container:
|
|
19
|
+
raise AssertionError(f"{message} Expected to contain: {item}")
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def matches(text: str, pattern: str, message: str = "") -> bool:
|
|
24
|
+
if not re.search(pattern, text):
|
|
25
|
+
raise AssertionError(f"{message} Text does not match pattern: {pattern}")
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def return_code_equals(actual: int, expected: int, message: str = "") -> bool:
|
|
30
|
+
if actual != expected:
|
|
31
|
+
raise AssertionError(f"{message} Expected return code: {expected}, got: {actual}")
|
|
32
|
+
return True
|
core/base_runner.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
from .test_case import TestCase
|
|
5
|
+
from .assertions import Assertions
|
|
6
|
+
|
|
7
|
+
class BaseRunner(ABC):
|
|
8
|
+
def __init__(self, config_file: str, workspace: Optional[str] = None):
|
|
9
|
+
if workspace:
|
|
10
|
+
self.workspace = Path(workspace)
|
|
11
|
+
else:
|
|
12
|
+
self.workspace = Path(__file__).parent.parent.parent
|
|
13
|
+
self.config_path = self.workspace / config_file
|
|
14
|
+
self.test_cases: List[TestCase] = []
|
|
15
|
+
self.results: Dict[str, Any] = {
|
|
16
|
+
"total": 0,
|
|
17
|
+
"passed": 0,
|
|
18
|
+
"failed": 0,
|
|
19
|
+
"details": []
|
|
20
|
+
}
|
|
21
|
+
self.assertions = Assertions()
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def load_test_cases(self) -> None:
|
|
25
|
+
"""Load test cases from configuration file"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def run_tests(self) -> bool:
|
|
29
|
+
"""Run all test cases and return whether all tests passed"""
|
|
30
|
+
self.load_test_cases()
|
|
31
|
+
self.results["total"] = len(self.test_cases)
|
|
32
|
+
|
|
33
|
+
print(f"\nStarting test execution... Total tests: {self.results['total']}")
|
|
34
|
+
print("=" * 50)
|
|
35
|
+
|
|
36
|
+
for i, case in enumerate(self.test_cases, 1):
|
|
37
|
+
print(f"\nRunning test {i}/{self.results['total']}: {case.name}")
|
|
38
|
+
result = self.run_single_test(case)
|
|
39
|
+
self.results["details"].append(result)
|
|
40
|
+
if result["status"] == "passed":
|
|
41
|
+
self.results["passed"] += 1
|
|
42
|
+
print(f"✓ Test passed: {case.name}")
|
|
43
|
+
else:
|
|
44
|
+
self.results["failed"] += 1
|
|
45
|
+
print(f"✗ Test failed: {case.name}")
|
|
46
|
+
if result["message"]:
|
|
47
|
+
print(f" Error: {result['message']}")
|
|
48
|
+
|
|
49
|
+
print("\n" + "=" * 50)
|
|
50
|
+
print(f"Test execution completed. Passed: {self.results['passed']}, Failed: {self.results['failed']}")
|
|
51
|
+
return self.results["failed"] == 0
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def run_single_test(self, case: TestCase) -> Dict[str, str]:
|
|
55
|
+
"""Run a single test case and return the result"""
|
|
56
|
+
pass
|
core/parallel_runner.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
|
|
3
|
+
from typing import List, Dict, Any, Optional, Union
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
from .base_runner import BaseRunner
|
|
7
|
+
from .test_case import TestCase
|
|
8
|
+
from .process_worker import run_test_in_process
|
|
9
|
+
|
|
10
|
+
class ParallelRunner(BaseRunner):
|
|
11
|
+
"""并行测试运行器基类,支持多线程和多进程执行"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config_file: str, workspace: Optional[str] = None,
|
|
14
|
+
max_workers: Optional[int] = None,
|
|
15
|
+
execution_mode: str = "thread"):
|
|
16
|
+
"""
|
|
17
|
+
初始化并行运行器
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
config_file: 配置文件路径
|
|
21
|
+
workspace: 工作目录
|
|
22
|
+
max_workers: 最大并发数,默认为CPU核心数
|
|
23
|
+
execution_mode: 执行模式,'thread'(线程) 或 'process'(进程)
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(config_file, workspace)
|
|
26
|
+
self.max_workers = max_workers
|
|
27
|
+
self.execution_mode = execution_mode
|
|
28
|
+
self.lock = threading.Lock() # 用于线程安全的结果更新
|
|
29
|
+
|
|
30
|
+
def run_tests(self) -> bool:
|
|
31
|
+
"""并行运行所有测试用例"""
|
|
32
|
+
self.load_test_cases()
|
|
33
|
+
self.results["total"] = len(self.test_cases)
|
|
34
|
+
|
|
35
|
+
print(f"\nStarting parallel test execution... Total tests: {self.results['total']}")
|
|
36
|
+
print(f"Execution mode: {self.execution_mode}, Max workers: {self.max_workers or 'auto'}")
|
|
37
|
+
print("=" * 50)
|
|
38
|
+
|
|
39
|
+
start_time = time.time()
|
|
40
|
+
|
|
41
|
+
if self.execution_mode == "process":
|
|
42
|
+
executor_class = ProcessPoolExecutor
|
|
43
|
+
else:
|
|
44
|
+
executor_class = ThreadPoolExecutor
|
|
45
|
+
|
|
46
|
+
with executor_class(max_workers=self.max_workers) as executor:
|
|
47
|
+
# 提交所有测试任务
|
|
48
|
+
if self.execution_mode == "process":
|
|
49
|
+
# 进程模式:使用独立的工作器函数
|
|
50
|
+
future_to_case = {
|
|
51
|
+
executor.submit(
|
|
52
|
+
run_test_in_process,
|
|
53
|
+
i,
|
|
54
|
+
{
|
|
55
|
+
"name": case.name,
|
|
56
|
+
"command": case.command,
|
|
57
|
+
"args": case.args,
|
|
58
|
+
"expected": case.expected
|
|
59
|
+
},
|
|
60
|
+
str(self.workspace) if self.workspace else None
|
|
61
|
+
): (i, case)
|
|
62
|
+
for i, case in enumerate(self.test_cases, 1)
|
|
63
|
+
}
|
|
64
|
+
else:
|
|
65
|
+
# 线程模式:使用实例方法
|
|
66
|
+
future_to_case = {
|
|
67
|
+
executor.submit(self._run_test_with_index, i, case): (i, case)
|
|
68
|
+
for i, case in enumerate(self.test_cases, 1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# 收集结果
|
|
72
|
+
for future in as_completed(future_to_case):
|
|
73
|
+
test_index, case = future_to_case[future]
|
|
74
|
+
try:
|
|
75
|
+
result = future.result()
|
|
76
|
+
self._update_results(result, test_index, case)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
error_result = {
|
|
79
|
+
"name": case.name,
|
|
80
|
+
"status": "failed",
|
|
81
|
+
"message": f"Test execution failed: {str(exc)}",
|
|
82
|
+
"output": "",
|
|
83
|
+
"command": "",
|
|
84
|
+
"return_code": None
|
|
85
|
+
}
|
|
86
|
+
self._update_results(error_result, test_index, case)
|
|
87
|
+
|
|
88
|
+
end_time = time.time()
|
|
89
|
+
execution_time = end_time - start_time
|
|
90
|
+
|
|
91
|
+
print("\n" + "=" * 50)
|
|
92
|
+
print(f"Parallel test execution completed in {execution_time:.2f} seconds")
|
|
93
|
+
print(f"Passed: {self.results['passed']}, Failed: {self.results['failed']}")
|
|
94
|
+
return self.results["failed"] == 0
|
|
95
|
+
|
|
96
|
+
def _run_test_with_index(self, test_index: int, case: TestCase) -> Dict[str, Any]:
|
|
97
|
+
"""运行单个测试并返回结果(包含索引信息)"""
|
|
98
|
+
print(f"[Worker] Running test {test_index}: {case.name}")
|
|
99
|
+
result = self.run_single_test(case)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
def _update_results(self, result: Dict[str, Any], test_index: int, case: TestCase) -> None:
|
|
103
|
+
"""线程安全地更新测试结果"""
|
|
104
|
+
with self.lock:
|
|
105
|
+
self.results["details"].append(result)
|
|
106
|
+
if result["status"] == "passed":
|
|
107
|
+
self.results["passed"] += 1
|
|
108
|
+
print(f"✓ Test {test_index} passed: {case.name}")
|
|
109
|
+
else:
|
|
110
|
+
self.results["failed"] += 1
|
|
111
|
+
print(f"✗ Test {test_index} failed: {case.name}")
|
|
112
|
+
if result["message"]:
|
|
113
|
+
print(f" Error: {result['message']}")
|
|
114
|
+
|
|
115
|
+
def run_tests_sequential(self) -> bool:
|
|
116
|
+
"""回退到顺序执行模式"""
|
|
117
|
+
print("Falling back to sequential execution...")
|
|
118
|
+
return super().run_tests()
|
core/process_worker.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
进程工作器模块
|
|
3
|
+
用于多进程并行测试执行,避免序列化问题
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
from .test_case import TestCase
|
|
10
|
+
from .assertions import Assertions
|
|
11
|
+
|
|
12
|
+
def run_test_in_process(test_index: int, case_data: Dict[str, Any], workspace: str = None) -> Dict[str, Any]:
|
|
13
|
+
"""
|
|
14
|
+
在独立进程中运行单个测试用例
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
test_index: 测试索引
|
|
18
|
+
case_data: 测试用例数据字典
|
|
19
|
+
workspace: 工作目录
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
测试结果字典
|
|
23
|
+
"""
|
|
24
|
+
# 重新创建TestCase对象(避免序列化问题)
|
|
25
|
+
case = TestCase(
|
|
26
|
+
name=case_data["name"],
|
|
27
|
+
command=case_data["command"],
|
|
28
|
+
args=case_data["args"],
|
|
29
|
+
expected=case_data["expected"]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# 创建断言对象
|
|
33
|
+
assertions = Assertions()
|
|
34
|
+
|
|
35
|
+
result = {
|
|
36
|
+
"name": case.name,
|
|
37
|
+
"status": "failed",
|
|
38
|
+
"message": "",
|
|
39
|
+
"output": "",
|
|
40
|
+
"command": "",
|
|
41
|
+
"return_code": None
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
command = f"{case.command} {' '.join(case.args)}"
|
|
46
|
+
result["command"] = command
|
|
47
|
+
print(f" [Process Worker {test_index}] Executing command: {command}")
|
|
48
|
+
|
|
49
|
+
process = subprocess.run(
|
|
50
|
+
command,
|
|
51
|
+
cwd=workspace if workspace else None,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
shell=True
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
output = process.stdout + process.stderr
|
|
59
|
+
result["output"] = output
|
|
60
|
+
result["return_code"] = process.returncode
|
|
61
|
+
|
|
62
|
+
if output.strip():
|
|
63
|
+
print(f" [Process Worker {test_index}] Command output for {case.name}:")
|
|
64
|
+
for line in output.splitlines():
|
|
65
|
+
print(f" {line}")
|
|
66
|
+
|
|
67
|
+
# 检查返回码
|
|
68
|
+
if "return_code" in case.expected:
|
|
69
|
+
print(f" [Process Worker {test_index}] Checking return code for {case.name}: {process.returncode} (expected: {case.expected['return_code']})")
|
|
70
|
+
assertions.return_code_equals(
|
|
71
|
+
process.returncode,
|
|
72
|
+
case.expected["return_code"]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# 检查输出包含
|
|
76
|
+
if "output_contains" in case.expected:
|
|
77
|
+
print(f" [Process Worker {test_index}] Checking output contains for {case.name}...")
|
|
78
|
+
for expected_text in case.expected["output_contains"]:
|
|
79
|
+
assertions.contains(output, expected_text)
|
|
80
|
+
|
|
81
|
+
# 检查正则匹配
|
|
82
|
+
if "output_matches" in case.expected:
|
|
83
|
+
print(f" [Process Worker {test_index}] Checking output matches regex for {case.name}...")
|
|
84
|
+
assertions.matches(output, case.expected["output_matches"])
|
|
85
|
+
|
|
86
|
+
result["status"] = "passed"
|
|
87
|
+
|
|
88
|
+
except AssertionError as e:
|
|
89
|
+
result["message"] = str(e)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
result["message"] = f"Execution error: {str(e)}"
|
|
92
|
+
|
|
93
|
+
return result
|
core/test_case.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class TestCase:
|
|
6
|
+
name: str
|
|
7
|
+
command: str
|
|
8
|
+
args: List[str]
|
|
9
|
+
expected: Dict[str, Any]
|
|
10
|
+
description: str = ""
|
|
11
|
+
|
|
12
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
13
|
+
"""Convert test case to dictionary format"""
|
|
14
|
+
print("Convert test case to dictionary format")
|
|
15
|
+
print(self.command)
|
|
16
|
+
return {
|
|
17
|
+
"name": self.name,
|
|
18
|
+
"command": self.command,
|
|
19
|
+
"args": self.args,
|
|
20
|
+
"expected": self.expected
|
|
21
|
+
}
|
runners/__init__.py
ADDED
runners/json_runner.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from ..core.base_runner import BaseRunner
|
|
3
|
+
from ..core.test_case import TestCase
|
|
4
|
+
from ..utils.path_resolver import PathResolver
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
|
|
10
|
+
class JSONRunner(BaseRunner):
|
|
11
|
+
def __init__(self, config_file="test_cases.json", workspace: Optional[str] = None):
|
|
12
|
+
super().__init__(config_file, workspace)
|
|
13
|
+
self.path_resolver = PathResolver(self.workspace)
|
|
14
|
+
|
|
15
|
+
def load_test_cases(self) -> None:
|
|
16
|
+
"""Load test cases from a JSON file."""
|
|
17
|
+
try:
|
|
18
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
19
|
+
config = json.load(f)
|
|
20
|
+
|
|
21
|
+
required_fields = ["name", "command", "args", "expected"]
|
|
22
|
+
for case in config["test_cases"]:
|
|
23
|
+
if not all(field in case for field in required_fields):
|
|
24
|
+
raise ValueError(f"Test case {case.get('name', 'unnamed')} is missing required fields")
|
|
25
|
+
|
|
26
|
+
# 使用智能命令解析,正确处理包含空格的路径
|
|
27
|
+
case["command"] = self.path_resolver.parse_command_string(case["command"])
|
|
28
|
+
|
|
29
|
+
case["args"] = self.path_resolver.resolve_paths(case["args"])
|
|
30
|
+
self.test_cases.append(TestCase(**case))
|
|
31
|
+
|
|
32
|
+
print(f"Successfully loaded {len(self.test_cases)} test cases")
|
|
33
|
+
# print(self.test_cases)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
sys.exit(f"Failed to load configuration file: {str(e)}")
|
|
36
|
+
|
|
37
|
+
def run_single_test(self, case: TestCase) -> Dict[str, str]:
|
|
38
|
+
result = {
|
|
39
|
+
"name": case.name,
|
|
40
|
+
"status": "failed",
|
|
41
|
+
"message": "",
|
|
42
|
+
"output": "", # 添加命令输出字段
|
|
43
|
+
"command": "", # 添加执行的命令字段
|
|
44
|
+
"return_code": None # 添加返回码字段
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
command = f"{case.command} {' '.join(case.args)}"
|
|
49
|
+
result["command"] = command # 保存执行的命令
|
|
50
|
+
print(f" Executing command: {command}")
|
|
51
|
+
|
|
52
|
+
process = subprocess.run(
|
|
53
|
+
command,
|
|
54
|
+
cwd=self.workspace if self.workspace else None,
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
check=False,
|
|
58
|
+
shell=True
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
output = process.stdout + process.stderr
|
|
62
|
+
result["output"] = output # 保存命令的完整输出
|
|
63
|
+
result["return_code"] = process.returncode # 保存返回码
|
|
64
|
+
|
|
65
|
+
if output.strip():
|
|
66
|
+
print(" Command output:")
|
|
67
|
+
for line in output.splitlines():
|
|
68
|
+
print(f" {line}")
|
|
69
|
+
|
|
70
|
+
# Check return code
|
|
71
|
+
if "return_code" in case.expected:
|
|
72
|
+
print(f" Checking return code: {process.returncode} (expected: {case.expected['return_code']})")
|
|
73
|
+
self.assertions.return_code_equals(
|
|
74
|
+
process.returncode,
|
|
75
|
+
case.expected["return_code"]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Check output contains
|
|
79
|
+
if "output_contains" in case.expected:
|
|
80
|
+
print(" Checking output contains expected text...")
|
|
81
|
+
for expected_text in case.expected["output_contains"]:
|
|
82
|
+
self.assertions.contains(output, expected_text)
|
|
83
|
+
|
|
84
|
+
# Check regex patterns
|
|
85
|
+
if "output_matches" in case.expected:
|
|
86
|
+
print(" Checking output matches regex pattern...")
|
|
87
|
+
self.assertions.matches(output, case.expected["output_matches"])
|
|
88
|
+
|
|
89
|
+
result["status"] = "passed"
|
|
90
|
+
|
|
91
|
+
except AssertionError as e:
|
|
92
|
+
result["message"] = str(e)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
result["message"] = f"Execution error: {str(e)}"
|
|
95
|
+
|
|
96
|
+
return result
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any
|
|
2
|
+
from ..core.parallel_runner import ParallelRunner
|
|
3
|
+
from ..core.test_case import TestCase
|
|
4
|
+
from ..utils.path_resolver import PathResolver
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
class ParallelJSONRunner(ParallelRunner):
|
|
11
|
+
"""并行JSON测试运行器"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config_file="test_cases.json", workspace: Optional[str] = None,
|
|
14
|
+
max_workers: Optional[int] = None, execution_mode: str = "thread"):
|
|
15
|
+
"""
|
|
16
|
+
初始化并行JSON运行器
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config_file: JSON配置文件路径
|
|
20
|
+
workspace: 工作目录
|
|
21
|
+
max_workers: 最大并发数
|
|
22
|
+
execution_mode: 执行模式,'thread' 或 'process'
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(config_file, workspace, max_workers, execution_mode)
|
|
25
|
+
self.path_resolver = PathResolver(self.workspace)
|
|
26
|
+
self._print_lock = threading.Lock() # 用于控制输出顺序
|
|
27
|
+
|
|
28
|
+
def load_test_cases(self) -> None:
|
|
29
|
+
"""从JSON文件加载测试用例"""
|
|
30
|
+
try:
|
|
31
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
32
|
+
config = json.load(f)
|
|
33
|
+
|
|
34
|
+
required_fields = ["name", "command", "args", "expected"]
|
|
35
|
+
for case in config["test_cases"]:
|
|
36
|
+
if not all(field in case for field in required_fields):
|
|
37
|
+
raise ValueError(f"Test case {case.get('name', 'unnamed')} is missing required fields")
|
|
38
|
+
|
|
39
|
+
case["command"] = self.path_resolver.parse_command_string(case["command"])
|
|
40
|
+
case["args"] = self.path_resolver.resolve_paths(case["args"])
|
|
41
|
+
self.test_cases.append(TestCase(**case))
|
|
42
|
+
|
|
43
|
+
print(f"Successfully loaded {len(self.test_cases)} test cases")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
sys.exit(f"Failed to load configuration file: {str(e)}")
|
|
46
|
+
|
|
47
|
+
def run_single_test(self, case: TestCase) -> Dict[str, Any]:
|
|
48
|
+
"""运行单个测试用例(线程安全版本)"""
|
|
49
|
+
result = {
|
|
50
|
+
"name": case.name,
|
|
51
|
+
"status": "failed",
|
|
52
|
+
"message": "",
|
|
53
|
+
"output": "",
|
|
54
|
+
"command": "",
|
|
55
|
+
"return_code": None
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
command = f"{case.command} {' '.join(case.args)}"
|
|
60
|
+
result["command"] = command
|
|
61
|
+
|
|
62
|
+
# 线程安全的输出
|
|
63
|
+
with self._print_lock:
|
|
64
|
+
print(f" [Worker] Executing command: {command}")
|
|
65
|
+
|
|
66
|
+
process = subprocess.run(
|
|
67
|
+
command,
|
|
68
|
+
cwd=self.workspace if self.workspace else None,
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
check=False,
|
|
72
|
+
shell=True
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
output = process.stdout + process.stderr
|
|
76
|
+
result["output"] = output
|
|
77
|
+
result["return_code"] = process.returncode
|
|
78
|
+
|
|
79
|
+
# 线程安全的输出
|
|
80
|
+
if output.strip():
|
|
81
|
+
with self._print_lock:
|
|
82
|
+
print(f" [Worker] Command output for {case.name}:")
|
|
83
|
+
for line in output.splitlines():
|
|
84
|
+
print(f" {line}")
|
|
85
|
+
|
|
86
|
+
# 检查返回码
|
|
87
|
+
if "return_code" in case.expected:
|
|
88
|
+
with self._print_lock:
|
|
89
|
+
print(f" [Worker] Checking return code for {case.name}: {process.returncode} (expected: {case.expected['return_code']})")
|
|
90
|
+
self.assertions.return_code_equals(
|
|
91
|
+
process.returncode,
|
|
92
|
+
case.expected["return_code"]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 检查输出包含
|
|
96
|
+
if "output_contains" in case.expected:
|
|
97
|
+
with self._print_lock:
|
|
98
|
+
print(f" [Worker] Checking output contains for {case.name}...")
|
|
99
|
+
for expected_text in case.expected["output_contains"]:
|
|
100
|
+
self.assertions.contains(output, expected_text)
|
|
101
|
+
|
|
102
|
+
# 检查正则匹配
|
|
103
|
+
if "output_matches" in case.expected:
|
|
104
|
+
with self._print_lock:
|
|
105
|
+
print(f" [Worker] Checking output matches regex for {case.name}...")
|
|
106
|
+
self.assertions.matches(output, case.expected["output_matches"])
|
|
107
|
+
|
|
108
|
+
result["status"] = "passed"
|
|
109
|
+
|
|
110
|
+
except AssertionError as e:
|
|
111
|
+
result["message"] = str(e)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
result["message"] = f"Execution error: {str(e)}"
|
|
114
|
+
|
|
115
|
+
return result
|
runners/yaml_runner.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any
|
|
2
|
+
from ..core.base_runner import BaseRunner
|
|
3
|
+
from ..core.test_case import TestCase
|
|
4
|
+
from ..utils.path_resolver import PathResolver
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
class YAMLRunner(BaseRunner):
|
|
9
|
+
def __init__(self, config_file="test_cases.yaml", workspace: Optional[str] = None):
|
|
10
|
+
super().__init__(config_file, workspace)
|
|
11
|
+
self.path_resolver = PathResolver(self.workspace)
|
|
12
|
+
|
|
13
|
+
def load_test_cases(self):
|
|
14
|
+
"""Load test cases from a YAML file."""
|
|
15
|
+
try:
|
|
16
|
+
import yaml
|
|
17
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
18
|
+
config = yaml.safe_load(f)
|
|
19
|
+
|
|
20
|
+
required_fields = ["name", "command", "args", "expected"]
|
|
21
|
+
for case in config["test_cases"]:
|
|
22
|
+
if not all(field in case for field in required_fields):
|
|
23
|
+
raise ValueError(f"Test case {case.get('name', 'unnamed')} is missing required fields")
|
|
24
|
+
|
|
25
|
+
case["command"] = self.path_resolver.parse_command_string(case["command"])
|
|
26
|
+
case["args"] = self.path_resolver.resolve_paths(case["args"])
|
|
27
|
+
self.test_cases.append(TestCase(**case))
|
|
28
|
+
|
|
29
|
+
print(f"Successfully loaded {len(self.test_cases)} test cases")
|
|
30
|
+
except Exception as e:
|
|
31
|
+
sys.exit(f"Failed to load configuration file: {str(e)}")
|
|
32
|
+
|
|
33
|
+
def run_single_test(self, case: TestCase) -> Dict[str, Any]:
|
|
34
|
+
"""Run a single test case and return the result"""
|
|
35
|
+
result = {
|
|
36
|
+
"name": case.name,
|
|
37
|
+
"status": "failed",
|
|
38
|
+
"message": "",
|
|
39
|
+
"output": "",
|
|
40
|
+
"command": "",
|
|
41
|
+
"return_code": None
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
command = f"{case.command} {' '.join(case.args)}"
|
|
46
|
+
result["command"] = command
|
|
47
|
+
print(f" Executing command: {command}")
|
|
48
|
+
|
|
49
|
+
process = subprocess.run(
|
|
50
|
+
command,
|
|
51
|
+
cwd=self.workspace if self.workspace else None,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
shell=True
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
output = process.stdout + process.stderr
|
|
59
|
+
result["output"] = output
|
|
60
|
+
result["return_code"] = process.returncode
|
|
61
|
+
|
|
62
|
+
if output.strip():
|
|
63
|
+
print(" Command output:")
|
|
64
|
+
for line in output.splitlines():
|
|
65
|
+
print(f" {line}")
|
|
66
|
+
|
|
67
|
+
# Check return code
|
|
68
|
+
if "return_code" in case.expected:
|
|
69
|
+
print(f" Checking return code: {process.returncode} (expected: {case.expected['return_code']})")
|
|
70
|
+
self.assertions.return_code_equals(
|
|
71
|
+
process.returncode,
|
|
72
|
+
case.expected["return_code"]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Check output contains
|
|
76
|
+
if "output_contains" in case.expected:
|
|
77
|
+
print(" Checking output contains expected text...")
|
|
78
|
+
for expected_text in case.expected["output_contains"]:
|
|
79
|
+
self.assertions.contains(output, expected_text)
|
|
80
|
+
|
|
81
|
+
# Check regex patterns
|
|
82
|
+
if "output_matches" in case.expected:
|
|
83
|
+
print(" Checking output matches regex pattern...")
|
|
84
|
+
self.assertions.matches(output, case.expected["output_matches"])
|
|
85
|
+
|
|
86
|
+
result["status"] = "passed"
|
|
87
|
+
|
|
88
|
+
except AssertionError as e:
|
|
89
|
+
result["message"] = str(e)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
result["message"] = f"Execution error: {str(e)}"
|
|
92
|
+
|
|
93
|
+
return result
|
utils/__init__.py
ADDED
utils/path_resolver.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List
|
|
3
|
+
import shlex
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
class PathResolver:
|
|
7
|
+
def __init__(self, workspace: Path):
|
|
8
|
+
self.workspace = workspace
|
|
9
|
+
|
|
10
|
+
def resolve_paths(self, args: List[str]) -> List[str]:
|
|
11
|
+
resolved_args = []
|
|
12
|
+
for arg in args:
|
|
13
|
+
if not arg.startswith("--"):
|
|
14
|
+
# Only prepend workspace if the path is relative
|
|
15
|
+
if not Path(arg).is_absolute():
|
|
16
|
+
resolved_args.append(str(self.workspace / arg))
|
|
17
|
+
else:
|
|
18
|
+
resolved_args.append(arg)
|
|
19
|
+
else:
|
|
20
|
+
resolved_args.append(arg)
|
|
21
|
+
return resolved_args
|
|
22
|
+
|
|
23
|
+
def resolve_command(self, command: str) -> str:
|
|
24
|
+
"""
|
|
25
|
+
解析命令路径
|
|
26
|
+
- 系统命令(如echo, ping, dir等)保持原样
|
|
27
|
+
- 相对路径的可执行文件转换为绝对路径
|
|
28
|
+
"""
|
|
29
|
+
# 常见的系统命令列表
|
|
30
|
+
system_commands = {
|
|
31
|
+
'echo', 'ping', 'dir', 'ls', 'cat', 'grep', 'find', 'sort',
|
|
32
|
+
'head', 'tail', 'wc', 'curl', 'wget', 'git', 'python', 'node',
|
|
33
|
+
'npm', 'pip', 'java', 'javac', 'gcc', 'make', 'cmake', 'docker',
|
|
34
|
+
'kubectl', 'helm', 'terraform', 'ansible', 'ssh', 'scp', 'rsync'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# 如果是系统命令或绝对路径,保持原样
|
|
38
|
+
if command in system_commands or Path(command).is_absolute():
|
|
39
|
+
return command
|
|
40
|
+
|
|
41
|
+
# 否则当作相对路径处理
|
|
42
|
+
return str(self.workspace / command)
|
|
43
|
+
|
|
44
|
+
def parse_command_string(self, command_string: str) -> str:
|
|
45
|
+
"""
|
|
46
|
+
智能解析命令字符串,正确处理包含空格的路径
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
command_string: 原始命令字符串,如 "python ./script.py" 或 r"C:\\Program Files (x86)\\python.exe script.py"
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
解析后的完整命令字符串
|
|
53
|
+
"""
|
|
54
|
+
# 特殊处理:如果命令字符串包含引号,使用shlex解析
|
|
55
|
+
if '"' in command_string or "'" in command_string:
|
|
56
|
+
try:
|
|
57
|
+
# 对于所有系统都使用posix=True来正确处理引号
|
|
58
|
+
# 这样可以去掉引号,但保留路径内容
|
|
59
|
+
parts = shlex.split(command_string, posix=True)
|
|
60
|
+
|
|
61
|
+
if not parts:
|
|
62
|
+
return command_string
|
|
63
|
+
|
|
64
|
+
# 第一部分是命令,其余是参数
|
|
65
|
+
command_part = parts[0]
|
|
66
|
+
remaining_parts = parts[1:]
|
|
67
|
+
|
|
68
|
+
# 解析命令部分(如果是绝对路径,保持原样;否则解析)
|
|
69
|
+
if Path(command_part).is_absolute():
|
|
70
|
+
resolved_command = command_part
|
|
71
|
+
else:
|
|
72
|
+
resolved_command = self.resolve_command(command_part)
|
|
73
|
+
|
|
74
|
+
# 解析参数部分
|
|
75
|
+
resolved_parts = []
|
|
76
|
+
for part in remaining_parts:
|
|
77
|
+
if part.startswith('-'):
|
|
78
|
+
# 选项参数,保持原样
|
|
79
|
+
resolved_parts.append(part)
|
|
80
|
+
elif ('.' in part or '/' in part or '\\' in part) and not part.isdigit():
|
|
81
|
+
# 看起来像文件路径
|
|
82
|
+
if not Path(part).is_absolute():
|
|
83
|
+
resolved_parts.append(str(self.workspace / part))
|
|
84
|
+
else:
|
|
85
|
+
resolved_parts.append(part)
|
|
86
|
+
else:
|
|
87
|
+
# 其他参数,保持原样
|
|
88
|
+
resolved_parts.append(part)
|
|
89
|
+
|
|
90
|
+
return f"{resolved_command} {' '.join(resolved_parts)}"
|
|
91
|
+
|
|
92
|
+
except ValueError:
|
|
93
|
+
# shlex解析失败,回退到简单处理
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# 简单情况:没有引号的命令字符串
|
|
97
|
+
# 先尝试识别是否以绝对路径开头
|
|
98
|
+
if self._starts_with_absolute_path(command_string):
|
|
99
|
+
# 处理以绝对路径开头的命令
|
|
100
|
+
return self._parse_absolute_path_command(command_string)
|
|
101
|
+
else:
|
|
102
|
+
# 普通命令处理
|
|
103
|
+
parts = command_string.split()
|
|
104
|
+
if not parts:
|
|
105
|
+
return command_string
|
|
106
|
+
|
|
107
|
+
if len(parts) == 1:
|
|
108
|
+
return self.resolve_command(parts[0])
|
|
109
|
+
else:
|
|
110
|
+
command_part = parts[0]
|
|
111
|
+
remaining_parts = parts[1:]
|
|
112
|
+
|
|
113
|
+
resolved_command = self.resolve_command(command_part)
|
|
114
|
+
resolved_parts = []
|
|
115
|
+
|
|
116
|
+
for part in remaining_parts:
|
|
117
|
+
if part.startswith('-'):
|
|
118
|
+
resolved_parts.append(part)
|
|
119
|
+
elif ('.' in part or '/' in part or '\\' in part) and not part.isdigit():
|
|
120
|
+
if not Path(part).is_absolute():
|
|
121
|
+
resolved_parts.append(str(self.workspace / part))
|
|
122
|
+
else:
|
|
123
|
+
resolved_parts.append(part)
|
|
124
|
+
else:
|
|
125
|
+
resolved_parts.append(part)
|
|
126
|
+
|
|
127
|
+
return f"{resolved_command} {' '.join(resolved_parts)}"
|
|
128
|
+
|
|
129
|
+
def _starts_with_absolute_path(self, command_string: str) -> bool:
|
|
130
|
+
"""检查命令字符串是否以绝对路径开头"""
|
|
131
|
+
if os.name == 'nt': # Windows
|
|
132
|
+
# Windows绝对路径模式:C:\... 或 \\server\...
|
|
133
|
+
return (len(command_string) >= 3 and
|
|
134
|
+
command_string[1:3] == ':\\') or command_string.startswith('\\\\')
|
|
135
|
+
else: # Unix/Linux
|
|
136
|
+
return command_string.startswith('/')
|
|
137
|
+
|
|
138
|
+
def _parse_absolute_path_command(self, command_string: str) -> str:
|
|
139
|
+
"""解析以绝对路径开头的命令字符串"""
|
|
140
|
+
# 对于Windows路径,需要特殊处理空格
|
|
141
|
+
if os.name == 'nt':
|
|
142
|
+
# 尝试找到第一个.exe或.bat等可执行文件扩展名
|
|
143
|
+
exe_extensions = ['.exe', '.bat', '.cmd', '.com']
|
|
144
|
+
|
|
145
|
+
for ext in exe_extensions:
|
|
146
|
+
if ext in command_string:
|
|
147
|
+
# 找到可执行文件的结束位置
|
|
148
|
+
ext_pos = command_string.find(ext)
|
|
149
|
+
if ext_pos != -1:
|
|
150
|
+
command_end = ext_pos + len(ext)
|
|
151
|
+
command_part = command_string[:command_end]
|
|
152
|
+
remaining = command_string[command_end:].strip()
|
|
153
|
+
|
|
154
|
+
if remaining:
|
|
155
|
+
# 解析剩余参数
|
|
156
|
+
remaining_parts = remaining.split()
|
|
157
|
+
resolved_parts = []
|
|
158
|
+
|
|
159
|
+
for part in remaining_parts:
|
|
160
|
+
if part.startswith('-'):
|
|
161
|
+
resolved_parts.append(part)
|
|
162
|
+
elif ('.' in part or '/' in part or '\\' in part) and not part.isdigit():
|
|
163
|
+
if not Path(part).is_absolute():
|
|
164
|
+
resolved_parts.append(str(self.workspace / part))
|
|
165
|
+
else:
|
|
166
|
+
resolved_parts.append(part)
|
|
167
|
+
else:
|
|
168
|
+
resolved_parts.append(part)
|
|
169
|
+
|
|
170
|
+
return f"{command_part} {' '.join(resolved_parts)}"
|
|
171
|
+
else:
|
|
172
|
+
return command_part
|
|
173
|
+
|
|
174
|
+
# 如果没有找到可执行文件扩展名,回退到简单分割
|
|
175
|
+
parts = command_string.split()
|
|
176
|
+
if not parts:
|
|
177
|
+
return command_string
|
|
178
|
+
|
|
179
|
+
# 假设第一个部分是命令
|
|
180
|
+
command_part = parts[0]
|
|
181
|
+
remaining_parts = parts[1:]
|
|
182
|
+
|
|
183
|
+
resolved_parts = []
|
|
184
|
+
for part in remaining_parts:
|
|
185
|
+
if part.startswith('-'):
|
|
186
|
+
resolved_parts.append(part)
|
|
187
|
+
elif ('.' in part or '/' in part or '\\' in part) and not part.isdigit():
|
|
188
|
+
if not Path(part).is_absolute():
|
|
189
|
+
resolved_parts.append(str(self.workspace / part))
|
|
190
|
+
else:
|
|
191
|
+
resolved_parts.append(part)
|
|
192
|
+
else:
|
|
193
|
+
resolved_parts.append(part)
|
|
194
|
+
|
|
195
|
+
return f"{command_part} {' '.join(resolved_parts)}"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class ReportGenerator:
|
|
2
|
+
def __init__(self, results: dict, file_path: str):
|
|
3
|
+
self.results = results
|
|
4
|
+
self.file_path = file_path
|
|
5
|
+
|
|
6
|
+
def generate_report(self) -> str:
|
|
7
|
+
report = "Test Results Summary:\n"
|
|
8
|
+
report += f"Total Tests: {self.results['total']}\n"
|
|
9
|
+
report += f"Passed: {self.results['passed']}\n"
|
|
10
|
+
report += f"Failed: {self.results['failed']}\n\n"
|
|
11
|
+
|
|
12
|
+
report += "Detailed Results:\n"
|
|
13
|
+
for detail in self.results['details']:
|
|
14
|
+
status_icon = "✓" if detail['status'] == 'passed' else "✗"
|
|
15
|
+
report += f"{status_icon} {detail['name']}\n"
|
|
16
|
+
if detail.get('message'):
|
|
17
|
+
report += f" -> {detail['message']}\n"
|
|
18
|
+
|
|
19
|
+
# 添加失败案例的详细输出信息
|
|
20
|
+
failed_tests = [detail for detail in self.results['details'] if detail['status'] == 'failed']
|
|
21
|
+
if failed_tests:
|
|
22
|
+
report += "\n" + "="*50 + "\n"
|
|
23
|
+
report += "FAILED TEST CASES DETAILS:\n"
|
|
24
|
+
report += "="*50 + "\n\n"
|
|
25
|
+
|
|
26
|
+
for i, failed_test in enumerate(failed_tests, 1):
|
|
27
|
+
report += f"{i}. Test: {failed_test['name']}\n"
|
|
28
|
+
report += "-" * 40 + "\n"
|
|
29
|
+
|
|
30
|
+
# 添加执行的命令
|
|
31
|
+
if failed_test.get('command'):
|
|
32
|
+
report += f"Command: {failed_test['command']}\n"
|
|
33
|
+
|
|
34
|
+
# 添加返回码
|
|
35
|
+
if failed_test.get('return_code') is not None:
|
|
36
|
+
report += f"Return Code: {failed_test['return_code']}\n"
|
|
37
|
+
|
|
38
|
+
# 添加失败原因
|
|
39
|
+
if failed_test.get('message'):
|
|
40
|
+
report += f"Error Message: {failed_test['message']}\n"
|
|
41
|
+
|
|
42
|
+
# 添加命令的完整输出(这是最重要的部分)
|
|
43
|
+
if failed_test.get('output'):
|
|
44
|
+
report += f"\nCommand Output:\n"
|
|
45
|
+
report += "=" * 30 + "\n"
|
|
46
|
+
report += f"{failed_test['output']}\n"
|
|
47
|
+
report += "=" * 30 + "\n"
|
|
48
|
+
|
|
49
|
+
# 添加错误堆栈信息(如果有的话)
|
|
50
|
+
if failed_test.get('error_trace'):
|
|
51
|
+
report += f"Error Trace:\n{failed_test['error_trace']}\n"
|
|
52
|
+
|
|
53
|
+
# 添加执行时间(如果有的话)
|
|
54
|
+
if failed_test.get('duration'):
|
|
55
|
+
report += f"Duration: {failed_test['duration']}s\n"
|
|
56
|
+
|
|
57
|
+
report += "\n"
|
|
58
|
+
|
|
59
|
+
return report
|
|
60
|
+
|
|
61
|
+
def save_report(self) -> None:
|
|
62
|
+
report = self.generate_report()
|
|
63
|
+
with open(self.file_path, 'w', encoding='utf-8') as f:
|
|
64
|
+
f.write(report)
|
|
65
|
+
|
|
66
|
+
def print_report(self) -> None:
|
|
67
|
+
report = self.generate_report()
|
|
68
|
+
print(report)
|