1+ using System ;
2+ using System . Collections . Generic ;
3+ using System . Linq ;
4+ using System . Threading ;
5+ using System . Threading . Tasks ;
6+ using McpUnity . Unity ;
7+ using McpUnity . Utils ;
8+ using UnityEngine ;
9+ using UnityEditor ;
10+ using UnityEditor . TestTools . TestRunner . Api ;
11+ using Newtonsoft . Json . Linq ;
12+
13+ namespace McpUnity . Services
14+ {
15+ /// <summary>
16+ /// Service for accessing Unity Test Runner functionality
17+ /// Implements ICallbacks for TestRunnerApi.
18+ /// </summary>
19+ public class TestRunnerService : ITestRunnerService , ICallbacks
20+ {
21+ private readonly TestRunnerApi _testRunnerApi ;
22+ private TaskCompletionSource < JObject > _tcs ;
23+ private bool _returnOnlyFailures ;
24+ private List < ITestResultAdaptor > _results ;
25+
26+ /// <summary>
27+ /// Constructor
28+ /// </summary>
29+ public TestRunnerService ( )
30+ {
31+ _testRunnerApi = ScriptableObject . CreateInstance < TestRunnerApi > ( ) ;
32+
33+ _testRunnerApi . RegisterCallbacks ( this ) ;
34+ }
35+
36+ [ MenuItem ( "Tools/MCP Unity/Debug call path" ) ]
37+ public static async void DebugCallGetAllTests ( )
38+ {
39+ var service = new TestRunnerService ( ) ;
40+ var tests = await service . GetAllTestsAsync ( ) ;
41+ Debug . Log ( $ "Retrieved { tests . Count } tests:") ;
42+ foreach ( var t in tests )
43+ Debug . Log ( $ "Test: { t . FullName } ({ t . TestMode } ) - State: { t . RunState } ") ;
44+ }
45+
46+ /// <summary>
47+ /// Asynchronously retrieves all available tests using the TestRunnerApi.
48+ /// </summary>
49+ /// <param name="testModeFilter">Optional test mode filter (EditMode, PlayMode, or empty for all)</param>
50+ /// <returns>List of test items matching the specified test mode, or all tests if no mode specified</returns>
51+ public async Task < List < ITestAdaptor > > GetAllTestsAsync ( string testModeFilter = "" )
52+ {
53+ McpLogger . LogInfo ( $ "Retrieving tests with filter: { testModeFilter ?? "All" } ") ;
54+
55+ var tests = new List < ITestAdaptor > ( ) ;
56+ var tcs = new TaskCompletionSource < bool > ( ) ;
57+ var pendingOperations = 0 ;
58+
59+ // Determine which test modes to retrieve based on the filter
60+ var modesToRetrieve = GetTestModesToRetrieve ( testModeFilter ) ;
61+
62+ // Exit early if no modes match the filter
63+ if ( modesToRetrieve . Count == 0 )
64+ {
65+ McpLogger . LogInfo ( "No matching test modes found for filter" ) ;
66+ return tests ;
67+ }
68+
69+ // Start retrieval for each test mode
70+ foreach ( var mode in modesToRetrieve )
71+ {
72+ RetrieveTestsForMode ( mode , tests , ref pendingOperations , tcs ) ;
73+ }
74+
75+ // Wait for all retrievals to complete
76+ await tcs . Task ;
77+ McpLogger . LogInfo ( $ "Retrieved { tests . Count } tests matching filter: { testModeFilter ?? "All" } ") ;
78+
79+ return tests ;
80+ }
81+
82+ /// <summary>
83+ /// Determines which test modes to retrieve based on the provided filter.
84+ /// </summary>
85+ private List < TestMode > GetTestModesToRetrieve ( string testModeFilter )
86+ {
87+ var modes = new List < TestMode > ( ) ;
88+
89+ // If no filter or explicitly requesting EditMode tests
90+ if ( string . IsNullOrEmpty ( testModeFilter ) ||
91+ testModeFilter . Equals ( "EditMode" , StringComparison . OrdinalIgnoreCase ) )
92+ {
93+ modes . Add ( TestMode . EditMode ) ;
94+ }
95+
96+ // If no filter or explicitly requesting PlayMode tests
97+ if ( string . IsNullOrEmpty ( testModeFilter ) ||
98+ testModeFilter . Equals ( "PlayMode" , StringComparison . OrdinalIgnoreCase ) )
99+ {
100+ modes . Add ( TestMode . PlayMode ) ;
101+ }
102+
103+ return modes ;
104+ }
105+
106+ /// <summary>
107+ /// Retrieves tests for a specific test mode and adds them to the results collection.
108+ /// </summary>
109+ private void RetrieveTestsForMode (
110+ TestMode mode ,
111+ List < ITestAdaptor > tests ,
112+ ref int pendingOperations ,
113+ TaskCompletionSource < bool > tcs )
114+ {
115+ Interlocked . Increment ( ref pendingOperations ) ;
116+
117+ McpLogger . LogInfo ( $ "Retrieving { mode } tests...") ;
118+ _testRunnerApi . RetrieveTestList ( mode , adaptor =>
119+ {
120+ // Process the test results
121+ CollectTestItems ( adaptor , tests ) ;
122+
123+ // Check if this was the last pending operation
124+ if ( Interlocked . Decrement ( ref pendingOperations ) == 0 )
125+ {
126+ McpLogger . LogInfo ( $ "All test retrievals completed") ;
127+ tcs . TrySetResult ( true ) ;
128+ }
129+ } ) ;
130+ }
131+
132+ /// <summary>
133+ /// Executes tests and returns a JSON summary.
134+ /// </summary>
135+ /// <param name="testMode">The test mode to run (EditMode or PlayMode).</param>
136+ /// <param name="returnOnlyFailures">If true, only failed test results are included in the output.</param>
137+ /// <param name="testFilter">A filter string to select specific tests to run.</param>
138+ /// <returns>Task that resolves with test results when tests are complete</returns>
139+ public async Task < JObject > ExecuteTestsAsync ( TestMode testMode , bool returnOnlyFailures , string testFilter = "" )
140+ {
141+ _tcs = new TaskCompletionSource < JObject > ( ) ;
142+ _results = new List < ITestResultAdaptor > ( ) ;
143+ _returnOnlyFailures = returnOnlyFailures ;
144+ var filter = new Filter { testMode = testMode } ;
145+
146+ if ( ! string . IsNullOrEmpty ( testFilter ) )
147+ {
148+ filter . testNames = new [ ] { testFilter } ;
149+ }
150+
151+ _testRunnerApi . Execute ( new ExecutionSettings ( filter ) ) ;
152+
153+ return await WaitForCompletionAsync (
154+ McpUnitySettings . Instance . RequestTimeoutSeconds ) ;
155+ }
156+
157+ /// <summary>
158+ /// Recursively collect test items from test adaptors
159+ /// </summary>
160+ private void CollectTestItems ( ITestAdaptor testAdaptor , List < ITestAdaptor > tests )
161+ {
162+ if ( testAdaptor . IsSuite )
163+ {
164+ // For suites (namespaces, classes), collect all children
165+ foreach ( var child in testAdaptor . Children )
166+ {
167+ CollectTestItems ( child , tests ) ;
168+ }
169+ }
170+ else
171+ {
172+ tests . Add ( testAdaptor ) ;
173+ }
174+ }
175+
176+ #region ICallbacks Implementation
177+
178+ /// <summary>
179+ /// Called when the test run starts.
180+ /// </summary>
181+ public void RunStarted ( ITestAdaptor testsToRun )
182+ {
183+ McpLogger . LogInfo ( $ "Test run started: { testsToRun ? . Name } ") ;
184+ }
185+
186+ /// <summary>
187+ /// Called when an individual test starts.
188+ /// </summary>
189+ public void TestStarted ( ITestAdaptor test )
190+ {
191+ // Optionally implement per-test start logic or logging.
192+ }
193+
194+ /// <summary>
195+ /// Called when an individual test finishes.
196+ /// </summary>
197+ public void TestFinished ( ITestResultAdaptor result )
198+ {
199+ _results . Add ( result ) ;
200+ }
201+
202+ /// <summary>
203+ /// Called when the test run finishes.
204+ /// </summary>
205+ public void RunFinished ( ITestResultAdaptor result )
206+ {
207+ var summary = BuildResultJson ( _results , result ) ;
208+ _tcs ? . TrySetResult ( summary ) ;
209+ }
210+
211+ #endregion
212+
213+ #region Helpers
214+
215+ private async Task < JObject > WaitForCompletionAsync ( int timeoutSeconds )
216+ {
217+ var delayTask = Task . Delay ( TimeSpan . FromSeconds ( timeoutSeconds ) ) ;
218+ var winner = await Task . WhenAny ( _tcs . Task , delayTask ) ;
219+
220+ if ( winner != _tcs . Task )
221+ {
222+ _tcs . TrySetResult (
223+ McpUnitySocketHandler . CreateErrorResponse (
224+ $ "Test run timed out after { timeoutSeconds } seconds",
225+ "test_runner_timeout" ) ) ;
226+ }
227+ return await _tcs . Task ;
228+ }
229+
230+ private JObject BuildResultJson ( List < ITestResultAdaptor > results , ITestResultAdaptor result )
231+ {
232+ int pass = results . Count ( r => r . ResultState == "Passed" ) ;
233+ int fail = results . Count ( r => r . ResultState == "Failed" ) ;
234+ int skip = results . Count ( r => r . ResultState == "Skipped" ) ;
235+
236+ var arr = new JArray ( results
237+ . Where ( r => ! _returnOnlyFailures || r . ResultState == "Failed" )
238+ . Select ( r => new JObject {
239+ [ "name" ] = r . Name ,
240+ [ "fullName" ] = r . FullName ,
241+ [ "state" ] = r . ResultState ,
242+ [ "message" ] = r . Message ,
243+ [ "duration" ] = r . Duration
244+ } ) ) ;
245+
246+ return new JObject {
247+ [ "success" ] = true ,
248+ [ "type" ] = "text" ,
249+ [ "message" ] = $ "{ result . Test . Name } test run completed: { pass } /{ results . Count } passed - { fail } /{ results . Count } failed - { skip } /{ results . Count } skipped",
250+ [ "resultState" ] = result . ResultState ,
251+ [ "durationSeconds" ] = result . Duration ,
252+ [ "testCount" ] = results . Count ,
253+ [ "passCount" ] = pass ,
254+ [ "failCount" ] = fail ,
255+ [ "skipCount" ] = skip ,
256+ [ "results" ] = arr
257+ } ;
258+ }
259+
260+ #endregion
261+ }
262+ }
0 commit comments