chore(client): add gdUnit4 test framework addon

Vendor gdUnit4 for Godot 4 client testing (D-030).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 16:52:00 +01:00
co-authored by Claude Opus 4.6
parent 3a2fe4bd71
commit 61e0762a84
979 changed files with 55221 additions and 0 deletions
@@ -0,0 +1,41 @@
extends RefCounted
class_name ErrorLogEntry
enum TYPE {
SCRIPT_ERROR,
PUSH_ERROR,
PUSH_WARNING
}
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
var _type: TYPE
var _line: int
var _message: String
var _details: String
func _init(type: TYPE, line: int, message: String, details: String) -> void:
_type = type
_line = line
_message = message
_details = details
func _to_string() -> String:
return _message
static func of_push_warning(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
return ErrorLogEntry.new(TYPE.PUSH_WARNING, line, message, "\n".join(stack_trace))
static func of_push_error(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
return ErrorLogEntry.new(TYPE.PUSH_ERROR, line, message, "\n".join(stack_trace))
static func of_script_error(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
return ErrorLogEntry.new(TYPE.SCRIPT_ERROR, line, message, "\n".join(stack_trace))
@@ -0,0 +1 @@
uid://chyghm1nj3766
@@ -0,0 +1,24 @@
# GdUnit Monitoring Base Class
class_name GdUnitMonitor
extends RefCounted
var _id :String
# constructs new Monitor with given id
func _init(p_id :String) -> void:
_id = p_id
# Returns the id of the monitor to uniqe identify
func id() -> String:
return _id
# starts monitoring
func start() -> void:
pass
# stops monitoring
func stop() -> void:
pass
@@ -0,0 +1 @@
uid://hxg5v23jhepc
@@ -0,0 +1,70 @@
class_name GdUnitOrphanNodeInfo
extends RefCounted
enum GdUnitOrphanType {
member,
variable,
unknown
}
var _id: int
var _orphan_type: GdUnitOrphanType
var _type: String
var _name: String
var _script_ref: String
var _func_ref: String
var _next: GdUnitOrphanNodeInfo
const text_color := Color.ANTIQUE_WHITE
const function_color := Color.SKY_BLUE
const member_variable_color := Color.SALMON
const engine_type_color := Color.LIGHT_GREEN
const script_path_color := Color.CORNFLOWER_BLUE
func _init(orphan_type: GdUnitOrphanType, id: int, type: String, name: String, script_ref: String, func_ref: String = "") -> void:
_orphan_type = orphan_type
_id = id
_type = type
_name = name
_script_ref = script_ref
_func_ref = func_ref
func as_trace(info: GdUnitOrphanNodeInfo, show_orphan_id := true) -> String:
var trace := ""
if show_orphan_id:
trace += "• <%s> Id:%s\n" % [
_colored(info._type, engine_type_color),
_colored(info._id, engine_type_color)]
match info._orphan_type:
GdUnitOrphanType.member:
return trace + " at %s script: %s" % [
_colored(info._name, member_variable_color),
_colored(info._script_ref, script_path_color)
] + sub_info(info._next)
GdUnitOrphanType.variable:
return trace + " at %s script: %s.%s()" % [
_colored(info._name, member_variable_color),
_colored(info._script_ref, script_path_color),
_colored(info._func_ref, function_color),
]
GdUnitOrphanType.unknown:
return trace + " %s" % [
_colored(info._name, member_variable_color)
]
_:
return trace + " No details available"
func sub_info(next: GdUnitOrphanNodeInfo) -> String:
if next == null:
return ""
return "\n" + as_trace(next, false)
static func _colored(value: Variant, color: Color) -> String:
return "[color=%s]%s[/color]" % [color.to_html(), value]
@@ -0,0 +1 @@
uid://cwe0iegjojy6f
@@ -0,0 +1,221 @@
class_name GdUnitOrphanNodesMonitor
extends GdUnitMonitor
const excluded_frame_files: PackedStringArray = [
"GdUnitOrphanNodesMonitor",
"GdUnitExecutionContext",
"_TestCase",
"IGdUnitExecutionStage",
"GdUnitTestCaseSingleTestStage",
"GdUnitTestCaseSingleExecutionStage",
"GdUnitTestCaseExecutionStage",
"GdUnitTestSuiteExecutionStage",
"GdUnitTestSuiteExecutor"
]
var _child_monitors: Array[GdUnitOrphanNodesMonitor] = []
var _orphan_detection_enabled :bool
var _initial_orphans: Array[int] = []
var _orphan_ids_at_start: Array[int] = []
var _orphan_ids_at_stop: Array[int] = []
var _collected_orphan_infos: Array[GdUnitOrphanNodeInfo] = []
func _init(name: String) -> void:
super("OrphanNodesMonitor:" + name)
_orphan_detection_enabled = GdUnitSettings.is_verbose_orphans()
_initial_orphans = _get_orphan_node_ids()
func add_child_monitor(monitor: GdUnitOrphanNodesMonitor) -> void:
if not _orphan_detection_enabled:
return
_child_monitors.append(monitor)
func start() -> void:
if not _orphan_detection_enabled:
return
_collected_orphan_infos.clear()
# Collect current orphan id's to be filtered out at `stop`
_orphan_ids_at_start = _get_orphan_node_ids()
func stop() -> void:
if not _orphan_detection_enabled:
return
# Collect only new detected orphan id's, we want only to collect orphans between start and stop time
_orphan_ids_at_stop = _get_orphan_node_ids().filter(func(element: int) -> bool:
# Excluding sub monitores orphans
if _collect_child_orphan_ids().has(element):
return false
# Excluding orphans at start
return not _orphan_ids_at_start.has(element) and not _initial_orphans.has(element)
)
func _collect_child_orphan_ids() -> Array[int]:
var collected_ids: Array[int] = []
for child_monitor in _child_monitors:
collected_ids.append_array(child_monitor._orphan_ids_at_stop)
collected_ids.append_array(child_monitor._collect_child_orphan_ids())
return collected_ids
func detected_orphans() -> Array[GdUnitOrphanNodeInfo]:
if not _orphan_detection_enabled:
return []
return _collected_orphan_infos.filter(func(info: GdUnitOrphanNodeInfo) -> bool:
return info._id in _orphan_ids_at_stop
)
func orphans_count() -> int:
if not _orphan_detection_enabled:
return 0
return _orphan_ids_at_stop.size()
func collect() -> void:
if not _orphan_detection_enabled:
return
for orphan_id in _get_orphan_node_ids():
var orphan_to_find := instance_from_id(orphan_id)
_collect_orphan_info(orphan_to_find)
func _collect_orphan_info(orphan_to_find: Object) -> void:
if orphan_to_find == null:
return
var orphan_node := _find_orphan_on_backtraces(orphan_to_find)
if orphan_node:
_collected_orphan_infos.append(orphan_node)
return
if Engine.has_meta("GdUnitSceneRunner"):
var current_scene_runner:GdUnitSceneRunner = Engine.get_meta("GdUnitSceneRunner")
if is_instance_valid(current_scene_runner):
orphan_node = _find_orphan_at_node(orphan_to_find, current_scene_runner.scene())
if orphan_node:
_collected_orphan_infos.append(orphan_node)
return
# not able to find the orphan node via backtrace loaded nodeds
var message := "No details found. Verify called functions manually."
if not EngineDebugger.is_active():
message = "No details available. [color=yellow]Run tests in debug mode to collect details.[/color]"
_collected_orphan_infos.append(GdUnitOrphanNodeInfo.new(
GdUnitOrphanNodeInfo.GdUnitOrphanType.unknown,
orphan_to_find.get_instance_id(),
orphan_to_find.get_class(),
message,
""))
func _find_orphan_at_node(orphan_to_find: Object, node: Node) -> GdUnitOrphanNodeInfo:
var script: Script = node.get_script()
if script is not GDScript:
return null
# First search over all properties
for property in script.get_script_property_list():
var property_name: String = property["name"]
var property_type: int = property["type"]
# Is untyped or type object
if property_type in [TYPE_NIL, TYPE_OBJECT]:
var property_instance: Variant = node.get(property_name)
@warning_ignore("unsafe_cast")
var property_as_node := property_instance as Node if property_instance != null else null
if property_as_node == null:
continue
if property_as_node == orphan_to_find:
return GdUnitOrphanNodeInfo.new(
GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
orphan_to_find.get_instance_id(),
orphan_to_find.get_class(),
property_name,
script.resource_path)
# Search on node childs
var orphan_node_info := _find_orphan_at_node(orphan_to_find, property_as_node)
if orphan_node_info:
orphan_node_info._next = GdUnitOrphanNodeInfo.new(
GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
orphan_to_find.get_instance_id(),
orphan_to_find.get_class(),
property_name,
script.resource_path)
return orphan_node_info
# Second over all children
for child_node in node.get_children():
var orphan_node_info := _find_orphan_at_node(orphan_to_find, child_node)
if orphan_node_info:
return orphan_node_info
return null
func _is_frame_file_excluded(frame_file: String) -> bool:
for file in excluded_frame_files:
if frame_file.contains(file):
return true
return false
func _find_orphan_on_backtraces(orphan_to_find: Object) -> GdUnitOrphanNodeInfo:
for script_backtrace in Engine.capture_script_backtraces(true):
for frame in script_backtrace.get_frame_count():
var frame_file := script_backtrace.get_frame_file(frame)
if _is_frame_file_excluded(frame_file):
continue
# Scan function variables
for l_index in script_backtrace.get_local_variable_count(frame):
var variable_instance: Variant = script_backtrace.get_local_variable_value(frame, l_index)
var variable_name := script_backtrace.get_local_variable_name(frame, l_index)
if typeof(variable_instance) in [TYPE_NIL, TYPE_OBJECT]:
@warning_ignore("unsafe_cast")
var node := variable_instance as Node
if node == null:
continue
if variable_instance == orphan_to_find:
return GdUnitOrphanNodeInfo.new(
GdUnitOrphanNodeInfo.GdUnitOrphanType.variable,
orphan_to_find.get_instance_id(),
orphan_to_find.get_class(),
variable_name,
script_backtrace.get_frame_file(frame),
script_backtrace.get_frame_function(frame))
else:
var orphan_node_info := _find_orphan_at_node(orphan_to_find, node)
if orphan_node_info:
return orphan_node_info
# Scan class members
for m_index in script_backtrace.get_member_variable_count(frame):
var member_instance: Variant = script_backtrace.get_member_variable_value(frame, m_index)
var member_name := script_backtrace.get_member_variable_name(frame, m_index)
if typeof(member_instance) in [TYPE_NIL, TYPE_OBJECT]:
@warning_ignore("unsafe_cast")
var node := member_instance as Node
if node == null:
continue
if member_instance == orphan_to_find:
return GdUnitOrphanNodeInfo.new(
GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
orphan_to_find.get_instance_id(),
orphan_to_find.get_class(),
member_name,
script_backtrace.get_frame_file(frame))
else:
var orphan_node_info := _find_orphan_at_node(orphan_to_find, node)
if orphan_node_info:
return orphan_node_info
return null
static func _get_orphan_node_ids() -> Array[int]:
@warning_ignore("unsafe_property_access", "unsafe_method_access")
return Engine.get_main_loop().root.get_orphan_node_ids()
@@ -0,0 +1 @@
uid://c405e140cjoke
@@ -0,0 +1,122 @@
class_name GodotGdErrorMonitor
extends GdUnitMonitor
var _logger: GdUnitLogger
class GdUnitLogger extends Logger:
var _entries: Array[ErrorLogEntry] = []
var _line_number: int
var _is_report_push_errors: bool
var _is_report_script_errors: bool
func _init(is_report_push_errors: bool, is_report_script_errors: bool) -> void:
_is_report_push_errors = is_report_push_errors
_is_report_script_errors = is_report_script_errors
OS.add_logger(self)
func entries() -> Array[ErrorLogEntry]:
return _entries
func erase_log_entry(log_entry: ErrorLogEntry) -> void:
for entry in _entries:
if entry._type == log_entry._type and entry._message == log_entry._message:
_entries.erase(entry)
return
func _log_error(
_function: String,
_file: String,
_line: int,
message: String,
_rationale: String,
_editor_notify: bool,
error_type: int,
script_backtraces: Array[ScriptBacktrace]
) -> void:
match error_type:
ErrorType.ERROR_TYPE_WARNING:
if _is_report_push_errors:
var stack_trace := _build_stack_trace(script_backtraces)
_entries.append(ErrorLogEntry.of_push_warning(_line_number, message, stack_trace))
ErrorType.ERROR_TYPE_ERROR:
if _is_report_push_errors:
var stack_trace := _build_stack_trace(script_backtraces)
_entries.append(ErrorLogEntry.of_push_error(_line_number, message, stack_trace))
ErrorType.ERROR_TYPE_SCRIPT:
if _is_report_script_errors:
var stack_trace := _build_stack_trace(script_backtraces)
_entries.append(ErrorLogEntry.of_script_error(_line_number, message, stack_trace))
ErrorType.ERROR_TYPE_SHADER:
pass
_:
prints("Unknwon log type", message)
func _log_message(_message: String, _error: bool) -> void:
pass
func _build_stack_trace(script_backtraces: Array[ScriptBacktrace]) -> PackedStringArray:
for sb in script_backtraces:
for frame in sb.get_frame_count():
# Find start of test stack
if sb.get_frame_file(frame) == "res://addons/gdUnit4/src/core/_TestCase.gd":
var stack_trace := PackedStringArray()
for test_case_frame in range(0, frame):
_line_number = sb.get_frame_line(test_case_frame)
stack_trace.append(" at %s:%s" % [sb.get_frame_file(test_case_frame), sb.get_frame_line(test_case_frame)])
return stack_trace
# if no stack trace collected, we in an await function call
var sb := script_backtraces[0]
return [" at %s:%s" % [sb.get_frame_file(0), sb.get_frame_line(0)]]
func _init() -> void:
super("GdUnitLoggerMonitor")
_logger = GdUnitLogger.new(GdUnitSettings.is_report_push_errors(), GdUnitSettings.is_report_script_errors())
func start() -> void:
clear_logs()
func stop() -> void:
pass
func log_entries() -> Array[ErrorLogEntry]:
return _logger.entries()
func erase_log_entry(log_entry: ErrorLogEntry) -> void:
_logger.erase_log_entry(log_entry)
func to_reports() -> Array[GdUnitReport]:
var reports_: Array[GdUnitReport] = []
reports_.assign(log_entries().map(_to_report))
return reports_
static func _to_report(errorLog: ErrorLogEntry) -> GdUnitReport:
var failure := """
%s
%s %s
%s""".dedent().trim_prefix("\n") % [
GdAssertMessages._error("Godot Runtime Error !"),
GdAssertMessages._error("Error:"),
GdAssertMessages._colored_value(errorLog._message),
GdAssertMessages._colored(errorLog._details, GdAssertMessages.VALUE_COLOR)]
return GdUnitReport.new().create(GdUnitReport.ABORT, errorLog._line, failure)
func clear_logs() -> void:
log_entries().clear()
@@ -0,0 +1 @@
uid://shxe3cj71fix