unity-mcp/MCPForUnity/Editor/Tools/RunTestsAsync.cs

129 lines
4.7 KiB
C#
Raw Normal View History

Async Test Infrastructure & Editor Readiness Status + new refresh_unity tool (#507) * Add editor readiness v2, refresh tool, and preflight guards * Detect external package changes and harden refresh retry * feat: add TestRunnerNoThrottle and async test running with background stall prevention - Add TestRunnerNoThrottle.cs: Sets editor to 'No Throttling' mode during test runs with SessionState persistence across domain reload - Add run_tests_async and get_test_job tools for non-blocking test execution - Add TestJobManager for async test job tracking with progress monitoring - Add ForceSynchronousImport to all AssetDatabase.Refresh() calls to prevent stalls - Mark DomainReloadResilienceTests as [Explicit] with documentation explaining the test infrastructure limitation (internal coroutine waits vs MCP socket polling) - MCP workflow is unaffected - socket messages provide external stimulus that keeps Unity responsive even when backgrounded * refactor: simplify and clean up code - Remove unused Newtonsoft.Json.Linq import from TestJobManager - Add throttling to SessionState persistence (once per second) to reduce overhead - Critical job state changes (start/finish) still persist immediately - Fix duplicate XML summary tag in DomainReloadResilienceTests * docs: add async test tools to README, document domain reload limitation - Add run_tests_async and get_test_job to main README tools list - Document background stall limitation for domain reload tests in DEV readme * ci: add separate job for domain reload tests Run [Explicit] domain_reload tests in their own job using -testCategory * ci: run domain reload tests in same job as regular tests Combines into single job with two test steps to reuse cached Library * fix: address coderabbit review issues - Fix TOCTOU race in TestJobManager.StartJob (single lock scope for check-and-set) - Store TestRunnerApi reference with HideAndDontSave to prevent GC/serialization issues * docs: update tool descriptions to prefer run_tests_async - run_tests_async is now marked as preferred for long-running suites - run_tests description notes it blocks and suggests async alternative * docs: update README screenshot to v8.6 UI * docs: add v8.6 UI screenshot * Update README for MCP version and instructions for v8.7 * fix: handle preflight busy signals and derive job status from test results - manage_asset, manage_gameobject, manage_scene now check preflight return value and propagate busy/retry signals to clients (fixes Sourcery #1) - TestJobManager.FinalizeCurrentJobFromRunFinished now sets job status to Failed when resultPayload.Failed > 0, not always Succeeded (fixes Sourcery #2) * fix: increase HTTP server startup timeout for dev mode When 'Force fresh server install' is enabled, uvx uses --no-cache --refresh which rebuilds the package and takes significantly longer to start. - Increase timeout from 10s to 45s when dev mode is enabled - Add informative log message explaining the longer startup time - Show actual timeout value in warning message * fix: derive job status from test results in FinalizeFromTask fallback Apply same logic as FinalizeCurrentJobFromRunFinished: check result.Failed > 0 to correctly mark jobs as Failed when tests fail, even in the fallback path when RunFinished callback is not delivered.
2026-01-04 04:42:32 +08:00
using System;
using System.Linq;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Tests;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Starts a Unity Test Runner run asynchronously and returns a job id immediately.
/// Use get_test_job(job_id) to poll status/results.
/// </summary>
[McpForUnityTool("run_tests_async", AutoRegister = false)]
public static class RunTestsAsync
{
public static Task<object> HandleCommand(JObject @params)
{
try
{
string modeStr = @params?["mode"]?.ToString();
if (string.IsNullOrWhiteSpace(modeStr))
{
modeStr = "EditMode";
}
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
{
return Task.FromResult<object>(new ErrorResponse(parseError));
}
bool includeDetails = false;
bool includeFailedTests = false;
try
{
var includeDetailsToken = @params?["includeDetails"];
if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails))
{
includeDetails = parsedIncludeDetails;
}
var includeFailedTestsToken = @params?["includeFailedTests"];
if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests))
{
includeFailedTests = parsedIncludeFailedTests;
}
}
catch
{
// ignore parse failures
}
var filterOptions = GetFilterOptions(@params);
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
return Task.FromResult<object>(new SuccessResponse("Test job started.", new
{
job_id = jobId,
status = "running",
mode = parsedMode.Value.ToString(),
include_details = includeDetails,
include_failed_tests = includeFailedTests
}));
}
catch (Exception ex)
{
// Normalize the already-running case to a stable error token.
if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0)
{
return Task.FromResult<object>(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 }));
}
return Task.FromResult<object>(new ErrorResponse($"Failed to start test job: {ex.Message}"));
}
}
private static TestFilterOptions GetFilterOptions(JObject @params)
{
if (@params == null)
{
return null;
}
string[] ParseStringArray(string key)
{
var token = @params[key];
if (token == null) return null;
if (token.Type == JTokenType.String)
{
var value = token.ToString();
return string.IsNullOrWhiteSpace(value) ? null : new[] { value };
}
if (token.Type == JTokenType.Array)
{
var array = token as JArray;
if (array == null || array.Count == 0) return null;
var values = array
.Values<string>()
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
return null;
}
var testNames = ParseStringArray("testNames");
var groupNames = ParseStringArray("groupNames");
var categoryNames = ParseStringArray("categoryNames");
var assemblyNames = ParseStringArray("assemblyNames");
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
{
return null;
}
return new TestFilterOptions
{
TestNames = testNames,
GroupNames = groupNames,
CategoryNames = categoryNames,
AssemblyNames = assemblyNames
};
}
}
}