Files
spacegame/mcp-server/tests/test_e2e.py
Joshua Deville 496320cc8c Add empty test level and Unreal MCP tooling
- L_TestFlight: empty, zero-gravity level for Phase 0 flight testing,
  set as editor/game default map.
- Enable UnrealMCPython + Python Editor Script Plugin, third-party
  MCP plugin (GenOrca/unreal-mcp v2.2.0) giving direct editor control
  (actors, blueprints, levels, materials, etc.) alongside Epic's
  built-in Unreal MCP plugin.
- Vendor the mcp-server Python source used to bridge to the plugin.
- .mcp.json intentionally gitignored (machine-specific absolute paths).
2026-07-08 14:56:57 -04:00

223 lines
9.3 KiB
Python

# Copyright (c) 2025 GenOrca. All Rights Reserved.
"""
End-to-end tests: MCP server dispatcher -> real TCP -> C++ server -> ue_* -> back.
Unlike test_dispatcher.py (which mocks the TCP layer), these run the FULL chain
with NO mocks. They prove the actual scenario an MCP client triggers:
routing + socket round-trip + C++ dispatch + response unwrapping.
Requires a running Unreal editor with the UnrealMCPython TCP server on :12029.
If the port is not reachable, the whole module is skipped (so CI / offline runs
stay green; run locally with the editor open to exercise these).
Run:
cd mcp-server && uv run --extra dev pytest tests/test_e2e.py -v
"""
import asyncio
import socket
import pytest
import unreal_mcp.dispatcher as disp
from unreal_mcp.dispatchers._catalog import CATALOG
HOST, PORT = "127.0.0.1", 12029
# Actions excluded from the exhaustive empty-param round-trip:
# execute_python — needs {code}; empty-param hits a dispatcher guard, not TCP
# livecoding_compile — triggers a real C++ compile (slow, side-effecting)
# start_pie/stop_pie — change editor PIE mode (no params to guard); would leave
# the editor in PIE during the sweep
_EXCLUDE = {
("util", "execute_python"),
("util", "livecoding_compile"),
("util", "start_pie"),
("util", "stop_pie"),
# save_current_level can raise a modal Save-As dialog on an untitled level.
("level", "save_current_level"),
("level", "save_all_levels"),
# vision returns an MCP Image (not a dict) — covered by a dedicated E2E test below.
("vision", "capture_viewport"),
}
def _editor_reachable() -> bool:
try:
with socket.create_connection((HOST, PORT), timeout=0.5):
return True
except OSError:
return False
pytestmark = pytest.mark.skipif(
not _editor_reachable(),
reason=f"Unreal TCP server not reachable on {HOST}:{PORT} (open the editor to run E2E).",
)
# Editor-crash guard: if the editor was up when the module started but dies
# mid-suite, every remaining test must FAIL (not skip, and not "pass" via a
# connection-error dict that happens to carry a 'success' key). A green E2E run
# must mean the editor survived the whole sweep — otherwise a `pytest && ...`
# release chain would happily commit/PR on top of a crash.
_EDITOR_WAS_UP = _editor_reachable()
_CONNECTION_ERROR_MARKERS = ("Connection refused", "ConnectionReset", "Socket timeout",
"No response received", "[WinError")
@pytest.fixture(autouse=True)
def _fail_if_editor_crashed():
if _EDITOR_WAS_UP and not _editor_reachable():
pytest.fail(f"Unreal editor crashed during the E2E suite "
f"({HOST}:{PORT} no longer reachable). Investigate before merging.")
yield
def _assert_not_connection_error(r, label):
if isinstance(r, dict):
msg = str(r.get("message", ""))
assert not any(m in msg for m in _CONNECTION_ERROR_MARKERS), \
f"{label}: editor connection lost mid-call: {msg}"
def run(coro):
return asyncio.run(coro)
def test_list_actors_round_trip():
"""A read action proves unwrapping: 'actors' is an INNER field of the action result."""
r = run(disp._dispatch("actor", "list_all_with_locations", {}))
assert r.get("success") is True
assert "actors" in r, f"inner field missing — response not unwrapped: {r}"
assert isinstance(r["actors"], list)
def test_spawn_and_delete_round_trip():
spawn = run(disp._dispatch("actor", "spawn_from_class",
{"class_path": "/Script/Engine.PointLight",
"location": [0, 0, 700]}))
assert spawn.get("success") is True, spawn
label = spawn.get("actor_label")
assert label, f"actor_label missing — not unwrapped: {spawn}"
# cleanup through the same chain
deleted = run(disp._dispatch("actor", "delete_by_label", {"actor_label": label}))
assert deleted.get("success") is True, deleted
def test_inner_action_failure_is_surfaced():
"""Action failure must come back as success=False (not buried in a result string)."""
r = run(disp._dispatch("actor", "set_transform",
{"actor_label": "NoSuchActor_E2E_XYZ", "location": [0, 0, 0]}))
assert r.get("success") is False
assert "message" in r
def test_list_actions_offline_path_still_works():
"""list_actions must not touch TCP even when the editor is up."""
r = run(disp._dispatch("material", "list_actions", {}))
assert r["success"] is True
assert r["domain"] == "material"
def test_execute_python_round_trip():
"""execute_python runs real Unreal Python through the chain."""
r = run(disp.util.fn(action="execute_python",
params={"code": "print('e2e_marker_42')"}))
# send_python_exec returns the raw wrapper; the printed marker is in 'result'.
blob = r.get("result", "") + r.get("message", "")
assert "e2e_marker_42" in blob, f"execute_python did not echo marker: {r}"
def test_vision_capture_returns_image():
"""vision capture_viewport returns an MCP Image (PNG) through the full chain."""
from fastmcp import Image
r = run(disp.vision.fn(action="capture_viewport", params={"width": 320, "height": 180}))
assert isinstance(r, Image), f"expected an Image, got {type(r).__name__}: {r}"
# the PNG bytes should carry a valid signature
data = getattr(r, "data", b"")
assert data[:4] == b"\x89PNG", "capture did not return a PNG"
def test_gltf_import_round_trip():
"""glTF import via the deferred-tick path: export the engine Cube to .glb, import it,
then poll get_gltf_import_status until done. Each dispatch is its own game-thread task,
so the editor ticks between calls and the Interchange async import can run + complete."""
import time
# 1. export /Engine/BasicShapes/Cube to a temp .glb (via execute_python)
export_code = (
"import unreal, os\n"
"glb = os.path.join(unreal.Paths.project_saved_dir(), 'MCP_E2E_gltf.glb').replace(chr(92), '/')\n"
"cube = unreal.EditorAssetLibrary.load_asset('/Engine/BasicShapes/Cube')\n"
"t = unreal.AssetExportTask(); t.object = cube; t.filename = glb\n"
"t.automated = True; t.replace_identical = True; t.prompt = False\n"
"ok = unreal.Exporter.run_asset_export_task(t)\n"
"print('GLBPATH=' + glb if (ok and os.path.isfile(glb)) else 'GLBFAIL')\n"
)
r = run(disp.util.fn(action="execute_python", params={"code": export_code}))
blob = (r.get("result", "") or "") + (r.get("message", "") or "")
assert "GLBPATH=" in blob, f"glb export failed: {blob[:300]}"
glb = blob.split("GLBPATH=", 1)[1].split()[0].strip().strip('"')
dest = "/Game/Tests/MCP_E2E/glb"
try:
imp = run(disp._dispatch("asset", "import_gltf",
{"file_path": glb, "destination_path": dest}))
assert imp.get("success") and imp.get("pending"), f"schedule failed: {imp}"
st = {}
for _ in range(40): # ~20s budget for the async Interchange import
st = run(disp._dispatch("asset", "get_gltf_import_status",
{"destination_path": dest}))
_assert_not_connection_error(st, "get_gltf_import_status")
if st.get("done"):
break
time.sleep(0.5)
assert st.get("success") and st.get("done"), f"import did not complete: {st}"
classes = [a["class"] for a in st["imported_assets"]]
assert "StaticMesh" in classes, f"no StaticMesh among imported: {st}"
finally:
run(disp.util.fn(action="execute_python", params={
"code": f"import unreal; unreal.EditorAssetLibrary.delete_directory('{dest}')"}))
# ── exhaustive: every catalog action survives the full chain ───────────────────
def _all_action_pairs():
pairs = []
for domain, actions in CATALOG.items():
for action in actions:
if (domain, action) in _EXCLUDE:
continue
pairs.append((domain, action))
return pairs
@pytest.mark.parametrize("domain,action", _all_action_pairs())
def test_every_action_round_trips(domain, action):
"""
Drive every action through the real chain with empty params and assert we get
back an unwrapped dict containing 'success'. This proves routing + TCP +
C++ dispatch + result unwrapping work for the action — independent of whether
the action *succeeds* with no args (validation failures still return a dict).
Empty params are safe: execute_action wraps any exception (incl. missing-arg
TypeError) as {"success": false, ...}, and ue_* functions validate required
params before doing work.
"""
if domain == "util":
r = run(disp.util.fn(action=action, params={}))
elif domain == "vision":
r = run(disp.vision.fn(action=action, params={}))
else:
r = run(disp._dispatch(domain, action, {}))
assert isinstance(r, dict), f"{domain}.{action} returned non-dict: {r!r}"
assert "success" in r, f"chain/unwrap failed for {domain}.{action}: {r!r}"
_assert_not_connection_error(r, f"{domain}.{action}")
def test_zzz_editor_survived_suite():
"""Last test in the file: the editor must still be alive after the full sweep."""
assert _editor_reachable(), "Unreal editor is no longer reachable after the E2E suite (it crashed mid-run)."