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/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI Test Framework - A powerful command-line testing framework
|
|
3
|
+
|
|
4
|
+
This package provides tools for testing command-line applications and scripts
|
|
5
|
+
with support for parallel execution and advanced file comparison capabilities.
|
|
6
|
+
|
|
7
|
+
Logging
|
|
8
|
+
-------
|
|
9
|
+
All diagnostic and status messages go through Python's standard ``logging``
|
|
10
|
+
module under the ``symtest`` namespace. By default only a
|
|
11
|
+
``NullHandler`` is attached — no output is produced on import. The CLI
|
|
12
|
+
entry point calls ``setup_console_logging()`` to enable stderr output at
|
|
13
|
+
runtime. Library users can enable console logging explicitly::
|
|
14
|
+
|
|
15
|
+
from symtest.logging_config import setup_console_logging
|
|
16
|
+
setup_console_logging(level=logging.DEBUG)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "1.3.0"
|
|
20
|
+
__author__ = "Xiaotong Wang"
|
|
21
|
+
__email__ = "xiaotongwang98@gmail.com"
|
|
22
|
+
|
|
23
|
+
# Import main classes for convenient access
|
|
24
|
+
from .runners.json_runner import JSONRunner
|
|
25
|
+
from .runners.parallel_json_runner import ParallelJSONRunner
|
|
26
|
+
from .runners.yaml_runner import YAMLRunner
|
|
27
|
+
from .core.test_case import TestCase
|
|
28
|
+
from .core.assertions import Assertions
|
|
29
|
+
from .core.setup import BaseSetup, EnvironmentSetup, SetupManager
|
|
30
|
+
from .logging_config import get_logger, setup_console_logging
|
|
31
|
+
from .utils.junit_xml_writer import write_junit_xml
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
'JSONRunner',
|
|
35
|
+
'ParallelJSONRunner',
|
|
36
|
+
'YAMLRunner',
|
|
37
|
+
'TestCase',
|
|
38
|
+
'Assertions',
|
|
39
|
+
'BaseSetup',
|
|
40
|
+
'EnvironmentSetup',
|
|
41
|
+
'SetupManager',
|
|
42
|
+
'get_logger',
|
|
43
|
+
'setup_console_logging',
|
|
44
|
+
'write_junit_xml',
|
|
45
|
+
]
|
symtest/cli.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
CLI Test Framework - Command Line Interface
|
|
6
|
+
|
|
7
|
+
This module provides the main command-line interface for the CLI Testing Framework.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import os
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .logging_config import setup_console_logging
|
|
18
|
+
from .runners import JSONRunner, ParallelJSONRunner, ParallelYAMLRunner, YAMLRunner
|
|
19
|
+
from .utils.report_generator import ReportGenerator
|
|
20
|
+
from .utils.junit_xml_writer import write_junit_xml
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("symtest.cli")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_vars(var_list):
|
|
26
|
+
"""Parse ``['solver=/path', 'model=./m.dat']`` → ``{'solver': '/path', ...}``."""
|
|
27
|
+
variables = {}
|
|
28
|
+
for item in var_list or []:
|
|
29
|
+
if '=' not in item:
|
|
30
|
+
logger.warning("Ignoring invalid --var '%s' (expected KEY=VALUE)", item)
|
|
31
|
+
continue
|
|
32
|
+
key, _, value = item.partition('=')
|
|
33
|
+
variables[key.strip()] = value.strip()
|
|
34
|
+
return variables
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _confirm_baseline_update(args) -> bool:
|
|
38
|
+
"""Require an explicit confirmation before baseline files may be replaced.
|
|
39
|
+
|
|
40
|
+
Interactive CLI users type ``yes``. Automation must pass ``--yes`` so a
|
|
41
|
+
non-interactive process never hangs while waiting for input.
|
|
42
|
+
"""
|
|
43
|
+
if not getattr(args, 'update_baseline', False):
|
|
44
|
+
return True
|
|
45
|
+
if getattr(args, 'yes', False):
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
if not sys.stdin.isatty():
|
|
49
|
+
logger.error(
|
|
50
|
+
"--update-baseline can overwrite reference files. "
|
|
51
|
+
"Re-run with --yes in non-interactive environments."
|
|
52
|
+
)
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
response = input(
|
|
57
|
+
"WARNING: --update-baseline may overwrite reference files when "
|
|
58
|
+
"comparisons fail.\nType 'yes' to continue: "
|
|
59
|
+
)
|
|
60
|
+
except (EOFError, KeyboardInterrupt):
|
|
61
|
+
logger.warning("Baseline update cancelled.")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
if response.strip().lower() != "yes":
|
|
65
|
+
logger.warning("Baseline update cancelled.")
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_parser():
|
|
71
|
+
"""Create and configure the argument parser"""
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
description="Regression testing for command-line applications and scientific workflows",
|
|
74
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
75
|
+
epilog="""
|
|
76
|
+
Examples:
|
|
77
|
+
symtest run test_cases.json
|
|
78
|
+
symtest run test_cases.json --parallel --workers 4
|
|
79
|
+
symtest run test_cases.yaml --workspace /path/to/project
|
|
80
|
+
symtest tui test_cases.json
|
|
81
|
+
symtest validate main_config.json
|
|
82
|
+
symtest compare file1.json file2.json
|
|
83
|
+
symtest compare file1.txt file2.txt --output-format json
|
|
84
|
+
"""
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
88
|
+
|
|
89
|
+
# ---- Run command ----
|
|
90
|
+
run_parser = subparsers.add_parser('run', help='Run test cases from a configuration file')
|
|
91
|
+
run_parser.add_argument('config_file', help='Path to the test configuration file (JSON or YAML)')
|
|
92
|
+
run_parser.add_argument('--workspace', '-w', help='Working directory for test execution')
|
|
93
|
+
run_parser.add_argument('--parallel', '-p', action='store_true', help='Run tests in parallel')
|
|
94
|
+
run_parser.add_argument('--workers', type=int, help='Number of parallel workers (default: CPU count)')
|
|
95
|
+
run_parser.add_argument('--execution-mode', choices=['thread', 'process'], default='thread',
|
|
96
|
+
help='Parallel execution mode (default: thread)')
|
|
97
|
+
run_parser.add_argument('--output-format', choices=['text', 'json', 'html'], default='text',
|
|
98
|
+
help='Output format for test results')
|
|
99
|
+
run_parser.add_argument('--test-case', '-t', action='append', default=None,
|
|
100
|
+
help='Run only specified test case(s) by name (can be used multiple times)')
|
|
101
|
+
run_parser.add_argument('--tag', action='append', default=None,
|
|
102
|
+
help='Run only test cases with matching tag(s) (can be used multiple times)')
|
|
103
|
+
run_parser.add_argument('--history-dir',
|
|
104
|
+
help='Directory for .symtest runtime history (enables smart scheduling & regression detection)')
|
|
105
|
+
run_parser.add_argument('--regression-threshold', type=float, default=1.5,
|
|
106
|
+
help='Warn if a case runs N times slower than historical average (default: 1.5)')
|
|
107
|
+
run_parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose output')
|
|
108
|
+
run_parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
|
109
|
+
run_parser.add_argument('--junit-xml', dest='junit_xml',
|
|
110
|
+
help='Write JUnit XML report to the specified file path')
|
|
111
|
+
run_parser.add_argument('--var', action='append', default=[],
|
|
112
|
+
metavar='KEY=VALUE',
|
|
113
|
+
help='Set a variable for config placeholder substitution, '
|
|
114
|
+
'e.g. --var solver=/path/to/solver '
|
|
115
|
+
'(can be used multiple times)')
|
|
116
|
+
run_parser.add_argument('--last-failed', action='store_true',
|
|
117
|
+
help='Run only test cases that failed in the previous run')
|
|
118
|
+
run_parser.add_argument('--update-baseline', action='store_true',
|
|
119
|
+
help='On comparison failure, overwrite baseline files with actual output')
|
|
120
|
+
run_parser.add_argument('--yes', '-y', action='store_true',
|
|
121
|
+
help='Confirm potentially destructive actions without prompting '
|
|
122
|
+
'(required with --update-baseline in non-interactive environments)')
|
|
123
|
+
run_parser.add_argument('--update-history', action='store_true',
|
|
124
|
+
help='Clear .symtest runtime history for run-involved cases before '
|
|
125
|
+
'recording this run (requires --history-dir)')
|
|
126
|
+
run_parser.add_argument('--resume', action='store_true',
|
|
127
|
+
help='Resume sequence test cases from last failed step '
|
|
128
|
+
'(trusts workspace artifacts are unchanged)')
|
|
129
|
+
run_parser.add_argument('--error-analysis', action='store_true',
|
|
130
|
+
help='Enable streaming error statistics for numerical file comparisons '
|
|
131
|
+
'(CSV/H5): total_numeric_cells, mismatched_cells, '
|
|
132
|
+
'max_abs/rel_error, mean/rms_abs_error')
|
|
133
|
+
run_parser.add_argument('--plugin-dir', action='append', default=None,
|
|
134
|
+
dest='plugin_dirs',
|
|
135
|
+
help='Add a directory for workspace-level comparator plugins '
|
|
136
|
+
'(can be used multiple times). '
|
|
137
|
+
'The workspace/comparators/ directory is always auto-detected.')
|
|
138
|
+
|
|
139
|
+
# ---- TUI command ----
|
|
140
|
+
tui_parser = subparsers.add_parser(
|
|
141
|
+
'tui', help='Launch interactive TUI for managing test cases'
|
|
142
|
+
)
|
|
143
|
+
tui_parser.add_argument(
|
|
144
|
+
'config_file', help='Path to the test configuration file (JSON or YAML)'
|
|
145
|
+
)
|
|
146
|
+
tui_parser.add_argument(
|
|
147
|
+
'--workspace', '-w', help='Working directory'
|
|
148
|
+
)
|
|
149
|
+
tui_parser.add_argument(
|
|
150
|
+
'--update-baseline', action='store_true',
|
|
151
|
+
help='On comparison failure, overwrite baseline files with actual output'
|
|
152
|
+
)
|
|
153
|
+
tui_parser.add_argument(
|
|
154
|
+
'--yes', '-y', action='store_true',
|
|
155
|
+
help='Confirm baseline updates without prompting'
|
|
156
|
+
)
|
|
157
|
+
tui_parser.add_argument(
|
|
158
|
+
'--history-dir',
|
|
159
|
+
help='Directory for .symtest runtime history (enables regression detection per case)'
|
|
160
|
+
)
|
|
161
|
+
tui_parser.add_argument(
|
|
162
|
+
'--update-history', action='store_true',
|
|
163
|
+
help='Clear .symtest runtime history for run-involved cases before '
|
|
164
|
+
'recording this run (requires --history-dir)'
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ---- Validate command ----
|
|
168
|
+
validate_parser = subparsers.add_parser(
|
|
169
|
+
'validate', help='Validate test configuration without running tests'
|
|
170
|
+
)
|
|
171
|
+
validate_parser.add_argument(
|
|
172
|
+
'config_file', help='Path to the test configuration file (JSON or YAML)'
|
|
173
|
+
)
|
|
174
|
+
validate_parser.add_argument(
|
|
175
|
+
'--workspace', '-w', help='Working directory'
|
|
176
|
+
)
|
|
177
|
+
validate_parser.add_argument(
|
|
178
|
+
'--output-format', choices=['text', 'json'], default='text',
|
|
179
|
+
help='Output format for validation results (default: text)'
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ---- Schema command ----
|
|
183
|
+
subparsers.add_parser(
|
|
184
|
+
'schema',
|
|
185
|
+
help='Print the JSON Schema for test configuration files '
|
|
186
|
+
'(machine-readable contract for generating configs)',
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# ---- Compare command ----
|
|
190
|
+
compare_parser = subparsers.add_parser('compare', help='Compare two files')
|
|
191
|
+
compare_parser.add_argument('file1', help='Path to the first file')
|
|
192
|
+
compare_parser.add_argument('file2', help='Path to the second file')
|
|
193
|
+
compare_parser.add_argument('--start-line', type=int, default=1, help='Starting line number (1-based)')
|
|
194
|
+
compare_parser.add_argument('--end-line', type=int, help='Ending line number (1-based)')
|
|
195
|
+
compare_parser.add_argument('--start-column', type=int, default=1, help='Starting column number (1-based)')
|
|
196
|
+
compare_parser.add_argument('--end-column', type=int, help='Ending column number (1-based)')
|
|
197
|
+
compare_parser.add_argument('--file-type', help='Type of the files to compare', default='auto')
|
|
198
|
+
compare_parser.add_argument('--encoding', default='utf-8', help='File encoding for text files')
|
|
199
|
+
compare_parser.add_argument('--chunk-size', type=int, default=8192, help='Chunk size for binary comparison')
|
|
200
|
+
compare_parser.add_argument('--output-format', choices=['text', 'json', 'html'], default='text',
|
|
201
|
+
help='Output format for the comparison result')
|
|
202
|
+
compare_parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose output')
|
|
203
|
+
compare_parser.add_argument('--debug', action='store_true', help='Enable debug mode with detailed logging')
|
|
204
|
+
compare_parser.add_argument('--similarity', action='store_true',
|
|
205
|
+
help='When comparing binary files, compute and show similarity index')
|
|
206
|
+
compare_parser.add_argument('--num-threads', type=int, default=4, help='Number of threads for parallel processing')
|
|
207
|
+
|
|
208
|
+
# CSV comparison options
|
|
209
|
+
csv_group = compare_parser.add_argument_group('CSV comparison options')
|
|
210
|
+
csv_group.add_argument('--csv-rtol', type=float, default=1e-5,
|
|
211
|
+
help='Relative tolerance for numerical comparison in CSV files')
|
|
212
|
+
csv_group.add_argument('--csv-atol', type=float, default=1e-8,
|
|
213
|
+
help='Absolute tolerance for numerical comparison in CSV files')
|
|
214
|
+
csv_group.add_argument('--csv-delimiter', default=',', help='CSV field delimiter (default: comma)')
|
|
215
|
+
csv_group.add_argument('--csv-quotechar', default='"',
|
|
216
|
+
help='Character used for quoting fields in CSV (default: double quote)')
|
|
217
|
+
csv_group.add_argument('--csv-data-filter', type=str,
|
|
218
|
+
help='Data filter to apply before comparison. '
|
|
219
|
+
"Example: '>1e-6', '<=0.01', 'abs>1e-9'. "
|
|
220
|
+
'Filters out numeric cells that do not meet the criteria '
|
|
221
|
+
'from BOTH files before comparison.')
|
|
222
|
+
|
|
223
|
+
# JSON comparison options
|
|
224
|
+
json_group = compare_parser.add_argument_group('JSON comparison options')
|
|
225
|
+
json_group.add_argument('--json-compare-mode', choices=['exact', 'key-based'], default='exact',
|
|
226
|
+
help='JSON comparison mode: exact (default) or key-based')
|
|
227
|
+
json_group.add_argument('--json-key-field', help='Key field(s) to use for key-based JSON comparison')
|
|
228
|
+
|
|
229
|
+
# H5 comparison options
|
|
230
|
+
h5_group = compare_parser.add_argument_group('HDF5 comparison options')
|
|
231
|
+
h5_group.add_argument('--h5-table', help='Comma-separated list of table names to compare in HDF5 files')
|
|
232
|
+
h5_group.add_argument('--h5-table-regex',
|
|
233
|
+
help='Comma-separated list of regular expression patterns to match table names in HDF5 files')
|
|
234
|
+
h5_group.add_argument('--h5-structure-only', action='store_true',
|
|
235
|
+
help='Only compare HDF5 file structure without comparing content')
|
|
236
|
+
h5_group.add_argument('--h5-show-content-diff', action='store_true',
|
|
237
|
+
help='Show detailed content differences when content differs')
|
|
238
|
+
h5_group.add_argument('--h5-rtol', type=float, default=1e-5,
|
|
239
|
+
help='Relative tolerance for numerical comparison in HDF5 files')
|
|
240
|
+
h5_group.add_argument('--h5-atol', type=float, default=1e-8,
|
|
241
|
+
help='Absolute tolerance for numerical comparison in HDF5 files')
|
|
242
|
+
h5_group.add_argument('--h5-data-filter', type=str,
|
|
243
|
+
help='Data filter to apply before comparison')
|
|
244
|
+
h5_group.add_argument('--h5-no-expand-path', dest='h5_expand_path', action='store_false',
|
|
245
|
+
help='Do not expand HDF5 group paths to compare all sub-items')
|
|
246
|
+
|
|
247
|
+
return parser
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_tests(args):
|
|
251
|
+
"""Run tests based on command line arguments"""
|
|
252
|
+
# Resolve config_file relative to workspace if specified, otherwise cwd.
|
|
253
|
+
# This matches BaseRunner's resolution (workspace / config_file).
|
|
254
|
+
workspace_path = Path(args.workspace) if args.workspace else Path.cwd()
|
|
255
|
+
config_file = (workspace_path / args.config_file).resolve()
|
|
256
|
+
|
|
257
|
+
if not config_file.exists():
|
|
258
|
+
logger.error("Configuration file not found: %s", config_file)
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
if not _confirm_baseline_update(args):
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
# Determine file type
|
|
265
|
+
file_ext = config_file.suffix.lower()
|
|
266
|
+
|
|
267
|
+
# Use getattr for backward compatibility with external callers that
|
|
268
|
+
# construct Namespace objects without the newer arguments.
|
|
269
|
+
history_dir = getattr(args, 'history_dir', None)
|
|
270
|
+
regression_threshold = getattr(args, 'regression_threshold', 1.5)
|
|
271
|
+
var_list = getattr(args, 'var', [])
|
|
272
|
+
variables = _parse_vars(var_list)
|
|
273
|
+
update_baseline = getattr(args, 'update_baseline', False)
|
|
274
|
+
update_history = getattr(args, 'update_history', False)
|
|
275
|
+
error_analysis = getattr(args, 'error_analysis', False)
|
|
276
|
+
last_failed = getattr(args, 'last_failed', False)
|
|
277
|
+
resume = getattr(args, 'resume', False)
|
|
278
|
+
plugin_dirs = getattr(args, 'plugin_dirs', None)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
if args.parallel:
|
|
282
|
+
# Format-aware parallel runner selection
|
|
283
|
+
if file_ext in ['.json']:
|
|
284
|
+
runner = ParallelJSONRunner(
|
|
285
|
+
config_file=str(config_file),
|
|
286
|
+
workspace=args.workspace,
|
|
287
|
+
max_workers=args.workers,
|
|
288
|
+
execution_mode=args.execution_mode,
|
|
289
|
+
test_case_filter=args.test_case,
|
|
290
|
+
test_case_tag_filter=args.tag,
|
|
291
|
+
history_dir=history_dir,
|
|
292
|
+
regression_threshold=regression_threshold,
|
|
293
|
+
variables=variables,
|
|
294
|
+
update_baseline=update_baseline,
|
|
295
|
+
update_history=update_history,
|
|
296
|
+
error_analysis=error_analysis,
|
|
297
|
+
last_failed=last_failed,
|
|
298
|
+
resume=resume,
|
|
299
|
+
plugin_dirs=plugin_dirs,
|
|
300
|
+
)
|
|
301
|
+
elif file_ext in ['.yaml', '.yml']:
|
|
302
|
+
runner = ParallelYAMLRunner(
|
|
303
|
+
config_file=str(config_file),
|
|
304
|
+
workspace=args.workspace,
|
|
305
|
+
max_workers=args.workers,
|
|
306
|
+
execution_mode=args.execution_mode,
|
|
307
|
+
test_case_filter=args.test_case,
|
|
308
|
+
test_case_tag_filter=args.tag,
|
|
309
|
+
history_dir=history_dir,
|
|
310
|
+
regression_threshold=regression_threshold,
|
|
311
|
+
variables=variables,
|
|
312
|
+
update_baseline=update_baseline,
|
|
313
|
+
update_history=update_history,
|
|
314
|
+
error_analysis=error_analysis,
|
|
315
|
+
last_failed=last_failed,
|
|
316
|
+
resume=resume,
|
|
317
|
+
plugin_dirs=plugin_dirs,
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
logger.error("Unsupported configuration file format for parallel mode: %s", file_ext)
|
|
321
|
+
return False
|
|
322
|
+
else:
|
|
323
|
+
# Use appropriate single-threaded runner
|
|
324
|
+
if file_ext in ['.json']:
|
|
325
|
+
runner = JSONRunner(
|
|
326
|
+
config_file=str(config_file),
|
|
327
|
+
workspace=args.workspace,
|
|
328
|
+
test_case_filter=args.test_case,
|
|
329
|
+
test_case_tag_filter=args.tag,
|
|
330
|
+
history_dir=history_dir,
|
|
331
|
+
regression_threshold=regression_threshold,
|
|
332
|
+
variables=variables,
|
|
333
|
+
update_baseline=update_baseline,
|
|
334
|
+
update_history=update_history,
|
|
335
|
+
error_analysis=error_analysis,
|
|
336
|
+
last_failed=last_failed,
|
|
337
|
+
resume=resume,
|
|
338
|
+
plugin_dirs=plugin_dirs,
|
|
339
|
+
)
|
|
340
|
+
elif file_ext in ['.yaml', '.yml']:
|
|
341
|
+
runner = YAMLRunner(
|
|
342
|
+
config_file=str(config_file),
|
|
343
|
+
workspace=args.workspace,
|
|
344
|
+
test_case_filter=args.test_case,
|
|
345
|
+
test_case_tag_filter=args.tag,
|
|
346
|
+
history_dir=history_dir,
|
|
347
|
+
regression_threshold=regression_threshold,
|
|
348
|
+
variables=variables,
|
|
349
|
+
update_baseline=update_baseline,
|
|
350
|
+
update_history=update_history,
|
|
351
|
+
error_analysis=error_analysis,
|
|
352
|
+
last_failed=last_failed,
|
|
353
|
+
resume=resume,
|
|
354
|
+
plugin_dirs=plugin_dirs,
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
357
|
+
logger.error("Unsupported configuration file format: %s", file_ext)
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
# Run tests
|
|
361
|
+
logger.info("Running tests from: %s", config_file)
|
|
362
|
+
if args.parallel:
|
|
363
|
+
logger.info("Parallel mode: %s, workers: %s", args.execution_mode, args.workers or "auto")
|
|
364
|
+
|
|
365
|
+
success = runner.run_tests()
|
|
366
|
+
|
|
367
|
+
# Output results using ReportGenerator and honor --output-format
|
|
368
|
+
if hasattr(runner, 'results'):
|
|
369
|
+
results = runner.results
|
|
370
|
+
output_format = getattr(args, 'output_format', 'text')
|
|
371
|
+
|
|
372
|
+
if output_format == 'json':
|
|
373
|
+
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
374
|
+
elif output_format == 'html':
|
|
375
|
+
report_gen = ReportGenerator(results, '')
|
|
376
|
+
text_report = report_gen.generate_report()
|
|
377
|
+
html = _format_results_html(results, text_report)
|
|
378
|
+
print(html)
|
|
379
|
+
else:
|
|
380
|
+
report_gen = ReportGenerator(results, '')
|
|
381
|
+
report_gen.print_report()
|
|
382
|
+
|
|
383
|
+
# --- JUnit XML output (supplementary, works alongside any --output-format) ---
|
|
384
|
+
junit_xml_path = getattr(args, 'junit_xml', None)
|
|
385
|
+
if junit_xml_path and hasattr(runner, 'results'):
|
|
386
|
+
suite_name = config_file.stem
|
|
387
|
+
write_junit_xml(runner.results, junit_xml_path, suite_name=suite_name)
|
|
388
|
+
logger.info("JUnit XML report written to: %s", junit_xml_path)
|
|
389
|
+
|
|
390
|
+
return success
|
|
391
|
+
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.error("Error running tests: %s", e)
|
|
394
|
+
if args.debug:
|
|
395
|
+
import traceback
|
|
396
|
+
traceback.print_exc()
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _format_results_html(results, text_report):
|
|
401
|
+
"""Format test results as a basic HTML page."""
|
|
402
|
+
escaped_report = text_report.replace('&', '&').replace('<', '<').replace('>', '>')
|
|
403
|
+
pass_pct = (results['passed'] / max(results['total'], 1)) * 100
|
|
404
|
+
xfailed = results.get('xfailed', 0)
|
|
405
|
+
xpassed = results.get('xpassed', 0)
|
|
406
|
+
extras = ""
|
|
407
|
+
if xfailed:
|
|
408
|
+
extras += f" | XFailed: <span class=\"xfailed\">{xfailed}</span>"
|
|
409
|
+
if xpassed:
|
|
410
|
+
extras += f" | XPassed: <span class=\"xpassed\">{xpassed} (unexpected!)</span>"
|
|
411
|
+
return f"""<!DOCTYPE html>
|
|
412
|
+
<html lang="en">
|
|
413
|
+
<head>
|
|
414
|
+
<meta charset="utf-8">
|
|
415
|
+
<title>Test Results</title>
|
|
416
|
+
<style>
|
|
417
|
+
body {{ font-family: sans-serif; margin: 2em; }}
|
|
418
|
+
.summary {{ margin-bottom: 1em; }}
|
|
419
|
+
.passed {{ color: green; }}
|
|
420
|
+
.failed {{ color: red; }}
|
|
421
|
+
.xfailed {{ color: orange; }}
|
|
422
|
+
.xpassed {{ color: red; font-weight: bold; }}
|
|
423
|
+
pre {{ background: #f5f5f5; padding: 1em; border-radius: 4px; }}
|
|
424
|
+
</style>
|
|
425
|
+
</head>
|
|
426
|
+
<body>
|
|
427
|
+
<h1>CLI Test Results</h1>
|
|
428
|
+
<div class="summary">
|
|
429
|
+
<p>Total: {results['total']} | Passed: <span class="passed">{results['passed']}</span> | Failed: <span class="failed">{results['failed']}</span>{extras}</p>
|
|
430
|
+
<p>Pass rate: {pass_pct:.1f}%</p>
|
|
431
|
+
</div>
|
|
432
|
+
<pre>{escaped_report}</pre>
|
|
433
|
+
</body>
|
|
434
|
+
</html>"""
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def run_compare(args):
|
|
438
|
+
"""Execute file comparison via the compare subcommand."""
|
|
439
|
+
from .commands.compare import run_comparison
|
|
440
|
+
exit_code = run_comparison(args)
|
|
441
|
+
return bool(exit_code == 0)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def run_tui(args):
|
|
445
|
+
"""Launch the TUI manager."""
|
|
446
|
+
from .tui.app import run_tui as _run_tui
|
|
447
|
+
|
|
448
|
+
if not _confirm_baseline_update(args):
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
update_baseline = getattr(args, 'update_baseline', False)
|
|
452
|
+
history_dir = getattr(args, 'history_dir', None)
|
|
453
|
+
update_history = getattr(args, 'update_history', False)
|
|
454
|
+
_run_tui(args.config_file, args.workspace,
|
|
455
|
+
update_baseline=update_baseline,
|
|
456
|
+
history_dir=history_dir,
|
|
457
|
+
update_history=update_history)
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def run_validate(args):
|
|
462
|
+
"""Validate test configuration without running tests."""
|
|
463
|
+
from .config.config_io import validate_config
|
|
464
|
+
|
|
465
|
+
workspace_path = Path(args.workspace) if args.workspace else Path.cwd()
|
|
466
|
+
config_file = (workspace_path / args.config_file).resolve()
|
|
467
|
+
|
|
468
|
+
if not config_file.exists():
|
|
469
|
+
logger.error("Configuration file not found: %s", config_file)
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
logger.info("Validating configuration: %s", config_file)
|
|
473
|
+
report = validate_config(config_file, args.workspace)
|
|
474
|
+
|
|
475
|
+
output_format = getattr(args, 'output_format', 'text')
|
|
476
|
+
|
|
477
|
+
if output_format == 'json':
|
|
478
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
479
|
+
else:
|
|
480
|
+
# Print summary
|
|
481
|
+
summary = report["summary"]
|
|
482
|
+
print(f"\n [OK] Loaded {summary['cases']} test cases from {summary['files']} file(s)\n")
|
|
483
|
+
|
|
484
|
+
if report["errors"]:
|
|
485
|
+
for err in report["errors"]:
|
|
486
|
+
print(f" [FAIL] {err}")
|
|
487
|
+
print()
|
|
488
|
+
else:
|
|
489
|
+
print(" [OK] All required fields present")
|
|
490
|
+
print(" [OK] No circular imports detected")
|
|
491
|
+
|
|
492
|
+
if report.get("warnings"):
|
|
493
|
+
print()
|
|
494
|
+
for warn in report["warnings"]:
|
|
495
|
+
print(f" [WARN] {warn}")
|
|
496
|
+
print()
|
|
497
|
+
|
|
498
|
+
if summary.get("files_loaded"):
|
|
499
|
+
print("\n Files:")
|
|
500
|
+
for f in summary["files_loaded"]:
|
|
501
|
+
print(f" - {f}")
|
|
502
|
+
print()
|
|
503
|
+
|
|
504
|
+
return report["valid"]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def run_schema() -> None:
|
|
508
|
+
"""Print the JSON Schema for test configuration files."""
|
|
509
|
+
from .config.config_schema import get_config_schema
|
|
510
|
+
|
|
511
|
+
print(json.dumps(get_config_schema(), indent=2, ensure_ascii=False))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def main():
|
|
515
|
+
"""Main entry point for the CLI"""
|
|
516
|
+
parser = create_parser()
|
|
517
|
+
args = parser.parse_args()
|
|
518
|
+
|
|
519
|
+
# Activate console logging (stderr) and honour verbosity flags.
|
|
520
|
+
level = logging.DEBUG if (
|
|
521
|
+
getattr(args, 'debug', False) or getattr(args, 'verbose', False)
|
|
522
|
+
) else logging.INFO
|
|
523
|
+
setup_console_logging(level=level)
|
|
524
|
+
|
|
525
|
+
if args.command == 'run':
|
|
526
|
+
success = run_tests(args)
|
|
527
|
+
# 0 = all passed, 1 = test failures, 2 = config/framework errors
|
|
528
|
+
sys.exit(0 if success else 1)
|
|
529
|
+
elif args.command == 'tui':
|
|
530
|
+
success = run_tui(args)
|
|
531
|
+
if success is False:
|
|
532
|
+
sys.exit(1)
|
|
533
|
+
elif args.command == 'validate':
|
|
534
|
+
success = run_validate(args)
|
|
535
|
+
# 0 = valid, 1 = validation errors found
|
|
536
|
+
sys.exit(0 if success else 1)
|
|
537
|
+
elif args.command == 'schema':
|
|
538
|
+
run_schema()
|
|
539
|
+
sys.exit(0)
|
|
540
|
+
elif args.command == 'compare':
|
|
541
|
+
success = run_compare(args)
|
|
542
|
+
sys.exit(0 if success else 1)
|
|
543
|
+
else:
|
|
544
|
+
parser.print_help()
|
|
545
|
+
sys.exit(1)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == '__main__':
|
|
549
|
+
main()
|