gd-tools-cli 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,223 @@
1
+ """Lint runner module for gd-tools.
2
+
3
+ Wraps ``gdlint`` (via the gdtoolkit Python API) with config-driven
4
+ excludes and clean, formatted output. Discovers ``.gd`` files,
5
+ invokes gdlint programmatically, collects issues into structured
6
+ dataclasses, and renders results as either a rich terminal table
7
+ or JSON.
8
+ """
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from gdtoolkit.linter import lint_code
17
+ from lark.exceptions import LarkError
18
+
19
+ from gd_tools.config import GdToolsConfig
20
+ from gd_tools.file_discovery import discover_gd_files
21
+
22
+
23
+ @dataclass
24
+ class LintIssue:
25
+ """A single lint issue found in a GDScript file.
26
+
27
+ Attributes:
28
+ file: Path to the file containing the issue.
29
+ line: Line number (1-based) where the issue occurs.
30
+ column: Column number (1-based) where the issue occurs.
31
+ rule: The lint rule name (e.g. ``"function-name"``).
32
+ message: Human-readable description of the issue.
33
+ severity: Either ``"error"`` or ``"warning"``.
34
+ """
35
+
36
+ file: str
37
+ line: int
38
+ column: int
39
+ rule: str
40
+ message: str
41
+ severity: str
42
+
43
+
44
+ @dataclass
45
+ class LintResult:
46
+ """Aggregated lint results for a project.
47
+
48
+ Attributes:
49
+ files_checked: Number of ``.gd`` files that were linted.
50
+ errors: List of lint issues with severity ``"error"``.
51
+ warnings: List of lint issues with severity ``"warning"``.
52
+ """
53
+
54
+ files_checked: int
55
+ errors: list[LintIssue] = field(default_factory=list)
56
+ warnings: list[LintIssue] = field(default_factory=list)
57
+
58
+
59
+ def run_lint(
60
+ config: GdToolsConfig, path: str = ".", report_format: str = "text"
61
+ ) -> LintResult:
62
+ """Run gdlint on ``path``, respecting config excludes.
63
+
64
+ Discovers ``.gd`` files via :func:`discover_gd_files`, reads each
65
+ file, and invokes ``gdtoolkit.linter.lint_code`` to check for
66
+ issues. All gdlint problems are treated as errors (gdlint does
67
+ not distinguish severities).
68
+
69
+ Args:
70
+ config: Project configuration with lint excludes.
71
+ path: Root directory to lint.
72
+ report_format: Output format hint (unused here; formatting
73
+ is handled by :func:`format_lint_text` /
74
+ :func:`format_lint_json`).
75
+
76
+ Returns:
77
+ :class:`LintResult` with file count and issue lists.
78
+ """
79
+ excludes = config.lint.exclude
80
+ gd_files = discover_gd_files(path, excludes)
81
+
82
+ errors: list[LintIssue] = []
83
+ warnings: list[LintIssue] = []
84
+
85
+ for file_path in gd_files:
86
+ with open(file_path, "r", encoding="utf-8") as f:
87
+ code = f.read()
88
+ try:
89
+ problems = lint_code(code)
90
+ except LarkError as e:
91
+ # Parse/syntax error from Lark — report and continue linting
92
+ errors.append(
93
+ LintIssue(
94
+ file=file_path,
95
+ line=getattr(e, "line", 0),
96
+ column=getattr(e, "column", 0),
97
+ rule="SYNTAX_ERROR",
98
+ message=str(e),
99
+ severity="error",
100
+ )
101
+ )
102
+ continue
103
+ for problem in problems:
104
+ errors.append(
105
+ LintIssue(
106
+ file=file_path,
107
+ line=problem.line,
108
+ column=problem.column,
109
+ rule=problem.name,
110
+ message=problem.description,
111
+ severity="error",
112
+ )
113
+ )
114
+
115
+ return LintResult(
116
+ files_checked=len(gd_files),
117
+ errors=errors,
118
+ warnings=warnings,
119
+ )
120
+
121
+
122
+ def format_lint_text(result: LintResult) -> str:
123
+ """Format lint results as a rich terminal table.
124
+
125
+ Renders a table with columns File, Line, Column, Rule, Severity,
126
+ and Message. Error rows are styled red, warning rows yellow.
127
+ A summary line is appended below the table.
128
+
129
+ Args:
130
+ result: Lint results to format.
131
+
132
+ Returns:
133
+ Formatted string with table and summary, or an informational
134
+ message when there are no files or no issues.
135
+ """
136
+ if result.files_checked == 0:
137
+ return "No GDScript files found."
138
+
139
+ if not result.errors and not result.warnings:
140
+ return "[OK] No lint issues found."
141
+
142
+ console = Console(force_terminal=True)
143
+ table = Table()
144
+ table.add_column("File")
145
+ table.add_column("Line")
146
+ table.add_column("Column")
147
+ table.add_column("Rule")
148
+ table.add_column("Severity")
149
+ table.add_column("Message")
150
+
151
+ for issue in result.errors:
152
+ table.add_row(
153
+ issue.file,
154
+ str(issue.line),
155
+ str(issue.column),
156
+ issue.rule,
157
+ f"[red]{issue.severity}[/red]",
158
+ issue.message,
159
+ )
160
+
161
+ for issue in result.warnings:
162
+ table.add_row(
163
+ issue.file,
164
+ str(issue.line),
165
+ str(issue.column),
166
+ issue.rule,
167
+ f"[yellow]{issue.severity}[/yellow]",
168
+ issue.message,
169
+ )
170
+
171
+ with console.capture() as capture:
172
+ console.print(table)
173
+
174
+ summary = (
175
+ f"{len(result.errors)} errors, "
176
+ f"{len(result.warnings)} warnings, "
177
+ f"{result.files_checked} files checked"
178
+ )
179
+
180
+ return capture.get() + "\n" + summary
181
+
182
+
183
+ def format_lint_json(result: LintResult) -> str:
184
+ """Format lint results as a JSON string.
185
+
186
+ Serializes the :class:`LintResult` to a JSON object with
187
+ ``files_checked``, ``errors``, and ``warnings`` keys. Each
188
+ issue is a dict with ``file``, ``line``, ``column``, ``rule``,
189
+ ``message``, and ``severity`` fields. Empty lists are ``[]``,
190
+ not ``null``.
191
+
192
+ Args:
193
+ result: Lint results to format.
194
+
195
+ Returns:
196
+ Valid JSON string.
197
+ """
198
+ data = {
199
+ "files_checked": result.files_checked,
200
+ "errors": [
201
+ {
202
+ "file": issue.file,
203
+ "line": issue.line,
204
+ "column": issue.column,
205
+ "rule": issue.rule,
206
+ "message": issue.message,
207
+ "severity": issue.severity,
208
+ }
209
+ for issue in result.errors
210
+ ],
211
+ "warnings": [
212
+ {
213
+ "file": issue.file,
214
+ "line": issue.line,
215
+ "column": issue.column,
216
+ "rule": issue.rule,
217
+ "message": issue.message,
218
+ "severity": issue.severity,
219
+ }
220
+ for issue in result.warnings
221
+ ],
222
+ }
223
+ return json.dumps(data, indent=2)
@@ -0,0 +1,474 @@
1
+ """Test runner module for gd-tools.
2
+
3
+ Orchestrates GUT (Godot Unit Test) via the Godot CLI. Builds the GUT
4
+ command line from config, invokes Godot as a subprocess, captures
5
+ stdout/stderr, parses JUnit XML output into structured results, and
6
+ returns a :class:`TestResult`.
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from junitparser import JUnitXml
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from gd_tools.config import GdToolsConfig, TestConfig, find_project_root
19
+ from gd_tools.errors import (
20
+ GdToolsError,
21
+ GUTNotInstalledError,
22
+ TestFailureError,
23
+ )
24
+ from gd_tools.godot import find_godot, run_godot
25
+
26
+
27
+ @dataclass
28
+ class TestDetail:
29
+ """Details of a single test case.
30
+
31
+ Attributes:
32
+ name: Test method name (e.g., ``"test_addition"``).
33
+ suite: Suite/class name (e.g., ``"TestCalculator"``).
34
+ status: One of ``"pass"``, ``"fail"``, ``"skip"``.
35
+ message: Failure message or empty string on pass/skip.
36
+ duration: Execution time in seconds.
37
+ """
38
+
39
+ __test__ = False
40
+
41
+ name: str
42
+ suite: str
43
+ status: str
44
+ message: str
45
+ duration: float
46
+
47
+
48
+ @dataclass
49
+ class TestResult:
50
+ """Aggregated test results from a GUT run.
51
+
52
+ Attributes:
53
+ total: Total number of tests executed.
54
+ passed: Number of passing tests.
55
+ failed: Number of failing tests.
56
+ skipped: Number of skipped tests.
57
+ duration: Total test execution time in seconds.
58
+ junit_xml_path: Path to the JUnit XML file, or None.
59
+ coverage_data_path: Path to coverage data, or None when
60
+ ``--coverage`` not used.
61
+ stdout: GUT stdout (for debugging/surfacing on failure).
62
+ stderr: GUT stderr (for debugging/surfacing on failure).
63
+ test_details: Per-test breakdown.
64
+ """
65
+
66
+ __test__ = False
67
+
68
+ total: int
69
+ passed: int
70
+ failed: int
71
+ skipped: int
72
+ duration: float
73
+ junit_xml_path: Path | None
74
+ coverage_data_path: Path | None
75
+ stdout: str
76
+ stderr: str
77
+ test_details: list[TestDetail] = field(default_factory=list)
78
+
79
+
80
+ def build_gut_args(
81
+ config: TestConfig,
82
+ project_root: Path,
83
+ suite: str | None = None,
84
+ test_name: str | None = None,
85
+ junit_xml: str | None = None,
86
+ coverage: bool = False,
87
+ ) -> list[str]:
88
+ """Build GUT CLI arguments from test configuration.
89
+
90
+ Constructs the argument list for invoking GUT via ``run_godot()``.
91
+ The ``--path`` flag is added by ``run_godot()`` itself and is NOT
92
+ included here.
93
+
94
+ Args:
95
+ config: Test configuration (test_dirs, prefix, suffix).
96
+ project_root: Path to the Godot project root directory.
97
+ suite: Optional suite/script filter (maps to GUT ``-gselect``).
98
+ test_name: Optional test name filter (maps to GUT
99
+ ``-gunit_test_name``).
100
+ junit_xml: Optional JUnit XML output path. Defaults to
101
+ ``<project_root>/.gd-tools/results.xml``.
102
+ coverage: Whether coverage hooks should be included. When True,
103
+ adds ``-gpre_run_script`` and ``-gpost_run_script`` args.
104
+
105
+ Returns:
106
+ List of CLI arguments for Godot/GUT.
107
+ """
108
+ args: list[str] = [
109
+ "--headless",
110
+ "-s",
111
+ "addons/gut/gut_cmdln.gd",
112
+ "-gexit",
113
+ ]
114
+
115
+ # Test directories (comma-separated per GUT 9.x CLI spec).
116
+ if config.test_dirs:
117
+ dirs = ",".join(f"res://{d}/" for d in config.test_dirs)
118
+ args.append(f"-gdir={dirs}")
119
+
120
+ # Prefix/suffix for test file discovery.
121
+ args.append(f"-gprefix={config.prefix}")
122
+ args.append(f"-gsuffix={config.suffix}")
123
+
124
+ # Suite/script filter (GUT -gselect: matches against the script
125
+ # filename, not the full res:// path. Strip any res:// prefix
126
+ # and directory components so -gselect receives just the filename.
127
+ if suite:
128
+ select_name = suite
129
+ if select_name.startswith("res://"):
130
+ select_name = select_name[len("res://") :]
131
+ select_name = Path(select_name).name
132
+ args.append(f"-gselect={select_name}")
133
+
134
+ # Test name filter (GUT -gunit_test_name: tests containing the
135
+ # specified text will be run, others skipped).
136
+ if test_name:
137
+ args.append(f"-gunit_test_name={test_name}")
138
+
139
+ # JUnit XML output path (must be absolute — GUT may treat
140
+ # relative paths as user://-relative).
141
+ if junit_xml:
142
+ xml_path = Path(junit_xml).resolve()
143
+ else:
144
+ xml_path = (project_root / ".gd-tools" / "results.xml").resolve()
145
+ args.append(f"-gjunit_xml_file={xml_path}")
146
+
147
+ # Coverage hooks (pre/post run scripts for hybrid coverage system).
148
+ if coverage:
149
+ args.append(
150
+ "-gpre_run_script=res://addons/gd-tools-coverage/pre_run_hook.gd"
151
+ )
152
+ args.append(
153
+ "-gpost_run_script=res://addons/gd-tools-coverage/post_run_hook.gd"
154
+ )
155
+
156
+ return args
157
+
158
+
159
+ def check_gut_installed(project_root: Path) -> None:
160
+ """Verify that GUT is installed in the project.
161
+
162
+ Checks for the existence of ``addons/gut/gut_cmdln.gd`` relative to
163
+ the project root. This runs before any subprocess invocation for
164
+ fast, clear failure feedback.
165
+
166
+ Args:
167
+ project_root: Path to the Godot project root directory.
168
+
169
+ Raises:
170
+ GUTNotInstalledError: If GUT is not installed (exit code 2).
171
+ """
172
+ gut_script = project_root / "addons" / "gut" / "gut_cmdln.gd"
173
+ if not gut_script.exists():
174
+ raise GUTNotInstalledError(
175
+ "GUT is not installed. Run `gd-tools init` to install it."
176
+ )
177
+
178
+
179
+ def parse_junit_xml(
180
+ path: Path,
181
+ ) -> tuple[int, int, int, int, float, list[TestDetail]]:
182
+ """Parse a JUnit XML file into structured test results.
183
+
184
+ Uses ``junitparser`` to read a JUnit-format XML file (produced by
185
+ GUT's ``-gjunit_xml_file`` flag) and extract aggregate totals plus
186
+ per-test details.
187
+
188
+ Args:
189
+ path: Path to the JUnit XML file.
190
+
191
+ Returns:
192
+ A tuple of ``(total, passed, failed, skipped, duration,
193
+ test_details)`` where ``duration`` is the summed test-case time
194
+ in seconds and ``test_details`` is a list of
195
+ :class:`TestDetail` objects.
196
+
197
+ Raises:
198
+ GdToolsError: If the file is missing, empty, or contains
199
+ malformed XML (exit code 2).
200
+ """
201
+ if not path.exists():
202
+ raise GdToolsError(f"JUnit XML file not found: {path}")
203
+
204
+ try:
205
+ xml = JUnitXml.fromfile(str(path))
206
+ except Exception as exc:
207
+ raise GdToolsError(f"Failed to parse JUnit XML file: {path}") from exc
208
+
209
+ total = 0
210
+ passed = 0
211
+ failed = 0
212
+ skipped = 0
213
+ duration = 0.0
214
+ test_details: list[TestDetail] = []
215
+
216
+ for suite in xml:
217
+ suite_name = suite.name or ""
218
+ for tc in suite:
219
+ total += 1
220
+ tc_time = tc.time if tc.time is not None else 0.0
221
+ duration += tc_time
222
+
223
+ results = tc.result
224
+ if tc.is_failure or tc.is_error:
225
+ status = "fail"
226
+ failed += 1
227
+ elif tc.is_skipped:
228
+ status = "skip"
229
+ skipped += 1
230
+ else:
231
+ status = "pass"
232
+ passed += 1
233
+
234
+ message = ""
235
+ if results and results[0].message:
236
+ message = str(results[0].message)
237
+
238
+ detail = TestDetail(
239
+ name=tc.name or "",
240
+ suite=tc.classname or suite_name,
241
+ status=status,
242
+ message=message,
243
+ duration=tc_time,
244
+ )
245
+ test_details.append(detail)
246
+
247
+ return (total, passed, failed, skipped, duration, test_details)
248
+
249
+
250
+ def format_test_results(
251
+ result: TestResult, console: Console | None = None
252
+ ) -> None:
253
+ """Print a Rich table summarizing test results.
254
+
255
+ Always prints a table with total, passed, failed, skipped, and
256
+ duration. When tests fail, also prints GUT's stdout and stderr
257
+ for debugging context (truncated to 5000 characters if longer).
258
+
259
+ Args:
260
+ result: The :class:`TestResult` to format and print.
261
+ console: Optional :class:`rich.console.Console` for output.
262
+ When ``None``, a default ``Console()`` is created which
263
+ auto-detects terminal capabilities (no ANSI codes when
264
+ piped). Tests may pass ``Console(force_terminal=True)``
265
+ to force ANSI output for color assertions.
266
+ """
267
+ if console is None:
268
+ console = Console()
269
+ table = Table(title="Test Results")
270
+ table.add_column("Total", justify="right")
271
+ table.add_column("Passed", justify="right", style="green")
272
+ table.add_column("Failed", justify="right", style="red")
273
+ table.add_column("Skipped", justify="right", style="yellow")
274
+ table.add_column("Duration", justify="right")
275
+ table.add_row(
276
+ str(result.total),
277
+ str(result.passed),
278
+ str(result.failed),
279
+ str(result.skipped),
280
+ f"{result.duration:.2f}s",
281
+ )
282
+ console.print(table)
283
+
284
+ # On failure, surface GUT stdout/stderr for debugging.
285
+ if result.failed > 0:
286
+ if result.stdout:
287
+ console.print("\n--- GUT stdout ---")
288
+ stdout_text = result.stdout
289
+ if len(stdout_text) > 5000:
290
+ stdout_text = stdout_text[:5000] + "\n... (truncated)"
291
+ console.print(stdout_text, markup=False)
292
+ if result.stderr:
293
+ console.print("\n--- GUT stderr ---")
294
+ stderr_text = result.stderr
295
+ if len(stderr_text) > 5000:
296
+ stderr_text = stderr_text[:5000] + "\n... (truncated)"
297
+ console.print(stderr_text, markup=False)
298
+
299
+
300
+ def run_tests(
301
+ config: GdToolsConfig,
302
+ coverage: bool = False,
303
+ min_percent: float | None = None,
304
+ suite: str | None = None,
305
+ test_name: str | None = None,
306
+ junit_xml: str | None = None,
307
+ no_exit_code: bool = False,
308
+ timeout: int | None = 300,
309
+ ) -> TestResult:
310
+ """Run GUT tests via the Godot CLI.
311
+
312
+ Orchestrates the full test lifecycle: checks GUT is installed,
313
+ finds the Godot binary, runs a headless import to register GUT
314
+ class names, builds GUT args, invokes Godot as a subprocess,
315
+ parses the JUnit XML output, and returns a :class:`TestResult`.
316
+
317
+ Args:
318
+ config: Project configuration (godot, test, coverage sections).
319
+ coverage: If True, set coverage env vars and add hook script
320
+ args. Hook args are added in ``build_gut_args``.
321
+ min_percent: Coverage threshold. Stored but not enforced
322
+ (enforcement deferred to Phase 3).
323
+ suite: Optional suite/script filter (GUT ``-gselect``).
324
+ test_name: Optional test name filter (GUT
325
+ ``-gunit_test_name``).
326
+ junit_xml: Optional JUnit XML output path. Defaults to
327
+ ``<project_root>/.gd-tools/results.xml``.
328
+ no_exit_code: If True, always return normally even when tests
329
+ fail (for CI pipelines). If False, raises
330
+ :class:`TestFailureError` on test failures.
331
+ timeout: Optional timeout in seconds for the Godot subprocess.
332
+ Defaults to 300 (5 minutes). Pass ``None`` to disable.
333
+
334
+ Returns:
335
+ :class:`TestResult` with totals, per-test details, and
336
+ stdout/stderr from the subprocess.
337
+
338
+ Raises:
339
+ GUTNotInstalledError: If GUT is not installed (exit code 2).
340
+ GodotNotFoundError: If the Godot binary cannot be found.
341
+ GdToolsError: If the subprocess times out, Godot exits with a
342
+ crash code (>1), or JUnit XML is missing/malformed
343
+ (exit code 2).
344
+ TestFailureError: If tests fail and ``no_exit_code`` is False
345
+ (exit code 1).
346
+ """
347
+ # min_percent is accepted for API compatibility; enforcement deferred to Phase 3.
348
+
349
+ project_root = find_project_root()
350
+
351
+ # Check GUT is installed before any subprocess invocation.
352
+ check_gut_installed(project_root)
353
+
354
+ # Find the Godot binary.
355
+ godot_info = find_godot(config.godot)
356
+
357
+ # Run headless import to register GUT class names. Required for
358
+ # fresh projects without a .godot/ cache. Non-zero exit codes are
359
+ # ignored — Godot may emit warnings during import.
360
+ try:
361
+ run_godot(
362
+ godot_info.path,
363
+ project_root,
364
+ ["--headless", "--import"],
365
+ timeout=timeout,
366
+ )
367
+ except subprocess.TimeoutExpired:
368
+ raise GdToolsError(f"Godot import timed out after {timeout}s")
369
+
370
+ # Ensure .gd-tools/ directory exists for JUnit XML output.
371
+ results_dir = project_root / ".gd-tools"
372
+ results_dir.mkdir(exist_ok=True)
373
+
374
+ # Build GUT CLI arguments.
375
+ args = build_gut_args(
376
+ config.test,
377
+ project_root,
378
+ suite=suite,
379
+ test_name=test_name,
380
+ junit_xml=junit_xml,
381
+ coverage=coverage,
382
+ )
383
+
384
+ # Set up environment variables for coverage.
385
+ env: dict[str, str] | None = None
386
+ if coverage:
387
+ coverage_dir = project_root / config.coverage.output_dir
388
+ coverage_dir.mkdir(parents=True, exist_ok=True)
389
+ plan_path = (coverage_dir / "plan.json").resolve()
390
+ output_path = (coverage_dir / "coverage.json").resolve()
391
+ env = {
392
+ "GD_TOOLS_COVERAGE_ACTIVE": "1",
393
+ "GD_TOOLS_COVERAGE_PLAN": os.environ.get(
394
+ "GD_TOOLS_COVERAGE_PLAN", str(plan_path)
395
+ ),
396
+ "GD_TOOLS_COVERAGE_OUTPUT": os.environ.get(
397
+ "GD_TOOLS_COVERAGE_OUTPUT", str(output_path)
398
+ ),
399
+ }
400
+
401
+ # Run Godot with GUT.
402
+ try:
403
+ result = run_godot(
404
+ godot_info.path,
405
+ project_root,
406
+ args,
407
+ env=env,
408
+ timeout=timeout,
409
+ )
410
+ except subprocess.TimeoutExpired:
411
+ raise GdToolsError(f"Godot/GUT timed out after {timeout}s")
412
+
413
+ # Handle crash exit codes (>1). Exit code 1 means tests ran but
414
+ # some failed — proceed to parse JUnit XML for details.
415
+ if result.returncode > 1:
416
+ raise GdToolsError(
417
+ f"Godot exited with code {result.returncode}: "
418
+ f"{result.stderr.strip()}"
419
+ )
420
+
421
+ # Determine the JUnit XML path.
422
+ if junit_xml:
423
+ junit_path = Path(junit_xml).resolve()
424
+ else:
425
+ junit_path = (project_root / ".gd-tools" / "results.xml").resolve()
426
+
427
+ # Parse JUnit XML output.
428
+ try:
429
+ total, passed, failed, skipped, duration, test_details = (
430
+ parse_junit_xml(junit_path)
431
+ )
432
+ except GdToolsError:
433
+ if not junit_path.exists():
434
+ # JUnit XML wasn't created — GUT likely crashed before
435
+ # completing. Include Godot output for diagnostics.
436
+ stdout_tail = result.stdout[-2000:] if result.stdout else ""
437
+ stderr_full = result.stderr if result.stderr else ""
438
+ raise GdToolsError(
439
+ f"JUnit XML file not found: {junit_path}\n"
440
+ f"Godot exit code: {result.returncode}\n"
441
+ f"Godot stdout (last 2000 chars):\n{stdout_tail}\n"
442
+ f"Godot stderr:\n{stderr_full}"
443
+ ) from None
444
+ raise
445
+
446
+ # Determine coverage data path.
447
+ coverage_data_path: Path | None = None
448
+ if coverage:
449
+ coverage_data_path = (
450
+ project_root / config.coverage.output_dir / "coverage.json"
451
+ )
452
+
453
+ # Build TestResult from parsed data and subprocess output.
454
+ test_result = TestResult(
455
+ total=total,
456
+ passed=passed,
457
+ failed=failed,
458
+ skipped=skipped,
459
+ duration=duration,
460
+ junit_xml_path=junit_path,
461
+ coverage_data_path=coverage_data_path,
462
+ stdout=result.stdout,
463
+ stderr=result.stderr,
464
+ test_details=test_details,
465
+ )
466
+
467
+ # Print Rich summary table (always, on every run).
468
+ format_test_results(test_result)
469
+
470
+ # Raise TestFailureError if tests failed and no_exit_code is False.
471
+ if failed > 0 and not no_exit_code:
472
+ raise TestFailureError(f"{failed} test(s) failed")
473
+
474
+ return test_result