Add test filtering to run_tests tool (#462)
parent
f671bbcd06
commit
60a9f66949
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue