Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 98 additions & 3 deletions MCPForUnity/Editor/Services/TestRunnerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace MCPForUnity.Editor.Services
{
Expand All @@ -31,7 +33,7 @@ public TestRunnerService()

public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode)
{
await _operationLock.WaitAsync().ConfigureAwait(false);
await _operationLock.WaitAsync().ConfigureAwait(true);
try
{
var modes = mode.HasValue ? new[] { mode.Value } : AllModes;
Expand All @@ -58,25 +60,59 @@ public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestM

public async Task<TestRunResult> RunTestsAsync(TestMode mode)
{
await _operationLock.WaitAsync().ConfigureAwait(false);
await _operationLock.WaitAsync().ConfigureAwait(true);
Task<TestRunResult> runTask;
bool adjustedPlayModeOptions = false;
bool originalEnterPlayModeOptionsEnabled = false;
EnterPlayModeOptions originalEnterPlayModeOptions = EnterPlayModeOptions.None;
try
{
if (_runCompletionSource != null && !_runCompletionSource.Task.IsCompleted)
{
throw new InvalidOperationException("A Unity test run is already in progress.");
}

if (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode)
{
throw new InvalidOperationException("Cannot start a test run while the Editor is in or entering Play Mode. Stop Play Mode and try again.");
}

if (mode == TestMode.PlayMode)
{
// PlayMode runs transition the editor into play across multiple update ticks. Unity's
// built-in pipeline schedules SaveModifiedSceneTask early, but that task uses
// EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo which throws once play mode is
// active. To minimize that window we pre-save dirty scenes and disable domain reload (so the
// MCP bridge stays alive). We do NOT force runSynchronously here because that can freeze the
// editor in some projects. If the TestRunner still hits the save task after entering play, the
// run can fail; in that case, rerun from a clean Edit Mode state.
adjustedPlayModeOptions = EnsurePlayModeRunsWithoutDomainReload(
out originalEnterPlayModeOptionsEnabled,
out originalEnterPlayModeOptions);
}

_leafResults.Clear();
_runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously);

var filter = new Filter { testMode = mode };
_testRunnerApi.Execute(new ExecutionSettings(filter));
var settings = new ExecutionSettings(filter);

if (mode == TestMode.PlayMode)
{
SaveDirtyScenesIfNeeded();
}

_testRunnerApi.Execute(settings);

runTask = _runCompletionSource.Task;
}
catch
{
if (adjustedPlayModeOptions)
{
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
}

_operationLock.Release();
throw;
}
Expand All @@ -87,6 +123,11 @@ public async Task<TestRunResult> RunTestsAsync(TestMode mode)
}
finally
{
if (adjustedPlayModeOptions)
{
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
}

_operationLock.Release();
}
}
Expand Down Expand Up @@ -149,6 +190,60 @@ public void TestFinished(ITestResultAdaptor result)

#endregion

private static bool EnsurePlayModeRunsWithoutDomainReload(
out bool originalEnterPlayModeOptionsEnabled,
out EnterPlayModeOptions originalEnterPlayModeOptions)
{
originalEnterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled;
originalEnterPlayModeOptions = EditorSettings.enterPlayModeOptions;

// When Play Mode triggers a domain reload, the MCP connection is torn down and the pending
// test run response never makes it back to the caller. To keep the bridge alive for this
// invocation, temporarily enable Enter Play Mode Options with domain reload disabled.
bool domainReloadDisabled = (originalEnterPlayModeOptions & EnterPlayModeOptions.DisableDomainReload) != 0;
bool needsChange = !originalEnterPlayModeOptionsEnabled || !domainReloadDisabled;
if (!needsChange)
{
return false;
}

var desired = originalEnterPlayModeOptions | EnterPlayModeOptions.DisableDomainReload;
EditorSettings.enterPlayModeOptionsEnabled = true;
EditorSettings.enterPlayModeOptions = desired;
return true;
}

private static void RestoreEnterPlayModeOptions(bool originalEnabled, EnterPlayModeOptions originalOptions)
{
EditorSettings.enterPlayModeOptions = originalOptions;
EditorSettings.enterPlayModeOptionsEnabled = originalEnabled;
}

private static void SaveDirtyScenesIfNeeded()
{
int sceneCount = SceneManager.sceneCount;
for (int i = 0; i < sceneCount; i++)
{
var scene = SceneManager.GetSceneAt(i);
if (scene.isDirty)
{
if (string.IsNullOrEmpty(scene.path))
{
McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running PlayMode tests.");
continue;
}
try
{
EditorSceneManager.SaveScene(scene);
}
catch (Exception ex)
{
McpLog.Warn($"[TestRunnerService] Failed to save dirty scene '{scene.name}': {ex.Message}");
}
}
}
}

#region Test list helpers

private async Task<ITestAdaptor> RetrieveTestRootAsync(TestMode mode)
Expand Down