110 lines
4.1 KiB
GDScript
110 lines
4.1 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
|
|
## Discovers and runs McpTestSuite scripts from res://tests/.
|
|
## Exposes run_tests and get_test_results as MCP commands.
|
|
|
|
var _runner: McpTestRunner
|
|
var _undo_redo: EditorUndoRedoManager
|
|
var _log_buffer: McpLogBuffer
|
|
|
|
|
|
func _init(undo_redo: EditorUndoRedoManager, log_buffer: McpLogBuffer) -> void:
|
|
_runner = McpTestRunner.new()
|
|
_undo_redo = undo_redo
|
|
_log_buffer = log_buffer
|
|
|
|
|
|
func run_tests(params: Dictionary) -> Dictionary:
|
|
var suite_filter: String = params.get("suite", "")
|
|
var test_filter: String = params.get("test_name", "")
|
|
var exclude_test_filter: String = params.get("exclude_test_name", "")
|
|
var verbose: bool = params.get("verbose", false)
|
|
|
|
var discovery := _discover_suites()
|
|
var suites: Array = discovery.suites
|
|
if suites.is_empty():
|
|
var msg := "No test suites found in res://tests/"
|
|
if not discovery.errors.is_empty():
|
|
msg += " (%d script(s) failed to load: %s)" % [
|
|
discovery.errors.size(),
|
|
", ".join(discovery.errors),
|
|
]
|
|
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
|
|
## Keep the edited_scene annotation on the no-suites error payload too,
|
|
## so the response contract is consistent across every return path.
|
|
_annotate_edited_scene(no_suites)
|
|
return {"data": no_suites}
|
|
|
|
var ctx := {
|
|
"undo_redo": _undo_redo,
|
|
"log_buffer": _log_buffer,
|
|
}
|
|
|
|
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
|
|
if not discovery.errors.is_empty():
|
|
results["load_errors"] = discovery.errors
|
|
_annotate_edited_scene(results)
|
|
return {"data": results}
|
|
|
|
|
|
## Many suites assume the project's main scene is the edited scene (they read
|
|
## /Main/... nodes directly). Running with another scene open produces a flood
|
|
## of phantom failures that look like real regressions. Surface the edited
|
|
## scene and a warning when it differs from run/main_scene so the failures are
|
|
## attributable at a glance instead of costing a debugging round (#635).
|
|
func _annotate_edited_scene(results: Dictionary) -> void:
|
|
var scene_root := EditorInterface.get_edited_scene_root()
|
|
var edited := scene_root.scene_file_path if scene_root else ""
|
|
results["edited_scene"] = edited
|
|
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
|
if main_scene.is_empty() or edited == main_scene:
|
|
return
|
|
if int(results.get("failed", 0)) <= 0:
|
|
return
|
|
results["scene_warning"] = (
|
|
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
|
|
% [edited if not edited.is_empty() else "<none>", main_scene]
|
|
+ "assume the main scene is open and will report phantom failures "
|
|
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
|
|
)
|
|
|
|
|
|
func get_test_results(params: Dictionary) -> Dictionary:
|
|
var verbose: bool = params.get("verbose", false)
|
|
return {"data": _runner.get_results(verbose)}
|
|
|
|
|
|
func _discover_suites() -> Dictionary:
|
|
## Returns {"suites": Array, "errors": Array[String]}.
|
|
## Resilient: a broken script doesn't kill discovery of the rest.
|
|
var suites := []
|
|
var errors: Array[String] = []
|
|
var dir := DirAccess.open("res://tests")
|
|
if dir == null:
|
|
return {"suites": suites, "errors": ["DirAccess.open('res://tests') returned null — directory may not exist"]}
|
|
|
|
dir.list_dir_begin()
|
|
var file_name := dir.get_next()
|
|
while not file_name.is_empty():
|
|
if file_name.begins_with("test_") and file_name.ends_with(".gd"):
|
|
var path := "res://tests/" + file_name
|
|
var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
if script == null:
|
|
errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name)
|
|
elif script.can_instantiate():
|
|
var instance = script.new()
|
|
if instance is McpTestSuite:
|
|
suites.append(instance)
|
|
else:
|
|
errors.append("%s (not a McpTestSuite subclass)" % file_name)
|
|
else:
|
|
errors.append("%s (cannot instantiate — abstract or broken)" % file_name)
|
|
file_name = dir.get_next()
|
|
|
|
## Sort by suite name for deterministic order.
|
|
suites.sort_custom(func(a, b) -> bool:
|
|
return a.suite_name() < b.suite_name()
|
|
)
|
|
return {"suites": suites, "errors": errors}
|