diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs index a1d91d406..e1cab7d9b 100644 --- a/MCPForUnity/Editor/Tools/CommandRegistry.cs +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -292,7 +292,21 @@ public static object ExecuteCommand(string commandName, JObject @params, TaskCom throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation"); } - return handlerInfo.SyncHandler(@params); + object result = handlerInfo.SyncHandler(@params); + // A synchronous handler may return a Task to answer later, as manage_scene's + // play-mode screenshot does. Only Task is awaited here: a handler that needs + // another Task type should be declared async, which registration handles. + if (result is Task returnedTask) + { + ExecuteAsyncHandler( + new HandlerInfo(commandName, null, _ => returnedTask), + @params, + commandName, + tcs); + return null; + } + + return result; } /// @@ -322,6 +336,12 @@ public static Task InvokeCommandAsync(string commandName, JObject @param } object result = handlerInfo.SyncHandler(payload); + // Same contract as ExecuteCommand: a returned Task is the answer to await. + if (result is Task returnedTask) + { + return returnedTask; + } + return Task.FromResult(result); } diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index fbc324356..5736224c8 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; // For Response class using MCPForUnity.Runtime.Helpers; // For ScreenshotUtility using Newtonsoft.Json.Linq; @@ -611,18 +612,9 @@ private static object CaptureScreenshot(SceneCommand cmd) if (includeImage && Application.isPlaying) { if (!Application.isBatchMode) EnsureGameView(); - - string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); - ScreenshotCaptureResult result = ScreenshotUtility.CaptureComposited( - fileName, resolvedSuperSize, ensureUniqueFileName: true, - includeImage: true, maxResolution: maxResolution, - folderOverride: folderOverride); - - if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath)) - AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); - string cameraName = Camera.main != null ? Camera.main.name : "composited"; - string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {cameraName})."; - return new SuccessResponse(message, BuildScreenshotResponseData(result, cameraName, includeImage: true)); + // A Task, not a response: the capture waits for the end of the frame, + // and CommandRegistry awaits a Task that a synchronous handler returns. + return CaptureCompositedScreenshotAsync(cmd, fileName, resolvedSuperSize, maxResolution); } if (includeImage) @@ -751,9 +743,51 @@ private static Dictionary BuildScreenshotResponseData( data["imageHeight"] = result.ImageHeight; } + if (result.FallbackReason != null) + { + data["captureSource"] = "camera_fallback"; + data["fallbackReason"] = result.FallbackReason; + } + return data; } + private static async Task CaptureCompositedScreenshotAsync( + SceneCommand cmd, + string fileName, + int resolvedSuperSize, + int maxResolution) + { + string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); + ScreenshotCaptureResult result; + try + { + result = await ScreenshotUtility.CaptureCompositedAsync( + fileName, resolvedSuperSize, ensureUniqueFileName: true, + includeImage: true, maxResolution: maxResolution, + folderOverride: folderOverride).ConfigureAwait(true); + } + catch (TimeoutException ex) + { + return new ErrorResponse(ex.Message); + } + catch (InvalidOperationException ex) + { + return new ErrorResponse(ex.Message); + } + + if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath)) + AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); + + // A fallback names the camera that actually rendered, which need not be Camera.main. + string cameraName = result.FallbackCameraName + ?? (Camera.main != null ? Camera.main.name : "composited"); + string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {cameraName})."; + if (result.FallbackReason != null) + message += " " + result.FallbackReason; + return new SuccessResponse(message, BuildScreenshotResponseData(result, cameraName, includeImage: true)); + } + private static object CaptureSceneViewScreenshot( SceneCommand cmd, string fileName, diff --git a/MCPForUnity/Editor/Tools/ManageUI.cs b/MCPForUnity/Editor/Tools/ManageUI.cs index cad174139..f497516c4 100644 --- a/MCPForUnity/Editor/Tools/ManageUI.cs +++ b/MCPForUnity/Editor/Tools/ManageUI.cs @@ -866,6 +866,14 @@ private static object RenderUI(JObject @params) playFullPath = EnsureUniqueFilePath(playFullPath); string playProjectRelPath = ScreenshotUtility.ToProjectRelativePath(playFullPath); + if (s_pendingCaptureDone && s_pendingCaptureTex == null) + { + s_pendingCaptureDone = false; + s_pendingCaptureStarted = false; + return new ErrorResponse( + "Play-mode screenshot timed out or captured nothing. Keep the Game view visible and the editor unpaused."); + } + // ── Case 1: capture is ready ────────────────────────────────────── if (s_pendingCaptureDone && s_pendingCaptureTex != null) { diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index 5d01042ec..e4399a9b6 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using UnityEngine; using UnityEngine.Experimental.Rendering; @@ -22,6 +24,13 @@ public ScreenshotCaptureResult(string fullPath, string projectRelativePath, int public ScreenshotCaptureResult(string fullPath, string projectRelativePath, int superSize, bool isAsync, string imageBase64, int imageWidth, int imageHeight) + : this(fullPath, projectRelativePath, superSize, isAsync, imageBase64, imageWidth, imageHeight, + fallbackReason: null, fallbackCameraName: null) + { + } + + public ScreenshotCaptureResult(string fullPath, string projectRelativePath, int superSize, bool isAsync, + string imageBase64, int imageWidth, int imageHeight, string fallbackReason, string fallbackCameraName) { FullPath = fullPath; ProjectRelativePath = projectRelativePath; @@ -30,6 +39,8 @@ public ScreenshotCaptureResult(string fullPath, string projectRelativePath, int ImageBase64 = imageBase64; ImageWidth = imageWidth; ImageHeight = imageHeight; + FallbackReason = fallbackReason; + FallbackCameraName = fallbackCameraName; } public string FullPath { get; } @@ -41,6 +52,13 @@ public ScreenshotCaptureResult(string fullPath, string projectRelativePath, int public string ImageBase64 { get; } public int ImageWidth { get; } public int ImageHeight { get; } + /// + /// Set when a composited capture was replaced by a camera render, and says why. A camera + /// render has no Screen Space - Overlay canvases or UI Toolkit panels. Null otherwise. + /// + public string FallbackReason { get; } + /// The camera that rendered the image when is set; null otherwise. + public string FallbackCameraName { get; } } public static class ScreenshotUtility @@ -51,6 +69,7 @@ public static class ScreenshotUtility /// or globally via ScreenshotPreferences in the Editor assembly. /// public const string DefaultFolder = "Assets/Screenshots"; + private static readonly SemaphoreSlim CompositedCaptureGate = new SemaphoreSlim(1, 1); private static Camera FindAvailableCamera() { @@ -167,52 +186,15 @@ public static ScreenshotCaptureResult CaptureFromCameraToProjectFolder( return result; } -#if UNITY_EDITOR - // Synchronously drive a WaitForEndOfFrame ScreenshotCapturer by pumping the editor's - // player loop. Play-mode only; EditorApplication.Step is a no-op in edit mode. - private static Texture2D CaptureCompositedAfterFrame(int superSize, int timeoutSteps = 5) - { - Texture2D result = null; - bool done = false; - bool callerReturned = false; - ScreenshotCapturer.Begin(superSize, tex => - { - // Late completion after the spin loop timed out: caller will never consume - // the texture, so destroy it here to avoid leaking a Unity object. - if (callerReturned) - { - if (tex != null) DestroyTexture(tex); - return; - } - result = tex; - done = true; - }); - // Step() pauses play mode as a side effect; restore the prior state so a screenshot - // doesn't leave a running game paused (an already-paused game stays paused). - bool wasPaused = UnityEditor.EditorApplication.isPaused; - try - { - for (int i = 0; i < timeoutSteps && !done; i++) - { - UnityEditor.EditorApplication.Step(); - } - } - finally - { - if (!wasPaused) - UnityEditor.EditorApplication.isPaused = false; - } - callerReturned = true; - return result; - } -#endif - /// - /// Captures a screenshot using ScreenCapture.CaptureScreenshotAsTexture, which captures the - /// final composited frame including UI Toolkit overlays, post-processing, etc. - /// Falls back to camera-based capture if ScreenCapture returns null at runtime. + /// Play-mode composited capture that waits for end-of-frame without pumping + /// EditorApplication.Step. MCP commands run inside + /// UnitySynchronizationContext.ExecuteTasks, so a synchronous Step() + /// re-enters the PlayerLoop and can flood Editor.log until the Editor dies. + /// When no end of frame can arrive, this returns a camera render instead and sets + /// . /// - public static ScreenshotCaptureResult CaptureComposited( + public static async Task CaptureCompositedAsync( string fileName = null, int superSize = 1, bool ensureUniqueFileName = true, @@ -220,70 +202,148 @@ public static ScreenshotCaptureResult CaptureComposited( int maxResolution = 0, string folderOverride = null) { - ScreenshotCaptureResult result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, folderOverride: folderOverride, isAsync: false); - Texture2D tex = null; - Texture2D downscaled = null; - string imageBase64 = null; - int imgW = 0, imgH = 0; - try + // Batch mode renders no frames, so WaitForEndOfFrame never resumes there and every + // capture would sit out the timeout before falling back. + if (Application.isBatchMode) { -#if UNITY_EDITOR - // In play mode, inline ScreenCapture reads a backbuffer before UITK has - // composited; route through WaitForEndOfFrame instead. - tex = Application.isPlaying - ? CaptureCompositedAfterFrame(result.SuperSize) - : ScreenCapture.CaptureScreenshotAsTexture(result.SuperSize); -#else - tex = ScreenCapture.CaptureScreenshotAsTexture(result.SuperSize); -#endif - if (tex == null) - { - // Fallback to camera-based if ScreenCapture fails - var cam = FindAvailableCamera(); - if (cam != null) - return CaptureFromCameraToProjectFolder(cam, fileName, superSize, ensureUniqueFileName, - includeImage, maxResolution, folderOverride: folderOverride); - throw new InvalidOperationException("ScreenCapture.CaptureScreenshotAsTexture returned null and no fallback camera available."); - } - - int width = tex.width; - int height = tex.height; + return CaptureWithCameraInstead(fileName, superSize, ensureUniqueFileName, includeImage, + maxResolution, folderOverride, "Batch mode renders no frames"); + } - byte[] png = tex.EncodeToPNG(); - File.WriteAllBytes(result.FullPath, png); + if (!await CompositedCaptureGate + .WaitAsync(TimeSpan.FromSeconds(ScreenshotCapturer.DefaultTimeoutSeconds * 4)) + .ConfigureAwait(true)) + { + throw new TimeoutException( + "Another composited screenshot capture is still in progress. Retry shortly."); + } + try + { + return await CaptureCompositedAsyncUngated( + fileName, superSize, ensureUniqueFileName, includeImage, maxResolution, folderOverride) + .ConfigureAwait(true); + } + finally + { + CompositedCaptureGate.Release(); + } + } - if (includeImage) + private static Task CaptureCompositedAsyncUngated( + string fileName, + int superSize, + bool ensureUniqueFileName, + bool includeImage, + int maxResolution, + string folderOverride) + { + // Fail fast on a bad folder, but pick the file name only when the image is written: + // a camera capture that runs during the wait could otherwise take the same unique + // name, and this write would then replace that file. + ResolveFolderAbsolute(folderOverride); + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + ScreenshotCapturer.Begin(Mathf.Max(1, superSize), (tex, timedOut) => + { + Texture2D downscaled = null; + try { - int targetMax = maxResolution > 0 ? maxResolution : 640; - if (width > targetMax || height > targetMax) + // Not an error: the caller still gets an image, and the result says it is a + // camera render rather than the composited Game view. + if (timedOut) { - downscaled = DownscaleTexture(tex, targetMax); - byte[] smallPng = downscaled.EncodeToPNG(); - imageBase64 = System.Convert.ToBase64String(smallPng); - imgW = downscaled.width; - imgH = downscaled.height; + tcs.TrySetResult(CaptureWithCameraInstead( + fileName, superSize, ensureUniqueFileName, includeImage, maxResolution, folderOverride, + $"No frame was rendered within {ScreenshotCapturer.DefaultTimeoutSeconds:0.#} s " + + "(for example, the game is paused or the Editor is not rendering while unfocused)")); + return; } - else + + if (tex == null) { - imageBase64 = System.Convert.ToBase64String(png); - imgW = width; - imgH = height; + tcs.TrySetResult(CaptureWithCameraInstead( + fileName, superSize, ensureUniqueFileName, includeImage, maxResolution, folderOverride, + "ScreenCapture returned no image")); + return; } + + var prepared = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, folderOverride: folderOverride, isAsync: false); + tcs.TrySetResult(EncodeAndSaveComposited(tex, prepared, includeImage, maxResolution, ref downscaled)); } - } - finally + catch (Exception ex) + { + tcs.TrySetException(ex); + } + finally + { + DestroyTexture(tex); + DestroyTexture(downscaled); + } + }); + + return tcs.Task; + } + + /// Renders a scene camera in place of a composited capture; says why. + private static ScreenshotCaptureResult CaptureWithCameraInstead( + string fileName, + int superSize, + bool ensureUniqueFileName, + bool includeImage, + int maxResolution, + string folderOverride, + string cause) + { + var cam = FindAvailableCamera(); + if (cam == null) + throw new InvalidOperationException(cause + ", and there is no camera to render instead."); + + var r = CaptureFromCameraToProjectFolder(cam, fileName, superSize, ensureUniqueFileName, + includeImage, maxResolution, folderOverride: folderOverride); + return new ScreenshotCaptureResult(r.FullPath, r.ProjectRelativePath, r.SuperSize, r.IsAsync, + r.ImageBase64, r.ImageWidth, r.ImageHeight, + $"{cause}, so this is a render of camera '{cam.name}'. A camera render does not show " + + "Screen Space - Overlay canvases or UI Toolkit panels.", + cam.name); + } + + private static ScreenshotCaptureResult EncodeAndSaveComposited( + Texture2D tex, + ScreenshotCaptureResult prepared, + bool includeImage, + int maxResolution, + ref Texture2D downscaled) + { + int width = tex.width; + int height = tex.height; + byte[] png = tex.EncodeToPNG(); + File.WriteAllBytes(prepared.FullPath, png); + + if (!includeImage) + return prepared; + + int targetMax = maxResolution > 0 ? maxResolution : 640; + string imageBase64; + int imgW; + int imgH; + if (width > targetMax || height > targetMax) { - DestroyTexture(tex); - DestroyTexture(downscaled); + downscaled = DownscaleTexture(tex, targetMax); + imageBase64 = Convert.ToBase64String(downscaled.EncodeToPNG()); + imgW = downscaled.width; + imgH = downscaled.height; } - - if (includeImage && imageBase64 != null) + else { - return new ScreenshotCaptureResult( - result.FullPath, result.ProjectRelativePath, result.SuperSize, false, - imageBase64, imgW, imgH); + imageBase64 = Convert.ToBase64String(png); + imgW = width; + imgH = height; } - return result; + + return new ScreenshotCaptureResult( + prepared.FullPath, prepared.ProjectRelativePath, prepared.SuperSize, false, + imageBase64, imgW, imgH); } /// @@ -764,29 +824,123 @@ private static string GetProjectRootPath() /// /// Transient MonoBehaviour that yields WaitForEndOfFrame, calls /// ScreenCapture.CaptureScreenshotAsTexture, invokes the callback, and self-destructs. + /// Times out via the editor update loop so a paused or unfocused PlayerLoop cannot leak + /// hidden capturer objects for the rest of the session. /// public sealed class ScreenshotCapturer : MonoBehaviour { + public const float DefaultTimeoutSeconds = 2f; + private int _superSize = 1; - private Action _onComplete; + private Action _onComplete; + private float _timeoutSeconds = DefaultTimeoutSeconds; + private float _startedAt; + private bool _finished; + private bool _destroying; + + /// Spawns a hidden GameObject, attaches a capturer, returns immediately. + public static ScreenshotCapturer Begin(int superSize, Action onComplete, float timeoutSeconds = DefaultTimeoutSeconds) + { + return Begin(superSize, (tex, _) => onComplete?.Invoke(tex), timeoutSeconds); + } /// Spawns a hidden GameObject, attaches a capturer, returns immediately. - public static void Begin(int superSize, Action onComplete) + public static ScreenshotCapturer Begin(int superSize, Action onComplete, float timeoutSeconds = DefaultTimeoutSeconds) { var go = new GameObject("__MCP_ScreenshotCapturer__") { hideFlags = HideFlags.HideAndDontSave }; var c = go.AddComponent(); c._superSize = Mathf.Max(1, superSize); c._onComplete = onComplete; + c._timeoutSeconds = Mathf.Max(0.05f, timeoutSeconds); + c._startedAt = Time.realtimeSinceStartup; + c.ArmTimeout(); + return c; + } + + private void ArmTimeout() + { +#if UNITY_EDITOR + UnityEditor.EditorApplication.update += TickTimeout; +#else + StartCoroutine(TimeoutWatch()); +#endif + } + + private void OnDestroy() + { + _destroying = true; + DisarmTimeout(); + if (!_finished) + Complete(null, timedOut: true); } + private void DisarmTimeout() + { +#if UNITY_EDITOR + UnityEditor.EditorApplication.update -= TickTimeout; +#endif + } + +#if UNITY_EDITOR + private void TickTimeout() + { + if (_finished) return; + if (Time.realtimeSinceStartup - _startedAt < _timeoutSeconds) return; + Complete(null, timedOut: true); + } +#else + private System.Collections.IEnumerator TimeoutWatch() + { + yield return new WaitForSecondsRealtime(_timeoutSeconds); + if (!_finished) + Complete(null, timedOut: true); + } +#endif + private System.Collections.IEnumerator Start() { yield return new WaitForEndOfFrame(); + if (_finished) yield break; + Texture2D tex = null; try { tex = ScreenCapture.CaptureScreenshotAsTexture(_superSize); } catch (Exception ex) { Debug.LogError($"[MCP for Unity] CaptureScreenshotAsTexture failed: {ex.Message}"); } - _onComplete?.Invoke(tex); - Destroy(gameObject); + Complete(tex, timedOut: false); + } + + private void Complete(Texture2D tex, bool timedOut) + { + if (_finished) + { + if (tex != null) + { + if (Application.isPlaying) + Destroy(tex); + else + DestroyImmediate(tex); + } + return; + } + _finished = true; + DisarmTimeout(); + try + { + _onComplete?.Invoke(tex, timedOut); + } + finally + { + // `this == null` once something else destroyed the capturer: outside play mode + // Unity sends it no OnDestroy, so _destroying stays false, and touching + // gameObject then throws MissingReferenceException. + if (!_destroying && this != null) + { +#if UNITY_EDITOR + DestroyImmediate(gameObject); +#else + Destroy(gameObject); +#endif + } + } } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ScreenshotCapturerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ScreenshotCapturerTests.cs new file mode 100644 index 000000000..2af9804ae --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ScreenshotCapturerTests.cs @@ -0,0 +1,104 @@ +using System.Collections; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.TestTools; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnityTests.Editor.Helpers +{ + public class ScreenshotCapturerTests + { + [TearDown] + public void TearDown() + { + foreach (var capturer in UnityEngine.Resources.FindObjectsOfTypeAll()) + { + if (capturer != null) + Object.DestroyImmediate(capturer.gameObject); + } + } + + [UnityTest] + public IEnumerator Begin_DoesNotLeakCapturerWhenFrameNeverCompletes() + { + bool called = false; + var capturer = ScreenshotCapturer.Begin(1, _ => called = true, timeoutSeconds: 0.15f); + Assert.IsNotNull(capturer, "Begin should return the live capturer."); + + float deadline = Time.realtimeSinceStartup + 2f; + while (!called && Time.realtimeSinceStartup < deadline) + yield return null; + + Assert.IsTrue(called, "Capturer must complete even if WaitForEndOfFrame never resumes."); + yield return null; + + Assert.IsTrue(capturer == null, "Hidden __MCP_ScreenshotCapturer__ must destroy itself after completion."); + Assert.AreEqual(0, UnityEngine.Resources.FindObjectsOfTypeAll().Length); + } + + [UnityTest] + public IEnumerator Destroy_StillCompletesPendingCallback() + { + // Outside play mode Unity sends this component no OnDestroy, so after an outside + // destroy it is the editor-update timeout that completes the waiter, and it must + // do so without touching the destroyed object. + bool called = false; + Texture2D received = null; + + var capturer = ScreenshotCapturer.Begin(1, tex => + { + received = tex; + called = true; + }, timeoutSeconds: 0.15f); + + Assert.IsNotNull(capturer); + Object.DestroyImmediate(capturer.gameObject); + + float deadline = Time.realtimeSinceStartup + 2f; + while (!called && Time.realtimeSinceStartup < deadline) + yield return null; + + Assert.IsTrue(called, "Destroying the capturer must complete the waiter so MCP commands cannot hang."); + Assert.IsNull(received); + Assert.AreEqual(0, UnityEngine.Resources.FindObjectsOfTypeAll().Length); + } + + [Test] + public void CaptureCompositedAsync_InBatchMode_RendersACameraAtOnceAndSaysWhy() + { + if (!Application.isBatchMode) + Assert.Ignore("Covers the batch-mode path; run the suite with -batchmode."); + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + Assert.Ignore("Requires a graphics device for the camera render; unavailable under -nographics."); + + var cameraObject = new GameObject("__ScreenshotBatchModeTestCamera"); + cameraObject.AddComponent(); + const string folder = "Temp/ScreenshotCapturerTests"; + try + { + var task = ScreenshotUtility.CaptureCompositedAsync( + "batch_mode", includeImage: true, maxResolution: 64, folderOverride: folder); + + // Batch mode renders no frames, so waiting for one would only sit out the timeout. + Assert.IsTrue(task.IsCompleted, "the call must not wait for an end of frame in batch mode"); + var result = task.Result; + StringAssert.StartsWith("Batch mode renders no frames", result.FallbackReason); + // The camera the response reports must be the one that rendered, as the reason says. + Assert.IsNotNull(result.FallbackCameraName); + StringAssert.Contains($"render of camera '{result.FallbackCameraName}'", result.FallbackReason); + Assert.IsNotNull(result.ImageBase64, "the caller still gets an image"); + Assert.AreEqual(0, UnityEngine.Resources.FindObjectsOfTypeAll().Length, + "no capturer may start when no frame can come"); + } + finally + { + Object.DestroyImmediate(cameraObject); + string absolute = ScreenshotUtility.ResolveFolderAbsolute(folder); + if (Directory.Exists(absolute)) + Directory.Delete(absolute, true); + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ScreenshotCapturerTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ScreenshotCapturerTests.cs.meta new file mode 100644 index 000000000..33db75ebe --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ScreenshotCapturerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8e4c91a2d7f4a3e9c5b1f0e8d7a6c5b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs index dbb658599..6b7ca1aaa 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs @@ -1,5 +1,11 @@ using System; +using System.Collections; +using System.Reflection; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; using MCPForUnity.Editor.Tools; namespace MCPForUnityTests.Editor.Tools @@ -52,5 +58,40 @@ public void AutoDiscovery_RegistersAllBuiltInTools() Assert.IsNotNull(result, $"Handler for '{toolName}' should return a result even for empty params"); } } + + [UnityTest] + public IEnumerator SyncHandlerThatReturnsATask_IsAwaitedOnBothEntryPoints() + { + // manage_scene's play-mode screenshot returns a Task from its synchronous handler, + // so the capture can wait for the end of the frame. The dispatcher (ExecuteCommand) + // and batch_execute (InvokeCommandAsync) must both wait for that Task instead of + // answering with the Task object itself. + const string name = "__test_sync_handler_returns_task"; + var handlers = (IDictionary)typeof(CommandRegistry) + .GetField("_handlers", BindingFlags.NonPublic | BindingFlags.Static) + .GetValue(null); + var pending = new TaskCompletionSource(); + handlers[name] = new HandlerInfo(name, _ => pending.Task, null); + try + { + var tcs = new TaskCompletionSource(); + Assert.IsNull(CommandRegistry.ExecuteCommand(name, new JObject(), tcs), + "the answer must come through the completion source, after the task"); + Assert.AreSame(pending.Task, CommandRegistry.InvokeCommandAsync(name, new JObject())); + Assert.IsFalse(tcs.Task.IsCompleted, "the command answered before its task finished"); + + pending.SetResult("captured"); + float deadline = Time.realtimeSinceStartup + 5f; + while (!tcs.Task.IsCompleted && Time.realtimeSinceStartup < deadline) + yield return null; + + Assert.IsTrue(tcs.Task.IsCompleted, "the command never answered"); + StringAssert.Contains("\"result\":\"captured\"", tcs.Task.Result); + } + finally + { + handlers.Remove(name); + } + } } }