/* ┌──────────────────────────────────────────────────────────────────┐ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ │ Copyright (c) 2025 Ivan Murzak │ │ Licensed under the Apache License, Version 2.0. │ │ See the LICENSE file in the project root for more information. │ └──────────────────────────────────────────────────────────────────┘ */ #nullable enable using System; using System.Collections.Generic; using System.Linq; using com.IvanMurzak.McpPlugin.Common.Model; using com.IvanMurzak.Unity.MCP.Runtime.Utils; using Extensions.Unity.PlayerPrefsEx; using UnityEditor.TestTools.TestRunner.Api; using UnityEngine; namespace com.IvanMurzak.Unity.MCP.Editor.API.TestRunner { public class TestResultCollector : ICallbacks { static volatile int counter = 0; readonly object _logsMutex = new(); readonly List _results = new(); readonly TestSummaryData _summary = new(); readonly List _logs = new(); readonly TestMode _testMode; DateTime startTime; public List GetResults() => _results; public TestSummaryData GetSummary() => _summary; public List GetLogs() { lock (_logsMutex) { return _logs.ToList(); } } public TestMode GetTestMode() => _testMode; public string TestModeAsString => _testMode switch { TestMode.EditMode => "EditMode", TestMode.PlayMode => "PlayMode", _ => "Unknown" }; public static PlayerPrefsString TestCallRequestID = new PlayerPrefsString("Unity_MCP_TestRunner_TestCallRequestID"); public static PlayerPrefsBool IncludePassingTests = new PlayerPrefsBool("Unity_MCP_TestRunner_IncludePassingTests"); public static PlayerPrefsBool IncludeMessage = new PlayerPrefsBool("Unity_MCP_TestRunner_IncludeMessage", true); public static PlayerPrefsBool IncludeMessageStacktrace = new PlayerPrefsBool("Unity_MCP_TestRunner_IncludeStacktrace"); public static PlayerPrefsBool IncludeLogs = new PlayerPrefsBool("Unity_MCP_TestRunner_IncludeLogs"); public static PlayerPrefsInt IncludeLogsMinLevel = new PlayerPrefsInt("Unity_MCP_TestRunner_IncludeLogsMinLevel", (int)LogType.Warning); public static PlayerPrefsBool IncludeLogsStacktrace = new PlayerPrefsBool("Unity_MCP_TestRunner_IncludeLogsStacktrace"); public TestResultCollector() { int newCount = System.Threading.Interlocked.Increment(ref counter); UnityMcpPluginEditor.Instance.LogTrace("Ctor", typeof(TestResultCollector)); if (newCount > 1) throw new InvalidOperationException($"Only one instance of {nameof(TestResultCollector)} is allowed. Current count: {newCount}"); } public void RunStarted(ITestAdaptor testsToRun) { UnityMcpPluginEditor.Instance.LogInfo("RunStarted", typeof(TestResultCollector)); startTime = DateTime.Now; var testCount = CountTests(testsToRun); lock (_logsMutex) { _logs.Clear(); } _results.Clear(); _summary.Clear(); _summary.TotalTests = testCount; // Subscribe to log messages (using threaded version to catch logs from all threads) Application.logMessageReceivedThreaded -= OnLogMessageReceived; Application.logMessageReceivedThreaded += OnLogMessageReceived; UnityMcpPluginEditor.Instance.LogInfo("Run {testMode} started: {testCount} tests.", typeof(TestResultCollector), TestModeAsString, testCount); } public void RunFinished(ITestResultAdaptor result) { UnityMcpPluginEditor.Instance.LogInfo("RunFinished", typeof(TestResultCollector)); // Unsubscribe from log messages Application.logMessageReceivedThreaded -= OnLogMessageReceived; var duration = DateTime.Now - startTime; _summary.Duration = DateTime.Now - startTime; _summary.TotalTests = CountTests(result.Test); if (_summary.FailedTests > 0) { _summary.Status = TestRunStatus.Failed; } else if (_summary.PassedTests > 0) { _summary.Status = TestRunStatus.Passed; } else { _summary.Status = TestRunStatus.Unknown; } UnityMcpPluginEditor.Instance.LogInfo("Run {testMode} finished with {totalTests} test results. Result status: {status}", typeof(TestResultCollector), TestModeAsString, _summary.TotalTests, result.TestStatus); UnityMcpPluginEditor.Instance.LogInfo("Final duration: {duration:mm\\:ss\\.fff}. Completed: {completed}/{total}", typeof(TestResultCollector), duration, _results.Count, _summary.TotalTests); UnityMcpPluginEditor.Instance.BuildMcpPluginIfNeeded(); if (!EnvironmentUtils.IsCi()) UnityMcpPluginEditor.ConnectIfNeeded(); var requestId = TestCallRequestID.Value; TestCallRequestID.Value = string.Empty; if (string.IsNullOrEmpty(requestId) == false) { var structuredResponse = CreateStructuredResponse( includePassingTests: IncludePassingTests.Value, includeMessage: IncludeMessage.Value, includeLogs: IncludeLogs.Value, includeMessageStacktrace: IncludeMessageStacktrace.Value, includeLogsStacktrace: IncludeLogsStacktrace.Value); var mcpPlugin = UnityMcpPluginEditor.Instance.McpPluginInstance ?? throw new InvalidOperationException("MCP Plugin instance is not available."); var response = ResponseCallValueTool .SuccessStructured(mcpPlugin.McpManager.Reflector.JsonSerializer.SerializeToNode(structuredResponse)) .SetRequestID(requestId); _ = UnityMcpPluginEditor.NotifyToolRequestCompleted(new RequestToolCompletedData { RequestId = requestId, Result = response }); } } public void TestStarted(ITestAdaptor test) { // Test started - could log this if needed } public void TestFinished(ITestResultAdaptor result) { // Only count actual tests, not test suites if (!result.Test.IsSuite) { var testResult = new TestResultData { Name = result.Test.FullName, Status = ConvertTestStatus(result.TestStatus), Duration = TimeSpan.FromSeconds(result.Duration), Message = result.Message, StackTrace = result.StackTrace }; _results.Add(testResult); var statusEmoji = result.TestStatus switch { TestStatus.Passed => "✅", TestStatus.Failed => "❌", TestStatus.Skipped => "⚠️", _ => string.Empty }; UnityMcpPluginEditor.Instance.LogInfo("{emoji} Test finished ({counter}/{total}): {testName} - {testStatus}", typeof(TestResultCollector), statusEmoji, _results.Count, _summary.TotalTests, result.Test.FullName, result.TestStatus); // Update summary counts switch (result.TestStatus) { case TestStatus.Passed: _summary.PassedTests++; break; case TestStatus.Failed: _summary.FailedTests++; break; case TestStatus.Skipped: _summary.SkippedTests++; break; } // Update duration as tests complete _summary.Duration = DateTime.Now - startTime; // Check if all tests are complete if (_results.Count >= _summary.TotalTests) { UnityMcpPluginEditor.Instance.LogInfo("All tests completed via TestFinished. Final duration: {duration:mm\\:ss\\.fff}", typeof(TestResultCollector), _summary.Duration); } } } void OnLogMessageReceived(string condition, string stackTrace, LogType type) { var entry = new TestLogEntry(type, condition, stackTrace); lock (_logsMutex) { _logs.Add(entry); } } TestRunResponse CreateStructuredResponse(bool includePassingTests, bool includeMessage, bool includeMessageStacktrace, bool includeLogs, bool includeLogsStacktrace) { var results = GetResults(); var summary = GetSummary(); var logs = GetLogs(); var response = new TestRunResponse { Summary = summary, Results = new List() }; // Filter test results based on includePassingTests, includeMessage and includeMessageStacktrace foreach (var result in results) { // Skip passing tests if includePassingTests is false if (!includePassingTests && result.Status == TestResultStatus.Passed) continue; var filteredResult = new TestResultData { Name = result.Name, Status = result.Status, Duration = result.Duration, Message = includeMessage ? result.Message : null, StackTrace = includeMessageStacktrace ? result.StackTrace : null }; response.Results.Add(filteredResult); } // Include logs if requested if (includeLogs && logs.Any()) { var minLogLevel = TestLogEntry.ToLogLevel((LogType)IncludeLogsMinLevel.Value); response.Logs = logs .Where(log => log.LogLevel >= minLogLevel) .Select(log => includeLogsStacktrace ? log : new TestLogEntry(log.Type, log.Condition, null, log.Timestamp)) .ToList(); } return response; } public static int CountTests(ITestAdaptor test) { try { if (test == null) return 0; if (test.HasChildren && test.Children != null) return test.Children.Sum(CountTests); return test.IsSuite ? 0 : 1; } catch { return 0; } } static TestResultStatus ConvertTestStatus(TestStatus testStatus) { return testStatus switch { TestStatus.Passed => TestResultStatus.Passed, TestStatus.Failed => TestResultStatus.Failed, TestStatus.Skipped => TestResultStatus.Skipped, _ => TestResultStatus.Skipped }; } } }