Skip to main content
Glama
TestRunnerService.cs16.8 kB
using System; using System.Collections.Generic; using System.Linq; using System.Threading; 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 { /// <summary> /// Concrete implementation of <see cref="ITestRunnerService"/>. /// Coordinates Unity Test Runner operations and produces structured results. /// </summary> internal sealed class TestRunnerService : ITestRunnerService, ICallbacks, IDisposable { private static readonly TestMode[] AllModes = { TestMode.EditMode, TestMode.PlayMode }; private readonly TestRunnerApi _testRunnerApi; private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); private readonly List<ITestResultAdaptor> _leafResults = new List<ITestResultAdaptor>(); private TaskCompletionSource<TestRunResult> _runCompletionSource; public TestRunnerService() { _testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>(); _testRunnerApi.RegisterCallbacks(this); } public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode) { await _operationLock.WaitAsync().ConfigureAwait(true); try { var modes = mode.HasValue ? new[] { mode.Value } : AllModes; var results = new List<Dictionary<string, string>>(); var seen = new HashSet<string>(StringComparer.Ordinal); foreach (var m in modes) { var root = await RetrieveTestRootAsync(m).ConfigureAwait(true); if (root != null) { CollectFromNode(root, m, results, seen, new List<string>()); } } return results; } finally { _operationLock.Release(); } } public async Task<TestRunResult> RunTestsAsync(TestMode mode) { 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 }; 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; } try { return await runTask.ConfigureAwait(true); } finally { if (adjustedPlayModeOptions) { RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions); } _operationLock.Release(); } } public void Dispose() { try { _testRunnerApi?.UnregisterCallbacks(this); } catch { // Ignore cleanup errors } if (_testRunnerApi != null) { ScriptableObject.DestroyImmediate(_testRunnerApi); } _operationLock.Dispose(); } #region TestRunnerApi callbacks public void RunStarted(ITestAdaptor testsToRun) { _leafResults.Clear(); } public void RunFinished(ITestResultAdaptor result) { if (_runCompletionSource == null) { return; } var payload = TestRunResult.Create(result, _leafResults); _runCompletionSource.TrySetResult(payload); _runCompletionSource = null; } public void TestStarted(ITestAdaptor test) { // No-op } public void TestFinished(ITestResultAdaptor result) { if (result == null) { return; } if (!result.HasChildren) { _leafResults.Add(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) { var tcs = new TaskCompletionSource<ITestAdaptor>(TaskCreationOptions.RunContinuationsAsynchronously); _testRunnerApi.RetrieveTestList(mode, root => { tcs.TrySetResult(root); }); // Ensure the editor pumps at least one additional update in case the window is unfocused. EditorApplication.QueuePlayerLoopUpdate(); var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(true); if (completed != tcs.Task) { McpLog.Warn($"[TestRunnerService] Timeout waiting for test retrieval callback for {mode}"); return null; } try { return await tcs.Task.ConfigureAwait(true); } catch (Exception ex) { McpLog.Error($"[TestRunnerService] Error retrieving tests for {mode}: {ex.Message}\n{ex.StackTrace}"); return null; } } private static void CollectFromNode( ITestAdaptor node, TestMode mode, List<Dictionary<string, string>> output, HashSet<string> seen, List<string> path) { if (node == null) { return; } bool hasName = !string.IsNullOrEmpty(node.Name); if (hasName) { path.Add(node.Name); } bool hasChildren = node.HasChildren && node.Children != null; if (!hasChildren) { string fullName = string.IsNullOrEmpty(node.FullName) ? node.Name ?? string.Empty : node.FullName; string key = $"{mode}:{fullName}"; if (!string.IsNullOrEmpty(fullName) && seen.Add(key)) { string computedPath = path.Count > 0 ? string.Join("/", path) : fullName; output.Add(new Dictionary<string, string> { ["name"] = node.Name ?? fullName, ["full_name"] = fullName, ["path"] = computedPath, ["mode"] = mode.ToString(), }); } } else if (node.Children != null) { foreach (var child in node.Children) { CollectFromNode(child, mode, output, seen, path); } } if (hasName && path.Count > 0) { path.RemoveAt(path.Count - 1); } } #endregion } /// <summary> /// Summary of a Unity test run. /// </summary> public sealed class TestRunResult { internal TestRunResult(TestRunSummary summary, IReadOnlyList<TestRunTestResult> results) { Summary = summary; Results = results; } public TestRunSummary Summary { get; } public IReadOnlyList<TestRunTestResult> Results { get; } public int Total => Summary.Total; public int Passed => Summary.Passed; public int Failed => Summary.Failed; public int Skipped => Summary.Skipped; public object ToSerializable(string mode) { return new { mode, summary = Summary.ToSerializable(), results = Results.Select(r => r.ToSerializable()).ToList(), }; } internal static TestRunResult Create(ITestResultAdaptor summary, IReadOnlyList<ITestResultAdaptor> tests) { var materializedTests = tests.Select(TestRunTestResult.FromAdaptor).ToList(); int passed = summary?.PassCount ?? materializedTests.Count(t => string.Equals(t.State, "Passed", StringComparison.OrdinalIgnoreCase)); int failed = summary?.FailCount ?? materializedTests.Count(t => string.Equals(t.State, "Failed", StringComparison.OrdinalIgnoreCase)); int skipped = summary?.SkipCount ?? materializedTests.Count(t => string.Equals(t.State, "Skipped", StringComparison.OrdinalIgnoreCase)); double duration = summary?.Duration ?? materializedTests.Sum(t => t.DurationSeconds); int total = summary != null ? passed + failed + skipped : materializedTests.Count; var summaryPayload = new TestRunSummary( total, passed, failed, skipped, duration, summary?.ResultState ?? "Unknown"); return new TestRunResult(summaryPayload, materializedTests); } } public sealed class TestRunSummary { internal TestRunSummary(int total, int passed, int failed, int skipped, double durationSeconds, string resultState) { Total = total; Passed = passed; Failed = failed; Skipped = skipped; DurationSeconds = durationSeconds; ResultState = resultState; } public int Total { get; } public int Passed { get; } public int Failed { get; } public int Skipped { get; } public double DurationSeconds { get; } public string ResultState { get; } internal object ToSerializable() { return new { total = Total, passed = Passed, failed = Failed, skipped = Skipped, durationSeconds = DurationSeconds, resultState = ResultState, }; } } public sealed class TestRunTestResult { internal TestRunTestResult( string name, string fullName, string state, double durationSeconds, string message, string stackTrace, string output) { Name = name; FullName = fullName; State = state; DurationSeconds = durationSeconds; Message = message; StackTrace = stackTrace; Output = output; } public string Name { get; } public string FullName { get; } public string State { get; } public double DurationSeconds { get; } public string Message { get; } public string StackTrace { get; } public string Output { get; } internal object ToSerializable() { return new { name = Name, fullName = FullName, state = State, durationSeconds = DurationSeconds, message = Message, stackTrace = StackTrace, output = Output, }; } internal static TestRunTestResult FromAdaptor(ITestResultAdaptor adaptor) { if (adaptor == null) { return new TestRunTestResult(string.Empty, string.Empty, "Unknown", 0.0, string.Empty, string.Empty, string.Empty); } return new TestRunTestResult( adaptor.Name, adaptor.FullName, adaptor.ResultState, adaptor.Duration, adaptor.Message, adaptor.StackTrace, adaptor.Output); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/CoplayDev/unity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server