From 39bff4ae27f9d610e29059cbedae33dd9ca962d5 Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 14 Jan 2026 15:02:40 -0800 Subject: [PATCH] Fix PlayMode tests stalling when unfocused (python refresh utility), improve domain reload recovery and refresh tool (#554) * Fix test job state management after domain reload - TestRunnerService.RunFinished: Always clean up job state even when _runCompletionSource is null (happens after PlayMode domain reload) - TestJobManager: Detect and clear stale jobs (5+ min without updates) on startup to recover from stuck state after domain reload - refresh_unity.py: Add "could not connect" to retryable errors when wait_for_ready=True, so connection failures during domain reload trigger waiting instead of immediate failure Co-Authored-By: Claude Opus 4.5 * Add focus nudge to handle OS-level throttling during PlayMode tests When Unity is unfocused, macOS App Nap (and similar OS features) can throttle the process, causing PlayMode tests to stall even with Unity No Throttling mode enabled. Changes: - Add ApplyNoThrottlingPreemptive() to TestRunnerNoThrottle for early throttle prevention before PlayMode Execute() - Add focus_nudge.py utility that temporarily focuses Unity and returns focus to the original app (supports macOS, Windows, Linux) - Integrate focus nudge into get_test_job polling - when tests appear stalled (unfocused + no progress for 10s), automatically nudge Unity Co-Authored-By: Claude Opus 4.5 * Fix code review issues in focus_nudge.py - Remove redundant time import (already imported at module level) - Escape window titles in PowerShell script to prevent injection - Remove unused Callable import Co-Authored-By: Claude Opus 4.5 * Improve focus nudge logging and fix skipped tests - Improve logging in focus_nudge.py: rate limit skip and focus return at INFO level - Improve logging in run_tests.py: show nudge completion status - Fix path resolution in test_logging_stdout.py and test_transport_framing.py - Add PlayMode tests to UnityMCPTests project for testing PlayMode runner Co-Authored-By: Claude Opus 4.5 * Add troubleshooting note about focus permission requests When running PlayMode tests with Unity in the background, the focus nudge feature may trigger OS permission prompts (especially on macOS for accessibility/automation). Document this expected behavior. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- MCPForUnity/Editor/Services/TestJobManager.cs | 20 ++ .../Editor/Services/TestRunnerNoThrottle.cs | 11 + .../Editor/Services/TestRunnerService.cs | 30 +- README.md | 9 +- Server/src/services/tools/refresh_unity.py | 9 +- Server/src/services/tools/run_tests.py | 37 +- Server/src/utils/focus_nudge.py | 321 ++++++++++++++++++ .../tests/integration/test_logging_stdout.py | 4 +- .../integration/test_transport_framing.py | 4 +- .../UnityMCPTests/Assets/Tests/PlayMode.meta | 8 + .../PlayMode/MCPForUnityTests.PlayMode.asmdef | 21 ++ .../MCPForUnityTests.PlayMode.asmdef.meta | 7 + .../Tests/PlayMode/PlayModeBasicTests.cs | 86 +++++ .../Tests/PlayMode/PlayModeBasicTests.cs.meta | 11 + 14 files changed, 556 insertions(+), 22 deletions(-) create mode 100644 Server/src/utils/focus_nudge.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs.meta diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index f22e7d9..96b9243 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -178,6 +178,26 @@ namespace MCPForUnity.Editor.Services { _currentJobId = null; } + + // Detect and clean up stale "running" jobs that were orphaned by domain reload. + // After a domain reload, TestRunStatus resets to not-running, but _currentJobId + // may still be set. If the job hasn't been updated recently, it's likely orphaned. + if (!string.IsNullOrEmpty(_currentJobId) && Jobs.TryGetValue(_currentJobId, out var currentJob)) + { + if (currentJob.Status == TestJobStatus.Running) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long staleCutoffMs = 5 * 60 * 1000; // 5 minutes + if (now - currentJob.LastUpdateUnixMs > staleCutoffMs) + { + McpLog.Warn($"[TestJobManager] Clearing stale job {_currentJobId} (last update {(now - currentJob.LastUpdateUnixMs) / 1000}s ago)"); + currentJob.Status = TestJobStatus.Failed; + currentJob.Error = "Job orphaned after domain reload"; + currentJob.FinishedUnixMs = now; + _currentJobId = null; + } + } + } } } catch (Exception ex) diff --git a/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs b/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs index f379fd1..ddcfe87 100644 --- a/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs +++ b/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs @@ -73,6 +73,17 @@ namespace MCPForUnity.Editor.Services #endregion + /// + /// Apply no-throttling preemptively before tests start. + /// Call this before Execute() for PlayMode tests to ensure Unity isn't throttled + /// during the Play mode transition (before RunStarted fires). + /// + public static void ApplyNoThrottlingPreemptive() + { + SetTestRunActive(true); + ApplyNoThrottling(); + } + private static void ApplyNoThrottling() { if (!AreSettingsCaptured()) diff --git a/MCPForUnity/Editor/Services/TestRunnerService.cs b/MCPForUnity/Editor/Services/TestRunnerService.cs index 47f864d..374715b 100644 --- a/MCPForUnity/Editor/Services/TestRunnerService.cs +++ b/MCPForUnity/Editor/Services/TestRunnerService.cs @@ -110,6 +110,15 @@ namespace MCPForUnity.Editor.Services // (Issue #525: EditMode tests were blocked by save dialog) SaveDirtyScenesIfNeeded(); + // Apply no-throttling preemptively for PlayMode tests. This ensures Unity + // isn't throttled during the Play mode transition (which requires multiple + // editor frames). Without this, unfocused Unity may never reach RunStarted + // where throttling would normally be disabled. + if (mode == TestMode.PlayMode) + { + TestRunnerNoThrottle.ApplyNoThrottlingPreemptive(); + } + _testRunnerApi.Execute(settings); runTask = _runCompletionSource.Task; @@ -184,17 +193,24 @@ namespace MCPForUnity.Editor.Services public void RunFinished(ITestResultAdaptor result) { - if (_runCompletionSource == null) - { - return; - } - + // Always create payload and clean up job state, even if _runCompletionSource is null. + // This handles domain reload scenarios (e.g., PlayMode tests) where the TestRunnerService + // is recreated and _runCompletionSource is lost, but TestJobManager state persists via + // SessionState and the Test Runner still delivers the RunFinished callback. var payload = TestRunResult.Create(result, _leafResults); - _runCompletionSource.TrySetResult(payload); - _runCompletionSource = null; + + // Clean up state regardless of _runCompletionSource - these methods safely handle + // the case where no MCP job exists (e.g., manual test runs via Unity UI). TestRunStatus.MarkFinished(); TestJobManager.OnRunFinished(); TestJobManager.FinalizeCurrentJobFromRunFinished(payload); + + // Report result to awaiting caller if we have a completion source + if (_runCompletionSource != null) + { + _runCompletionSource.TrySetResult(payload); + _runCompletionSource = null; + } } public void TestStarted(ITestAdaptor test) diff --git a/README.md b/README.md index c3cc87a..b723b99 100644 --- a/README.md +++ b/README.md @@ -439,8 +439,13 @@ Your privacy matters to us. All telemetry is optional and designed to respect yo ## Troubleshooting ❓ -
-Click to view common issues and fixes... +
+Click to view common issues and fixes... + +- **Focus Permission Request (macOS/Windows/Linux):** + - When running PlayMode tests with Unity in the background, MCP for Unity may temporarily switch focus to Unity to prevent OS-level throttling from stalling tests. + - On **macOS**, you may be prompted to grant accessibility/automation permissions for your terminal or IDE to control window focus. + - This is normal behavior to ensure tests complete reliably when Unity is not the active window. - **Unity Bridge Not Running/Connecting:** - Ensure Unity Editor is open. diff --git a/Server/src/services/tools/refresh_unity.py b/Server/src/services/tools/refresh_unity.py index 927c090..80f805f 100644 --- a/Server/src/services/tools/refresh_unity.py +++ b/Server/src/services/tools/refresh_unity.py @@ -55,10 +55,13 @@ async def refresh_unity( # interpret that as a hard failure (#503-style loops). if isinstance(response, dict) and not response.get("success", True): hint = response.get("hint") - err = (response.get("error") or response.get("message") or "") + err = (response.get("error") or response.get("message") or "").lower() reason = _extract_response_reason(response) - is_retryable = (hint == "retry") or ( - "disconnected" in str(err).lower()) + is_retryable = ( + hint == "retry" + or "disconnected" in err + or "could not connect" in err # Connection failed during domain reload + ) if (not wait_for_ready) or (not is_retryable): return MCPResponse(**response) if reason not in {"reloading", "no_unity_session"}: diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index d5aa4f8..9e5e3c6 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +import logging +import time from typing import Annotated, Any, Literal from fastmcp import Context @@ -14,6 +16,9 @@ from services.tools import get_unity_instance_from_context from services.tools.preflight import preflight import transport.unity_transport as unity_transport from transport.legacy.unity_connection import async_send_command_with_retry +from utils.focus_nudge import nudge_unity_focus, should_nudge + +logger = logging.getLogger(__name__) class RunTestsSummary(BaseModel): @@ -195,28 +200,48 @@ async def get_test_job( if wait_timeout and wait_timeout > 0: deadline = asyncio.get_event_loop().time() + wait_timeout poll_interval = 2.0 # Poll Unity every 2 seconds - + while True: response = await _fetch_status() - + if not isinstance(response, dict): return MCPResponse(success=False, error=str(response)) - + if not response.get("success", True): return MCPResponse(**response) - + # Check if tests are done data = response.get("data", {}) status = data.get("status", "") if status in ("succeeded", "failed", "cancelled"): return GetTestJobResponse(**response) - + + # Check if Unity needs a focus nudge to make progress + # This handles OS-level throttling (e.g., macOS App Nap) that can + # stall PlayMode tests when Unity is in the background. + progress = data.get("progress", {}) + editor_is_focused = progress.get("editor_is_focused", True) + last_update_unix_ms = data.get("last_update_unix_ms") + current_time_ms = int(time.time() * 1000) + + if should_nudge( + status=status, + editor_is_focused=editor_is_focused, + last_update_unix_ms=last_update_unix_ms, + current_time_ms=current_time_ms, + stall_threshold_ms=10_000, # 10 seconds without progress + ): + logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...") + nudged = await nudge_unity_focus(focus_duration_s=0.5) + if nudged: + logger.info(f"Test job {job_id} nudge completed") + # Check timeout remaining = deadline - asyncio.get_event_loop().time() if remaining <= 0: # Timeout reached, return current status return GetTestJobResponse(**response) - + # Wait before next poll (but don't exceed remaining time) await asyncio.sleep(min(poll_interval, remaining)) diff --git a/Server/src/utils/focus_nudge.py b/Server/src/utils/focus_nudge.py new file mode 100644 index 0000000..5382986 --- /dev/null +++ b/Server/src/utils/focus_nudge.py @@ -0,0 +1,321 @@ +""" +Focus nudge utility for handling OS-level throttling of background Unity. + +When Unity is unfocused, the OS (especially macOS App Nap) can heavily throttle +the process, causing PlayMode tests to stall. This utility temporarily brings +Unity to focus, allows it to process, then returns focus to the original app. +""" + +from __future__ import annotations + +import asyncio +import logging +import platform +import shutil +import subprocess +import time + +logger = logging.getLogger(__name__) + +# Minimum seconds between nudges to avoid focus thrashing +_MIN_NUDGE_INTERVAL_S = 5.0 +_last_nudge_time: float = 0.0 + + +def _is_available() -> bool: + """Check if focus nudging is available on this platform.""" + system = platform.system() + if system == "Darwin": + return shutil.which("osascript") is not None + elif system == "Windows": + # PowerShell is typically available on Windows + return shutil.which("powershell") is not None + elif system == "Linux": + return shutil.which("xdotool") is not None + return False + + +def _get_frontmost_app_macos() -> str | None: + """Get the name of the frontmost application on macOS.""" + try: + result = subprocess.run( + [ + "osascript", "-e", + 'tell application "System Events" to get name of first process whose frontmost is true' + ], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception as e: + logger.debug(f"Failed to get frontmost app: {e}") + return None + + +def _focus_app_macos(app_name: str) -> bool: + """Focus an application by name on macOS.""" + try: + result = subprocess.run( + ["osascript", "-e", f'tell application "{app_name}" to activate'], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except Exception as e: + logger.debug(f"Failed to focus app {app_name}: {e}") + return False + + +def _get_frontmost_app_windows() -> str | None: + """Get the title of the frontmost window on Windows.""" + try: + # PowerShell command to get active window title + script = ''' +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class Win32 { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] + public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count); +} +"@ +$hwnd = [Win32]::GetForegroundWindow() +$sb = New-Object System.Text.StringBuilder 256 +[Win32]::GetWindowText($hwnd, $sb, 256) +$sb.ToString() +''' + result = subprocess.run( + ["powershell", "-Command", script], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception as e: + logger.debug(f"Failed to get frontmost window: {e}") + return None + + +def _focus_app_windows(window_title: str) -> bool: + """Focus a window by title on Windows. For Unity, uses Unity Editor pattern.""" + try: + # For Unity, we use a pattern match since the title varies + if window_title == "Unity": + script = ''' +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class Win32 { + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +} +"@ +$unity = Get-Process | Where-Object {$_.MainWindowTitle -like "*Unity*"} | Select-Object -First 1 +if ($unity) { + [Win32]::ShowWindow($unity.MainWindowHandle, 9) + [Win32]::SetForegroundWindow($unity.MainWindowHandle) +} +''' + else: + # Try to find window by title - escape special PowerShell characters + safe_title = window_title.replace("'", "''").replace("`", "``") + script = f''' +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class Win32 {{ + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +}} +"@ +$proc = Get-Process | Where-Object {{$_.MainWindowTitle -eq '{safe_title}'}} | Select-Object -First 1 +if ($proc) {{ + [Win32]::ShowWindow($proc.MainWindowHandle, 9) + [Win32]::SetForegroundWindow($proc.MainWindowHandle) +}} +''' + result = subprocess.run( + ["powershell", "-Command", script], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except Exception as e: + logger.debug(f"Failed to focus window {window_title}: {e}") + return False + + +def _get_frontmost_app_linux() -> str | None: + """Get the window ID of the frontmost window on Linux.""" + try: + result = subprocess.run( + ["xdotool", "getactivewindow"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception as e: + logger.debug(f"Failed to get active window: {e}") + return None + + +def _focus_app_linux(window_id: str) -> bool: + """Focus a window by ID on Linux, or Unity by name.""" + try: + if window_id == "Unity": + # Find Unity window by name pattern + result = subprocess.run( + ["xdotool", "search", "--name", "Unity"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + window_id = result.stdout.strip().split("\n")[0] + else: + return False + + result = subprocess.run( + ["xdotool", "windowactivate", window_id], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except Exception as e: + logger.debug(f"Failed to focus window {window_id}: {e}") + return False + + +def _get_frontmost_app() -> str | None: + """Get the frontmost application/window (platform-specific).""" + system = platform.system() + if system == "Darwin": + return _get_frontmost_app_macos() + elif system == "Windows": + return _get_frontmost_app_windows() + elif system == "Linux": + return _get_frontmost_app_linux() + return None + + +def _focus_app(app_or_window: str) -> bool: + """Focus an application/window (platform-specific).""" + system = platform.system() + if system == "Darwin": + return _focus_app_macos(app_or_window) + elif system == "Windows": + return _focus_app_windows(app_or_window) + elif system == "Linux": + return _focus_app_linux(app_or_window) + return False + + +async def nudge_unity_focus( + focus_duration_s: float = 0.5, + force: bool = False, +) -> bool: + """ + Temporarily focus Unity to allow it to process, then return focus. + + Args: + focus_duration_s: How long to keep Unity focused (seconds) + force: If True, ignore the minimum interval between nudges + + Returns: + True if nudge was performed, False if skipped or failed + """ + global _last_nudge_time + + if not _is_available(): + logger.debug("Focus nudging not available on this platform") + return False + + # Rate limit nudges + now = time.monotonic() + if not force and (now - _last_nudge_time) < _MIN_NUDGE_INTERVAL_S: + logger.info("Skipping nudge - too soon since last nudge") + return False + + # Get current frontmost app + original_app = _get_frontmost_app() + if original_app is None: + logger.debug("Could not determine frontmost app") + return False + + # Check if Unity is already focused (no nudge needed) + if "Unity" in original_app: + logger.debug("Unity already focused, no nudge needed") + return False + + logger.info(f"Nudging Unity focus (will return to {original_app})") + _last_nudge_time = now + + # Focus Unity + if not _focus_app("Unity"): + logger.warning("Failed to focus Unity") + return False + + # Wait for Unity to process + await asyncio.sleep(focus_duration_s) + + # Return focus to original app + if original_app and original_app != "Unity": + if _focus_app(original_app): + logger.info(f"Returned focus to {original_app}") + else: + logger.warning(f"Failed to return focus to {original_app}") + + return True + + +def should_nudge( + status: str, + editor_is_focused: bool, + last_update_unix_ms: int | None, + current_time_ms: int | None = None, + stall_threshold_ms: int = 10_000, +) -> bool: + """ + Determine if we should nudge Unity based on test job state. + + Args: + status: Job status ("running", "succeeded", "failed") + editor_is_focused: Whether Unity reports being focused + last_update_unix_ms: Last time the job was updated (Unix ms) + current_time_ms: Current time (Unix ms), or None to use current time + stall_threshold_ms: How long without updates before considering it stalled + + Returns: + True if conditions suggest a nudge would help + """ + # Only nudge running jobs + if status != "running": + return False + + # Only nudge unfocused Unity + if editor_is_focused: + return False + + # Check if job appears stalled + if last_update_unix_ms is None: + return True # No updates yet, might be stuck at start + + if current_time_ms is None: + current_time_ms = int(time.time() * 1000) + + time_since_update_ms = current_time_ms - last_update_unix_ms + return time_since_update_ms > stall_threshold_ms diff --git a/Server/tests/integration/test_logging_stdout.py b/Server/tests/integration/test_logging_stdout.py index 66d5d39..4314e41 100644 --- a/Server/tests/integration/test_logging_stdout.py +++ b/Server/tests/integration/test_logging_stdout.py @@ -5,9 +5,9 @@ import pytest # locate server src dynamically to avoid hardcoded layout assumptions -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server candidates = [ - ROOT / "Server", + ROOT / "src", ] SRC = next((p for p in candidates if p.exists()), None) if SRC is None: diff --git a/Server/tests/integration/test_transport_framing.py b/Server/tests/integration/test_transport_framing.py index 78bc20a..db4621a 100644 --- a/Server/tests/integration/test_transport_framing.py +++ b/Server/tests/integration/test_transport_framing.py @@ -11,9 +11,9 @@ from pathlib import Path import pytest # locate server src dynamically to avoid hardcoded layout assumptions -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server candidates = [ - ROOT / "Server", + ROOT / "src", ] SRC = next((p for p in candidates if p.exists()), None) if SRC is None: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/PlayMode.meta b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode.meta new file mode 100644 index 0000000..2279233 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7d8f92bb5476145f7b4a14a3ff0181c6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef new file mode 100644 index 0000000..aacb889 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef @@ -0,0 +1,21 @@ +{ + "name": "MCPForUnityTests.PlayMode", + "rootNamespace": "", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef.meta b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef.meta new file mode 100644 index 0000000..54170ba --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ee22713734c3444ea97b26bc4f4009c6 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs new file mode 100644 index 0000000..8dd9959 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs @@ -0,0 +1,86 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace MCPForUnityTests.PlayMode +{ + /// + /// Basic PlayMode tests to verify the MCP test runner handles PlayMode correctly. + /// These tests exercise coroutine-based testing which requires Play mode. + /// + public class PlayModeBasicTests + { + [UnityTest] + public IEnumerator GameObjectCreation_InPlayMode_Succeeds() + { + var go = new GameObject("TestObject"); + Assert.IsNotNull(go); + Assert.AreEqual("TestObject", go.name); + + yield return null; // Wait one frame + + Assert.IsTrue(go != null); // Still exists after frame + Object.Destroy(go); + } + + [UnityTest] + public IEnumerator WaitForSeconds_CompletesAfterDelay() + { + float startTime = Time.time; + + yield return new WaitForSeconds(0.1f); + + float elapsed = Time.time - startTime; + Assert.GreaterOrEqual(elapsed, 0.09f, "Should have waited at least 0.09 seconds"); + } + + [UnityTest] + public IEnumerator MultipleFrames_ProgressCorrectly() + { + int frameCount = Time.frameCount; + + yield return null; + yield return null; + yield return null; + + int newFrameCount = Time.frameCount; + Assert.Greater(newFrameCount, frameCount, "Frame count should have advanced"); + } + + [UnityTest] + public IEnumerator Component_AddAndRemove_InPlayMode() + { + var go = new GameObject("ComponentTest"); + + yield return null; + + var rb = go.AddComponent(); + Assert.IsNotNull(rb); + Assert.IsTrue(go.GetComponent() != null); + + yield return null; + + Object.Destroy(rb); + + yield return null; + + Assert.IsTrue(go.GetComponent() == null); + Object.Destroy(go); + } + + [UnityTest] + public IEnumerator Coroutine_CanYieldMultipleTimes() + { + int counter = 0; + + for (int i = 0; i < 5; i++) + { + counter++; + yield return null; + } + + Assert.AreEqual(5, counter); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs.meta new file mode 100644 index 0000000..9a39175 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0fdf985950dd444e4977139e67d778a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: