Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 31 additions & 2 deletions MCPForUnity/Editor/Services/ITestRunnerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@

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>
/// Provides access to Unity Test Runner data and execution.
/// </summary>
Expand All @@ -16,8 +43,10 @@ public interface ITestRunnerService
Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode);

/// <summary>
/// Execute tests for the supplied mode.
/// Execute tests for the supplied mode with optional filtering.
/// </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);
}
}
11 changes: 9 additions & 2 deletions MCPForUnity/Editor/Services/TestRunnerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestM
}
}

public async Task<TestRunResult> RunTestsAsync(TestMode mode)
public async Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null)
{
await _operationLock.WaitAsync().ConfigureAwait(true);
Task<TestRunResult> runTask;
Expand Down Expand Up @@ -94,7 +94,14 @@ public async Task<TestRunResult> RunTestsAsync(TestMode mode)
_leafResults.Clear();
_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);

if (mode == TestMode.PlayMode)
Expand Down
66 changes: 65 additions & 1 deletion MCPForUnity/Editor/Tools/RunTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Tests;
Expand Down Expand Up @@ -42,11 +43,13 @@ public static async Task<object> HandleCommand(JObject @params)
// Preserve default timeout if parsing fails
}

var filterOptions = ParseFilterOptions(@params);

var testService = MCPServiceLocator.Tests;
Task<TestRunResult> runTask;
try
{
runTask = testService.RunTestsAsync(parsedMode.Value);
runTask = testService.RunTestsAsync(parsedMode.Value, filterOptions);
}
catch (Exception ex)
{
Expand All @@ -69,5 +72,66 @@ public static async Task<object> HandleCommand(JObject @params)
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;
}
}
}
38 changes: 34 additions & 4 deletions Server/src/services/tools/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -68,11 +70,39 @@ def _coerce_int(value, default=None):
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