@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]: # 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}}