refactor: 拆分 claude-dev-stack 为 windows-dev-stack 和 wsl-dev-stack
将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
## Test automation framework tools.
|
||||
## Editor-side orchestration + runtime assertions via file-based IPC.
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"run_test_scenario": _run_test_scenario,
|
||||
"assert_node_state": _assert_node_state,
|
||||
"assert_screen_text": _assert_screen_text,
|
||||
"run_stress_test": _run_stress_test,
|
||||
"get_test_report": _get_test_report,
|
||||
}
|
||||
|
||||
|
||||
# ── Internal test result accumulator ──────────────────────────────────────────
|
||||
|
||||
var _test_results: Array[Dictionary] = []
|
||||
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _run_test_scenario(params: Dictionary) -> Dictionary:
|
||||
## Execute a test scenario: optionally play a scene, run a sequence of steps
|
||||
## (input simulation, waits, assertions, screenshots), return pass/fail results.
|
||||
##
|
||||
## Steps array: [{type: "input"|"wait"|"assert"|"screenshot", ...params}]
|
||||
## - input: {type:"input", action:str, pressed:bool} or {type:"input", keycode:str}
|
||||
## - wait: {type:"wait", seconds:float} or {type:"wait", node_path:str, timeout:float}
|
||||
## - assert: {type:"assert", node_path:str, property:str, expected:val, operator:str}
|
||||
## - screenshot: {type:"screenshot"} — captures a frame for visual inspection
|
||||
|
||||
if not params.has("steps") or not params["steps"] is Array:
|
||||
return error_invalid_params("Missing required parameter: steps (Array)")
|
||||
|
||||
var steps: Array = params["steps"]
|
||||
if steps.is_empty():
|
||||
return error_invalid_params("Steps array is empty")
|
||||
|
||||
var scene_path: String = optional_string(params, "scene_path")
|
||||
var ei := get_editor()
|
||||
|
||||
# Play scene if requested
|
||||
if not scene_path.is_empty():
|
||||
if ei.is_playing_scene():
|
||||
ei.stop_playing_scene()
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
if scene_path == "main":
|
||||
ei.play_main_scene()
|
||||
elif scene_path == "current":
|
||||
ei.play_current_scene()
|
||||
else:
|
||||
if not FileAccess.file_exists(scene_path):
|
||||
return error_not_found("Scene file '%s'" % scene_path)
|
||||
ei.play_custom_scene(scene_path)
|
||||
|
||||
# Wait for game to start
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
|
||||
# Verify game is running
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {
|
||||
"suggestion": "Provide scene_path or use play_scene first"
|
||||
})
|
||||
|
||||
var results: Array[Dictionary] = []
|
||||
var pass_count: int = 0
|
||||
var fail_count: int = 0
|
||||
var error_count: int = 0
|
||||
|
||||
for i in steps.size():
|
||||
var step: Dictionary = steps[i]
|
||||
if not step.has("type"):
|
||||
results.append({"step": i, "error": "Missing 'type' field"})
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
var step_type: String = str(step["type"])
|
||||
var step_result: Dictionary = {"step": i, "type": step_type}
|
||||
|
||||
match step_type:
|
||||
"input":
|
||||
var input_result := await _execute_input_step(step)
|
||||
step_result.merge(input_result)
|
||||
|
||||
"wait":
|
||||
var wait_result := await _execute_wait_step(step)
|
||||
step_result.merge(wait_result)
|
||||
|
||||
"assert":
|
||||
var assert_result := await _execute_assert_step(step)
|
||||
step_result.merge(assert_result)
|
||||
if assert_result.get("passed", false):
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
"screenshot":
|
||||
var screenshot_result := await _send_game_command("capture_frames", {
|
||||
"count": 1,
|
||||
"frame_interval": 1,
|
||||
"half_resolution": optional_bool(step, "half_resolution", true),
|
||||
}, 5.0)
|
||||
if screenshot_result.has("result"):
|
||||
step_result["captured"] = true
|
||||
else:
|
||||
step_result["captured"] = false
|
||||
step_result["error"] = "Screenshot capture failed"
|
||||
error_count += 1
|
||||
|
||||
_:
|
||||
step_result["error"] = "Unknown step type: %s" % step_type
|
||||
error_count += 1
|
||||
|
||||
results.append(step_result)
|
||||
|
||||
# Check if game crashed between steps
|
||||
if not ei.is_playing_scene():
|
||||
results.append({"step": i + 1, "error": "Game stopped unexpectedly"})
|
||||
error_count += 1
|
||||
break
|
||||
|
||||
var summary := {
|
||||
"total_steps": steps.size(),
|
||||
"completed_steps": results.size(),
|
||||
"assertions_passed": pass_count,
|
||||
"assertions_failed": fail_count,
|
||||
"errors": error_count,
|
||||
"all_passed": fail_count == 0 and error_count == 0,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
# Store results for get_test_report
|
||||
_test_results.append_array(results)
|
||||
|
||||
return success(summary)
|
||||
|
||||
|
||||
func _assert_node_state(params: Dictionary) -> Dictionary:
|
||||
## Assert a node's property equals expected value in the running game.
|
||||
## Supports operators: eq, neq, gt, lt, gte, lte, contains, type_is.
|
||||
## Returns pass/fail with actual value.
|
||||
|
||||
var path_result := require_string(params, "node_path")
|
||||
if path_result[1] != null:
|
||||
return path_result[1]
|
||||
|
||||
var prop_result := require_string(params, "property")
|
||||
if prop_result[1] != null:
|
||||
return prop_result[1]
|
||||
|
||||
if not params.has("expected"):
|
||||
return error_invalid_params("Missing required parameter: expected")
|
||||
|
||||
var operator: String = optional_string(params, "operator", "eq")
|
||||
var valid_operators := ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"]
|
||||
if operator not in valid_operators:
|
||||
return error_invalid_params("Invalid operator '%s'. Valid: %s" % [operator, str(valid_operators)])
|
||||
|
||||
var result := await _send_game_command("assert_node_state", {
|
||||
"node_path": path_result[0],
|
||||
"property": prop_result[0],
|
||||
"expected": params["expected"],
|
||||
"operator": operator,
|
||||
}, 5.0)
|
||||
|
||||
# Store for test report
|
||||
if result.has("result"):
|
||||
_test_results.append(result["result"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _assert_screen_text(params: Dictionary) -> Dictionary:
|
||||
## Assert that specific text is visible on screen.
|
||||
## Uses find_ui_elements internally to check all visible UI text.
|
||||
|
||||
var text_result := require_string(params, "text")
|
||||
if text_result[1] != null:
|
||||
return text_result[1]
|
||||
|
||||
var expected_text: String = text_result[0]
|
||||
var partial: bool = optional_bool(params, "partial", true)
|
||||
var case_sensitive: bool = optional_bool(params, "case_sensitive", true)
|
||||
|
||||
# Use find_ui_elements to get all visible UI text
|
||||
var ui_result := await _send_game_command("find_ui_elements", {})
|
||||
if ui_result.has("error"):
|
||||
return ui_result
|
||||
|
||||
var elements: Array = []
|
||||
if ui_result.has("result") and ui_result["result"].has("elements"):
|
||||
elements = ui_result["result"]["elements"]
|
||||
|
||||
var found := false
|
||||
var matched_element: Dictionary = {}
|
||||
var all_texts: Array[String] = []
|
||||
|
||||
for element: Dictionary in elements:
|
||||
var element_text: String = str(element.get("text", ""))
|
||||
if element_text.is_empty():
|
||||
continue
|
||||
all_texts.append(element_text)
|
||||
|
||||
var search_text := expected_text
|
||||
var compare_text := element_text
|
||||
if not case_sensitive:
|
||||
search_text = search_text.to_lower()
|
||||
compare_text = compare_text.to_lower()
|
||||
|
||||
if partial:
|
||||
if compare_text.contains(search_text):
|
||||
found = true
|
||||
matched_element = element
|
||||
break
|
||||
else:
|
||||
if compare_text == search_text:
|
||||
found = true
|
||||
matched_element = element
|
||||
break
|
||||
|
||||
var assertion := {
|
||||
"passed": found,
|
||||
"expected_text": expected_text,
|
||||
"partial": partial,
|
||||
"case_sensitive": case_sensitive,
|
||||
}
|
||||
|
||||
if found:
|
||||
assertion["matched_element"] = {
|
||||
"text": matched_element.get("text", ""),
|
||||
"type": matched_element.get("type", ""),
|
||||
"path": matched_element.get("path", ""),
|
||||
}
|
||||
else:
|
||||
assertion["visible_texts"] = all_texts
|
||||
|
||||
# Store for test report
|
||||
_test_results.append(assertion)
|
||||
|
||||
return success(assertion)
|
||||
|
||||
|
||||
func _run_stress_test(params: Dictionary) -> Dictionary:
|
||||
## Run rapid random inputs for N seconds and check for crashes.
|
||||
## Returns frame count, timing, and any errors from game output.
|
||||
|
||||
var duration: float = float(params.get("duration", 5.0))
|
||||
if duration <= 0 or duration > 60:
|
||||
return error_invalid_params("Duration must be between 0 and 60 seconds")
|
||||
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {
|
||||
"suggestion": "Use play_scene first"
|
||||
})
|
||||
|
||||
# Record initial error count from log
|
||||
var initial_errors := _count_log_errors()
|
||||
|
||||
# Generate random input events
|
||||
var actions := ["ui_up", "ui_down", "ui_left", "ui_right", "ui_accept", "ui_cancel"]
|
||||
# Add common game actions if specified
|
||||
var custom_actions: Array = params.get("actions", [])
|
||||
for action in custom_actions:
|
||||
actions.append(str(action))
|
||||
|
||||
var events_sent: int = 0
|
||||
var start_time := Time.get_ticks_msec()
|
||||
var duration_ms := int(duration * 1000.0)
|
||||
|
||||
while Time.get_ticks_msec() - start_time < duration_ms:
|
||||
if not ei.is_playing_scene():
|
||||
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
|
||||
return success({
|
||||
"completed": false,
|
||||
"crashed": true,
|
||||
"elapsed_seconds": elapsed,
|
||||
"events_sent": events_sent,
|
||||
"error": "Game stopped during stress test",
|
||||
})
|
||||
|
||||
# Send a batch of random inputs
|
||||
var batch: Array = []
|
||||
for j in 3:
|
||||
var action_name: String = actions[randi() % actions.size()]
|
||||
batch.append({
|
||||
"type": "action",
|
||||
"action": action_name,
|
||||
"pressed": true,
|
||||
"strength": 1.0,
|
||||
})
|
||||
batch.append({
|
||||
"type": "action",
|
||||
"action": action_name,
|
||||
"pressed": false,
|
||||
"strength": 0.0,
|
||||
})
|
||||
|
||||
# Write input commands directly (same as input_commands)
|
||||
var json := JSON.stringify({
|
||||
"sequence_events": batch,
|
||||
"frame_delay": 1,
|
||||
})
|
||||
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
events_sent += batch.size()
|
||||
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
|
||||
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
|
||||
var final_errors := _count_log_errors()
|
||||
var new_errors := final_errors - initial_errors
|
||||
|
||||
# Check if game is still running
|
||||
var still_running := ei.is_playing_scene()
|
||||
|
||||
return success({
|
||||
"completed": true,
|
||||
"crashed": not still_running,
|
||||
"duration_seconds": elapsed,
|
||||
"events_sent": events_sent,
|
||||
"new_errors": new_errors,
|
||||
"game_still_running": still_running,
|
||||
})
|
||||
|
||||
|
||||
func _get_test_report(params: Dictionary) -> Dictionary:
|
||||
## Collect and format results from accumulated assertions into a test report.
|
||||
## Returns pass count, fail count, and detailed results.
|
||||
|
||||
var clear: bool = optional_bool(params, "clear", true)
|
||||
|
||||
var pass_count: int = 0
|
||||
var fail_count: int = 0
|
||||
var details: Array[Dictionary] = []
|
||||
|
||||
for result: Dictionary in _test_results:
|
||||
var passed: bool = result.get("passed", false)
|
||||
if passed:
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
details.append(result)
|
||||
|
||||
var report := {
|
||||
"total": _test_results.size(),
|
||||
"passed": pass_count,
|
||||
"failed": fail_count,
|
||||
"pass_rate": ("%.1f%%" % (100.0 * pass_count / _test_results.size())) if not _test_results.is_empty() else "N/A",
|
||||
"all_passed": fail_count == 0 and not _test_results.is_empty(),
|
||||
"details": details,
|
||||
}
|
||||
|
||||
if clear:
|
||||
_test_results.clear()
|
||||
|
||||
return success(report)
|
||||
|
||||
|
||||
# ── Step Executors (for run_test_scenario) ────────────────────────────────────
|
||||
|
||||
func _execute_input_step(step: Dictionary) -> Dictionary:
|
||||
## Execute an input step: simulate action or key press.
|
||||
var events: Array = []
|
||||
|
||||
if step.has("action"):
|
||||
var pressed: bool = step.get("pressed", true) as bool
|
||||
events.append({
|
||||
"type": "action",
|
||||
"action": str(step["action"]),
|
||||
"pressed": pressed,
|
||||
"strength": float(step.get("strength", 1.0)),
|
||||
})
|
||||
# Auto-release if pressed
|
||||
if pressed and step.get("auto_release", true):
|
||||
events.append({
|
||||
"type": "action",
|
||||
"action": str(step["action"]),
|
||||
"pressed": false,
|
||||
"strength": 0.0,
|
||||
})
|
||||
elif step.has("keycode"):
|
||||
var pressed: bool = step.get("pressed", true) as bool
|
||||
events.append({
|
||||
"type": "key",
|
||||
"keycode": str(step["keycode"]),
|
||||
"pressed": pressed,
|
||||
"shift": step.get("shift", false),
|
||||
"ctrl": step.get("ctrl", false),
|
||||
"alt": step.get("alt", false),
|
||||
})
|
||||
else:
|
||||
return {"error": "Input step requires 'action' or 'keycode'"}
|
||||
|
||||
var json := JSON.stringify({
|
||||
"sequence_events": events,
|
||||
"frame_delay": int(step.get("frame_delay", 1)),
|
||||
})
|
||||
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
|
||||
if file == null:
|
||||
return {"error": "Failed to write input commands"}
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
|
||||
return {"sent": true, "event_count": events.size()}
|
||||
|
||||
|
||||
func _execute_wait_step(step: Dictionary) -> Dictionary:
|
||||
## Execute a wait step: wait for seconds or wait for a node to appear.
|
||||
if step.has("node_path"):
|
||||
var timeout: float = float(step.get("timeout", 5.0))
|
||||
var result := await _send_game_command("wait_for_node", {
|
||||
"node_path": str(step["node_path"]),
|
||||
"timeout": timeout,
|
||||
"poll_frames": int(step.get("poll_frames", 5)),
|
||||
}, timeout + 2.0)
|
||||
if result.has("error"):
|
||||
return {"error": "Wait for node failed: %s" % str(result["error"])}
|
||||
return {"waited_for": str(step["node_path"]), "found": true}
|
||||
else:
|
||||
var seconds: float = float(step.get("seconds", 1.0))
|
||||
await get_tree().create_timer(seconds).timeout
|
||||
return {"waited_seconds": seconds}
|
||||
|
||||
|
||||
func _execute_assert_step(step: Dictionary) -> Dictionary:
|
||||
## Execute an assertion step within a scenario.
|
||||
if step.has("text"):
|
||||
# Screen text assertion
|
||||
var ui_result := await _send_game_command("find_ui_elements", {})
|
||||
if ui_result.has("error"):
|
||||
return {"passed": false, "error": "Could not get UI elements"}
|
||||
|
||||
var elements: Array = []
|
||||
if ui_result.has("result") and ui_result["result"].has("elements"):
|
||||
elements = ui_result["result"]["elements"]
|
||||
|
||||
var expected_text: String = str(step["text"])
|
||||
var partial: bool = step.get("partial", true) as bool
|
||||
for element: Dictionary in elements:
|
||||
var element_text: String = str(element.get("text", ""))
|
||||
if partial and element_text.contains(expected_text):
|
||||
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
|
||||
elif not partial and element_text == expected_text:
|
||||
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
|
||||
|
||||
return {"passed": false, "type": "screen_text", "expected": expected_text, "error": "Text not found on screen"}
|
||||
|
||||
elif step.has("node_path") and step.has("property"):
|
||||
# Node state assertion
|
||||
var result := await _send_game_command("assert_node_state", {
|
||||
"node_path": str(step["node_path"]),
|
||||
"property": str(step["property"]),
|
||||
"expected": step.get("expected", null),
|
||||
"operator": str(step.get("operator", "eq")),
|
||||
}, 5.0)
|
||||
if result.has("result"):
|
||||
return result["result"]
|
||||
elif result.has("error"):
|
||||
return {"passed": false, "error": str(result["error"])}
|
||||
return {"passed": false, "error": "Unknown assertion error"}
|
||||
|
||||
else:
|
||||
return {"passed": false, "error": "Assert step requires 'text' or 'node_path'+'property'"}
|
||||
|
||||
|
||||
# ── IPC Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
||||
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_game_request"
|
||||
var response_path := user_dir + "/mcp_game_response"
|
||||
|
||||
# Clean stale response
|
||||
if FileAccess.file_exists(response_path):
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
# Write request
|
||||
var request_data := JSON.stringify({"command": command, "params": params})
|
||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if req == null:
|
||||
return error_internal("Could not create game request file")
|
||||
req.store_string(request_data)
|
||||
req.close()
|
||||
|
||||
# Poll for response
|
||||
var attempts := int(timeout_sec / 0.1)
|
||||
while attempts > 0:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
# Check if game is still running
|
||||
if not ei.is_playing_scene():
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game stopped during command execution")
|
||||
attempts -= 1
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
# Try to auto-resume the debugger
|
||||
if ei.is_playing_scene():
|
||||
_try_debugger_continue()
|
||||
for _retry in 20:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
|
||||
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
|
||||
})
|
||||
|
||||
# Read response
|
||||
var file := FileAccess.open(response_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Could not read game response file")
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
var parsed = JSON.parse_string(text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
return error_internal("Invalid response JSON from game")
|
||||
|
||||
if parsed.has("error"):
|
||||
return error(-32000, str(parsed["error"]))
|
||||
|
||||
return success(parsed)
|
||||
|
||||
|
||||
## Press the debugger "Continue" button to resume a paused game process.
|
||||
func _try_debugger_continue() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var queue: Array[Node] = [base]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
var inner: Array[Node] = [node]
|
||||
while not inner.is_empty():
|
||||
var n := inner.pop_front()
|
||||
if n is Button and n.tooltip_text == "Continue":
|
||||
n.emit_signal("pressed")
|
||||
push_warning("[MCP] Auto-resumed debugger after runtime error")
|
||||
return
|
||||
for c in n.get_children():
|
||||
inner.append(c)
|
||||
return
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
|
||||
|
||||
# ── Utility ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _count_log_errors() -> int:
|
||||
var count: int = 0
|
||||
var log_path := "user://logs/godot.log"
|
||||
if FileAccess.file_exists(log_path):
|
||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
||||
if file:
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
for line: String in lines:
|
||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
|
||||
count += 1
|
||||
return count
|
||||
Reference in New Issue
Block a user