diff --git a/MCPForUnity/Editor/Services/ITestRunnerService.cs b/MCPForUnity/Editor/Services/ITestRunnerService.cs index 575e4d9..c24d9e4 100644 --- a/MCPForUnity/Editor/Services/ITestRunnerService.cs +++ b/MCPForUnity/Editor/Services/ITestRunnerService.cs @@ -4,6 +4,33 @@ using UnityEditor.TestTools.TestRunner.Api; namespace MCPForUnity.Editor.Services { + /// + /// Options for filtering which tests to run. + /// All properties are optional - null or empty arrays are ignored. + /// + public class TestFilterOptions + { + /// + /// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod"). + /// + public string[] TestNames { get; set; } + + /// + /// Same as TestNames, except it allows for Regex. + /// + public string[] GroupNames { get; set; } + + /// + /// NUnit category names to filter by (tests marked with [Category] attribute). + /// + public string[] CategoryNames { get; set; } + + /// + /// Assembly names to filter tests by. + /// + public string[] AssemblyNames { get; set; } + } + /// /// Provides access to Unity Test Runner data and execution. /// @@ -16,8 +43,10 @@ namespace MCPForUnity.Editor.Services Task>> GetTestsAsync(TestMode? mode); /// - /// Execute tests for the supplied mode. + /// Execute tests for the supplied mode with optional filtering. /// - Task RunTestsAsync(TestMode mode); + /// The test mode (EditMode or PlayMode). + /// Optional filter options to run specific tests. Pass null to run all tests. + Task RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null); } } diff --git a/MCPForUnity/Editor/Services/TestRunnerService.cs b/MCPForUnity/Editor/Services/TestRunnerService.cs index f8cbd13..2ec9084 100644 --- a/MCPForUnity/Editor/Services/TestRunnerService.cs +++ b/MCPForUnity/Editor/Services/TestRunnerService.cs @@ -58,7 +58,7 @@ namespace MCPForUnity.Editor.Services } } - public async Task RunTestsAsync(TestMode mode) + public async Task RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null) { await _operationLock.WaitAsync().ConfigureAwait(true); Task runTask; @@ -94,7 +94,14 @@ namespace MCPForUnity.Editor.Services _leafResults.Clear(); _runCompletionSource = new TaskCompletionSource(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); if (mode == TestMode.PlayMode) diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index f49e57f..b40df25 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Resources.Tests; @@ -42,11 +43,13 @@ namespace MCPForUnity.Editor.Tools // Preserve default timeout if parsing fails } + var filterOptions = ParseFilterOptions(@params); + var testService = MCPServiceLocator.Tests; Task runTask; try { - runTask = testService.RunTestsAsync(parsedMode.Value); + runTask = testService.RunTestsAsync(parsedMode.Value, filterOptions); } catch (Exception ex) { @@ -69,5 +72,66 @@ namespace MCPForUnity.Editor.Tools var data = result.ToSerializable(parsedMode.Value.ToString()); 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; + } } } diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 520a9db..7d49daa 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -45,10 +45,12 @@ class RunTestsResponse(MCPResponse): ) async def run_tests( ctx: Context, - mode: Annotated[Literal["EditMode", "PlayMode"], Field( - description="Unity test mode to run")] = "EditMode", - timeout_seconds: Annotated[int | str, Field( - description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None, + mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode", + timeout_seconds: Annotated[int | str, "Optional timeout in seconds for the test run"] | None = None, + test_names: Annotated[list[str] | str, "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | 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: unity_instance = get_unity_instance_from_context(ctx) @@ -68,11 +70,39 @@ async def run_tests( except Exception: 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} ts = _coerce_int(timeout_seconds) if ts is not None: 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) await ctx.info(f'Response {response}') return RunTestsResponse(**response) if isinstance(response, dict) else response