Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static partial class Tool_TestRunner
)]
[Description(@"Execute Unity tests and return detailed results. Supports filtering by test mode, assembly, namespace, class, and method.
Be default recommended to use 'EditMode' for faster iteration during development.")]
public static async Task<ResponseCallTool> Run
public static async Task<ResponseCallValueTool<TestRunResponse>> Run
(
[Description("Test mode to run. Options: '" + nameof(TestMode.EditMode) + "', '" + nameof(TestMode.PlayMode) + "'. Default: '" + nameof(TestMode.EditMode) + "'")]
TestMode testMode = TestMode.EditMode,
Expand All @@ -47,7 +47,9 @@ public static async Task<ResponseCallTool> Run
[Description("Specific fully qualified test method to run (optional). Example: 'MyTestNamespace.FixtureName.TestName'")]
string? testMethod = null,

[Description("Include test result messages in the test results (default: true). If just need pass/fail status, set to false.")]
[Description("Include details for all tests, both passing and failing (default: false). If you just need details for failing tests, set to false.")]
bool includePassingTests = false,
[Description("Include test result messages in the test results (default: true). If you just need pass/fail status, set to false.")]
bool includeMessages = true,
[Description("Include stack traces in the test results (default: false).")]
bool includeStacktrace = false,
Expand All @@ -64,15 +66,17 @@ public static async Task<ResponseCallTool> Run
)
{
if (requestId == null || string.IsNullOrWhiteSpace(requestId))
return ResponseCallTool.Error("Original request with valid RequestID must be provided.");
return ResponseCallValueTool<TestRunResponse>.Error("Original request with valid RequestID must be provided.");

return await MainThread.Instance.RunAsync(async () =>
{
if (UnityMcpPlugin.IsLogEnabled(LogLevel.Info))
Debug.Log($"[TestRunner] ------------------------------------- Preparing to run {testMode} tests.");

try
{
TestResultCollector.TestCallRequestID.Value = requestId;
TestResultCollector.IncludePassingTests.Value = includePassingTests;
TestResultCollector.IncludeMessage.Value = includeMessages;
TestResultCollector.IncludeMessageStacktrace.Value = includeStacktrace;

Expand All @@ -86,17 +90,16 @@ public static async Task<ResponseCallTool> Run
if (UnityMcpPlugin.IsLogEnabled(LogLevel.Info))
Debug.Log($"[TestRunner] Running {testMode} tests with filters: {filterParams}");

// Validate specific test mode filter
var validation = await ValidateTestFilters(TestRunnerApi, testMode, filterParams);
if (validation != null)
return ResponseCallTool.Error(validation).SetRequestID(requestId);
return ResponseCallValueTool<TestRunResponse>.Error(validation).SetRequestID(requestId);

var filter = CreateTestFilter(testMode, filterParams);

// Delay test running, first need to return response to caller
MainThread.Instance.Run(() => TestRunnerApi.Execute(new ExecutionSettings(filter)));

return ResponseCallTool.Processing().SetRequestID(requestId);
return ResponseCallValueTool<TestRunResponse>.Processing().SetRequestID(requestId);
}
catch (Exception ex)
{
Expand All @@ -105,7 +108,7 @@ public static async Task<ResponseCallTool> Run
Debug.LogException(ex);
Debug.LogError($"[TestRunner] ------------------------------------- Exception {testMode} tests.");
}
return ResponseCallTool.Error(Error.TestExecutionFailed(ex.Message)).SetRequestID(requestId);
return ResponseCallValueTool<TestRunResponse>.Error(Error.TestExecutionFailed(ex.Message)).SetRequestID(requestId);
}
}).Unwrap();
}
Expand Down
25 changes: 22 additions & 3 deletions Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/TestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Collections.Generic;
using com.IvanMurzak.McpPlugin;
using com.IvanMurzak.Unity.MCP.Editor.API.TestRunner;
using com.IvanMurzak.Unity.MCP.Runtime.Utils;
using UnityEditor;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
Expand All @@ -25,6 +26,8 @@ public static partial class Tool_TestRunner
static readonly object _lock = new();
static volatile TestRunnerApi? _testRunnerApi = null!;
static volatile TestResultCollector? _resultCollector = null!;
static volatile bool _callbacksRegistered = false;

static Tool_TestRunner()
{
_testRunnerApi ??= CreateInstance();
Expand All @@ -44,12 +47,28 @@ public static TestRunnerApi TestRunnerApi
}
public static TestRunnerApi CreateInstance()
{
// if (UnityMcpPlugin.IsLogActive(MCP.Utils.LogLevel.Trace))
// Debug.Log($"[{nameof(TestRunnerApi)}] Ctor.");
if (UnityMcpPlugin.IsLogEnabled(LogLevel.Trace))
Debug.Log($"[{nameof(TestRunnerApi)}] Creating new instance. Existing API: {_testRunnerApi != null}, Existing Collector: {_resultCollector != null}, Callbacks Registered: {_callbacksRegistered}");

_resultCollector ??= new TestResultCollector();
var testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();
testRunnerApi.RegisterCallbacks(_resultCollector);

// Only register callbacks once globally to prevent accumulation
// Unity's TestRunnerApi maintains a static callback list, so multiple RegisterCallbacks calls add duplicates
if (!_callbacksRegistered)
{
if (UnityMcpPlugin.IsLogEnabled(LogLevel.Trace))
Debug.Log($"[{nameof(TestRunnerApi)}] Registering callbacks for the first (and only) time.");

testRunnerApi.RegisterCallbacks(_resultCollector);
_callbacksRegistered = true;
}
else
{
if (UnityMcpPlugin.IsLogEnabled(LogLevel.Trace))
Debug.LogWarning($"[{nameof(TestRunnerApi)}] Callbacks already registered globally - skipping registration.");
}

return testRunnerApi;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ namespace com.IvanMurzak.Unity.MCP.Editor.API.TestRunner
{
public class TestLogEntry
{
public string Condition;
public string? StackTrace;
public LogType Type;
public DateTime Timestamp;
public string Condition { get; set; }
public string? StackTrace { get; set; }
public LogType Type { get; set; }
public DateTime Timestamp { get; set; }

public int LogLevel => ToLogLevel(Type);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace com.IvanMurzak.Unity.MCP.Editor.API.TestRunner
{
public class TestResultCollector : ICallbacks
{
static int counter = 0;
static volatile int counter = 0;

readonly object _logsMutex = new();
readonly List<TestResultData> _results = new();
Expand Down Expand Up @@ -53,6 +53,7 @@ public List<TestLogEntry> GetLogs()

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");

Expand Down Expand Up @@ -85,10 +86,7 @@ public void RunStarted(ITestAdaptor testsToRun)
_summary.Clear();
_summary.TotalTests = testCount;

// Subscribe on log messages
Application.logMessageReceived -= OnLogMessageReceived;
Application.logMessageReceived += OnLogMessageReceived;

// Subscribe to log messages (using threaded version to catch logs from all threads)
Application.logMessageReceivedThreaded -= OnLogMessageReceived;
Application.logMessageReceivedThreaded += OnLogMessageReceived;

Expand All @@ -101,7 +99,6 @@ public void RunFinished(ITestResultAdaptor result)
UnityMcpPlugin.Instance.LogInfo("RunFinished", typeof(TestResultCollector));

// Unsubscribe from log messages
Application.logMessageReceived -= OnLogMessageReceived;
Application.logMessageReceivedThreaded -= OnLogMessageReceived;

var duration = DateTime.Now - startTime;
Expand Down Expand Up @@ -134,12 +131,22 @@ public void RunFinished(ITestResultAdaptor result)
TestCallRequestID.Value = string.Empty;
if (string.IsNullOrEmpty(requestId) == false)
{
var response = ResponseCallTool
.Success(FormatTestResults(
includeMessage: IncludeMessage.Value,
includeLogs: IncludeLogs.Value,
includeMessageStacktrace: IncludeMessageStacktrace.Value,
includeLogsStacktrace: IncludeLogsStacktrace.Value))
var structuredResponse = CreateStructuredResponse(
includePassingTests: IncludePassingTests.Value,
includeMessage: IncludeMessage.Value,
includeLogs: IncludeLogs.Value,
includeMessageStacktrace: IncludeMessageStacktrace.Value,
includeLogsStacktrace: IncludeLogsStacktrace.Value);

var mcpPlugin = UnityMcpPlugin.Instance.McpPluginInstance ?? throw new InvalidOperationException("MCP Plugin instance is not available.");
var jsonOptions = mcpPlugin.McpManager.Reflector.JsonSerializerOptions;
var jsonNode = System.Text.Json.JsonSerializer.SerializeToNode(structuredResponse, jsonOptions);
var jsonString = jsonNode?.ToJsonString();

var response = ResponseCallValueTool<TestRunResponse>
.SuccessStructured(
structuredContent: jsonNode,
message: jsonString ?? "[Success] Test execution completed.") // Needed for MCP backward compatibility: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
.SetRequestID(requestId);

_ = UnityMcpPlugin.NotifyToolRequestCompleted(new RequestToolCompletedData
Expand All @@ -163,7 +170,7 @@ public void TestFinished(ITestResultAdaptor result)
var testResult = new TestResultData
{
Name = result.Test.FullName,
Status = result.TestStatus.ToString(),
Status = ConvertTestStatus(result.TestStatus),
Duration = TimeSpan.FromSeconds(result.Duration),
Message = result.Message,
StackTrace = result.StackTrace
Expand Down Expand Up @@ -217,67 +224,49 @@ void OnLogMessageReceived(string condition, string stackTrace, LogType type)
}
}

string FormatTestResults(bool includeMessage, bool includeMessageStacktrace, bool includeLogs, bool includeLogsStacktrace)
TestRunResponse CreateStructuredResponse(bool includePassingTests, bool includeMessage, bool includeMessageStacktrace, bool includeLogs, bool includeLogsStacktrace)
{
var results = GetResults();
var summary = GetSummary();
var logs = GetLogs();

var output = new StringBuilder();
output.AppendLine("[Success] Test execution completed.");
output.AppendLine();

// Summary
output.AppendLine("=== TEST SUMMARY ===");
output.AppendLine($"Status: {summary.Status}");
output.AppendLine($"Total: {summary.TotalTests}");
output.AppendLine($"Passed: {summary.PassedTests}");
output.AppendLine($"Failed: {summary.FailedTests}");
output.AppendLine($"Skipped: {summary.SkippedTests}");
output.AppendLine($"Duration: {summary.Duration:hh\\:mm\\:ss\\.fff}");
output.AppendLine();

// Individual test results
if (results.Any())
var response = new TestRunResponse
{
Summary = summary,
Results = new List<TestResultData>()
};

// Filter test results based on includePassingTests, includeMessage and includeMessageStacktrace
foreach (var result in results)
{
output.AppendLine("=== TEST RESULTS ===");
foreach (var result in results)
// Skip passing tests if includePassingTests is false
if (!includePassingTests && result.Status == TestResultStatus.Passed)
continue;

var filteredResult = new TestResultData
{
output.AppendLine($"[{result.Status}] {result.Name}");
output.AppendLine($" Duration: {result.Duration:ss\\.fff}s");

if (includeMessage)
{
if (!string.IsNullOrEmpty(result.Message))
output.AppendLine($" Message: {result.Message}");
}

if (includeMessageStacktrace)
{
if (!string.IsNullOrEmpty(result.StackTrace))
output.AppendLine($" Stack Trace: {result.StackTrace}");
}

output.AppendLine();
}
Name = result.Name,
Status = result.Status,
Duration = result.Duration,
Message = includeMessage ? result.Message : null,
StackTrace = includeMessageStacktrace ? result.StackTrace : null
};
response.Results.Add(filteredResult);
}

// Console logs
// Include logs if requested
if (includeLogs && logs.Any())
{
var minLogLevel = TestLogEntry.ToLogLevel((LogType)IncludeLogsMinLevel.Value);
output.AppendLine("=== CONSOLE LOGS ===");
foreach (var log in logs)
{
if (log.LogLevel < minLogLevel)
continue;
output.AppendLine(log.ToStringFormat(
includeType: true,
includeStacktrace: includeLogsStacktrace));
}
response.Logs = logs
.Where(log => log.LogLevel >= minLogLevel)
.Select(log => includeLogsStacktrace
? log
: new TestLogEntry(log.Type, log.Condition, null, log.Timestamp))
.ToList();
}

return output.ToString();
return response;
}

public static int CountTests(ITestAdaptor test)
Expand All @@ -297,5 +286,16 @@ public static int CountTests(ITestAdaptor test)
return 0;
}
}

static TestResultStatus ConvertTestStatus(TestStatus testStatus)
{
return testStatus switch
{
TestStatus.Passed => TestResultStatus.Passed,
TestStatus.Failed => TestResultStatus.Failed,
TestStatus.Skipped => TestResultStatus.Skipped,
_ => TestResultStatus.Skipped
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace com.IvanMurzak.Unity.MCP.Editor.API.TestRunner
public class TestResultData
{
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public TestResultStatus Status { get; set; } = TestResultStatus.Skipped;
public TimeSpan Duration { get; set; }
public string? Message { get; set; }
public string? StackTrace { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
┌──────────────────────────────────────────────────────────────────┐
│ 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. │
└──────────────────────────────────────────────────────────────────┘
*/
#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

namespace com.IvanMurzak.Unity.MCP.Editor.API.TestRunner
{
public enum TestResultStatus
{
Passed,
Failed,
Skipped
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
┌──────────────────────────────────────────────────────────────────┐
│ 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.Collections.Generic;

namespace com.IvanMurzak.Unity.MCP.Editor.API.TestRunner
{
public class TestRunResponse
{
public TestSummaryData Summary { get; set; } = new TestSummaryData();
public List<TestResultData> Results { get; set; } = new List<TestResultData>();
public List<TestLogEntry>? Logs { get; set; }
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading