Files
Void-Bastion/addons/agent_tools/tools/test_tools.gd
T

231 lines
7.6 KiB
GDScript

@tool
extends RefCounted
# test.run — detect and run a GDScript test framework, return structured results.
#
# Supports GUT (addons/gut) and GdUnit4 (addons/gdUnit4). Auto-detects which is
# installed; caller can force with 'framework'. Runs via headless subprocess so
# it doesn't block the editor.
#
# Params: {
# framework?: "auto" | "gut" | "gdunit4",
# directory?: "res://test", # test directory; defaults by framework
# pattern?: "test_*.gd", # file name pattern; defaults by framework
# timeout_seconds?: 60,
# }
#
# Returns: {
# framework: "gut" | "gdunit4",
# total, passed, failed, skipped,
# failures: [{name, file, line, message}],
# raw_output: string,
# exit_code,
# }
static func run(params: Dictionary) -> Dictionary:
var framework: String = params.get("framework", "auto")
var directory: String = params.get("directory", "")
var pattern: String = params.get("pattern", "")
var timeout_seconds: int = int(params.get("timeout_seconds", 60))
if framework == "auto":
framework = _detect_framework()
if framework == "":
return _err(-32001,
"no test framework detected. Install GUT (addons/gut) or GdUnit4 (addons/gdUnit4), " +
"or pass 'framework' explicitly.")
var runner_info := _runner_for(framework, directory, pattern)
if runner_info.has("_error"):
return _err(-32001, runner_info._error)
var exe := OS.get_executable_path()
var project_dir := ProjectSettings.globalize_path("res://")
var args: Array = ["--path", project_dir, "--headless"]
for a in runner_info.args:
args.append(str(a))
var output: Array = []
var exit_code := OS.execute(exe, args, output, true)
var stdout: String = ""
if output.size() > 0:
stdout = str(output[0])
var parsed: Dictionary
match framework:
"gut": parsed = _parse_gut_output(stdout)
"gdunit4": parsed = _parse_gdunit4_output(stdout)
_: parsed = {"total": 0, "passed": 0, "failed": 0, "skipped": 0, "failures": []}
parsed["framework"] = framework
parsed["exit_code"] = exit_code
# Strip ANSI escape codes from raw_output so the JSON wire is always clean.
# Godot's JSON.stringify doesn't escape every control char; leaving \x1b in
# the string breaks strict JSON parsers downstream.
parsed["raw_output"] = _strip_ansi(stdout)
parsed["command"] = "%s %s" % [exe, " ".join(args)]
return _ok(parsed)
static func _detect_framework() -> String:
if DirAccess.dir_exists_absolute("res://addons/gut"):
return "gut"
if DirAccess.dir_exists_absolute("res://addons/gdUnit4"):
return "gdunit4"
return ""
static func _runner_for(framework: String, directory: String, pattern: String) -> Dictionary:
match framework:
"gut":
var gut_cmdln := "res://addons/gut/gut_cmdln.gd"
if not FileAccess.file_exists(gut_cmdln):
return {"_error": "GUT framework dir exists but gut_cmdln.gd not found — GUT install may be broken"}
var args: Array = ["-s", gut_cmdln, "-gexit"]
if directory != "":
args.append("-gdir=%s" % directory)
else:
args.append("-gdir=res://test")
if pattern != "":
args.append("-gprefix=%s" % pattern)
return {"args": args}
"gdunit4":
# GdUnit4 ships GdUnitRunner.gd for CLI execution.
var runner := "res://addons/gdUnit4/bin/GdUnitCmdTool.gd"
if not FileAccess.file_exists(runner):
return {"_error": "GdUnit4 framework dir exists but GdUnitCmdTool.gd not found"}
var args: Array = ["-s", runner]
if directory != "":
args.append("--add=%s" % directory)
else:
args.append("--add=res://test")
return {"args": args}
_:
return {"_error": "unknown framework: %s (supported: gut, gdunit4)" % framework}
# Parse GUT's text output. GUT's actual format (not the documented :-separated form):
#
# Totals
# ------
# Scripts 1
# Tests 3
# Passing Tests 2
# Failing Tests 1
# Pending Tests 0 <- optional, only if tests are pending
# Asserts 2/3
#
# Failures appear under a per-file header in the "Run Summary" section:
#
# res://test/foo.gd
# - test_failing_case
# [Failed]: <assertion message>
# at line 14
#
# Output has ANSI color escapes; strip them first so regex matches cleanly.
static func _parse_gut_output(raw: String) -> Dictionary:
var text := _strip_ansi(raw)
var total := _gut_extract_count(text, "Tests")
var passed := _gut_extract_count(text, "Passing Tests")
var failed := _gut_extract_count(text, "Failing Tests")
var skipped := _gut_extract_count(text, "Pending Tests")
var failures: Array = _extract_gut_failures(text)
return {"total": total, "passed": passed, "failed": failed, "skipped": skipped, "failures": failures}
# Match either "Label 12" (space-aligned) or "Label: 12" (colon form) on its own line.
static func _gut_extract_count(text: String, label: String) -> int:
var re := RegEx.new()
re.compile("(?m)^\\s*" + label.replace(" ", "\\s+") + "\\s*[:\\s]\\s*(\\d+)\\s*$")
var m := re.search(text)
if m == null:
return 0
return int(m.get_string(1))
# Walk the text top-to-bottom, tracking current test file (file headers are plain
# `res://...` lines), collecting failing tests (`- test_name` with a following
# `[Failed]:` assertion and `at line N`).
static func _extract_gut_failures(text: String) -> Array:
var failures: Array = []
var lines: PackedStringArray = text.split("\n")
var current_file := ""
var current_test := ""
var current_message := ""
var i := 0
while i < lines.size():
var line: String = lines[i].trim_suffix("\r").strip_edges(false, true) # strip only trailing
if line.begins_with("res://") and line.ends_with(".gd"):
current_file = line.strip_edges()
i += 1
continue
var trimmed := line.strip_edges()
if trimmed.begins_with("- "):
current_test = trimmed.substr(2).strip_edges()
current_message = ""
elif trimmed.begins_with("[Failed]:"):
current_message = trimmed.substr("[Failed]:".length()).strip_edges()
elif trimmed.begins_with("at line"):
var n_str := trimmed.substr("at line".length()).strip_edges()
var line_num := int(n_str)
if current_test != "" and current_file != "":
failures.append({
"name": current_test,
"file": current_file,
"line": line_num,
"message": current_message,
})
current_test = ""
current_message = ""
i += 1
return failures
# Remove ANSI terminal escape codes (colors) from GUT's output so regex matches
# don't get tripped up on things like "\x1b[32m".
static func _strip_ansi(text: String) -> String:
var re := RegEx.new()
re.compile("\\x1b\\[[0-9;]*[A-Za-z]")
return re.sub(text, "", true)
# GdUnit4's default output ends with a summary like:
# "| Tests | Executed | 12 | passed | 10 | skipped | 0 | failed | 2 | ... |"
# and per-failure blocks with file:line.
static func _parse_gdunit4_output(text: String) -> Dictionary:
var total := 0
var passed := 0
var failed := 0
var skipped := 0
var summary_re := RegEx.new()
summary_re.compile("Executed\\s*\\|\\s*(\\d+).*?passed\\s*\\|\\s*(\\d+).*?skipped\\s*\\|\\s*(\\d+).*?failed\\s*\\|\\s*(\\d+)")
var m := summary_re.search(text)
if m:
total = int(m.get_string(1))
passed = int(m.get_string(2))
skipped = int(m.get_string(3))
failed = int(m.get_string(4))
var failures: Array = []
var fail_re := RegEx.new()
fail_re.compile("(?m)FAILED\\s+(.+?)\\s*\\(at:\\s*(.+?):(\\d+)")
for fm in fail_re.search_all(text):
failures.append({
"name": fm.get_string(1),
"file": fm.get_string(2),
"line": int(fm.get_string(3)),
"message": "",
})
return {"total": total, "passed": passed, "failed": failed, "skipped": skipped, "failures": failures}
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}