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.
- gd_tools/__init__.py +3 -0
- gd_tools/__main__.py +31 -0
- gd_tools/addons/__init__.py +0 -0
- gd_tools/addons/gd-tools-coverage/coverage.gd +43 -0
- gd_tools/addons/gd-tools-coverage/post_run_hook.gd +113 -0
- gd_tools/addons/gd-tools-coverage/pre_run_hook.gd +229 -0
- gd_tools/cli.py +329 -0
- gd_tools/config.py +289 -0
- gd_tools/coverage/__init__.py +23 -0
- gd_tools/coverage/cobertura_reporter.py +136 -0
- gd_tools/coverage/html_reporter.py +95 -0
- gd_tools/coverage/lcov_reporter.py +90 -0
- gd_tools/coverage/orchestrator.py +282 -0
- gd_tools/coverage/plan_generator.py +377 -0
- gd_tools/coverage/reporter.py +518 -0
- gd_tools/coverage/templates/file.html +72 -0
- gd_tools/coverage/templates/index.html +90 -0
- gd_tools/coverage/terminal_reporter.py +107 -0
- gd_tools/doctor.py +495 -0
- gd_tools/errors.py +79 -0
- gd_tools/file_discovery.py +41 -0
- gd_tools/format_runner.py +118 -0
- gd_tools/godot.py +346 -0
- gd_tools/init.py +619 -0
- gd_tools/lint_runner.py +223 -0
- gd_tools/test_runner.py +474 -0
- gd_tools_cli-0.1.0.dist-info/METADATA +191 -0
- gd_tools_cli-0.1.0.dist-info/RECORD +31 -0
- gd_tools_cli-0.1.0.dist-info/WHEEL +5 -0
- gd_tools_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gd_tools_cli-0.1.0.dist-info/top_level.txt +1 -0
gd_tools/lint_runner.py
ADDED
|
@@ -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)
|
gd_tools/test_runner.py
ADDED
|
@@ -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
|