Add test filtering to run_tests tool (#462)

main
Voon Foo 2025-12-18 04:59:21 +08:00 committed by GitHub
parent f671bbcd06
commit 60a9f66949
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 139 additions and 9 deletions

View File

@ -4,6 +4,33 @@ using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Services namespace MCPForUnity.Editor.Services
{ {
/// <summary>
/// Options for filtering which tests to run.
/// All properties are optional - null or empty arrays are ignored.
/// </summary>
public class TestFilterOptions
{
/// <summary>
/// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod").
/// </summary>
public string[] TestNames { get; set; }
/// <summary>
/// Same as TestNames, except it allows for Regex.
/// </summary>
public string[] GroupNames { get; set; }
/// <summary>
/// NUnit category names to filter by (tests marked with [Category] attribute).
/// </summary>
public string[] CategoryNames { get; set; }
/// <summary>
/// Assembly names to filter tests by.
/// </summary>
public string[] AssemblyNames { get; set; }
}
/// <summary> /// <summary>
/// Provides access to Unity Test Runner data and execution. /// Provides access to Unity Test Runner data and execution.
/// </summary> /// </summary>
@ -16,8 +43,10 @@ namespace MCPForUnity.Editor.Services
Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode); Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode);
/// <summary> /// <summary>
/// Execute tests for the supplied mode. /// Execute tests for the supplied mode with optional filtering.
/// </summary> /// </summary>
Task<TestRunResult> RunTestsAsync(TestMode mode); /// <param name="mode">The test mode (EditMode or PlayMode).</param>
/// <param name="filterOptions">Optional filter options to run specific tests. Pass null to run all tests.</param>
Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null);
} }
} }

View File

@ -58,7 +58,7 @@ namespace MCPForUnity.Editor.Services
} }
} }
public async Task<TestRunResult> RunTestsAsync(TestMode mode) public async Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null)
{ {
await _operationLock.WaitAsync().ConfigureAwait(true); await _operationLock.WaitAsync().ConfigureAwait(true);
Task<TestRunResult> runTask; Task<TestRunResult> runTask;
@ -94,7 +94,14 @@ namespace MCPForUnity.Editor.Services
_leafResults.Clear(); _leafResults.Clear();
_runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously); _runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously);
var filter = new Filter { testMode = mode }; var filter = new Filter
{
testMode = mode,
testNames = filterOptions?.TestNames,
groupNames = filterOptions?.GroupNames,
categoryNames = filterOptions?.CategoryNames,
assemblyNames = filterOptions?.AssemblyNames
};
var settings = new ExecutionSettings(filter); var settings = new ExecutionSettings(filter);
if (mode == TestMode.PlayMode) if (mode == TestMode.PlayMode)

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Tests; using MCPForUnity.Editor.Resources.Tests;
@ -42,11 +43,13 @@ namespace MCPForUnity.Editor.Tools
// Preserve default timeout if parsing fails // Preserve default timeout if parsing fails
} }
var filterOptions = ParseFilterOptions(@params);
var testService = MCPServiceLocator.Tests; var testService = MCPServiceLocator.Tests;
Task<TestRunResult> runTask; Task<TestRunResult> runTask;
try try
{ {
runTask = testService.RunTestsAsync(parsedMode.Value); runTask = testService.RunTestsAsync(parsedMode.Value, filterOptions);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -69,5 +72,66 @@ namespace MCPForUnity.Editor.Tools
var data = result.ToSerializable(parsedMode.Value.ToString()); var data = result.ToSerializable(parsedMode.Value.ToString());
return new SuccessResponse(message, data); return new SuccessResponse(message, data);
} }
private static TestFilterOptions ParseFilterOptions(JObject @params)
{
if (@params == null)
{
return null;
}
var testNames = ParseStringArray(@params, "testNames");
var groupNames = ParseStringArray(@params, "groupNames");
var categoryNames = ParseStringArray(@params, "categoryNames");
var assemblyNames = ParseStringArray(@params, "assemblyNames");
// Return null if no filters specified
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
{
return null;
}
return new TestFilterOptions
{
TestNames = testNames,
GroupNames = groupNames,
CategoryNames = categoryNames,
AssemblyNames = assemblyNames
};
}
private static string[] ParseStringArray(JObject @params, 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
.Where(t => t.Type == JTokenType.String)
.Select(t => t.ToString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
return null;
}
} }
} }

View File

@ -45,10 +45,12 @@ class RunTestsResponse(MCPResponse):
) )
async def run_tests( async def run_tests(
ctx: Context, ctx: Context,
mode: Annotated[Literal["EditMode", "PlayMode"], Field( mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
description="Unity test mode to run")] = "EditMode", timeout_seconds: Annotated[int | str, "Optional timeout in seconds for the test run"] | None = None,
timeout_seconds: Annotated[int | str, Field( test_names: Annotated[list[str] | str, "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None,
description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None, group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
) -> RunTestsResponse: ) -> RunTestsResponse:
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
@ -68,11 +70,39 @@ async def run_tests(
except Exception: except Exception:
return default return default
# Coerce string or list to list of strings
def _coerce_string_list(value) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value] if value.strip() else None
if isinstance(value, list):
result = [str(v).strip() for v in value if v and str(v).strip()]
return result if result else None
return None
params: dict[str, Any] = {"mode": mode} params: dict[str, Any] = {"mode": mode}
ts = _coerce_int(timeout_seconds) ts = _coerce_int(timeout_seconds)
if ts is not None: if ts is not None:
params["timeoutSeconds"] = ts params["timeoutSeconds"] = ts
# Add filter parameters if provided
test_names_list = _coerce_string_list(test_names)
if test_names_list:
params["testNames"] = test_names_list
group_names_list = _coerce_string_list(group_names)
if group_names_list:
params["groupNames"] = group_names_list
category_names_list = _coerce_string_list(category_names)
if category_names_list:
params["categoryNames"] = category_names_list
assembly_names_list = _coerce_string_list(assembly_names)
if assembly_names_list:
params["assemblyNames"] = assembly_names_list
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params) response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
await ctx.info(f'Response {response}') await ctx.info(f'Response {response}')
return RunTestsResponse(**response) if isinstance(response, dict) else response return RunTestsResponse(**response) if isinstance(response, dict) else response