using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEditor.TestTools.TestRunner.Api; using UnityEngine; namespace MCPForUnity.Editor.Services { /// /// Concrete implementation of . /// Coordinates Unity Test Runner operations and produces structured results. /// 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 _leafResults = new List(); private TaskCompletionSource _runCompletionSource; public TestRunnerService() { _testRunnerApi = ScriptableObject.CreateInstance(); _testRunnerApi.RegisterCallbacks(this); } public async Task>> GetTestsAsync(TestMode? mode) { await _operationLock.WaitAsync().ConfigureAwait(false); try { var modes = mode.HasValue ? new[] { mode.Value } : AllModes; var results = new List>(); var seen = new HashSet(StringComparer.Ordinal); foreach (var m in modes) { var root = await RetrieveTestRootAsync(m).ConfigureAwait(true); if (root != null) { CollectFromNode(root, m, results, seen, new List()); } } return results; } finally { _operationLock.Release(); } } public async Task RunTestsAsync(TestMode mode) { await _operationLock.WaitAsync().ConfigureAwait(false); Task runTask; try { if (_runCompletionSource != null && !_runCompletionSource.Task.IsCompleted) { throw new InvalidOperationException("A Unity test run is already in progress."); } _leafResults.Clear(); _runCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var filter = new Filter { testMode = mode }; _testRunnerApi.Execute(new ExecutionSettings(filter)); runTask = _runCompletionSource.Task; } catch { _operationLock.Release(); throw; } try { return await runTask.ConfigureAwait(true); } finally { _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 #region Test list helpers private async Task RetrieveTestRootAsync(TestMode mode) { var tcs = new TaskCompletionSource(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> output, HashSet seen, List 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 { ["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 of a Unity test run. /// public sealed class TestRunResult { internal TestRunResult(TestRunSummary summary, IReadOnlyList results) { Summary = summary; Results = results; } public TestRunSummary Summary { get; } public IReadOnlyList 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 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); } } }