symtest-cli 1.3.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.
- symtest/__init__.py +45 -0
- symtest/cli.py +549 -0
- symtest/commands/__init__.py +9 -0
- symtest/commands/compare.py +221 -0
- symtest/config/__init__.py +7 -0
- symtest/config/config_io.py +346 -0
- symtest/config/config_schema.py +330 -0
- symtest/config/import_expander.py +149 -0
- symtest/config/inheritance_expander.py +197 -0
- symtest/core/__init__.py +15 -0
- symtest/core/assertions.py +253 -0
- symtest/core/base_runner.py +299 -0
- symtest/core/config_loader.py +536 -0
- symtest/core/execution.py +498 -0
- symtest/core/history_store.py +96 -0
- symtest/core/last_run_store.py +109 -0
- symtest/core/parallel_runner.py +251 -0
- symtest/core/process_worker.py +93 -0
- symtest/core/sequence_state.py +143 -0
- symtest/core/setup.py +137 -0
- symtest/core/test_case.py +76 -0
- symtest/core/types.py +92 -0
- symtest/file_comparator/__init__.py +10 -0
- symtest/file_comparator/base_comparator.py +109 -0
- symtest/file_comparator/binary_comparator.py +399 -0
- symtest/file_comparator/csv_comparator.py +241 -0
- symtest/file_comparator/factory.py +191 -0
- symtest/file_comparator/h5_comparator.py +777 -0
- symtest/file_comparator/json_comparator.py +323 -0
- symtest/file_comparator/result.py +213 -0
- symtest/file_comparator/script_comparator.py +182 -0
- symtest/file_comparator/text_comparator.py +182 -0
- symtest/file_comparator/xml_comparator.py +150 -0
- symtest/logging_config.py +66 -0
- symtest/runners/__init__.py +15 -0
- symtest/runners/config_runner.py +96 -0
- symtest/runners/json_runner.py +21 -0
- symtest/runners/parallel_config_runner.py +278 -0
- symtest/runners/parallel_json_runner.py +26 -0
- symtest/runners/parallel_yaml_runner.py +31 -0
- symtest/runners/yaml_runner.py +26 -0
- symtest/tui/__init__.py +11 -0
- symtest/tui/app.py +90 -0
- symtest/tui/controllers/__init__.py +0 -0
- symtest/tui/controllers/case_controller.py +322 -0
- symtest/tui/screens/__init__.py +0 -0
- symtest/tui/screens/case_editor.py +244 -0
- symtest/tui/screens/case_list.py +255 -0
- symtest/tui/widgets/__init__.py +0 -0
- symtest/tui/widgets/case_table.py +113 -0
- symtest/tui/widgets/expected_editor.py +159 -0
- symtest/tui/widgets/search_bar.py +160 -0
- symtest/tui/widgets/steps_editor.py +243 -0
- symtest/utils/__init__.py +21 -0
- symtest/utils/junit_xml_writer.py +137 -0
- symtest/utils/path_resolver.py +124 -0
- symtest/utils/report_generator.py +208 -0
- symtest_cli-1.3.0.dist-info/METADATA +316 -0
- symtest_cli-1.3.0.dist-info/RECORD +63 -0
- symtest_cli-1.3.0.dist-info/WHEEL +5 -0
- symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
- symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
symtest/core/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core components for the CLI Testing Framework
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_runner import BaseRunner
|
|
6
|
+
from .parallel_runner import ParallelRunner
|
|
7
|
+
from .test_case import TestCase
|
|
8
|
+
from .assertions import Assertions
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'BaseRunner',
|
|
12
|
+
'ParallelRunner',
|
|
13
|
+
'TestCase',
|
|
14
|
+
'Assertions'
|
|
15
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, List, Optional, Pattern
|
|
6
|
+
|
|
7
|
+
from ..file_comparator.factory import ComparatorFactory
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("symtest.core.assertions")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _detect_file_type(file_path: str) -> str:
|
|
13
|
+
"""Auto-detect comparator type from file extension."""
|
|
14
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
15
|
+
_type_map = {
|
|
16
|
+
'.h5': 'h5', '.hdf5': 'h5', '.hdf': 'h5',
|
|
17
|
+
'.json': 'json',
|
|
18
|
+
'.csv': 'csv', '.tsv': 'csv',
|
|
19
|
+
'.xml': 'xml', '.html': 'xml', '.htm': 'xml',
|
|
20
|
+
'.txt': 'text', '.log': 'text', '.out': 'text', '.py': 'text',
|
|
21
|
+
}
|
|
22
|
+
if ext in _type_map:
|
|
23
|
+
return _type_map[ext]
|
|
24
|
+
# For unknown extensions, use binary comparator
|
|
25
|
+
return 'binary'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(AssertionError):
|
|
29
|
+
"""Structured assertion failure carrying failure_kind and detailed data.
|
|
30
|
+
|
|
31
|
+
Extends ``AssertionError`` for backward compatibility with existing
|
|
32
|
+
``except AssertionError`` catch blocks, while adding structured fields
|
|
33
|
+
that reporters and AI consumers can use directly.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str = "",
|
|
39
|
+
*,
|
|
40
|
+
failure_kind: str = "",
|
|
41
|
+
compare_failures: Optional[List[Dict[str, Any]]] = None,
|
|
42
|
+
baseline_updated: Optional[List[str]] = None,
|
|
43
|
+
assertion_results: Optional[List[Dict[str, Any]]] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.failure_kind = failure_kind
|
|
47
|
+
self.compare_failures = compare_failures or []
|
|
48
|
+
self.baseline_updated = baseline_updated or []
|
|
49
|
+
self.assertion_results = assertion_results or []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_diff_summary(result: Any) -> Dict[str, Any]:
|
|
53
|
+
"""Extract a numerical diff summary from a ``ComparisonResult``.
|
|
54
|
+
|
|
55
|
+
Walks the ``differences`` list and computes:
|
|
56
|
+
- ``total_differences``
|
|
57
|
+
- ``max_rel_error`` / ``max_abs_error`` (for cells that are numeric)
|
|
58
|
+
- ``max_rel_error_at`` / ``max_abs_error_at`` (position string)
|
|
59
|
+
"""
|
|
60
|
+
differences = getattr(result, "differences", []) or []
|
|
61
|
+
summary: Dict[str, Any] = {
|
|
62
|
+
"total_differences": len(differences),
|
|
63
|
+
"max_rel_error": None,
|
|
64
|
+
"max_rel_error_at": None,
|
|
65
|
+
"max_abs_error": None,
|
|
66
|
+
"max_abs_error_at": None,
|
|
67
|
+
}
|
|
68
|
+
for diff in differences:
|
|
69
|
+
exp = getattr(diff, "expected", None)
|
|
70
|
+
act = getattr(diff, "actual", None)
|
|
71
|
+
pos = getattr(diff, "position", None)
|
|
72
|
+
try:
|
|
73
|
+
ve = float(exp)
|
|
74
|
+
va = float(act)
|
|
75
|
+
abs_err = abs(va - ve)
|
|
76
|
+
rel_err = abs_err / max(abs(ve), 1e-300) if abs(ve) > 0 else float("inf")
|
|
77
|
+
if summary["max_abs_error"] is None or abs_err > summary["max_abs_error"]:
|
|
78
|
+
summary["max_abs_error"] = abs_err
|
|
79
|
+
summary["max_abs_error_at"] = pos
|
|
80
|
+
if summary["max_rel_error"] is None or rel_err > summary["max_rel_error"]:
|
|
81
|
+
summary["max_rel_error"] = rel_err
|
|
82
|
+
summary["max_rel_error_at"] = pos
|
|
83
|
+
except (ValueError, TypeError):
|
|
84
|
+
pass
|
|
85
|
+
return summary
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Assertions:
|
|
89
|
+
@staticmethod
|
|
90
|
+
def equals(actual: Any, expected: Any, message: str = "") -> bool:
|
|
91
|
+
if actual != expected:
|
|
92
|
+
raise AssertionError(f"{message} Expected: {expected}, but got: {actual}")
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def contains(container: str, item: str, message: str = "") -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Check if the item is contained within the container string.
|
|
99
|
+
This method returns True if the item is found anywhere within the container,
|
|
100
|
+
even if the container contains other information.
|
|
101
|
+
"""
|
|
102
|
+
if item not in container:
|
|
103
|
+
raise AssertionError(f"{message} Expected to contain: {item}")
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def matches(text: str, pattern: str, message: str = "") -> bool:
|
|
108
|
+
if not re.search(pattern, text):
|
|
109
|
+
raise AssertionError(f"{message} Text does not match pattern: {pattern}")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def return_code_equals(actual: int, expected: int, message: str = "") -> bool:
|
|
114
|
+
if actual != expected:
|
|
115
|
+
raise AssertionError(f"{message} Expected return code: {expected}, got: {actual}")
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def compare_files(
|
|
120
|
+
actual_path: str,
|
|
121
|
+
baseline_path: str,
|
|
122
|
+
file_type: Optional[str] = None,
|
|
123
|
+
workspace: Optional[str] = None,
|
|
124
|
+
*,
|
|
125
|
+
update_baseline: bool = False,
|
|
126
|
+
error_analysis: bool = False,
|
|
127
|
+
**comparator_kwargs: Any,
|
|
128
|
+
) -> Dict[str, Any]:
|
|
129
|
+
"""
|
|
130
|
+
Compare two files using the appropriate file comparator.
|
|
131
|
+
|
|
132
|
+
:param actual_path: Path to the file generated by the test command.
|
|
133
|
+
:param baseline_path: Path to the golden / reference file.
|
|
134
|
+
:param file_type: Comparator type ('h5','json','csv','xml','text','binary').
|
|
135
|
+
Auto-detected from file extension if omitted.
|
|
136
|
+
:param workspace: Working directory; both paths are resolved relative to
|
|
137
|
+
this directory when they are not absolute.
|
|
138
|
+
:param update_baseline: If True, on comparison failure copy ``actual`` over
|
|
139
|
+
``baseline`` and report the update.
|
|
140
|
+
:param error_analysis: If True, enable streaming error statistics over ALL
|
|
141
|
+
numeric cells (CSV/H5 comparators).
|
|
142
|
+
:param comparator_kwargs: Extra keyword arguments forwarded to the comparator
|
|
143
|
+
(e.g. ``rtol=1e-5``, ``atol=1e-8``, ``encoding='utf-8'``).
|
|
144
|
+
:return: A dict with ``identical``, ``error``, ``diff_summary``,
|
|
145
|
+
``differences``, ``actual``, ``baseline``, ``type``,
|
|
146
|
+
``comparator_kwargs``, ``baseline_updated``.
|
|
147
|
+
:raises ValidationError: when files differ (unless update_baseline is enabled).
|
|
148
|
+
"""
|
|
149
|
+
# Resolve paths relative to workspace
|
|
150
|
+
orig_actual = actual_path
|
|
151
|
+
orig_baseline = baseline_path
|
|
152
|
+
if actual_path and workspace and not os.path.isabs(actual_path):
|
|
153
|
+
actual_path = os.path.join(workspace, actual_path)
|
|
154
|
+
if baseline_path and workspace and not os.path.isabs(baseline_path):
|
|
155
|
+
baseline_path = os.path.join(workspace, baseline_path)
|
|
156
|
+
|
|
157
|
+
# Auto-detect file type from extension
|
|
158
|
+
if not file_type:
|
|
159
|
+
if not actual_path:
|
|
160
|
+
raise ValidationError(
|
|
161
|
+
"File type cannot be auto-detected: 'actual' path is empty. "
|
|
162
|
+
"Specify 'type' explicitly (e.g. 'script' or a custom plugin type).",
|
|
163
|
+
failure_kind="file_compare",
|
|
164
|
+
)
|
|
165
|
+
file_type = _detect_file_type(actual_path)
|
|
166
|
+
|
|
167
|
+
# Extract compare_files() method-level parameters (not constructor kwargs).
|
|
168
|
+
# These control line/column ranges in the comparator's compare_files() call.
|
|
169
|
+
_method_keys = {"start_line", "end_line", "start_column", "end_column"}
|
|
170
|
+
method_params: Dict[str, Any] = {}
|
|
171
|
+
for k in list(comparator_kwargs):
|
|
172
|
+
if k in _method_keys:
|
|
173
|
+
method_params[k] = comparator_kwargs.pop(k)
|
|
174
|
+
|
|
175
|
+
# Convert 1-based user input to 0-based (matches CLI behaviour)
|
|
176
|
+
if "start_line" in method_params:
|
|
177
|
+
method_params["start_line"] = max(0, int(method_params["start_line"]) - 1)
|
|
178
|
+
if "end_line" in method_params and method_params["end_line"] is not None:
|
|
179
|
+
method_params["end_line"] = max(0, int(method_params["end_line"]) - 1)
|
|
180
|
+
if "start_column" in method_params:
|
|
181
|
+
method_params["start_column"] = max(0, int(method_params["start_column"]) - 1)
|
|
182
|
+
if "end_column" in method_params and method_params["end_column"] is not None:
|
|
183
|
+
method_params["end_column"] = max(0, int(method_params["end_column"]) - 1)
|
|
184
|
+
|
|
185
|
+
# Collect tolerances for reporting (before they're consumed by factory)
|
|
186
|
+
reported_kwargs = {
|
|
187
|
+
k: v for k, v in comparator_kwargs.items()
|
|
188
|
+
if k not in ("verbose", "debug", "num_threads", "chunk_size", "encoding")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
comparator = ComparatorFactory.create_comparator(
|
|
193
|
+
file_type,
|
|
194
|
+
verbose=True, # always include diff details in the assertion message
|
|
195
|
+
error_analysis=error_analysis,
|
|
196
|
+
**comparator_kwargs,
|
|
197
|
+
)
|
|
198
|
+
result = comparator.compare_files(actual_path, baseline_path, **method_params)
|
|
199
|
+
|
|
200
|
+
# Build structured response
|
|
201
|
+
diff_summary = _build_diff_summary(result)
|
|
202
|
+
response: Dict[str, Any] = {
|
|
203
|
+
"identical": result.identical,
|
|
204
|
+
"error": result.error,
|
|
205
|
+
"actual": orig_actual,
|
|
206
|
+
"baseline": orig_baseline,
|
|
207
|
+
"type": file_type,
|
|
208
|
+
"comparator_kwargs": reported_kwargs,
|
|
209
|
+
"diff_summary": diff_summary,
|
|
210
|
+
"differences": (
|
|
211
|
+
[d.to_dict() for d in result.differences]
|
|
212
|
+
if result.differences else []
|
|
213
|
+
),
|
|
214
|
+
"error_stats": result.error_stats,
|
|
215
|
+
"command_output": result.command_output,
|
|
216
|
+
"baseline_updated": False,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if result.error:
|
|
220
|
+
raise ValidationError(
|
|
221
|
+
f"File comparison error ({orig_actual} vs {orig_baseline}): {result.error}",
|
|
222
|
+
failure_kind="file_compare",
|
|
223
|
+
compare_failures=[response],
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if not result.identical:
|
|
227
|
+
if update_baseline:
|
|
228
|
+
# Overwrite baseline with actual
|
|
229
|
+
os.makedirs(os.path.dirname(baseline_path), exist_ok=True)
|
|
230
|
+
shutil.copy2(actual_path, baseline_path)
|
|
231
|
+
response["baseline_updated"] = True
|
|
232
|
+
response["identical"] = True # treated as pass
|
|
233
|
+
logger.info(
|
|
234
|
+
" [UPDATE BASELINE] %s → %s",
|
|
235
|
+
orig_actual, orig_baseline,
|
|
236
|
+
)
|
|
237
|
+
return response
|
|
238
|
+
else:
|
|
239
|
+
raise ValidationError(
|
|
240
|
+
f"File comparison failed ({orig_actual} vs {orig_baseline}):\n{result}",
|
|
241
|
+
failure_kind="file_compare",
|
|
242
|
+
compare_failures=[response],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return response
|
|
246
|
+
|
|
247
|
+
except ValidationError:
|
|
248
|
+
raise
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
raise ValidationError(
|
|
251
|
+
f"File comparison error ({orig_actual} vs {orig_baseline}): {exc}",
|
|
252
|
+
failure_kind="file_compare",
|
|
253
|
+
)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from .test_case import TestCase
|
|
7
|
+
from .assertions import Assertions
|
|
8
|
+
from .setup import SetupManager, EnvironmentSetup
|
|
9
|
+
from .execution import execute_single_test_case
|
|
10
|
+
from .history_store import load_history, update_case, check_regression, save_history, reset_cases
|
|
11
|
+
from .last_run_store import update_last_run, get_last_failed_names
|
|
12
|
+
from ..file_comparator.factory import ComparatorFactory
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("symtest.core.base_runner")
|
|
15
|
+
|
|
16
|
+
class BaseRunner(ABC):
|
|
17
|
+
def __init__(self, config_file: str, workspace: Optional[str] = None,
|
|
18
|
+
test_case_filter: Optional[List[str]] = None,
|
|
19
|
+
test_case_tag_filter: Optional[List[str]] = None,
|
|
20
|
+
history_dir: Optional[str] = None,
|
|
21
|
+
regression_threshold: float = 1.5,
|
|
22
|
+
update_baseline: bool = False,
|
|
23
|
+
update_history: bool = False,
|
|
24
|
+
error_analysis: bool = False,
|
|
25
|
+
last_failed: bool = False,
|
|
26
|
+
resume: bool = False,
|
|
27
|
+
plugin_dirs: Optional[List[str]] = None):
|
|
28
|
+
if workspace:
|
|
29
|
+
self.workspace = Path(workspace)
|
|
30
|
+
else:
|
|
31
|
+
self.workspace = Path.cwd()
|
|
32
|
+
config_path = Path(config_file)
|
|
33
|
+
if config_path.is_absolute():
|
|
34
|
+
self.config_path = config_path
|
|
35
|
+
else:
|
|
36
|
+
self.config_path = self.workspace / config_path
|
|
37
|
+
self.test_cases: List[TestCase] = []
|
|
38
|
+
self.test_case_filter: Optional[List[str]] = test_case_filter
|
|
39
|
+
self.test_case_tag_filter: Optional[List[str]] = test_case_tag_filter
|
|
40
|
+
if history_dir:
|
|
41
|
+
self.history_dir = str((self.workspace / history_dir).resolve())
|
|
42
|
+
else:
|
|
43
|
+
self.history_dir = None
|
|
44
|
+
self.regression_threshold = regression_threshold
|
|
45
|
+
self.update_baseline = update_baseline
|
|
46
|
+
self.update_history = update_history
|
|
47
|
+
self.error_analysis = error_analysis
|
|
48
|
+
self.last_failed = last_failed
|
|
49
|
+
self.resume = resume
|
|
50
|
+
|
|
51
|
+
# --- workspace plugin directories ---
|
|
52
|
+
resolved_plugin_dirs: List[str] = list(plugin_dirs) if plugin_dirs else []
|
|
53
|
+
default_plugin_dir = self.workspace / "comparators"
|
|
54
|
+
if default_plugin_dir.is_dir() and str(default_plugin_dir.resolve()) not in resolved_plugin_dirs:
|
|
55
|
+
resolved_plugin_dirs.append(str(default_plugin_dir.resolve()))
|
|
56
|
+
ComparatorFactory.set_plugin_dirs(resolved_plugin_dirs)
|
|
57
|
+
self.results: Dict[str, Any] = {
|
|
58
|
+
"total": 0,
|
|
59
|
+
"passed": 0,
|
|
60
|
+
"failed": 0,
|
|
61
|
+
"xfailed": 0,
|
|
62
|
+
"xpassed": 0,
|
|
63
|
+
"updated": 0,
|
|
64
|
+
"details": []
|
|
65
|
+
}
|
|
66
|
+
self.assertions = Assertions()
|
|
67
|
+
self.setup_manager = SetupManager()
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def load_test_cases(self) -> None:
|
|
71
|
+
"""Load test cases from configuration file"""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def load_setup_from_config(self, config: Dict[str, Any]) -> None:
|
|
75
|
+
"""从配置文件加载setup配置"""
|
|
76
|
+
setup_config = config.get("setup", {})
|
|
77
|
+
|
|
78
|
+
# 处理环境变量设置
|
|
79
|
+
if "environment_variables" in setup_config:
|
|
80
|
+
env_setup = EnvironmentSetup({"environment_variables": setup_config["environment_variables"]})
|
|
81
|
+
self.setup_manager.add_setup(env_setup)
|
|
82
|
+
|
|
83
|
+
# 这里可以扩展支持其他类型的setup插件
|
|
84
|
+
# 例如:
|
|
85
|
+
# if "custom_setups" in setup_config:
|
|
86
|
+
# for custom_setup_config in setup_config["custom_setups"]:
|
|
87
|
+
# # 动态加载自定义setup插件
|
|
88
|
+
# pass
|
|
89
|
+
|
|
90
|
+
def _apply_test_case_filter(self) -> None:
|
|
91
|
+
"""根据 test_case_filter / test_case_tag_filter / --last-failed 过滤测试用例"""
|
|
92
|
+
if self.last_failed and not self.test_case_filter:
|
|
93
|
+
ws = str(self.workspace) if self.workspace else str(Path.cwd())
|
|
94
|
+
failed_names = get_last_failed_names(ws)
|
|
95
|
+
if failed_names:
|
|
96
|
+
logger.info(
|
|
97
|
+
"--last-failed: filtering to %d previously failed case(s): %s",
|
|
98
|
+
len(failed_names), ", ".join(failed_names),
|
|
99
|
+
)
|
|
100
|
+
self.test_case_filter = (
|
|
101
|
+
(self.test_case_filter or []) + failed_names
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
logger.info("--last-failed: no previously failed cases found; running all.")
|
|
105
|
+
|
|
106
|
+
if self.test_case_filter or self.test_case_tag_filter:
|
|
107
|
+
original_count = len(self.test_cases)
|
|
108
|
+
self.test_cases = [
|
|
109
|
+
tc for tc in self.test_cases
|
|
110
|
+
if (not self.test_case_filter or tc.name in self.test_case_filter)
|
|
111
|
+
and (not self.test_case_tag_filter
|
|
112
|
+
or set(tc.tags or []) & set(self.test_case_tag_filter))
|
|
113
|
+
]
|
|
114
|
+
filtered_out = original_count - len(self.test_cases)
|
|
115
|
+
if filtered_out > 0:
|
|
116
|
+
logger.info("Filtered out %d test case(s). Running %d specified case(s).",
|
|
117
|
+
filtered_out, len(self.test_cases))
|
|
118
|
+
if not self.test_cases:
|
|
119
|
+
logger.warning("No matching test cases found for: names=%s, tags=%s",
|
|
120
|
+
self.test_case_filter, self.test_case_tag_filter)
|
|
121
|
+
|
|
122
|
+
def run_tests(self) -> bool:
|
|
123
|
+
"""Run all test cases and return whether all tests passed"""
|
|
124
|
+
try:
|
|
125
|
+
self.load_test_cases()
|
|
126
|
+
self._apply_test_case_filter()
|
|
127
|
+
self.results["total"] = len(self.test_cases)
|
|
128
|
+
|
|
129
|
+
if self.results["total"] == 0:
|
|
130
|
+
logger.warning("No test cases to run.")
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
# 执行setup任务
|
|
134
|
+
self.setup_manager.setup_all()
|
|
135
|
+
|
|
136
|
+
total_start_time = time.time()
|
|
137
|
+
|
|
138
|
+
logger.info("Starting test execution... Total tests: %d", self.results["total"])
|
|
139
|
+
logger.info("=" * 50)
|
|
140
|
+
|
|
141
|
+
for i, case in enumerate(self.test_cases, 1):
|
|
142
|
+
logger.info("Running test %d/%d: %s", i, self.results["total"], case.name)
|
|
143
|
+
result = self.run_single_test(case)
|
|
144
|
+
|
|
145
|
+
# Apply xfail status mapping before counting
|
|
146
|
+
self._apply_xfail_status(result, case)
|
|
147
|
+
|
|
148
|
+
# ── Echo expected / description / tags ──
|
|
149
|
+
result["expected"] = case.expected if case.expected else None
|
|
150
|
+
result["description"] = case.description or None
|
|
151
|
+
result["tags"] = case.tags or []
|
|
152
|
+
self._fill_hint_command(result, case.name)
|
|
153
|
+
|
|
154
|
+
self.results["details"].append(result)
|
|
155
|
+
duration = result.get("duration", 0)
|
|
156
|
+
status = result["status"]
|
|
157
|
+
if status == "passed":
|
|
158
|
+
self.results["passed"] += 1
|
|
159
|
+
# Check for baseline updates
|
|
160
|
+
if result.get("baseline_updated"):
|
|
161
|
+
self.results["updated"] += 1
|
|
162
|
+
logger.info("✓ Test passed (baseline updated): %s (%.2fs)", case.name, duration)
|
|
163
|
+
elif result.get("flaky"):
|
|
164
|
+
logger.info("✓ Test passed (flaky, %d attempts): %s (%.2fs)",
|
|
165
|
+
result.get("attempts", 1), case.name, duration)
|
|
166
|
+
else:
|
|
167
|
+
logger.info("✓ Test passed: %s (%.2fs)", case.name, duration)
|
|
168
|
+
elif status == "xfailed":
|
|
169
|
+
self.results["xfailed"] += 1
|
|
170
|
+
attempt_info = f" ({result.get('attempts', 1)} attempts)" if result.get("attempts", 1) > 1 else ""
|
|
171
|
+
logger.info("✓ Test xfailed (expected)%s: %s (%.2fs)", attempt_info, case.name, duration)
|
|
172
|
+
if result.get("message"):
|
|
173
|
+
logger.info(" Reason: %s", result.get("xfail_reason", ""))
|
|
174
|
+
logger.info(" Detail: %s", result["message"])
|
|
175
|
+
elif status == "xpassed":
|
|
176
|
+
self.results["xpassed"] += 1
|
|
177
|
+
self.results["failed"] += 1
|
|
178
|
+
logger.error("✗ Test xpassed (unexpected!): %s (%.2fs)", case.name, duration)
|
|
179
|
+
if result.get("message"):
|
|
180
|
+
logger.error(" Error: %s", result["message"])
|
|
181
|
+
logger.warning(" [XPass] Marked as expected_failure but passed — remove the xfail marker.")
|
|
182
|
+
else:
|
|
183
|
+
self.results["failed"] += 1
|
|
184
|
+
if result.get("flaky"):
|
|
185
|
+
logger.error("✗ Test failed (%d attempts): %s (%.2fs)",
|
|
186
|
+
result.get("attempts", 1), case.name, duration)
|
|
187
|
+
else:
|
|
188
|
+
logger.error("✗ Test failed: %s (%.2fs)", case.name, duration)
|
|
189
|
+
if result["message"]:
|
|
190
|
+
logger.error(" Error: %s", result["message"])
|
|
191
|
+
|
|
192
|
+
total_duration = time.time() - total_start_time
|
|
193
|
+
logger.info("=" * 50)
|
|
194
|
+
logger.info(
|
|
195
|
+
"Test execution completed in %.2fs. "
|
|
196
|
+
"Passed: %d, Failed: %d, XFailed: %d, XPassed: %d",
|
|
197
|
+
total_duration,
|
|
198
|
+
self.results["passed"], self.results["failed"],
|
|
199
|
+
self.results["xfailed"], self.results["xpassed"],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Update history & regression detection
|
|
203
|
+
self._update_history()
|
|
204
|
+
|
|
205
|
+
# Save last-run state for --last-failed
|
|
206
|
+
self._save_last_run()
|
|
207
|
+
|
|
208
|
+
# Exit-code rule: failed + xpassed > 0 → non-zero
|
|
209
|
+
return self.results["failed"] == 0 and self.results["xpassed"] == 0
|
|
210
|
+
finally:
|
|
211
|
+
# 确保teardown总是被执行
|
|
212
|
+
self.setup_manager.teardown_all()
|
|
213
|
+
|
|
214
|
+
def _fill_hint_command(self, result: Dict[str, Any], case_name: str) -> None:
|
|
215
|
+
"""Fill in the concrete CLI command inside ``next_action_hint``.
|
|
216
|
+
|
|
217
|
+
The execution layer attaches the hint with ``command=None`` because it
|
|
218
|
+
does not know the config file path; the runner does.
|
|
219
|
+
"""
|
|
220
|
+
hint = result.get("next_action_hint")
|
|
221
|
+
if not hint or hint.get("command"):
|
|
222
|
+
return
|
|
223
|
+
config = str(self.config_path)
|
|
224
|
+
if hint.get("action") == "update_baseline":
|
|
225
|
+
hint["command"] = (
|
|
226
|
+
f'symtest run "{config}" --update-baseline -t "{case_name}"'
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
hint["command"] = f'symtest run "{config}" -t "{case_name}"'
|
|
230
|
+
|
|
231
|
+
def _apply_xfail_status(self, result: Dict[str, Any], case: "TestCase") -> None:
|
|
232
|
+
"""Apply xfail (expected failure) status mapping to a test result.
|
|
233
|
+
|
|
234
|
+
When ``case.expected_failure`` is True:
|
|
235
|
+
- ``passed`` → ``xpassed`` (unexpected pass; counts as a suite failure)
|
|
236
|
+
- any non-passed status → ``xfailed`` (expected failure; not a failure)
|
|
237
|
+
|
|
238
|
+
The ``xfail_reason`` from the case is attached to the result dict so the
|
|
239
|
+
report can display it.
|
|
240
|
+
"""
|
|
241
|
+
if not getattr(case, "expected_failure", False):
|
|
242
|
+
return
|
|
243
|
+
xfail_reason = getattr(case, "xfail_reason", "") or ""
|
|
244
|
+
result["xfail_reason"] = xfail_reason
|
|
245
|
+
result["xfail_quiet"] = getattr(case, "xfail_quiet", False)
|
|
246
|
+
if result.get("status") == "passed":
|
|
247
|
+
result["status"] = "xpassed"
|
|
248
|
+
else:
|
|
249
|
+
result["status"] = "xfailed"
|
|
250
|
+
|
|
251
|
+
def _save_last_run(self) -> None:
|
|
252
|
+
"""Persist per-case status for ``--last-failed`` support."""
|
|
253
|
+
ws = str(self.workspace) if self.workspace else str(Path.cwd())
|
|
254
|
+
update_last_run(ws, self.results["details"])
|
|
255
|
+
|
|
256
|
+
def _update_history(self) -> None:
|
|
257
|
+
"""Update .symtest history with successful run results and check for regressions."""
|
|
258
|
+
if not self.history_dir:
|
|
259
|
+
return
|
|
260
|
+
history = load_history(self.history_dir)
|
|
261
|
+
|
|
262
|
+
if self.update_history:
|
|
263
|
+
run_names = {r["name"] for r in self.results["details"]}
|
|
264
|
+
cleared = reset_cases(history, run_names)
|
|
265
|
+
if cleared:
|
|
266
|
+
logger.info(
|
|
267
|
+
"History reset: cleared %d case(s) before recording this run",
|
|
268
|
+
cleared,
|
|
269
|
+
)
|
|
270
|
+
self.results["history_reset"] = True
|
|
271
|
+
self.results["history_cleared"] = cleared
|
|
272
|
+
|
|
273
|
+
for result in self.results["details"]:
|
|
274
|
+
# Only record successful cases in history; skip failed ones
|
|
275
|
+
if result["status"] != "passed":
|
|
276
|
+
continue
|
|
277
|
+
duration = result.get("duration", 0)
|
|
278
|
+
# Check regression BEFORE updating (compare against old avg)
|
|
279
|
+
warning = check_regression(history, result["name"], duration, self.regression_threshold)
|
|
280
|
+
if warning:
|
|
281
|
+
logger.warning(warning)
|
|
282
|
+
update_case(history, result["name"], duration)
|
|
283
|
+
save_history(self.history_dir, history)
|
|
284
|
+
|
|
285
|
+
def _run_sequence(self, case: TestCase) -> Dict[str, Any]:
|
|
286
|
+
"""Run a sequence test case with multiple steps (fail-fast)."""
|
|
287
|
+
from .config_loader import execute_sequence
|
|
288
|
+
return execute_sequence(
|
|
289
|
+
case_name=case.name,
|
|
290
|
+
steps=case.steps,
|
|
291
|
+
workspace=str(self.workspace) if self.workspace else None,
|
|
292
|
+
case_expected=case.expected if case.expected else None,
|
|
293
|
+
resume=self.resume,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@abstractmethod
|
|
297
|
+
def run_single_test(self, case: TestCase) -> Dict[str, str]:
|
|
298
|
+
"""Run a single test case and return the result"""
|
|
299
|
+
pass
|