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.
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ core
2
+ runners
3
+ utils
core/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .base_runner import BaseRunner
2
+ from .parallel_runner import ParallelRunner
3
+ from .test_case import TestCase
4
+ from .assertions import Assertions
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
@@ -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
@@ -0,0 +1,3 @@
1
+ from .json_runner import JSONRunner
2
+ from .yaml_runner import YAMLRunner
3
+ from .parallel_json_runner import ParallelJSONRunner
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
@@ -0,0 +1,3 @@
1
+ # File: /python-test-framework/python-test-framework/src/utils/__init__.py
2
+
3
+ # This file is intentionally left blank.
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)