refactor: enhance test framework with automated resource tracking and scripted error capture capabilities
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
@tool
|
||||
extends Logger
|
||||
|
||||
## Captures GDScript runtime errors emitted while a test is running.
|
||||
##
|
||||
## Deliberately no class_name: this script is compiled dynamically by
|
||||
## script_error_capture_loader.gd only when Logger exists. It lives in a
|
||||
## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`.
|
||||
##
|
||||
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
|
||||
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
|
||||
## should not abort the test.
|
||||
|
||||
var _mutex := Mutex.new()
|
||||
var _capturing := false
|
||||
var _errors := PackedStringArray()
|
||||
|
||||
|
||||
func begin_capture() -> void:
|
||||
_mutex.lock()
|
||||
_capturing = true
|
||||
_errors.clear()
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func end_capture() -> PackedStringArray:
|
||||
_mutex.lock()
|
||||
var captured := _errors.duplicate()
|
||||
_capturing = false
|
||||
_errors.clear()
|
||||
_mutex.unlock()
|
||||
return captured
|
||||
|
||||
|
||||
func _log_error(
|
||||
function: String,
|
||||
file: String,
|
||||
line: int,
|
||||
code: String,
|
||||
rationale: String,
|
||||
_editor_notify: bool,
|
||||
error_type: int,
|
||||
_script_backtraces: Array,
|
||||
) -> void:
|
||||
if error_type != ERROR_TYPE_SCRIPT:
|
||||
return
|
||||
_mutex.lock()
|
||||
if _capturing:
|
||||
var text := rationale if not rationale.is_empty() else code
|
||||
_errors.append("%s (%s:%d in %s)" % [text, file, line, function])
|
||||
_mutex.unlock()
|
||||
@@ -0,0 +1,26 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Builds the Logger-based test script-error capture lazily.
|
||||
##
|
||||
## Logger exists only on newer Godot versions. The capture implementation lives
|
||||
## under a `.gdignore`d folder and is compiled from source only after the runner
|
||||
## verifies the Logger API is present, so older editor scans do not parse an
|
||||
## `extends Logger` file and emit red startup errors.
|
||||
|
||||
const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd"
|
||||
|
||||
|
||||
static func build() -> Object:
|
||||
if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"):
|
||||
return null
|
||||
if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH):
|
||||
return null
|
||||
var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH)
|
||||
if source.is_empty():
|
||||
return null
|
||||
var script := GDScript.new()
|
||||
script.source_code = source
|
||||
if script.reload() != OK:
|
||||
return null
|
||||
return script.new()
|
||||
@@ -0,0 +1 @@
|
||||
uid://pmtc07go8ty4
|
||||
@@ -5,11 +5,25 @@ extends RefCounted
|
||||
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
|
||||
## on McpTestSuite instances, runs them, and collects structured results.
|
||||
|
||||
const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd")
|
||||
|
||||
var _results: Array[Dictionary] = []
|
||||
var _last_run_ms: int = 0
|
||||
var _script_error_capture: Object = null
|
||||
var _capture_registered := false
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
|
||||
OS.call("remove_logger", _script_error_capture)
|
||||
_capture_registered = false
|
||||
|
||||
|
||||
func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
|
||||
var owns_capture := not _capture_registered
|
||||
if owns_capture:
|
||||
_register_capture()
|
||||
|
||||
var name := suite.suite_name()
|
||||
var methods := _get_test_methods(suite)
|
||||
var exclusions := _parse_exclusions(exclude_test_filter)
|
||||
@@ -29,9 +43,12 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
|
||||
continue
|
||||
|
||||
suite._reset()
|
||||
_begin_script_error_capture()
|
||||
suite.setup()
|
||||
suite.call(method_name)
|
||||
suite.teardown()
|
||||
var script_errors := suite._unexpected_script_errors(_end_script_error_capture())
|
||||
suite._free_tracked()
|
||||
|
||||
## Issue #19 defence: free any `_McpTest*` nodes the test created, even
|
||||
## nested ones. If the scene gets auto-saved mid-test while one of these
|
||||
@@ -39,10 +56,23 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
|
||||
## with a "missing dependency" error. Runs after every test, not just at
|
||||
## suite boundaries, so a test that fails mid-flow can't leave a trap
|
||||
## for the next test or for scene autosave.
|
||||
var scene_root_for_cleanup := EditorInterface.get_edited_scene_root()
|
||||
var scene_root_for_cleanup := _edited_scene_root()
|
||||
if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree():
|
||||
_free_mcp_test_nodes_recursive(scene_root_for_cleanup)
|
||||
|
||||
if not script_errors.is_empty():
|
||||
var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors)
|
||||
if suite._failed:
|
||||
abort_message += " (after assertion failure: %s)" % suite._message
|
||||
_results.append({
|
||||
"suite": name,
|
||||
"test": method_name,
|
||||
"passed": false,
|
||||
"message": abort_message,
|
||||
"assertion_count": suite._assertion_count,
|
||||
})
|
||||
continue
|
||||
|
||||
if suite._skipped:
|
||||
_results.append({
|
||||
"suite": name,
|
||||
@@ -70,6 +100,9 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
|
||||
"assertion_count": suite._assertion_count,
|
||||
})
|
||||
|
||||
if owns_capture:
|
||||
_unregister_capture()
|
||||
|
||||
|
||||
func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary:
|
||||
_results.clear()
|
||||
@@ -83,12 +116,17 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
|
||||
var _prev_console_echo := McpLogBuffer.console_echo
|
||||
McpLogBuffer.console_echo = false
|
||||
|
||||
## If a prior run was interrupted after registering the logger but before
|
||||
## normal teardown, remove that stale registration before starting fresh.
|
||||
_unregister_capture()
|
||||
_register_capture()
|
||||
|
||||
for suite: McpTestSuite in suites:
|
||||
if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
|
||||
continue
|
||||
|
||||
## Snapshot scene children before the suite so we can clean up leaks.
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var scene_root := _edited_scene_root()
|
||||
var before_children: Array[Node] = []
|
||||
if scene_root != null:
|
||||
before_children = _get_children_snapshot(scene_root)
|
||||
@@ -119,6 +157,7 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
|
||||
else:
|
||||
run_suite(suite, test_filter, exclude_test_filter)
|
||||
suite.suite_teardown()
|
||||
suite._free_tracked()
|
||||
|
||||
## Remove any nodes the suite left behind (failed undo, missing cleanup).
|
||||
if scene_root != null and scene_root.is_inside_tree():
|
||||
@@ -126,9 +165,48 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
|
||||
|
||||
_last_run_ms = Time.get_ticks_msec() - start
|
||||
McpLogBuffer.console_echo = _prev_console_echo
|
||||
_unregister_capture()
|
||||
return get_results(verbose)
|
||||
|
||||
|
||||
func _register_capture() -> void:
|
||||
if _capture_registered:
|
||||
return
|
||||
if _script_error_capture == null:
|
||||
_script_error_capture = ScriptErrorCaptureLoader.build()
|
||||
if _script_error_capture == null:
|
||||
return
|
||||
OS.call("add_logger", _script_error_capture)
|
||||
_capture_registered = true
|
||||
|
||||
|
||||
func _unregister_capture() -> void:
|
||||
if not _capture_registered:
|
||||
return
|
||||
if _script_error_capture == null:
|
||||
_capture_registered = false
|
||||
return
|
||||
OS.call("remove_logger", _script_error_capture)
|
||||
_capture_registered = false
|
||||
|
||||
|
||||
func _begin_script_error_capture() -> void:
|
||||
if _script_error_capture != null and _capture_registered:
|
||||
_script_error_capture.call("begin_capture")
|
||||
|
||||
|
||||
func _end_script_error_capture() -> PackedStringArray:
|
||||
if _script_error_capture == null or not _capture_registered:
|
||||
return PackedStringArray()
|
||||
return _script_error_capture.call("end_capture") as PackedStringArray
|
||||
|
||||
|
||||
static func _edited_scene_root() -> Node:
|
||||
if not Engine.is_editor_hint():
|
||||
return null
|
||||
return EditorInterface.get_edited_scene_root()
|
||||
|
||||
|
||||
func get_results(verbose: bool = false) -> Dictionary:
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
@@ -31,6 +31,34 @@ func suite_teardown() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# ----- tracked allocations (freed by the runner after each test) -----
|
||||
|
||||
var _tracked_objects: Array[Object] = []
|
||||
|
||||
|
||||
## Register a manually-managed Object (plain Object or out-of-tree Node) so
|
||||
## the runner frees it after the current test. RefCounted instances are
|
||||
## accepted but ignored because they manage their own lifetime.
|
||||
func track(obj: Object) -> Object:
|
||||
if obj != null and not obj is RefCounted:
|
||||
_tracked_objects.append(obj)
|
||||
return obj
|
||||
|
||||
|
||||
## Free everything registered via track(). Called by the runner after each
|
||||
## test's teardown() and again after suite_teardown().
|
||||
func _free_tracked() -> void:
|
||||
for obj in _tracked_objects:
|
||||
if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()):
|
||||
continue
|
||||
if obj is Node:
|
||||
var parent := (obj as Node).get_parent()
|
||||
if parent != null:
|
||||
parent.remove_child(obj)
|
||||
obj.free()
|
||||
_tracked_objects.clear()
|
||||
|
||||
|
||||
# ----- assertion state (managed by McpTestRunner) -----
|
||||
|
||||
var _failed: bool = false
|
||||
@@ -38,6 +66,7 @@ var _message: String = ""
|
||||
var _assertion_count: int = 0
|
||||
var _skipped: bool = false
|
||||
var _skip_reason: String = ""
|
||||
var _expected_script_error_substrings: Array[String] = []
|
||||
|
||||
# ----- suite-level state (managed by McpTestRunner) -----
|
||||
|
||||
@@ -53,6 +82,7 @@ func _reset() -> void:
|
||||
_assertion_count = 0
|
||||
_skipped = false
|
||||
_skip_reason = ""
|
||||
_expected_script_error_substrings.clear()
|
||||
|
||||
|
||||
func _reset_suite_state() -> void:
|
||||
@@ -121,6 +151,29 @@ func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## Allow one captured SCRIPT ERROR whose text contains `substring`.
|
||||
## Use only for negative-path tests that intentionally compile or execute
|
||||
## invalid GDScript and assert on the resulting diagnostics.
|
||||
func expect_script_error_containing(substring: String) -> void:
|
||||
_expected_script_error_substrings.append(substring)
|
||||
|
||||
|
||||
func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray:
|
||||
var unexpected := PackedStringArray()
|
||||
var remaining := _expected_script_error_substrings.duplicate()
|
||||
for error in captured:
|
||||
var matched_index := -1
|
||||
for i in range(remaining.size()):
|
||||
if error.find(remaining[i]) != -1:
|
||||
matched_index = i
|
||||
break
|
||||
if matched_index == -1:
|
||||
unexpected.append(error)
|
||||
else:
|
||||
remaining.remove_at(matched_index)
|
||||
return unexpected
|
||||
|
||||
|
||||
## Trigger an undo against whichever history (scene or global) holds the most
|
||||
## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()`
|
||||
## directly — you resolve the history's underlying UndoRedo and call it there.
|
||||
|
||||
Reference in New Issue
Block a user