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.
Files changed (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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()
@@ -0,0 +1,9 @@
1
+ """
2
+ Command-line commands for the CLI Testing Framework
3
+ """
4
+
5
+ from . import compare
6
+
7
+ __all__ = [
8
+ 'compare'
9
+ ]