55using System . Threading . Tasks ;
66using MCPForUnity . Editor . Helpers ;
77using UnityEditor ;
8+ using UnityEditor . SceneManagement ;
89using UnityEditor . TestTools . TestRunner . Api ;
910using UnityEngine ;
11+ using UnityEngine . SceneManagement ;
1012
1113namespace MCPForUnity . Editor . Services
1214{
@@ -31,7 +33,7 @@ public TestRunnerService()
3133
3234 public async Task < IReadOnlyList < Dictionary < string , string > > > GetTestsAsync ( TestMode ? mode )
3335 {
34- await _operationLock . WaitAsync ( ) . ConfigureAwait ( false ) ;
36+ await _operationLock . WaitAsync ( ) . ConfigureAwait ( true ) ;
3537 try
3638 {
3739 var modes = mode . HasValue ? new [ ] { mode . Value } : AllModes ;
@@ -58,25 +60,59 @@ public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestM
5860
5961 public async Task < TestRunResult > RunTestsAsync ( TestMode mode )
6062 {
61- await _operationLock . WaitAsync ( ) . ConfigureAwait ( false ) ;
63+ await _operationLock . WaitAsync ( ) . ConfigureAwait ( true ) ;
6264 Task < TestRunResult > runTask ;
65+ bool adjustedPlayModeOptions = false ;
66+ bool originalEnterPlayModeOptionsEnabled = false ;
67+ EnterPlayModeOptions originalEnterPlayModeOptions = EnterPlayModeOptions . None ;
6368 try
6469 {
6570 if ( _runCompletionSource != null && ! _runCompletionSource . Task . IsCompleted )
6671 {
6772 throw new InvalidOperationException ( "A Unity test run is already in progress." ) ;
6873 }
6974
75+ if ( EditorApplication . isPlaying || EditorApplication . isPlayingOrWillChangePlaymode )
76+ {
77+ throw new InvalidOperationException ( "Cannot start a test run while the Editor is in or entering Play Mode. Stop Play Mode and try again." ) ;
78+ }
79+
80+ if ( mode == TestMode . PlayMode )
81+ {
82+ // PlayMode runs transition the editor into play across multiple update ticks. Unity's
83+ // built-in pipeline schedules SaveModifiedSceneTask early, but that task uses
84+ // EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo which throws once play mode is
85+ // active. To minimize that window we pre-save dirty scenes and disable domain reload (so the
86+ // MCP bridge stays alive). We do NOT force runSynchronously here because that can freeze the
87+ // editor in some projects. If the TestRunner still hits the save task after entering play, the
88+ // run can fail; in that case, rerun from a clean Edit Mode state.
89+ adjustedPlayModeOptions = EnsurePlayModeRunsWithoutDomainReload (
90+ out originalEnterPlayModeOptionsEnabled ,
91+ out originalEnterPlayModeOptions ) ;
92+ }
93+
7094 _leafResults . Clear ( ) ;
7195 _runCompletionSource = new TaskCompletionSource < TestRunResult > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
7296
7397 var filter = new Filter { testMode = mode } ;
74- _testRunnerApi . Execute ( new ExecutionSettings ( filter ) ) ;
98+ var settings = new ExecutionSettings ( filter ) ;
99+
100+ if ( mode == TestMode . PlayMode )
101+ {
102+ SaveDirtyScenesIfNeeded ( ) ;
103+ }
104+
105+ _testRunnerApi . Execute ( settings ) ;
75106
76107 runTask = _runCompletionSource . Task ;
77108 }
78109 catch
79110 {
111+ if ( adjustedPlayModeOptions )
112+ {
113+ RestoreEnterPlayModeOptions ( originalEnterPlayModeOptionsEnabled , originalEnterPlayModeOptions ) ;
114+ }
115+
80116 _operationLock . Release ( ) ;
81117 throw ;
82118 }
@@ -87,6 +123,11 @@ public async Task<TestRunResult> RunTestsAsync(TestMode mode)
87123 }
88124 finally
89125 {
126+ if ( adjustedPlayModeOptions )
127+ {
128+ RestoreEnterPlayModeOptions ( originalEnterPlayModeOptionsEnabled , originalEnterPlayModeOptions ) ;
129+ }
130+
90131 _operationLock . Release ( ) ;
91132 }
92133 }
@@ -149,6 +190,60 @@ public void TestFinished(ITestResultAdaptor result)
149190
150191 #endregion
151192
193+ private static bool EnsurePlayModeRunsWithoutDomainReload (
194+ out bool originalEnterPlayModeOptionsEnabled ,
195+ out EnterPlayModeOptions originalEnterPlayModeOptions )
196+ {
197+ originalEnterPlayModeOptionsEnabled = EditorSettings . enterPlayModeOptionsEnabled ;
198+ originalEnterPlayModeOptions = EditorSettings . enterPlayModeOptions ;
199+
200+ // When Play Mode triggers a domain reload, the MCP connection is torn down and the pending
201+ // test run response never makes it back to the caller. To keep the bridge alive for this
202+ // invocation, temporarily enable Enter Play Mode Options with domain reload disabled.
203+ bool domainReloadDisabled = ( originalEnterPlayModeOptions & EnterPlayModeOptions . DisableDomainReload ) != 0 ;
204+ bool needsChange = ! originalEnterPlayModeOptionsEnabled || ! domainReloadDisabled ;
205+ if ( ! needsChange )
206+ {
207+ return false ;
208+ }
209+
210+ var desired = originalEnterPlayModeOptions | EnterPlayModeOptions . DisableDomainReload ;
211+ EditorSettings . enterPlayModeOptionsEnabled = true ;
212+ EditorSettings . enterPlayModeOptions = desired ;
213+ return true ;
214+ }
215+
216+ private static void RestoreEnterPlayModeOptions ( bool originalEnabled , EnterPlayModeOptions originalOptions )
217+ {
218+ EditorSettings . enterPlayModeOptions = originalOptions ;
219+ EditorSettings . enterPlayModeOptionsEnabled = originalEnabled ;
220+ }
221+
222+ private static void SaveDirtyScenesIfNeeded ( )
223+ {
224+ int sceneCount = SceneManager . sceneCount ;
225+ for ( int i = 0 ; i < sceneCount ; i ++ )
226+ {
227+ var scene = SceneManager . GetSceneAt ( i ) ;
228+ if ( scene . isDirty )
229+ {
230+ if ( string . IsNullOrEmpty ( scene . path ) )
231+ {
232+ McpLog . Warn ( $ "[TestRunnerService] Skipping unsaved scene '{ scene . name } ': save it manually before running PlayMode tests.") ;
233+ continue ;
234+ }
235+ try
236+ {
237+ EditorSceneManager . SaveScene ( scene ) ;
238+ }
239+ catch ( Exception ex )
240+ {
241+ McpLog . Warn ( $ "[TestRunnerService] Failed to save dirty scene '{ scene . name } ': { ex . Message } ") ;
242+ }
243+ }
244+ }
245+ }
246+
152247 #region Test list helpers
153248
154249 private async Task < ITestAdaptor > RetrieveTestRootAsync ( TestMode mode )
0 commit comments