Async Test Infrastructure & Editor Readiness Status + new refresh_unity tool (#507)
* Add editor readiness v2, refresh tool, and preflight guards * Detect external package changes and harden refresh retry * feat: add TestRunnerNoThrottle and async test running with background stall prevention - Add TestRunnerNoThrottle.cs: Sets editor to 'No Throttling' mode during test runs with SessionState persistence across domain reload - Add run_tests_async and get_test_job tools for non-blocking test execution - Add TestJobManager for async test job tracking with progress monitoring - Add ForceSynchronousImport to all AssetDatabase.Refresh() calls to prevent stalls - Mark DomainReloadResilienceTests as [Explicit] with documentation explaining the test infrastructure limitation (internal coroutine waits vs MCP socket polling) - MCP workflow is unaffected - socket messages provide external stimulus that keeps Unity responsive even when backgrounded * refactor: simplify and clean up code - Remove unused Newtonsoft.Json.Linq import from TestJobManager - Add throttling to SessionState persistence (once per second) to reduce overhead - Critical job state changes (start/finish) still persist immediately - Fix duplicate XML summary tag in DomainReloadResilienceTests * docs: add async test tools to README, document domain reload limitation - Add run_tests_async and get_test_job to main README tools list - Document background stall limitation for domain reload tests in DEV readme * ci: add separate job for domain reload tests Run [Explicit] domain_reload tests in their own job using -testCategory * ci: run domain reload tests in same job as regular tests Combines into single job with two test steps to reuse cached Library * fix: address coderabbit review issues - Fix TOCTOU race in TestJobManager.StartJob (single lock scope for check-and-set) - Store TestRunnerApi reference with HideAndDontSave to prevent GC/serialization issues * docs: update tool descriptions to prefer run_tests_async - run_tests_async is now marked as preferred for long-running suites - run_tests description notes it blocks and suggests async alternative * docs: update README screenshot to v8.6 UI * docs: add v8.6 UI screenshot * Update README for MCP version and instructions for v8.7 * fix: handle preflight busy signals and derive job status from test results - manage_asset, manage_gameobject, manage_scene now check preflight return value and propagate busy/retry signals to clients (fixes Sourcery #1) - TestJobManager.FinalizeCurrentJobFromRunFinished now sets job status to Failed when resultPayload.Failed > 0, not always Succeeded (fixes Sourcery #2) * fix: increase HTTP server startup timeout for dev mode When 'Force fresh server install' is enabled, uvx uses --no-cache --refresh which rebuilds the package and takes significantly longer to start. - Increase timeout from 10s to 45s when dev mode is enabled - Add informative log message explaining the longer startup time - Show actual timeout value in warning message * fix: derive job status from test results in FinalizeFromTask fallback Apply same logic as FinalizeCurrentJobFromRunFinished: check result.Failed > 0 to correctly mark jobs as Failed when tests fail, even in the fallback path when RunFinished callback is not delivered.main
parent
191b730a47
commit
711768d064
|
|
@ -25,13 +25,11 @@ jobs:
|
||||||
unityVersion:
|
unityVersion:
|
||||||
- 2021.3.45f2
|
- 2021.3.45f2
|
||||||
steps:
|
steps:
|
||||||
# Checkout
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
lfs: true
|
lfs: true
|
||||||
|
|
||||||
# Cache
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ matrix.projectPath }}/Library
|
path: ${{ matrix.projectPath }}/Library
|
||||||
|
|
@ -40,7 +38,20 @@ jobs:
|
||||||
Library-${{ matrix.projectPath }}-
|
Library-${{ matrix.projectPath }}-
|
||||||
Library-
|
Library-
|
||||||
|
|
||||||
# Test
|
# Run domain reload tests first (they're [Explicit] so need explicit category)
|
||||||
|
- name: Run domain reload tests
|
||||||
|
uses: game-ci/unity-test-runner@v4
|
||||||
|
id: domain-tests
|
||||||
|
env:
|
||||||
|
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||||
|
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||||
|
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||||
|
with:
|
||||||
|
projectPath: ${{ matrix.projectPath }}
|
||||||
|
unityVersion: ${{ matrix.unityVersion }}
|
||||||
|
testMode: ${{ matrix.testMode }}
|
||||||
|
customParameters: -testCategory domain_reload
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
uses: game-ci/unity-test-runner@v4
|
uses: game-ci/unity-test-runner@v4
|
||||||
id: tests
|
id: tests
|
||||||
|
|
@ -53,7 +64,6 @@ jobs:
|
||||||
unityVersion: ${{ matrix.unityVersion }}
|
unityVersion: ${{ matrix.unityVersion }}
|
||||||
testMode: ${{ matrix.testMode }}
|
testMode: ${{ matrix.testMode }}
|
||||||
|
|
||||||
# Upload test results
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
using System;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using MCPForUnity.Editor.Services;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Resources.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a cached, v2 readiness snapshot. This is designed to remain responsive even when Unity is busy.
|
||||||
|
/// </summary>
|
||||||
|
[McpForUnityResource("get_editor_state_v2")]
|
||||||
|
public static class EditorStateV2
|
||||||
|
{
|
||||||
|
public static object HandleCommand(JObject @params)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = EditorStateCache.GetSnapshot();
|
||||||
|
return new SuccessResponse("Retrieved editor state (v2).", snapshot);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return new ErrorResponse($"Error getting editor state (v2): {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5514ec4eb8a294a55892a13194e250e8
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
using System;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditorInternal;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy.
|
||||||
|
/// Updated on the main thread via Editor callbacks and periodic update ticks.
|
||||||
|
/// </summary>
|
||||||
|
[InitializeOnLoad]
|
||||||
|
internal static class EditorStateCache
|
||||||
|
{
|
||||||
|
private static readonly object LockObj = new();
|
||||||
|
private static long _sequence;
|
||||||
|
private static long _observedUnixMs;
|
||||||
|
|
||||||
|
private static bool _lastIsCompiling;
|
||||||
|
private static long? _lastCompileStartedUnixMs;
|
||||||
|
private static long? _lastCompileFinishedUnixMs;
|
||||||
|
|
||||||
|
private static bool _domainReloadPending;
|
||||||
|
private static long? _domainReloadBeforeUnixMs;
|
||||||
|
private static long? _domainReloadAfterUnixMs;
|
||||||
|
|
||||||
|
private static double _lastUpdateTimeSinceStartup;
|
||||||
|
private const double MinUpdateIntervalSeconds = 0.25;
|
||||||
|
|
||||||
|
private static JObject _cached;
|
||||||
|
|
||||||
|
static EditorStateCache()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sequence = 0;
|
||||||
|
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
_cached = BuildSnapshot("init");
|
||||||
|
|
||||||
|
EditorApplication.update += OnUpdate;
|
||||||
|
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");
|
||||||
|
|
||||||
|
AssemblyReloadEvents.beforeAssemblyReload += () =>
|
||||||
|
{
|
||||||
|
_domainReloadPending = true;
|
||||||
|
_domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
ForceUpdate("before_domain_reload");
|
||||||
|
};
|
||||||
|
AssemblyReloadEvents.afterAssemblyReload += () =>
|
||||||
|
{
|
||||||
|
_domainReloadPending = false;
|
||||||
|
_domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
ForceUpdate("after_domain_reload");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnUpdate()
|
||||||
|
{
|
||||||
|
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
|
||||||
|
double now = EditorApplication.timeSinceStartup;
|
||||||
|
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
|
||||||
|
{
|
||||||
|
// Still update on compilation edge transitions to keep timestamps meaningful.
|
||||||
|
bool isCompiling = EditorApplication.isCompiling;
|
||||||
|
if (isCompiling == _lastIsCompiling)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUpdateTimeSinceStartup = now;
|
||||||
|
ForceUpdate("tick");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ForceUpdate(string reason)
|
||||||
|
{
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
_cached = BuildSnapshot(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject BuildSnapshot(string reason)
|
||||||
|
{
|
||||||
|
_sequence++;
|
||||||
|
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
bool isCompiling = EditorApplication.isCompiling;
|
||||||
|
if (isCompiling && !_lastIsCompiling)
|
||||||
|
{
|
||||||
|
_lastCompileStartedUnixMs = _observedUnixMs;
|
||||||
|
}
|
||||||
|
else if (!isCompiling && _lastIsCompiling)
|
||||||
|
{
|
||||||
|
_lastCompileFinishedUnixMs = _observedUnixMs;
|
||||||
|
}
|
||||||
|
_lastIsCompiling = isCompiling;
|
||||||
|
|
||||||
|
var scene = EditorSceneManager.GetActiveScene();
|
||||||
|
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
|
||||||
|
string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null;
|
||||||
|
|
||||||
|
bool testsRunning = TestRunStatus.IsRunning;
|
||||||
|
var testsMode = TestRunStatus.Mode?.ToString();
|
||||||
|
string currentJobId = TestJobManager.CurrentJobId;
|
||||||
|
bool isFocused = InternalEditorUtility.isApplicationActive;
|
||||||
|
|
||||||
|
var activityPhase = "idle";
|
||||||
|
if (testsRunning)
|
||||||
|
{
|
||||||
|
activityPhase = "running_tests";
|
||||||
|
}
|
||||||
|
else if (isCompiling)
|
||||||
|
{
|
||||||
|
activityPhase = "compiling";
|
||||||
|
}
|
||||||
|
else if (_domainReloadPending)
|
||||||
|
{
|
||||||
|
activityPhase = "domain_reload";
|
||||||
|
}
|
||||||
|
else if (EditorApplication.isUpdating)
|
||||||
|
{
|
||||||
|
activityPhase = "asset_import";
|
||||||
|
}
|
||||||
|
else if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||||
|
{
|
||||||
|
activityPhase = "playmode_transition";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep this as a plain JSON object for minimal friction with transports.
|
||||||
|
return JObject.FromObject(new
|
||||||
|
{
|
||||||
|
schema_version = "unity-mcp/editor_state@2",
|
||||||
|
observed_at_unix_ms = _observedUnixMs,
|
||||||
|
sequence = _sequence,
|
||||||
|
unity = new
|
||||||
|
{
|
||||||
|
instance_id = (string)null,
|
||||||
|
unity_version = Application.unityVersion,
|
||||||
|
project_id = (string)null,
|
||||||
|
platform = Application.platform.ToString(),
|
||||||
|
is_batch_mode = Application.isBatchMode
|
||||||
|
},
|
||||||
|
editor = new
|
||||||
|
{
|
||||||
|
is_focused = isFocused,
|
||||||
|
play_mode = new
|
||||||
|
{
|
||||||
|
is_playing = EditorApplication.isPlaying,
|
||||||
|
is_paused = EditorApplication.isPaused,
|
||||||
|
is_changing = EditorApplication.isPlayingOrWillChangePlaymode
|
||||||
|
},
|
||||||
|
active_scene = new
|
||||||
|
{
|
||||||
|
path = scenePath,
|
||||||
|
guid = sceneGuid,
|
||||||
|
name = scene.name ?? string.Empty
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activity = new
|
||||||
|
{
|
||||||
|
phase = activityPhase,
|
||||||
|
since_unix_ms = _observedUnixMs,
|
||||||
|
reasons = new[] { reason }
|
||||||
|
},
|
||||||
|
compilation = new
|
||||||
|
{
|
||||||
|
is_compiling = isCompiling,
|
||||||
|
is_domain_reload_pending = _domainReloadPending,
|
||||||
|
last_compile_started_unix_ms = _lastCompileStartedUnixMs,
|
||||||
|
last_compile_finished_unix_ms = _lastCompileFinishedUnixMs,
|
||||||
|
last_domain_reload_before_unix_ms = _domainReloadBeforeUnixMs,
|
||||||
|
last_domain_reload_after_unix_ms = _domainReloadAfterUnixMs
|
||||||
|
},
|
||||||
|
assets = new
|
||||||
|
{
|
||||||
|
is_updating = EditorApplication.isUpdating,
|
||||||
|
external_changes_dirty = false,
|
||||||
|
external_changes_last_seen_unix_ms = (long?)null,
|
||||||
|
refresh = new
|
||||||
|
{
|
||||||
|
is_refresh_in_progress = false,
|
||||||
|
last_refresh_requested_unix_ms = (long?)null,
|
||||||
|
last_refresh_finished_unix_ms = (long?)null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tests = new
|
||||||
|
{
|
||||||
|
is_running = testsRunning,
|
||||||
|
mode = testsMode,
|
||||||
|
current_job_id = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
|
||||||
|
started_unix_ms = TestRunStatus.StartedUnixMs,
|
||||||
|
started_by = "unknown",
|
||||||
|
last_run = TestRunStatus.FinishedUnixMs.HasValue
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
finished_unix_ms = TestRunStatus.FinishedUnixMs,
|
||||||
|
result = "unknown",
|
||||||
|
counts = (object)null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
transport = new
|
||||||
|
{
|
||||||
|
unity_bridge_connected = (bool?)null,
|
||||||
|
last_message_unix_ms = (long?)null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JObject GetSnapshot()
|
||||||
|
{
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
// Defensive: if something went wrong early, rebuild once.
|
||||||
|
if (_cached == null)
|
||||||
|
{
|
||||||
|
_cached = BuildSnapshot("rebuild");
|
||||||
|
}
|
||||||
|
return (JObject)_cached.DeepClone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aa7909967ce3c48c493181c978782a54
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,586 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditorInternal;
|
||||||
|
using UnityEditor.TestTools.TestRunner.Api;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Services
|
||||||
|
{
|
||||||
|
internal enum TestJobStatus
|
||||||
|
{
|
||||||
|
Running,
|
||||||
|
Succeeded,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TestJobFailure
|
||||||
|
{
|
||||||
|
public string FullName { get; set; }
|
||||||
|
public string Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TestJob
|
||||||
|
{
|
||||||
|
public string JobId { get; set; }
|
||||||
|
public TestJobStatus Status { get; set; }
|
||||||
|
public string Mode { get; set; }
|
||||||
|
public long StartedUnixMs { get; set; }
|
||||||
|
public long? FinishedUnixMs { get; set; }
|
||||||
|
public long LastUpdateUnixMs { get; set; }
|
||||||
|
public int? TotalTests { get; set; }
|
||||||
|
public int CompletedTests { get; set; }
|
||||||
|
public string CurrentTestFullName { get; set; }
|
||||||
|
public long? CurrentTestStartedUnixMs { get; set; }
|
||||||
|
public string LastFinishedTestFullName { get; set; }
|
||||||
|
public long? LastFinishedUnixMs { get; set; }
|
||||||
|
public List<TestJobFailure> FailuresSoFar { get; set; }
|
||||||
|
public string Error { get; set; }
|
||||||
|
public TestRunResult Result { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TestJobManager
|
||||||
|
{
|
||||||
|
// Keep this small to avoid ballooning payloads during polling.
|
||||||
|
private const int FailureCap = 25;
|
||||||
|
private const long StuckThresholdMs = 60_000;
|
||||||
|
private const int MaxJobsToKeep = 10;
|
||||||
|
private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead
|
||||||
|
|
||||||
|
// SessionState survives domain reloads within the same Unity Editor session.
|
||||||
|
private const string SessionKeyJobs = "MCPForUnity.TestJobsV1";
|
||||||
|
private const string SessionKeyCurrentJobId = "MCPForUnity.CurrentTestJobIdV1";
|
||||||
|
|
||||||
|
private static readonly object LockObj = new();
|
||||||
|
private static readonly Dictionary<string, TestJob> Jobs = new();
|
||||||
|
private static string _currentJobId;
|
||||||
|
private static long _lastPersistUnixMs;
|
||||||
|
|
||||||
|
static TestJobManager()
|
||||||
|
{
|
||||||
|
// Restore after domain reloads (e.g., compilation while a job is running).
|
||||||
|
TryRestoreFromSessionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CurrentJobId
|
||||||
|
{
|
||||||
|
get { lock (LockObj) return _currentJobId; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasRunningJob
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(_currentJobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PersistedState
|
||||||
|
{
|
||||||
|
public string current_job_id { get; set; }
|
||||||
|
public List<PersistedJob> jobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PersistedJob
|
||||||
|
{
|
||||||
|
public string job_id { get; set; }
|
||||||
|
public string status { get; set; }
|
||||||
|
public string mode { get; set; }
|
||||||
|
public long started_unix_ms { get; set; }
|
||||||
|
public long? finished_unix_ms { get; set; }
|
||||||
|
public long last_update_unix_ms { get; set; }
|
||||||
|
public int? total_tests { get; set; }
|
||||||
|
public int completed_tests { get; set; }
|
||||||
|
public string current_test_full_name { get; set; }
|
||||||
|
public long? current_test_started_unix_ms { get; set; }
|
||||||
|
public string last_finished_test_full_name { get; set; }
|
||||||
|
public long? last_finished_unix_ms { get; set; }
|
||||||
|
public List<TestJobFailure> failures_so_far { get; set; }
|
||||||
|
public string error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestJobStatus ParseStatus(string status)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(status))
|
||||||
|
{
|
||||||
|
return TestJobStatus.Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
string s = status.Trim().ToLowerInvariant();
|
||||||
|
return s switch
|
||||||
|
{
|
||||||
|
"succeeded" => TestJobStatus.Succeeded,
|
||||||
|
"failed" => TestJobStatus.Failed,
|
||||||
|
_ => TestJobStatus.Running
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryRestoreFromSessionState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = SessionState.GetString(SessionKeyJobs, string.Empty);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
var legacy = SessionState.GetString(SessionKeyCurrentJobId, string.Empty);
|
||||||
|
_currentJobId = string.IsNullOrWhiteSpace(legacy) ? null : legacy;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = JsonConvert.DeserializeObject<PersistedState>(json);
|
||||||
|
if (state?.jobs == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
Jobs.Clear();
|
||||||
|
foreach (var pj in state.jobs)
|
||||||
|
{
|
||||||
|
if (pj == null || string.IsNullOrWhiteSpace(pj.job_id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Jobs[pj.job_id] = new TestJob
|
||||||
|
{
|
||||||
|
JobId = pj.job_id,
|
||||||
|
Status = ParseStatus(pj.status),
|
||||||
|
Mode = pj.mode,
|
||||||
|
StartedUnixMs = pj.started_unix_ms,
|
||||||
|
FinishedUnixMs = pj.finished_unix_ms,
|
||||||
|
LastUpdateUnixMs = pj.last_update_unix_ms,
|
||||||
|
TotalTests = pj.total_tests,
|
||||||
|
CompletedTests = pj.completed_tests,
|
||||||
|
CurrentTestFullName = pj.current_test_full_name,
|
||||||
|
CurrentTestStartedUnixMs = pj.current_test_started_unix_ms,
|
||||||
|
LastFinishedTestFullName = pj.last_finished_test_full_name,
|
||||||
|
LastFinishedUnixMs = pj.last_finished_unix_ms,
|
||||||
|
FailuresSoFar = pj.failures_so_far ?? new List<TestJobFailure>(),
|
||||||
|
Error = pj.error,
|
||||||
|
// Intentionally not persisted to avoid ballooning SessionState.
|
||||||
|
Result = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentJobId = string.IsNullOrWhiteSpace(state.current_job_id) ? null : state.current_job_id;
|
||||||
|
if (!string.IsNullOrEmpty(_currentJobId) && !Jobs.ContainsKey(_currentJobId))
|
||||||
|
{
|
||||||
|
_currentJobId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Restoration is best-effort; never block editor load.
|
||||||
|
McpLog.Warn($"[TestJobManager] Failed to restore SessionState: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PersistToSessionState(bool force = false)
|
||||||
|
{
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
// Throttle non-critical updates to reduce overhead during large test runs
|
||||||
|
if (!force && (now - _lastPersistUnixMs) < MinPersistIntervalMs)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PersistedState snapshot;
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
var jobs = Jobs.Values
|
||||||
|
.OrderByDescending(j => j.LastUpdateUnixMs)
|
||||||
|
.Take(MaxJobsToKeep)
|
||||||
|
.Select(j => new PersistedJob
|
||||||
|
{
|
||||||
|
job_id = j.JobId,
|
||||||
|
status = j.Status.ToString().ToLowerInvariant(),
|
||||||
|
mode = j.Mode,
|
||||||
|
started_unix_ms = j.StartedUnixMs,
|
||||||
|
finished_unix_ms = j.FinishedUnixMs,
|
||||||
|
last_update_unix_ms = j.LastUpdateUnixMs,
|
||||||
|
total_tests = j.TotalTests,
|
||||||
|
completed_tests = j.CompletedTests,
|
||||||
|
current_test_full_name = j.CurrentTestFullName,
|
||||||
|
current_test_started_unix_ms = j.CurrentTestStartedUnixMs,
|
||||||
|
last_finished_test_full_name = j.LastFinishedTestFullName,
|
||||||
|
last_finished_unix_ms = j.LastFinishedUnixMs,
|
||||||
|
failures_so_far = (j.FailuresSoFar ?? new List<TestJobFailure>()).Take(FailureCap).ToList(),
|
||||||
|
error = j.Error
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
snapshot = new PersistedState
|
||||||
|
{
|
||||||
|
current_job_id = _currentJobId,
|
||||||
|
jobs = jobs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionState.SetString(SessionKeyCurrentJobId, snapshot.current_job_id ?? string.Empty);
|
||||||
|
SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot));
|
||||||
|
_lastPersistUnixMs = now;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
McpLog.Warn($"[TestJobManager] Failed to persist SessionState: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null)
|
||||||
|
{
|
||||||
|
string jobId = Guid.NewGuid().ToString("N");
|
||||||
|
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
string modeStr = mode.ToString();
|
||||||
|
|
||||||
|
var job = new TestJob
|
||||||
|
{
|
||||||
|
JobId = jobId,
|
||||||
|
Status = TestJobStatus.Running,
|
||||||
|
Mode = modeStr,
|
||||||
|
StartedUnixMs = started,
|
||||||
|
FinishedUnixMs = null,
|
||||||
|
LastUpdateUnixMs = started,
|
||||||
|
TotalTests = null,
|
||||||
|
CompletedTests = 0,
|
||||||
|
CurrentTestFullName = null,
|
||||||
|
CurrentTestStartedUnixMs = null,
|
||||||
|
LastFinishedTestFullName = null,
|
||||||
|
LastFinishedUnixMs = null,
|
||||||
|
FailuresSoFar = new List<TestJobFailure>(),
|
||||||
|
Error = null,
|
||||||
|
Result = null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single lock scope for check-and-set to avoid TOCTOU race
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_currentJobId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A Unity test run is already in progress.");
|
||||||
|
}
|
||||||
|
Jobs[jobId] = job;
|
||||||
|
_currentJobId = jobId;
|
||||||
|
}
|
||||||
|
PersistToSessionState(force: true);
|
||||||
|
|
||||||
|
// Kick the run (must be called on main thread; our command handlers already run there).
|
||||||
|
Task<TestRunResult> task = MCPServiceLocator.Tests.RunTestsAsync(mode, filterOptions);
|
||||||
|
|
||||||
|
void FinalizeJob(Action finalize)
|
||||||
|
{
|
||||||
|
// Ensure state mutation happens on main thread to avoid Unity API surprises.
|
||||||
|
EditorApplication.delayCall += () =>
|
||||||
|
{
|
||||||
|
try { finalize(); }
|
||||||
|
catch (Exception ex) { McpLog.Error($"[TestJobManager] Finalize failed: {ex.Message}\n{ex.StackTrace}"); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
task.ContinueWith(t =>
|
||||||
|
{
|
||||||
|
// NOTE: We now finalize jobs deterministically from the TestRunnerService RunFinished callback.
|
||||||
|
// This continuation is retained as a safety net in case RunFinished is not delivered.
|
||||||
|
FinalizeJob(() => FinalizeFromTask(jobId, t));
|
||||||
|
}, TaskScheduler.Default);
|
||||||
|
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void FinalizeCurrentJobFromRunFinished(TestRunResult resultPayload)
|
||||||
|
{
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.LastUpdateUnixMs = now;
|
||||||
|
job.FinishedUnixMs = now;
|
||||||
|
job.Status = resultPayload != null && resultPayload.Failed > 0
|
||||||
|
? TestJobStatus.Failed
|
||||||
|
: TestJobStatus.Succeeded;
|
||||||
|
job.Error = null;
|
||||||
|
job.Result = resultPayload;
|
||||||
|
job.CurrentTestFullName = null;
|
||||||
|
_currentJobId = null;
|
||||||
|
}
|
||||||
|
PersistToSessionState(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnRunStarted(int? totalTests)
|
||||||
|
{
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.LastUpdateUnixMs = now;
|
||||||
|
job.TotalTests = totalTests;
|
||||||
|
job.CompletedTests = 0;
|
||||||
|
job.CurrentTestFullName = null;
|
||||||
|
job.CurrentTestStartedUnixMs = null;
|
||||||
|
job.LastFinishedTestFullName = null;
|
||||||
|
job.LastFinishedUnixMs = null;
|
||||||
|
job.FailuresSoFar ??= new List<TestJobFailure>();
|
||||||
|
job.FailuresSoFar.Clear();
|
||||||
|
}
|
||||||
|
PersistToSessionState(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnTestStarted(string testFullName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(testFullName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.LastUpdateUnixMs = now;
|
||||||
|
job.CurrentTestFullName = testFullName;
|
||||||
|
job.CurrentTestStartedUnixMs = now;
|
||||||
|
}
|
||||||
|
PersistToSessionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnLeafTestFinished(string testFullName, bool isFailure, string message)
|
||||||
|
{
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.LastUpdateUnixMs = now;
|
||||||
|
job.CompletedTests = Math.Max(0, job.CompletedTests + 1);
|
||||||
|
job.LastFinishedTestFullName = testFullName;
|
||||||
|
job.LastFinishedUnixMs = now;
|
||||||
|
|
||||||
|
if (isFailure)
|
||||||
|
{
|
||||||
|
job.FailuresSoFar ??= new List<TestJobFailure>();
|
||||||
|
if (job.FailuresSoFar.Count < FailureCap)
|
||||||
|
{
|
||||||
|
job.FailuresSoFar.Add(new TestJobFailure
|
||||||
|
{
|
||||||
|
FullName = testFullName,
|
||||||
|
Message = string.IsNullOrWhiteSpace(message) ? "Test failed" : message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PersistToSessionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnRunFinished()
|
||||||
|
{
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.LastUpdateUnixMs = now;
|
||||||
|
job.CurrentTestFullName = null;
|
||||||
|
}
|
||||||
|
PersistToSessionState(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TestJob GetJob(string jobId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jobId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
return Jobs.TryGetValue(jobId, out var job) ? job : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests)
|
||||||
|
{
|
||||||
|
if (job == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
object resultPayload = null;
|
||||||
|
if (job.Status == TestJobStatus.Succeeded && job.Result != null)
|
||||||
|
{
|
||||||
|
resultPayload = job.Result.ToSerializable(job.Mode, includeDetails, includeFailedTests);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
job_id = job.JobId,
|
||||||
|
status = job.Status.ToString().ToLowerInvariant(),
|
||||||
|
mode = job.Mode,
|
||||||
|
started_unix_ms = job.StartedUnixMs,
|
||||||
|
finished_unix_ms = job.FinishedUnixMs,
|
||||||
|
last_update_unix_ms = job.LastUpdateUnixMs,
|
||||||
|
progress = new
|
||||||
|
{
|
||||||
|
completed = job.CompletedTests,
|
||||||
|
total = job.TotalTests,
|
||||||
|
current_test_full_name = job.CurrentTestFullName,
|
||||||
|
current_test_started_unix_ms = job.CurrentTestStartedUnixMs,
|
||||||
|
last_finished_test_full_name = job.LastFinishedTestFullName,
|
||||||
|
last_finished_unix_ms = job.LastFinishedUnixMs,
|
||||||
|
stuck_suspected = IsStuck(job),
|
||||||
|
editor_is_focused = InternalEditorUtility.isApplicationActive,
|
||||||
|
blocked_reason = GetBlockedReason(job),
|
||||||
|
failures_so_far = BuildFailuresPayload(job.FailuresSoFar),
|
||||||
|
failures_capped = (job.FailuresSoFar != null && job.FailuresSoFar.Count >= FailureCap)
|
||||||
|
},
|
||||||
|
error = job.Error,
|
||||||
|
result = resultPayload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetBlockedReason(TestJob job)
|
||||||
|
{
|
||||||
|
if (job == null || job.Status != TestJobStatus.Running)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsStuck(job))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This matches the real-world symptom you observed: background Unity can get heavily throttled by OS/Editor.
|
||||||
|
if (!InternalEditorUtility.isApplicationActive)
|
||||||
|
{
|
||||||
|
return "editor_unfocused";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorApplication.isCompiling)
|
||||||
|
{
|
||||||
|
return "compiling";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorApplication.isUpdating)
|
||||||
|
{
|
||||||
|
return "asset_import";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStuck(TestJob job)
|
||||||
|
{
|
||||||
|
if (job == null || job.Status != TestJobStatus.Running)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(job.CurrentTestFullName) || !job.CurrentTestStartedUnixMs.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
return (now - job.CurrentTestStartedUnixMs.Value) > StuckThresholdMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object[] BuildFailuresPayload(List<TestJobFailure> failures)
|
||||||
|
{
|
||||||
|
if (failures == null || failures.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new object[failures.Count];
|
||||||
|
for (int i = 0; i < failures.Count; i++)
|
||||||
|
{
|
||||||
|
var f = failures[i];
|
||||||
|
list[i] = new { full_name = f?.FullName, message = f?.Message };
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FinalizeFromTask(string jobId, Task<TestRunResult> task)
|
||||||
|
{
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
if (!Jobs.TryGetValue(jobId, out var existing))
|
||||||
|
{
|
||||||
|
if (_currentJobId == jobId) _currentJobId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If RunFinished already finalized the job, do nothing.
|
||||||
|
if (existing.Status != TestJobStatus.Running)
|
||||||
|
{
|
||||||
|
if (_currentJobId == jobId) _currentJobId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.LastUpdateUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
existing.FinishedUnixMs = existing.LastUpdateUnixMs;
|
||||||
|
|
||||||
|
if (task.IsFaulted)
|
||||||
|
{
|
||||||
|
existing.Status = TestJobStatus.Failed;
|
||||||
|
existing.Error = task.Exception?.GetBaseException()?.Message ?? "Unknown test job failure";
|
||||||
|
existing.Result = null;
|
||||||
|
}
|
||||||
|
else if (task.IsCanceled)
|
||||||
|
{
|
||||||
|
existing.Status = TestJobStatus.Failed;
|
||||||
|
existing.Error = "Test job canceled";
|
||||||
|
existing.Result = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = task.Result;
|
||||||
|
existing.Status = result != null && result.Failed > 0
|
||||||
|
? TestJobStatus.Failed
|
||||||
|
: TestJobStatus.Succeeded;
|
||||||
|
existing.Error = null;
|
||||||
|
existing.Result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentJobId == jobId)
|
||||||
|
{
|
||||||
|
_currentJobId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PersistToSessionState(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2d7a9b8c0e1f4a6b9c3d2e1f0a9b8c7d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
using System;
|
||||||
|
using UnityEditor.TestTools.TestRunner.Api;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe, minimal shared status for Unity Test Runner execution.
|
||||||
|
/// Used by editor readiness snapshots so callers can avoid starting overlapping runs.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TestRunStatus
|
||||||
|
{
|
||||||
|
private static readonly object LockObj = new();
|
||||||
|
|
||||||
|
private static bool _isRunning;
|
||||||
|
private static TestMode? _mode;
|
||||||
|
private static long? _startedUnixMs;
|
||||||
|
private static long? _finishedUnixMs;
|
||||||
|
|
||||||
|
public static bool IsRunning
|
||||||
|
{
|
||||||
|
get { lock (LockObj) return _isRunning; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TestMode? Mode
|
||||||
|
{
|
||||||
|
get { lock (LockObj) return _mode; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long? StartedUnixMs
|
||||||
|
{
|
||||||
|
get { lock (LockObj) return _startedUnixMs; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long? FinishedUnixMs
|
||||||
|
{
|
||||||
|
get { lock (LockObj) return _finishedUnixMs; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MarkStarted(TestMode mode)
|
||||||
|
{
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
_isRunning = true;
|
||||||
|
_mode = mode;
|
||||||
|
_startedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
_finishedUnixMs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MarkFinished()
|
||||||
|
{
|
||||||
|
lock (LockObj)
|
||||||
|
{
|
||||||
|
_isRunning = false;
|
||||||
|
_finishedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
_mode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b3d140c288f6e4b6aa2b7e8181a09c1e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
// TestRunnerNoThrottle.cs
|
||||||
|
// Sets Unity Editor to "No Throttling" mode during test runs.
|
||||||
|
// This helps tests that don't trigger compilation run smoothly in the background.
|
||||||
|
// Note: Tests that trigger mid-run compilation may still stall due to OS-level throttling.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.TestTools.TestRunner.Api;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Automatically sets the editor to "No Throttling" mode during test runs.
|
||||||
|
///
|
||||||
|
/// This helps prevent background stalls for normal tests. However, tests that trigger
|
||||||
|
/// script compilation mid-run may still stall because:
|
||||||
|
/// - Internal Unity coroutine waits rely on editor ticks
|
||||||
|
/// - OS-level throttling affects the main thread when Unity is backgrounded
|
||||||
|
/// - No amount of internal nudging can overcome OS thread scheduling
|
||||||
|
///
|
||||||
|
/// The MCP workflow is unaffected because socket messages provide external stimulus
|
||||||
|
/// that wakes Unity's main thread.
|
||||||
|
/// </summary>
|
||||||
|
[InitializeOnLoad]
|
||||||
|
public static class TestRunnerNoThrottle
|
||||||
|
{
|
||||||
|
private const string ApplicationIdleTimeKey = "ApplicationIdleTime";
|
||||||
|
private const string InteractionModeKey = "InteractionMode";
|
||||||
|
|
||||||
|
// SessionState keys to persist across domain reload
|
||||||
|
private const string SessionKey_TestRunActive = "TestRunnerNoThrottle_TestRunActive";
|
||||||
|
private const string SessionKey_PrevIdleTime = "TestRunnerNoThrottle_PrevIdleTime";
|
||||||
|
private const string SessionKey_PrevInteractionMode = "TestRunnerNoThrottle_PrevInteractionMode";
|
||||||
|
private const string SessionKey_SettingsCaptured = "TestRunnerNoThrottle_SettingsCaptured";
|
||||||
|
|
||||||
|
// Keep reference to avoid GC and set HideFlags to avoid serialization issues
|
||||||
|
private static TestRunnerApi _api;
|
||||||
|
|
||||||
|
static TestRunnerNoThrottle()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api = ScriptableObject.CreateInstance<TestRunnerApi>();
|
||||||
|
_api.hideFlags = HideFlags.HideAndDontSave;
|
||||||
|
_api.RegisterCallbacks(new TestCallbacks());
|
||||||
|
|
||||||
|
// Check if recovering from domain reload during an active test run
|
||||||
|
if (IsTestRunActive())
|
||||||
|
{
|
||||||
|
McpLog.Info("[TestRunnerNoThrottle] Recovered from domain reload - reapplying No Throttling.");
|
||||||
|
ApplyNoThrottling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
McpLog.Warn($"[TestRunnerNoThrottle] Failed to register callbacks: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region State Persistence
|
||||||
|
|
||||||
|
private static bool IsTestRunActive() => SessionState.GetBool(SessionKey_TestRunActive, false);
|
||||||
|
private static void SetTestRunActive(bool active) => SessionState.SetBool(SessionKey_TestRunActive, active);
|
||||||
|
private static bool AreSettingsCaptured() => SessionState.GetBool(SessionKey_SettingsCaptured, false);
|
||||||
|
private static void SetSettingsCaptured(bool captured) => SessionState.SetBool(SessionKey_SettingsCaptured, captured);
|
||||||
|
private static int GetPrevIdleTime() => SessionState.GetInt(SessionKey_PrevIdleTime, 4);
|
||||||
|
private static void SetPrevIdleTime(int value) => SessionState.SetInt(SessionKey_PrevIdleTime, value);
|
||||||
|
private static int GetPrevInteractionMode() => SessionState.GetInt(SessionKey_PrevInteractionMode, 0);
|
||||||
|
private static void SetPrevInteractionMode(int value) => SessionState.SetInt(SessionKey_PrevInteractionMode, value);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private static void ApplyNoThrottling()
|
||||||
|
{
|
||||||
|
if (!AreSettingsCaptured())
|
||||||
|
{
|
||||||
|
SetPrevIdleTime(EditorPrefs.GetInt(ApplicationIdleTimeKey, 4));
|
||||||
|
SetPrevInteractionMode(EditorPrefs.GetInt(InteractionModeKey, 0));
|
||||||
|
SetSettingsCaptured(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0ms idle + InteractionMode=1 (No Throttling)
|
||||||
|
EditorPrefs.SetInt(ApplicationIdleTimeKey, 0);
|
||||||
|
EditorPrefs.SetInt(InteractionModeKey, 1);
|
||||||
|
|
||||||
|
ForceEditorToApplyInteractionPrefs();
|
||||||
|
McpLog.Info("[TestRunnerNoThrottle] Applied No Throttling for test run.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreThrottling()
|
||||||
|
{
|
||||||
|
if (!AreSettingsCaptured()) return;
|
||||||
|
|
||||||
|
EditorPrefs.SetInt(ApplicationIdleTimeKey, GetPrevIdleTime());
|
||||||
|
EditorPrefs.SetInt(InteractionModeKey, GetPrevInteractionMode());
|
||||||
|
ForceEditorToApplyInteractionPrefs();
|
||||||
|
|
||||||
|
SetSettingsCaptured(false);
|
||||||
|
SetTestRunActive(false);
|
||||||
|
McpLog.Info("[TestRunnerNoThrottle] Restored Interaction Mode after test run.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ForceEditorToApplyInteractionPrefs()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = typeof(EditorApplication).GetMethod(
|
||||||
|
"UpdateInteractionModeSettings",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic
|
||||||
|
);
|
||||||
|
method?.Invoke(null, null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore reflection errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestCallbacks : ICallbacks
|
||||||
|
{
|
||||||
|
public void RunStarted(ITestAdaptor testsToRun)
|
||||||
|
{
|
||||||
|
SetTestRunActive(true);
|
||||||
|
ApplyNoThrottling();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RunFinished(ITestResultAdaptor result)
|
||||||
|
{
|
||||||
|
RestoreThrottling();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TestStarted(ITestAdaptor test) { }
|
||||||
|
public void TestFinished(ITestResultAdaptor result) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 07a60b029782d464a9506fa520d2a8c8
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -93,6 +93,8 @@ namespace MCPForUnity.Editor.Services
|
||||||
|
|
||||||
_leafResults.Clear();
|
_leafResults.Clear();
|
||||||
_runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
_runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
// Mark running immediately so readiness snapshots reflect the busy state even before callbacks fire.
|
||||||
|
TestRunStatus.MarkStarted(mode);
|
||||||
|
|
||||||
var filter = new Filter
|
var filter = new Filter
|
||||||
{
|
{
|
||||||
|
|
@ -115,6 +117,8 @@ namespace MCPForUnity.Editor.Services
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
// Ensure the status is cleared if we failed to start the run.
|
||||||
|
TestRunStatus.MarkFinished();
|
||||||
if (adjustedPlayModeOptions)
|
if (adjustedPlayModeOptions)
|
||||||
{
|
{
|
||||||
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
|
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
|
||||||
|
|
@ -163,6 +167,20 @@ namespace MCPForUnity.Editor.Services
|
||||||
public void RunStarted(ITestAdaptor testsToRun)
|
public void RunStarted(ITestAdaptor testsToRun)
|
||||||
{
|
{
|
||||||
_leafResults.Clear();
|
_leafResults.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Best-effort progress info for async polling (avoid heavy payloads).
|
||||||
|
int? total = null;
|
||||||
|
if (testsToRun != null)
|
||||||
|
{
|
||||||
|
total = CountLeafTests(testsToRun);
|
||||||
|
}
|
||||||
|
TestJobManager.OnRunStarted(total);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
TestJobManager.OnRunStarted(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RunFinished(ITestResultAdaptor result)
|
public void RunFinished(ITestResultAdaptor result)
|
||||||
|
|
@ -175,11 +193,27 @@ namespace MCPForUnity.Editor.Services
|
||||||
var payload = TestRunResult.Create(result, _leafResults);
|
var payload = TestRunResult.Create(result, _leafResults);
|
||||||
_runCompletionSource.TrySetResult(payload);
|
_runCompletionSource.TrySetResult(payload);
|
||||||
_runCompletionSource = null;
|
_runCompletionSource = null;
|
||||||
|
TestRunStatus.MarkFinished();
|
||||||
|
TestJobManager.OnRunFinished();
|
||||||
|
TestJobManager.FinalizeCurrentJobFromRunFinished(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void TestStarted(ITestAdaptor test)
|
public void TestStarted(ITestAdaptor test)
|
||||||
{
|
{
|
||||||
// No-op
|
try
|
||||||
|
{
|
||||||
|
// Prefer FullName for uniqueness; fall back to Name.
|
||||||
|
string fullName = test?.FullName;
|
||||||
|
if (string.IsNullOrWhiteSpace(fullName))
|
||||||
|
{
|
||||||
|
fullName = test?.Name;
|
||||||
|
}
|
||||||
|
TestJobManager.OnTestStarted(fullName);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void TestFinished(ITestResultAdaptor result)
|
public void TestFinished(ITestResultAdaptor result)
|
||||||
|
|
@ -192,11 +226,72 @@ namespace MCPForUnity.Editor.Services
|
||||||
if (!result.HasChildren)
|
if (!result.HasChildren)
|
||||||
{
|
{
|
||||||
_leafResults.Add(result);
|
_leafResults.Add(result);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string fullName = result.Test?.FullName;
|
||||||
|
if (string.IsNullOrWhiteSpace(fullName))
|
||||||
|
{
|
||||||
|
fullName = result.Test?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isFailure = false;
|
||||||
|
string message = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// NUnit outcomes are strings in the adaptor; keep it simple.
|
||||||
|
string outcome = result.ResultState;
|
||||||
|
if (!string.IsNullOrWhiteSpace(outcome))
|
||||||
|
{
|
||||||
|
var o = outcome.Trim().ToLowerInvariant();
|
||||||
|
isFailure = o.Contains("failed") || o.Contains("error");
|
||||||
|
}
|
||||||
|
message = result.Message;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore adaptor quirks
|
||||||
|
}
|
||||||
|
|
||||||
|
TestJobManager.OnLeafTestFinished(fullName, isFailure, message);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
private static int CountLeafTests(ITestAdaptor node)
|
||||||
|
{
|
||||||
|
if (node == null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.HasChildren)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
total += CountLeafTests(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If Unity changes the adaptor behavior, treat it as "unknown total".
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool EnsurePlayModeRunsWithoutDomainReload(
|
private static bool EnsurePlayModeRunsWithoutDomainReload(
|
||||||
out bool originalEnterPlayModeOptionsEnabled,
|
out bool originalEnterPlayModeOptionsEnabled,
|
||||||
out EnterPlayModeOptions originalEnterPlayModeOptions)
|
out EnterPlayModeOptions originalEnterPlayModeOptions)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
using System;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using MCPForUnity.Editor.Services;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Poll a previously started async test job by job_id.
|
||||||
|
/// </summary>
|
||||||
|
[McpForUnityTool("get_test_job", AutoRegister = false)]
|
||||||
|
public static class GetTestJob
|
||||||
|
{
|
||||||
|
public static object HandleCommand(JObject @params)
|
||||||
|
{
|
||||||
|
string jobId = @params?["job_id"]?.ToString() ?? @params?["jobId"]?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(jobId))
|
||||||
|
{
|
||||||
|
return new ErrorResponse("Missing required parameter 'job_id'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool includeDetails = false;
|
||||||
|
bool includeFailedTests = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var includeDetailsToken = @params?["includeDetails"];
|
||||||
|
if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails))
|
||||||
|
{
|
||||||
|
includeDetails = parsedIncludeDetails;
|
||||||
|
}
|
||||||
|
var includeFailedTestsToken = @params?["includeFailedTests"];
|
||||||
|
if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests))
|
||||||
|
{
|
||||||
|
includeFailedTests = parsedIncludeFailedTests;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore parse failures
|
||||||
|
}
|
||||||
|
|
||||||
|
var job = TestJobManager.GetJob(jobId);
|
||||||
|
if (job == null)
|
||||||
|
{
|
||||||
|
return new ErrorResponse("Unknown job_id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = TestJobManager.ToSerializable(job, includeDetails, includeFailedTests);
|
||||||
|
return new SuccessResponse("Test job status retrieved.", payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7f92c2b67a2c4b5c9d1a3c0e6f9b2d10
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,7 +180,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory)))
|
if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory)))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory));
|
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory));
|
||||||
AssetDatabase.Refresh(); // Make sure Unity knows about the new folder
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Make sure Unity knows about the new folder
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AssetExists(fullPath))
|
if (AssetExists(fullPath))
|
||||||
|
|
@ -869,7 +869,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
if (!Directory.Exists(fullDirPath))
|
if (!Directory.Exists(fullDirPath))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(fullDirPath);
|
Directory.CreateDirectory(fullDirPath);
|
||||||
AssetDatabase.Refresh(); // Let Unity know about the new folder
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Let Unity know about the new folder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -590,7 +590,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
System.IO.Directory.CreateDirectory(directoryPath);
|
System.IO.Directory.CreateDirectory(directoryPath);
|
||||||
AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Refresh asset database to recognize the new folder
|
||||||
Debug.Log(
|
Debug.Log(
|
||||||
$"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"
|
$"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
if (saved)
|
if (saved)
|
||||||
{
|
{
|
||||||
AssetDatabase.Refresh(); // Ensure Unity sees the new scene file
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity sees the new scene file
|
||||||
return new SuccessResponse(
|
return new SuccessResponse(
|
||||||
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
|
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
|
||||||
new { path = relativePath }
|
new { path = relativePath }
|
||||||
|
|
@ -362,7 +362,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
if (saved)
|
if (saved)
|
||||||
{
|
{
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
return new SuccessResponse(
|
return new SuccessResponse(
|
||||||
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
|
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
|
||||||
new { path = finalPath, name = currentScene.name }
|
new { path = finalPath, name = currentScene.name }
|
||||||
|
|
@ -408,7 +408,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true);
|
result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
|
|
||||||
string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
|
string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -978,7 +978,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
bool deleted = AssetDatabase.MoveAssetToTrash(relativePath);
|
bool deleted = AssetDatabase.MoveAssetToTrash(relativePath);
|
||||||
if (deleted)
|
if (deleted)
|
||||||
{
|
{
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
return new SuccessResponse(
|
return new SuccessResponse(
|
||||||
$"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.",
|
$"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.",
|
||||||
new { deleted = true }
|
new { deleted = true }
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(fullPathDir);
|
Directory.CreateDirectory(fullPathDir);
|
||||||
// Refresh AssetDatabase to recognize new folders
|
// Refresh AssetDatabase to recognize new folders
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
|
|
@ -174,7 +174,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
{
|
{
|
||||||
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
|
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
|
||||||
AssetDatabase.ImportAsset(relativePath);
|
AssetDatabase.ImportAsset(relativePath);
|
||||||
AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity recognizes the new shader
|
||||||
return new SuccessResponse(
|
return new SuccessResponse(
|
||||||
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
|
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
|
||||||
new { path = relativePath }
|
new { path = relativePath }
|
||||||
|
|
@ -242,7 +242,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
{
|
{
|
||||||
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
|
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
|
||||||
AssetDatabase.ImportAsset(relativePath);
|
AssetDatabase.ImportAsset(relativePath);
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
return new SuccessResponse(
|
return new SuccessResponse(
|
||||||
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
|
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
|
||||||
new { path = relativePath }
|
new { path = relativePath }
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs
|
||||||
if (!Directory.Exists(fullDirectory))
|
if (!Directory.Exists(fullDirectory))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(fullDirectory);
|
Directory.CreateDirectory(fullDirectory);
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using MCPForUnity.Editor.Services;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.Compilation;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Explicitly refreshes Unity's asset database and optionally requests a script compilation.
|
||||||
|
/// This is side-effectful and should be treated as a tool.
|
||||||
|
/// </summary>
|
||||||
|
[McpForUnityTool("refresh_unity", AutoRegister = false)]
|
||||||
|
public static class RefreshUnity
|
||||||
|
{
|
||||||
|
private const int DefaultWaitTimeoutSeconds = 60;
|
||||||
|
|
||||||
|
public static async Task<object> HandleCommand(JObject @params)
|
||||||
|
{
|
||||||
|
string mode = @params?["mode"]?.ToString() ?? "if_dirty";
|
||||||
|
string scope = @params?["scope"]?.ToString() ?? "all";
|
||||||
|
string compile = @params?["compile"]?.ToString() ?? "none";
|
||||||
|
bool waitForReady = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var waitToken = @params?["wait_for_ready"];
|
||||||
|
if (waitToken != null && bool.TryParse(waitToken.ToString(), out var parsed))
|
||||||
|
{
|
||||||
|
waitForReady = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore parse failures
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TestRunStatus.IsRunning)
|
||||||
|
{
|
||||||
|
return new ErrorResponse("tests_running", new
|
||||||
|
{
|
||||||
|
reason = "tests_running",
|
||||||
|
retry_after_ms = 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool refreshTriggered = false;
|
||||||
|
bool compileRequested = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Best-effort semantics: if_dirty currently behaves like force unless future dirty signals are added.
|
||||||
|
bool shouldRefresh = string.Equals(mode, "force", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(mode, "if_dirty", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (shouldRefresh)
|
||||||
|
{
|
||||||
|
if (string.Equals(scope, "scripts", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// For scripts, requesting compilation is usually the meaningful action.
|
||||||
|
// We avoid a heavyweight full refresh by default.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
|
||||||
|
refreshTriggered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(compile, "request", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CompilationPipeline.RequestScriptCompilation();
|
||||||
|
compileRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(scope, "all", StringComparison.OrdinalIgnoreCase) && !refreshTriggered)
|
||||||
|
{
|
||||||
|
// If the caller asked for "all" and we skipped refresh above (e.g., scripts-only path),
|
||||||
|
// do a lightweight refresh now. Use ForceSynchronousImport to ensure the refresh
|
||||||
|
// completes before returning, preventing stalls when Unity is backgrounded.
|
||||||
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
|
refreshTriggered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ErrorResponse($"refresh_failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waitForReady)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForUnityReadyAsync(TimeSpan.FromSeconds(DefaultWaitTimeoutSeconds)).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
return new ErrorResponse("refresh_timeout_waiting_for_ready", new
|
||||||
|
{
|
||||||
|
refresh_triggered = refreshTriggered,
|
||||||
|
compile_requested = compileRequested,
|
||||||
|
resulting_state = "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ErrorResponse($"refresh_wait_failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string resultingState = EditorApplication.isCompiling
|
||||||
|
? "compiling"
|
||||||
|
: (EditorApplication.isUpdating ? "asset_import" : "idle");
|
||||||
|
|
||||||
|
return new SuccessResponse("Refresh requested.", new
|
||||||
|
{
|
||||||
|
refresh_triggered = refreshTriggered,
|
||||||
|
compile_requested = compileRequested,
|
||||||
|
resulting_state = resultingState,
|
||||||
|
hint = waitForReady
|
||||||
|
? "Unity refresh completed; editor should be ready."
|
||||||
|
: "If Unity enters compilation/domain reload, poll editor_state until ready_for_tools is true."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WaitForUnityReadyAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var start = DateTime.UtcNow;
|
||||||
|
|
||||||
|
void Tick()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (tcs.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
EditorApplication.update -= Tick;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((DateTime.UtcNow - start) > timeout)
|
||||||
|
{
|
||||||
|
EditorApplication.update -= Tick;
|
||||||
|
tcs.TrySetException(new TimeoutException());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EditorApplication.isCompiling
|
||||||
|
&& !EditorApplication.isUpdating
|
||||||
|
&& !TestRunStatus.IsRunning
|
||||||
|
&& !EditorApplication.isPlayingOrWillChangePlaymode)
|
||||||
|
{
|
||||||
|
EditorApplication.update -= Tick;
|
||||||
|
tcs.TrySetResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
EditorApplication.update -= Tick;
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorApplication.update += Tick;
|
||||||
|
// Nudge Unity to pump once in case update is throttled.
|
||||||
|
try { EditorApplication.QueuePlayerLoopUpdate(); } catch { }
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c2c02170faca940d09c813706493ecb3
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MCPForUnity.Editor.Helpers;
|
||||||
|
using MCPForUnity.Editor.Resources.Tests;
|
||||||
|
using MCPForUnity.Editor.Services;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using UnityEditor.TestTools.TestRunner.Api;
|
||||||
|
|
||||||
|
namespace MCPForUnity.Editor.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a Unity Test Runner run asynchronously and returns a job id immediately.
|
||||||
|
/// Use get_test_job(job_id) to poll status/results.
|
||||||
|
/// </summary>
|
||||||
|
[McpForUnityTool("run_tests_async", AutoRegister = false)]
|
||||||
|
public static class RunTestsAsync
|
||||||
|
{
|
||||||
|
public static Task<object> HandleCommand(JObject @params)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string modeStr = @params?["mode"]?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(modeStr))
|
||||||
|
{
|
||||||
|
modeStr = "EditMode";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
|
||||||
|
{
|
||||||
|
return Task.FromResult<object>(new ErrorResponse(parseError));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool includeDetails = false;
|
||||||
|
bool includeFailedTests = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var includeDetailsToken = @params?["includeDetails"];
|
||||||
|
if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails))
|
||||||
|
{
|
||||||
|
includeDetails = parsedIncludeDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
var includeFailedTestsToken = @params?["includeFailedTests"];
|
||||||
|
if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests))
|
||||||
|
{
|
||||||
|
includeFailedTests = parsedIncludeFailedTests;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore parse failures
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterOptions = GetFilterOptions(@params);
|
||||||
|
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
|
||||||
|
|
||||||
|
return Task.FromResult<object>(new SuccessResponse("Test job started.", new
|
||||||
|
{
|
||||||
|
job_id = jobId,
|
||||||
|
status = "running",
|
||||||
|
mode = parsedMode.Value.ToString(),
|
||||||
|
include_details = includeDetails,
|
||||||
|
include_failed_tests = includeFailedTests
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Normalize the already-running case to a stable error token.
|
||||||
|
if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult<object>(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 }));
|
||||||
|
}
|
||||||
|
return Task.FromResult<object>(new ErrorResponse($"Failed to start test job: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestFilterOptions GetFilterOptions(JObject @params)
|
||||||
|
{
|
||||||
|
if (@params == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] ParseStringArray(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
|
||||||
|
.Values<string>()
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.ToArray();
|
||||||
|
return values.Length > 0 ? values : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var testNames = ParseStringArray("testNames");
|
||||||
|
var groupNames = ParseStringArray("groupNames");
|
||||||
|
var categoryNames = ParseStringArray("categoryNames");
|
||||||
|
var assemblyNames = ParseStringArray("assemblyNames");
|
||||||
|
|
||||||
|
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TestFilterOptions
|
||||||
|
{
|
||||||
|
TestNames = testNames,
|
||||||
|
GroupNames = groupNames,
|
||||||
|
CategoryNames = categoryNames,
|
||||||
|
AssemblyNames = assemblyNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -627,10 +627,20 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
{
|
{
|
||||||
// Wait until the HTTP server is actually accepting connections to reduce transient "unable to connect then recovers"
|
// Wait until the HTTP server is actually accepting connections to reduce transient "unable to connect then recovers"
|
||||||
// behavior (session start attempts can race the server startup).
|
// behavior (session start attempts can race the server startup).
|
||||||
bool serverReady = await WaitForHttpServerAcceptingConnectionsAsync(TimeSpan.FromSeconds(10));
|
// Dev mode uses --no-cache --refresh which causes uvx to rebuild the package, taking significantly longer.
|
||||||
|
bool devModeEnabled = false;
|
||||||
|
try { devModeEnabled = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }
|
||||||
|
var startupTimeout = devModeEnabled ? TimeSpan.FromSeconds(45) : TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
if (devModeEnabled)
|
||||||
|
{
|
||||||
|
McpLog.Info("Dev mode enabled: server startup may take longer while uvx rebuilds the package...");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool serverReady = await WaitForHttpServerAcceptingConnectionsAsync(startupTimeout);
|
||||||
if (!serverReady)
|
if (!serverReady)
|
||||||
{
|
{
|
||||||
McpLog.Warn("HTTP server did not become reachable within the expected startup window; will still attempt to start the session.");
|
McpLog.Warn($"HTTP server did not become reachable within {startupTimeout.TotalSeconds}s; will still attempt to start the session.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -16,9 +16,9 @@
|
||||||
|
|
||||||
**Create your Unity apps with LLMs!**
|
**Create your Unity apps with LLMs!**
|
||||||
|
|
||||||
MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity.
|
MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigravity, VS Code, etc) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity.
|
||||||
|
|
||||||
<img width="406" height="704" alt="MCP for Unity screenshot" src="docs/images/readme_ui.png">
|
<img width="406" height="704" alt="MCP for Unity screenshot" src="docs/images/unity-mcp-ui-v8.6.png">
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -51,7 +51,9 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
|
||||||
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets using Unity SerializedObject property paths.
|
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets using Unity SerializedObject property paths.
|
||||||
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
|
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
|
||||||
* `read_console`: Gets messages from or clears the console.
|
* `read_console`: Gets messages from or clears the console.
|
||||||
* `run_tests`: Runs tests in the Unity Editor.
|
* `run_tests_async`: Starts tests asynchronously and returns a job_id for polling (preferred).
|
||||||
|
* `get_test_job`: Polls an async test job for progress and results.
|
||||||
|
* `run_tests`: Runs tests synchronously (blocks until complete; prefer `run_tests_async` for long suites).
|
||||||
* `execute_custom_tool`: Execute a project-scoped custom tool registered by Unity.
|
* `execute_custom_tool`: Execute a project-scoped custom tool registered by Unity.
|
||||||
* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project").
|
* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project").
|
||||||
* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). Requires the exact `Name@hash` from `unity_instances`.
|
* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). Requires the exact `Name@hash` from `unity_instances`.
|
||||||
|
|
@ -152,7 +154,7 @@ MCP for Unity connects your tools using two components:
|
||||||
|
|
||||||
**Need a stable/fixed version?** Use a tagged URL instead (updates require uninstalling and re-installing):
|
**Need a stable/fixed version?** Use a tagged URL instead (updates require uninstalling and re-installing):
|
||||||
```
|
```
|
||||||
https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.2.1
|
https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
#### To install via OpenUPM
|
#### To install via OpenUPM
|
||||||
|
|
@ -168,8 +170,8 @@ https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.2.1
|
||||||
HTTP transport is enabled out of the box. The Unity window can launch the FastMCP server for you:
|
HTTP transport is enabled out of the box. The Unity window can launch the FastMCP server for you:
|
||||||
|
|
||||||
1. Open `Window > MCP for Unity`.
|
1. Open `Window > MCP for Unity`.
|
||||||
2. Make sure the **Transport** dropdown is set to `HTTP` (default) and the **HTTP URL** is what you want (defaults to `http://localhost:8080`).
|
2. Make sure the **Transport** dropdown is set to `HTTP Local` (default) and the **HTTP URL** is what you want (defaults to `http://localhost:8080`).
|
||||||
3. Click **Start Local HTTP Server**. Unity spawns a new operating-system terminal running `uv ... server.py --transport http`.
|
3. Click **Start Server**. Unity spawns a new operating-system terminal running `uv ... server.py --transport http`.
|
||||||
4. Keep that terminal window open while you work; closing it stops the server. Use the **Stop Session** button in the Unity window if you need to tear it down cleanly.
|
4. Keep that terminal window open while you work; closing it stops the server. Use the **Stop Session** button in the Unity window if you need to tear it down cleanly.
|
||||||
|
|
||||||
> Prefer stdio? Change the transport dropdown to `Stdio` and Unity will fall back to the embedded TCP bridge instead of launching the HTTP server.
|
> Prefer stdio? Change the transport dropdown to `Stdio` and Unity will fall back to the embedded TCP bridge instead of launching the HTTP server.
|
||||||
|
|
@ -179,7 +181,7 @@ HTTP transport is enabled out of the box. The Unity window can launch the FastMC
|
||||||
You can also start the server yourself from a terminal—useful for CI or when you want to see raw logs:
|
You can also start the server yourself from a terminal—useful for CI or when you want to see raw logs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.1.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080
|
uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep the process running while clients are connected.
|
Keep the process running while clients are connected.
|
||||||
|
|
@ -407,7 +409,7 @@ Your privacy matters to us. All telemetry is optional and designed to respect yo
|
||||||
- Check the status window: Window > MCP for Unity.
|
- Check the status window: Window > MCP for Unity.
|
||||||
- Restart Unity.
|
- Restart Unity.
|
||||||
- **MCP Client Not Connecting / Server Not Starting:**
|
- **MCP Client Not Connecting / Server Not Starting:**
|
||||||
- Make sure the local HTTP server is running (Window > MCP for Unity > Start Local HTTP Server). Keep the spawned terminal window open.
|
- Make sure the local HTTP server is running (Window > MCP for Unity > Start Server). Keep the spawned terminal window open.
|
||||||
- **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the installation location:
|
- **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the installation location:
|
||||||
- **Windows:** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src`
|
- **Windows:** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src`
|
||||||
- **macOS:** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src`
|
- **macOS:** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
|
||||||
|
from models import MCPResponse
|
||||||
|
from services.registry import mcp_for_unity_resource
|
||||||
|
from services.tools import get_unity_instance_from_context
|
||||||
|
import transport.unity_transport as unity_transport
|
||||||
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
from services.state.external_changes_scanner import external_changes_scanner
|
||||||
|
|
||||||
|
|
||||||
|
def _now_unix_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_pytest() -> bool:
|
||||||
|
# Avoid instance-discovery side effects during the Python integration test suite.
|
||||||
|
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _infer_single_instance_id(ctx: Context) -> str | None:
|
||||||
|
"""
|
||||||
|
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
|
||||||
|
This makes editor_state outputs self-describing even when no explicit active instance is set.
|
||||||
|
"""
|
||||||
|
if _in_pytest():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
transport = unity_transport._current_transport()
|
||||||
|
except Exception:
|
||||||
|
transport = None
|
||||||
|
|
||||||
|
if transport == "http":
|
||||||
|
# HTTP/WebSocket transport: derive from PluginHub sessions.
|
||||||
|
try:
|
||||||
|
from transport.plugin_hub import PluginHub
|
||||||
|
|
||||||
|
sessions_data = await PluginHub.get_sessions()
|
||||||
|
sessions = sessions_data.sessions if hasattr(sessions_data, "sessions") else {}
|
||||||
|
if isinstance(sessions, dict) and len(sessions) == 1:
|
||||||
|
session = next(iter(sessions.values()))
|
||||||
|
project = getattr(session, "project", None)
|
||||||
|
project_hash = getattr(session, "hash", None)
|
||||||
|
if project and project_hash:
|
||||||
|
return f"{project}@{project_hash}"
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Stdio/TCP transport: derive from connection pool discovery.
|
||||||
|
try:
|
||||||
|
from transport.legacy.unity_connection import get_unity_connection_pool
|
||||||
|
|
||||||
|
pool = get_unity_connection_pool()
|
||||||
|
instances = pool.discover_all_instances(force_refresh=False)
|
||||||
|
if isinstance(instances, list) and len(instances) == 1:
|
||||||
|
inst = instances[0]
|
||||||
|
inst_id = getattr(inst, "id", None)
|
||||||
|
return str(inst_id) if inst_id else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_v2_from_legacy(legacy: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Best-effort mapping from legacy get_editor_state payload into the v2 contract.
|
||||||
|
Legacy shape (Unity): {isPlaying,isPaused,isCompiling,isUpdating,timeSinceStartup,...}
|
||||||
|
"""
|
||||||
|
now_ms = _now_unix_ms()
|
||||||
|
# legacy may arrive already wrapped as MCPResponse-like {success,data:{...}}
|
||||||
|
state = legacy.get("data") if isinstance(legacy.get("data"), dict) else {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": "unity-mcp/editor_state@2",
|
||||||
|
"observed_at_unix_ms": now_ms,
|
||||||
|
"sequence": 0,
|
||||||
|
"unity": {
|
||||||
|
"instance_id": None,
|
||||||
|
"unity_version": None,
|
||||||
|
"project_id": None,
|
||||||
|
"platform": None,
|
||||||
|
"is_batch_mode": None,
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"is_focused": None,
|
||||||
|
"play_mode": {
|
||||||
|
"is_playing": bool(state.get("isPlaying", False)),
|
||||||
|
"is_paused": bool(state.get("isPaused", False)),
|
||||||
|
"is_changing": None,
|
||||||
|
},
|
||||||
|
"active_scene": {
|
||||||
|
"path": None,
|
||||||
|
"guid": None,
|
||||||
|
"name": state.get("activeSceneName", "") or "",
|
||||||
|
},
|
||||||
|
"selection": {
|
||||||
|
"count": int(state.get("selectionCount", 0) or 0),
|
||||||
|
"active_object_name": state.get("activeObjectName", None),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"phase": "unknown",
|
||||||
|
"since_unix_ms": now_ms,
|
||||||
|
"reasons": ["legacy_fallback"],
|
||||||
|
},
|
||||||
|
"compilation": {
|
||||||
|
"is_compiling": bool(state.get("isCompiling", False)),
|
||||||
|
"is_domain_reload_pending": None,
|
||||||
|
"last_compile_started_unix_ms": None,
|
||||||
|
"last_compile_finished_unix_ms": None,
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"is_updating": bool(state.get("isUpdating", False)),
|
||||||
|
"external_changes_dirty": False,
|
||||||
|
"external_changes_last_seen_unix_ms": None,
|
||||||
|
"refresh": {
|
||||||
|
"is_refresh_in_progress": False,
|
||||||
|
"last_refresh_requested_unix_ms": None,
|
||||||
|
"last_refresh_finished_unix_ms": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"is_running": False,
|
||||||
|
"mode": None,
|
||||||
|
"started_unix_ms": None,
|
||||||
|
"started_by": "unknown",
|
||||||
|
"last_run": None,
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"unity_bridge_connected": None,
|
||||||
|
"last_message_unix_ms": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now_ms = _now_unix_ms()
|
||||||
|
observed = state_v2.get("observed_at_unix_ms")
|
||||||
|
try:
|
||||||
|
observed_ms = int(observed)
|
||||||
|
except Exception:
|
||||||
|
observed_ms = now_ms
|
||||||
|
|
||||||
|
age_ms = max(0, now_ms - observed_ms)
|
||||||
|
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
|
||||||
|
is_stale = age_ms > 2000
|
||||||
|
|
||||||
|
compilation = state_v2.get("compilation") or {}
|
||||||
|
tests = state_v2.get("tests") or {}
|
||||||
|
assets = state_v2.get("assets") or {}
|
||||||
|
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
|
||||||
|
|
||||||
|
blocking: list[str] = []
|
||||||
|
if compilation.get("is_compiling") is True:
|
||||||
|
blocking.append("compiling")
|
||||||
|
if compilation.get("is_domain_reload_pending") is True:
|
||||||
|
blocking.append("domain_reload")
|
||||||
|
if tests.get("is_running") is True:
|
||||||
|
blocking.append("running_tests")
|
||||||
|
if refresh.get("is_refresh_in_progress") is True:
|
||||||
|
blocking.append("asset_refresh")
|
||||||
|
if is_stale:
|
||||||
|
blocking.append("stale_status")
|
||||||
|
|
||||||
|
ready_for_tools = len(blocking) == 0
|
||||||
|
|
||||||
|
state_v2["advice"] = {
|
||||||
|
"ready_for_tools": ready_for_tools,
|
||||||
|
"blocking_reasons": blocking,
|
||||||
|
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
|
||||||
|
"recommended_next_action": "none" if ready_for_tools else "retry_later",
|
||||||
|
}
|
||||||
|
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
|
||||||
|
return state_v2
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_for_unity_resource(
|
||||||
|
uri="unity://editor_state",
|
||||||
|
name="editor_state_v2",
|
||||||
|
description="Canonical editor readiness snapshot (v2). Includes advice and server-computed staleness.",
|
||||||
|
)
|
||||||
|
async def get_editor_state_v2(ctx: Context) -> MCPResponse:
|
||||||
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
# Try v2 snapshot first (Unity-side cache will make this fast once implemented).
|
||||||
|
response = await unity_transport.send_with_unity_instance(
|
||||||
|
async_send_command_with_retry,
|
||||||
|
unity_instance,
|
||||||
|
"get_editor_state_v2",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If Unity returns a structured retry hint or error, surface it directly.
|
||||||
|
if isinstance(response, dict) and not response.get("success", True):
|
||||||
|
return MCPResponse(**response)
|
||||||
|
|
||||||
|
# If v2 is unavailable (older plugin), fall back to legacy get_editor_state and map.
|
||||||
|
if not (isinstance(response, dict) and isinstance(response.get("data"), dict) and response["data"].get("schema_version")):
|
||||||
|
legacy = await unity_transport.send_with_unity_instance(
|
||||||
|
async_send_command_with_retry,
|
||||||
|
unity_instance,
|
||||||
|
"get_editor_state",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if isinstance(legacy, dict) and not legacy.get("success", True):
|
||||||
|
return MCPResponse(**legacy)
|
||||||
|
state_v2 = _build_v2_from_legacy(legacy if isinstance(legacy, dict) else {})
|
||||||
|
else:
|
||||||
|
state_v2 = response.get("data") if isinstance(response.get("data"), dict) else {}
|
||||||
|
# Ensure required v2 marker exists even if Unity returns partial.
|
||||||
|
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
|
||||||
|
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
|
||||||
|
state_v2.setdefault("sequence", 0)
|
||||||
|
|
||||||
|
# Ensure the returned snapshot is clearly associated with the targeted instance.
|
||||||
|
# (This matters when multiple Unity instances are connected and the client is polling readiness.)
|
||||||
|
unity_section = state_v2.get("unity")
|
||||||
|
if not isinstance(unity_section, dict):
|
||||||
|
unity_section = {}
|
||||||
|
state_v2["unity"] = unity_section
|
||||||
|
current_instance_id = unity_section.get("instance_id")
|
||||||
|
if current_instance_id in (None, ""):
|
||||||
|
if unity_instance:
|
||||||
|
unity_section["instance_id"] = unity_instance
|
||||||
|
else:
|
||||||
|
inferred = await _infer_single_instance_id(ctx)
|
||||||
|
if inferred:
|
||||||
|
unity_section["instance_id"] = inferred
|
||||||
|
|
||||||
|
# External change detection (server-side): compute per instance based on project root path.
|
||||||
|
# This helps detect stale assets when external tools edit the filesystem.
|
||||||
|
try:
|
||||||
|
instance_id = unity_section.get("instance_id")
|
||||||
|
if isinstance(instance_id, str) and instance_id.strip():
|
||||||
|
from services.resources.project_info import get_project_info
|
||||||
|
|
||||||
|
# Cache the project root for this instance (best-effort).
|
||||||
|
proj_resp = await get_project_info(ctx)
|
||||||
|
proj = proj_resp.model_dump() if hasattr(proj_resp, "model_dump") else proj_resp
|
||||||
|
proj_data = proj.get("data") if isinstance(proj, dict) else None
|
||||||
|
project_root = proj_data.get("projectRoot") if isinstance(proj_data, dict) else None
|
||||||
|
if isinstance(project_root, str) and project_root.strip():
|
||||||
|
external_changes_scanner.set_project_root(instance_id, project_root)
|
||||||
|
|
||||||
|
ext = external_changes_scanner.update_and_get(instance_id)
|
||||||
|
|
||||||
|
assets = state_v2.get("assets")
|
||||||
|
if not isinstance(assets, dict):
|
||||||
|
assets = {}
|
||||||
|
state_v2["assets"] = assets
|
||||||
|
# IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative
|
||||||
|
# for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner.
|
||||||
|
assets["external_changes_dirty"] = bool(ext.get("external_changes_dirty", False))
|
||||||
|
assets["external_changes_last_seen_unix_ms"] = ext.get("external_changes_last_seen_unix_ms")
|
||||||
|
# Extra bookkeeping fields (server-only) are safe to add under assets.
|
||||||
|
assets["external_changes_dirty_since_unix_ms"] = ext.get("dirty_since_unix_ms")
|
||||||
|
assets["external_changes_last_cleared_unix_ms"] = ext.get("last_cleared_unix_ms")
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; do not fail readiness resource if filesystem scan can't run.
|
||||||
|
pass
|
||||||
|
|
||||||
|
state_v2 = _enrich_advice_and_staleness(state_v2)
|
||||||
|
return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def _now_unix_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_pytest() -> bool:
|
||||||
|
# Keep scanner inert during the Python integration suite unless explicitly invoked.
|
||||||
|
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExternalChangesState:
|
||||||
|
project_root: str | None = None
|
||||||
|
last_scan_unix_ms: int | None = None
|
||||||
|
last_seen_mtime_ns: int | None = None
|
||||||
|
dirty: bool = False
|
||||||
|
dirty_since_unix_ms: int | None = None
|
||||||
|
external_changes_last_seen_unix_ms: int | None = None
|
||||||
|
last_cleared_unix_ms: int | None = None
|
||||||
|
# Cached package roots referenced by Packages/manifest.json "file:" dependencies
|
||||||
|
extra_roots: list[str] | None = None
|
||||||
|
manifest_last_mtime_ns: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalChangesScanner:
|
||||||
|
"""
|
||||||
|
Lightweight external-changes detector using recursive max-mtime scan.
|
||||||
|
|
||||||
|
This is intentionally conservative:
|
||||||
|
- It only marks dirty when it sees a strictly newer mtime than the baseline.
|
||||||
|
- It scans at most once per scan_interval_ms per instance to keep overhead bounded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, scan_interval_ms: int = 1500, max_entries: int = 20000):
|
||||||
|
self._states: dict[str, ExternalChangesState] = {}
|
||||||
|
self._scan_interval_ms = int(scan_interval_ms)
|
||||||
|
self._max_entries = int(max_entries)
|
||||||
|
|
||||||
|
def _get_state(self, instance_id: str) -> ExternalChangesState:
|
||||||
|
return self._states.setdefault(instance_id, ExternalChangesState())
|
||||||
|
|
||||||
|
def set_project_root(self, instance_id: str, project_root: str | None) -> None:
|
||||||
|
st = self._get_state(instance_id)
|
||||||
|
if project_root:
|
||||||
|
st.project_root = project_root
|
||||||
|
|
||||||
|
def clear_dirty(self, instance_id: str) -> None:
|
||||||
|
st = self._get_state(instance_id)
|
||||||
|
st.dirty = False
|
||||||
|
st.dirty_since_unix_ms = None
|
||||||
|
st.last_cleared_unix_ms = _now_unix_ms()
|
||||||
|
# Reset baseline to “now” on next scan.
|
||||||
|
st.last_seen_mtime_ns = None
|
||||||
|
|
||||||
|
def _scan_paths_max_mtime_ns(self, roots: Iterable[Path]) -> int | None:
|
||||||
|
newest: int | None = None
|
||||||
|
entries = 0
|
||||||
|
|
||||||
|
for root in roots:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Walk the tree; skip common massive/irrelevant dirs (Library/Temp/Logs).
|
||||||
|
for dirpath, dirnames, filenames in os.walk(str(root)):
|
||||||
|
entries += 1
|
||||||
|
if entries > self._max_entries:
|
||||||
|
return newest
|
||||||
|
|
||||||
|
dp = Path(dirpath)
|
||||||
|
name = dp.name.lower()
|
||||||
|
if name in {"library", "temp", "logs", "obj", ".git", "node_modules"}:
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Allow skipping hidden directories quickly
|
||||||
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
|
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.startswith("."):
|
||||||
|
continue
|
||||||
|
entries += 1
|
||||||
|
if entries > self._max_entries:
|
||||||
|
return newest
|
||||||
|
p = dp / fn
|
||||||
|
try:
|
||||||
|
stat = p.stat()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
m = getattr(stat, "st_mtime_ns", None)
|
||||||
|
if m is None:
|
||||||
|
# Fallback when st_mtime_ns is unavailable
|
||||||
|
m = int(stat.st_mtime * 1_000_000_000)
|
||||||
|
newest = m if newest is None else max(newest, int(m))
|
||||||
|
|
||||||
|
return newest
|
||||||
|
|
||||||
|
def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesState) -> list[Path]:
|
||||||
|
"""
|
||||||
|
Parse Packages/manifest.json for local file: dependencies and resolve them to absolute paths.
|
||||||
|
Returns a list of Paths that exist and are directories.
|
||||||
|
"""
|
||||||
|
manifest_path = project_root / "Packages" / "manifest.json"
|
||||||
|
try:
|
||||||
|
stat = manifest_path.stat()
|
||||||
|
except OSError:
|
||||||
|
st.extra_roots = []
|
||||||
|
st.manifest_last_mtime_ns = None
|
||||||
|
return []
|
||||||
|
|
||||||
|
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
|
||||||
|
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
|
||||||
|
return [Path(p) for p in st.extra_roots if p]
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = manifest_path.read_text(encoding="utf-8")
|
||||||
|
doc = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
st.extra_roots = []
|
||||||
|
st.manifest_last_mtime_ns = mtime_ns
|
||||||
|
return []
|
||||||
|
|
||||||
|
deps = doc.get("dependencies") if isinstance(doc, dict) else None
|
||||||
|
if not isinstance(deps, dict):
|
||||||
|
st.extra_roots = []
|
||||||
|
st.manifest_last_mtime_ns = mtime_ns
|
||||||
|
return []
|
||||||
|
|
||||||
|
roots: list[str] = []
|
||||||
|
base_dir = manifest_path.parent
|
||||||
|
|
||||||
|
for _, ver in deps.items():
|
||||||
|
if not isinstance(ver, str):
|
||||||
|
continue
|
||||||
|
v = ver.strip()
|
||||||
|
if not v.startswith("file:"):
|
||||||
|
continue
|
||||||
|
suffix = v[len("file:") :].strip()
|
||||||
|
# Handle file:///abs/path or file:/abs/path
|
||||||
|
if suffix.startswith("///"):
|
||||||
|
candidate = Path("/" + suffix.lstrip("/"))
|
||||||
|
elif suffix.startswith("/"):
|
||||||
|
candidate = Path(suffix)
|
||||||
|
else:
|
||||||
|
candidate = (base_dir / suffix).resolve()
|
||||||
|
try:
|
||||||
|
if candidate.exists() and candidate.is_dir():
|
||||||
|
roots.append(str(candidate))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# De-dupe, preserve order
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen = set()
|
||||||
|
for r in roots:
|
||||||
|
if r not in seen:
|
||||||
|
seen.add(r)
|
||||||
|
deduped.append(r)
|
||||||
|
|
||||||
|
st.extra_roots = deduped
|
||||||
|
st.manifest_last_mtime_ns = mtime_ns
|
||||||
|
return [Path(p) for p in deduped if p]
|
||||||
|
|
||||||
|
def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
|
||||||
|
"""
|
||||||
|
Returns a small dict suitable for embedding in editor_state_v2.assets:
|
||||||
|
- external_changes_dirty
|
||||||
|
- external_changes_last_seen_unix_ms
|
||||||
|
- dirty_since_unix_ms
|
||||||
|
- last_cleared_unix_ms
|
||||||
|
"""
|
||||||
|
st = self._get_state(instance_id)
|
||||||
|
|
||||||
|
if _in_pytest():
|
||||||
|
return {
|
||||||
|
"external_changes_dirty": st.dirty,
|
||||||
|
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
||||||
|
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
||||||
|
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
now = _now_unix_ms()
|
||||||
|
if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
|
||||||
|
return {
|
||||||
|
"external_changes_dirty": st.dirty,
|
||||||
|
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
||||||
|
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
||||||
|
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
st.last_scan_unix_ms = now
|
||||||
|
|
||||||
|
project_root = st.project_root
|
||||||
|
if not project_root:
|
||||||
|
return {
|
||||||
|
"external_changes_dirty": st.dirty,
|
||||||
|
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
||||||
|
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
||||||
|
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
root = Path(project_root)
|
||||||
|
paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
|
||||||
|
# Include any local package roots referenced by file: deps in Packages/manifest.json
|
||||||
|
try:
|
||||||
|
paths.extend(self._resolve_manifest_extra_roots(root, st))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
newest = self._scan_paths_max_mtime_ns(paths)
|
||||||
|
if newest is None:
|
||||||
|
return {
|
||||||
|
"external_changes_dirty": st.dirty,
|
||||||
|
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
||||||
|
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
||||||
|
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.last_seen_mtime_ns is None:
|
||||||
|
st.last_seen_mtime_ns = newest
|
||||||
|
elif newest > st.last_seen_mtime_ns:
|
||||||
|
st.last_seen_mtime_ns = newest
|
||||||
|
st.external_changes_last_seen_unix_ms = now
|
||||||
|
if not st.dirty:
|
||||||
|
st.dirty = True
|
||||||
|
st.dirty_since_unix_ms = now
|
||||||
|
|
||||||
|
return {
|
||||||
|
"external_changes_dirty": st.dirty,
|
||||||
|
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
||||||
|
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
||||||
|
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton (simple, process-local)
|
||||||
|
external_changes_scanner = ExternalChangesScanner()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ from services.tools import get_unity_instance_from_context
|
||||||
from services.tools.utils import parse_json_payload, coerce_int
|
from services.tools.utils import parse_json_payload, coerce_int
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
from services.tools.preflight import preflight
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
|
|
@ -47,6 +48,12 @@ async def manage_asset(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
|
||||||
|
# wait/refresh to avoid stale reads and flaky timeouts.
|
||||||
|
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||||
|
if gate is not None:
|
||||||
|
return gate.model_dump()
|
||||||
|
|
||||||
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from services.tools import get_unity_instance_from_context
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
||||||
|
from services.tools.preflight import preflight
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
|
|
@ -92,6 +93,10 @@ async def manage_gameobject(
|
||||||
# Removed session_state import
|
# Removed session_state import
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||||
|
if gate is not None:
|
||||||
|
return gate.model_dump()
|
||||||
|
|
||||||
if action is None:
|
if action is None:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from services.tools import get_unity_instance_from_context
|
||||||
from services.tools.utils import coerce_int, coerce_bool
|
from services.tools.utils import coerce_int, coerce_bool
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
from services.tools.preflight import preflight
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
|
|
@ -40,6 +41,9 @@ async def manage_scene(
|
||||||
# Get active instance from session state
|
# Get active instance from session state
|
||||||
# Removed session_state import
|
# Removed session_state import
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||||
|
if gate is not None:
|
||||||
|
return gate.model_dump()
|
||||||
try:
|
try:
|
||||||
coerced_build_index = coerce_int(build_index, default=None)
|
coerced_build_index = coerce_int(build_index, default=None)
|
||||||
coerced_super_size = coerce_int(screenshot_super_size, default=None)
|
coerced_super_size = coerce_int(screenshot_super_size, default=None)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from models import MCPResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _in_pytest() -> bool:
|
||||||
|
# Integration tests in this repo stub transports and do not run against a live Unity editor.
|
||||||
|
# Preflight must be a no-op in that environment to avoid breaking the existing test suite.
|
||||||
|
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
||||||
|
|
||||||
|
|
||||||
|
def _busy(reason: str, retry_after_ms: int) -> MCPResponse:
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
error="busy",
|
||||||
|
message=reason,
|
||||||
|
hint="retry",
|
||||||
|
data={"reason": reason, "retry_after_ms": int(retry_after_ms)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def preflight(
|
||||||
|
ctx,
|
||||||
|
*,
|
||||||
|
requires_no_tests: bool = False,
|
||||||
|
wait_for_no_compile: bool = False,
|
||||||
|
refresh_if_dirty: bool = False,
|
||||||
|
max_wait_s: float = 30.0,
|
||||||
|
) -> MCPResponse | None:
|
||||||
|
"""
|
||||||
|
Server-side preflight guard used by tools so they behave safely even if the client never reads resources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- MCPResponse busy/retry payload when the tool should not proceed right now
|
||||||
|
- None when the tool should proceed normally
|
||||||
|
"""
|
||||||
|
if _in_pytest():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load canonical v2 state (server enriches advice + staleness).
|
||||||
|
try:
|
||||||
|
from services.resources.editor_state_v2 import get_editor_state_v2
|
||||||
|
state_resp = await get_editor_state_v2(ctx)
|
||||||
|
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
||||||
|
except Exception:
|
||||||
|
# If we cannot determine readiness, fall back to proceeding (tools already contain retry logic).
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(state, dict) or not state.get("success", False):
|
||||||
|
# Unknown state; proceed rather than blocking (avoids false positives when Unity is reachable but status isn't).
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = state.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Optional refresh-if-dirty
|
||||||
|
if refresh_if_dirty:
|
||||||
|
assets = data.get("assets")
|
||||||
|
if isinstance(assets, dict) and assets.get("external_changes_dirty") is True:
|
||||||
|
try:
|
||||||
|
from services.tools.refresh_unity import refresh_unity
|
||||||
|
await refresh_unity(ctx, mode="if_dirty", scope="all", compile="request", wait_for_ready=True)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort only; fall through to normal tool dispatch.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Tests running: fail fast for tools that require exclusivity.
|
||||||
|
if requires_no_tests:
|
||||||
|
tests = data.get("tests")
|
||||||
|
if isinstance(tests, dict) and tests.get("is_running") is True:
|
||||||
|
return _busy("tests_running", 5000)
|
||||||
|
|
||||||
|
# Compilation: optionally wait for a bounded time.
|
||||||
|
if wait_for_no_compile:
|
||||||
|
deadline = time.monotonic() + float(max_wait_s)
|
||||||
|
while True:
|
||||||
|
compilation = data.get("compilation") if isinstance(data, dict) else None
|
||||||
|
is_compiling = isinstance(compilation, dict) and compilation.get("is_compiling") is True
|
||||||
|
is_domain_reload_pending = isinstance(compilation, dict) and compilation.get("is_domain_reload_pending") is True
|
||||||
|
if not is_compiling and not is_domain_reload_pending:
|
||||||
|
break
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return _busy("compiling", 500)
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
# Refresh state for the next loop iteration.
|
||||||
|
try:
|
||||||
|
from services.resources.editor_state_v2 import get_editor_state_v2
|
||||||
|
state_resp = await get_editor_state_v2(ctx)
|
||||||
|
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
||||||
|
data = state.get("data") if isinstance(state, dict) else None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off.
|
||||||
|
# In future we may make this strict for some tools.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
|
||||||
|
from models import MCPResponse
|
||||||
|
from services.registry import mcp_for_unity_tool
|
||||||
|
from services.tools import get_unity_instance_from_context
|
||||||
|
import transport.unity_transport as unity_transport
|
||||||
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
from services.state.external_changes_scanner import external_changes_scanner
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_for_unity_tool(
|
||||||
|
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness."
|
||||||
|
)
|
||||||
|
async def refresh_unity(
|
||||||
|
ctx: Context,
|
||||||
|
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
|
||||||
|
scope: Annotated[Literal["assets", "scripts", "all"], "Refresh scope"] = "all",
|
||||||
|
compile: Annotated[Literal["none", "request"], "Whether to request compilation"] = "none",
|
||||||
|
wait_for_ready: Annotated[bool, "If true, wait until editor_state.advice.ready_for_tools is true"] = True,
|
||||||
|
) -> MCPResponse | dict[str, Any]:
|
||||||
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"mode": mode,
|
||||||
|
"scope": scope,
|
||||||
|
"compile": compile,
|
||||||
|
"wait_for_ready": bool(wait_for_ready),
|
||||||
|
}
|
||||||
|
|
||||||
|
recovered_from_disconnect = False
|
||||||
|
response = await unity_transport.send_with_unity_instance(
|
||||||
|
async_send_command_with_retry,
|
||||||
|
unity_instance,
|
||||||
|
"refresh_unity",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Option A: treat disconnects / retry hints as recoverable when wait_for_ready is true.
|
||||||
|
# Unity can legitimately disconnect during refresh/compile/domain reload, so callers should not
|
||||||
|
# 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 "")
|
||||||
|
is_retryable = (hint == "retry") or ("disconnected" in str(err).lower())
|
||||||
|
if (not wait_for_ready) or (not is_retryable):
|
||||||
|
return MCPResponse(**response)
|
||||||
|
recovered_from_disconnect = True
|
||||||
|
|
||||||
|
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
|
||||||
|
# poll the canonical editor_state v2 resource until ready or timeout.
|
||||||
|
if wait_for_ready:
|
||||||
|
timeout_s = 60.0
|
||||||
|
start = time.monotonic()
|
||||||
|
from services.resources.editor_state_v2 import get_editor_state_v2
|
||||||
|
|
||||||
|
while time.monotonic() - start < timeout_s:
|
||||||
|
state_resp = await get_editor_state_v2(ctx)
|
||||||
|
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
|
||||||
|
data = (state or {}).get("data") if isinstance(state, dict) else None
|
||||||
|
advice = (data or {}).get("advice") if isinstance(data, dict) else None
|
||||||
|
if isinstance(advice, dict) and advice.get("ready_for_tools") is True:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
|
||||||
|
try:
|
||||||
|
from services.resources.editor_state_v2 import _infer_single_instance_id
|
||||||
|
|
||||||
|
inst = unity_instance or await _infer_single_instance_id(ctx)
|
||||||
|
if inst:
|
||||||
|
external_changes_scanner.clear_dirty(inst)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if recovered_from_disconnect:
|
||||||
|
return MCPResponse(
|
||||||
|
success=True,
|
||||||
|
message="Refresh recovered after Unity disconnect/retry; editor is ready.",
|
||||||
|
data={"recovered_from_disconnect": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
return MCPResponse(**response) if isinstance(response, dict) else response
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ from services.tools import get_unity_instance_from_context
|
||||||
from services.tools.utils import coerce_int
|
from services.tools.utils import coerce_int
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
from services.tools.preflight import preflight
|
||||||
|
|
||||||
|
|
||||||
class RunTestsSummary(BaseModel):
|
class RunTestsSummary(BaseModel):
|
||||||
|
|
@ -42,7 +43,7 @@ class RunTestsResponse(MCPResponse):
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
description="Runs Unity tests for the specified mode"
|
description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling."
|
||||||
)
|
)
|
||||||
async def run_tests(
|
async def run_tests(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
|
@ -54,9 +55,13 @@ async def run_tests(
|
||||||
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
|
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
|
||||||
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
||||||
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
||||||
) -> RunTestsResponse:
|
) -> RunTestsResponse | MCPResponse:
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||||
|
if isinstance(gate, MCPResponse):
|
||||||
|
return gate
|
||||||
|
|
||||||
# Coerce string or list to list of strings
|
# Coerce string or list to list of strings
|
||||||
def _coerce_string_list(value) -> list[str] | None:
|
def _coerce_string_list(value) -> list[str] | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -97,5 +102,19 @@ async def run_tests(
|
||||||
params["includeDetails"] = True
|
params["includeDetails"] = True
|
||||||
|
|
||||||
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
|
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
|
||||||
await ctx.info(f'Response {response}')
|
|
||||||
|
# If Unity indicates a run is already active, return a structured "busy" response rather than
|
||||||
|
# letting clients interpret this as a generic failure (avoids #503 retry loops).
|
||||||
|
if isinstance(response, dict) and not response.get("success", True):
|
||||||
|
err = (response.get("error") or response.get("message") or "").strip()
|
||||||
|
if "test run is already in progress" in err.lower():
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
error="tests_running",
|
||||||
|
message=err,
|
||||||
|
hint="retry",
|
||||||
|
data={"reason": "tests_running", "retry_after_ms": 5000},
|
||||||
|
)
|
||||||
|
return MCPResponse(**response)
|
||||||
|
|
||||||
return RunTestsResponse(**response) if isinstance(response, dict) else response
|
return RunTestsResponse(**response) if isinstance(response, dict) else response
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Async Unity Test Runner jobs: start + poll."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
|
||||||
|
from models import MCPResponse
|
||||||
|
from services.registry import mcp_for_unity_tool
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_for_unity_tool(description="Starts a Unity test run asynchronously and returns a job_id immediately. Preferred over run_tests for long-running suites. Poll with get_test_job for progress.")
|
||||||
|
async def run_tests_async(
|
||||||
|
ctx: Context,
|
||||||
|
mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
|
||||||
|
test_names: Annotated[list[str] | str, "Full names of specific tests to run"] | 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"] | None = None,
|
||||||
|
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
|
||||||
|
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
||||||
|
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
||||||
|
) -> dict[str, Any] | MCPResponse:
|
||||||
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||||
|
if isinstance(gate, MCPResponse):
|
||||||
|
return gate
|
||||||
|
|
||||||
|
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}
|
||||||
|
if (t := _coerce_string_list(test_names)):
|
||||||
|
params["testNames"] = t
|
||||||
|
if (g := _coerce_string_list(group_names)):
|
||||||
|
params["groupNames"] = g
|
||||||
|
if (c := _coerce_string_list(category_names)):
|
||||||
|
params["categoryNames"] = c
|
||||||
|
if (a := _coerce_string_list(assembly_names)):
|
||||||
|
params["assemblyNames"] = a
|
||||||
|
if include_failed_tests:
|
||||||
|
params["includeFailedTests"] = True
|
||||||
|
if include_details:
|
||||||
|
params["includeDetails"] = True
|
||||||
|
|
||||||
|
response = await unity_transport.send_with_unity_instance(
|
||||||
|
async_send_command_with_retry,
|
||||||
|
unity_instance,
|
||||||
|
"run_tests_async",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(response, dict) and not response.get("success", True):
|
||||||
|
return MCPResponse(**response)
|
||||||
|
return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_for_unity_tool(description="Polls an async Unity test job by job_id.")
|
||||||
|
async def get_test_job(
|
||||||
|
ctx: Context,
|
||||||
|
job_id: Annotated[str, "Job id returned by run_tests_async"],
|
||||||
|
include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False,
|
||||||
|
include_details: Annotated[bool, "Include details for all tests (default: false)"] = False,
|
||||||
|
) -> dict[str, Any] | MCPResponse:
|
||||||
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"job_id": job_id}
|
||||||
|
if include_failed_tests:
|
||||||
|
params["includeFailedTests"] = True
|
||||||
|
if include_details:
|
||||||
|
params["includeDetails"] = True
|
||||||
|
|
||||||
|
response = await unity_transport.send_with_unity_instance(
|
||||||
|
async_send_command_with_retry,
|
||||||
|
unity_instance,
|
||||||
|
"get_test_job",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
if isinstance(response, dict) and not response.get("success", True):
|
||||||
|
return MCPResponse(**response)
|
||||||
|
return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.registry import get_registered_resources
|
||||||
|
|
||||||
|
from .test_helpers import DummyContext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch):
|
||||||
|
"""
|
||||||
|
Red test: we expect a canonical v2 resource `unity://editor_state` with required top-level fields.
|
||||||
|
|
||||||
|
Today, only `unity://editor/state` exists and is minimal.
|
||||||
|
"""
|
||||||
|
# Import the v2 module to ensure it registers its decorator without disturbing global registry state.
|
||||||
|
import services.resources.editor_state_v2 # noqa: F401
|
||||||
|
|
||||||
|
resources = get_registered_resources()
|
||||||
|
|
||||||
|
v2 = next((r for r in resources if r.get("uri") == "unity://editor_state"), None)
|
||||||
|
assert v2 is not None, (
|
||||||
|
"Expected canonical readiness resource `unity://editor_state` to be registered. "
|
||||||
|
"This is required so clients can poll readiness/staleness and avoid tool loops."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||||
|
# Minimal stub payload for v2 resource tests. The server layer should enrich with staleness/advice.
|
||||||
|
assert command_type in {"get_editor_state_v2", "get_editor_state"}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"schema_version": "unity-mcp/editor_state@2",
|
||||||
|
"observed_at_unix_ms": 1730000000000,
|
||||||
|
"sequence": 1,
|
||||||
|
"compilation": {"is_compiling": False, "is_domain_reload_pending": False},
|
||||||
|
"tests": {"is_running": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Patch transport so the resource can be invoked without Unity running.
|
||||||
|
import transport.unity_transport as unity_transport
|
||||||
|
monkeypatch.setattr(unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||||
|
|
||||||
|
result = await v2["func"](DummyContext())
|
||||||
|
payload = result.model_dump() if hasattr(result, "model_dump") else result
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
|
||||||
|
# Contract assertions (top-level)
|
||||||
|
assert payload.get("success") is True
|
||||||
|
data = payload.get("data")
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert data.get("schema_version") == "unity-mcp/editor_state@2"
|
||||||
|
assert "observed_at_unix_ms" in data
|
||||||
|
assert "sequence" in data
|
||||||
|
assert "advice" in data
|
||||||
|
assert "staleness" in data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_changes_scanner_marks_dirty_and_clears(tmp_path, monkeypatch):
|
||||||
|
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
|
||||||
|
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||||
|
|
||||||
|
from services.state.external_changes_scanner import ExternalChangesScanner
|
||||||
|
|
||||||
|
# Create a minimal Unity-like layout
|
||||||
|
root = tmp_path / "Project"
|
||||||
|
(root / "Assets").mkdir(parents=True)
|
||||||
|
(root / "ProjectSettings").mkdir(parents=True)
|
||||||
|
(root / "Packages").mkdir(parents=True)
|
||||||
|
|
||||||
|
inst = "Test@deadbeef"
|
||||||
|
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
|
||||||
|
s.set_project_root(inst, str(root))
|
||||||
|
|
||||||
|
# Create a file before baseline so the initial scan establishes a stable reference point.
|
||||||
|
p = root / "Assets" / "x.txt"
|
||||||
|
p.write_text("hi")
|
||||||
|
|
||||||
|
# Baseline scan: should not be dirty.
|
||||||
|
first = s.update_and_get(inst)
|
||||||
|
assert first["external_changes_dirty"] is False
|
||||||
|
|
||||||
|
# Touch the file and scan again: should become dirty.
|
||||||
|
now = time.time()
|
||||||
|
os.utime(p, (now + 10.0, now + 10.0))
|
||||||
|
|
||||||
|
second = s.update_and_get(inst)
|
||||||
|
assert second["external_changes_dirty"] is True
|
||||||
|
assert isinstance(second["external_changes_last_seen_unix_ms"], int)
|
||||||
|
assert isinstance(second["dirty_since_unix_ms"], int)
|
||||||
|
|
||||||
|
# Clear and confirm dirty flag resets.
|
||||||
|
s.clear_dirty(inst)
|
||||||
|
third = s.update_and_get(inst)
|
||||||
|
assert third["external_changes_dirty"] is False
|
||||||
|
assert isinstance(third["last_cleared_unix_ms"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_changes_scanner_includes_file_dependency_roots(tmp_path, monkeypatch):
|
||||||
|
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
|
||||||
|
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||||
|
|
||||||
|
from services.state.external_changes_scanner import ExternalChangesScanner
|
||||||
|
|
||||||
|
# Unity project root
|
||||||
|
root = tmp_path / "Project"
|
||||||
|
(root / "Assets").mkdir(parents=True)
|
||||||
|
(root / "ProjectSettings").mkdir(parents=True)
|
||||||
|
(root / "Packages").mkdir(parents=True)
|
||||||
|
|
||||||
|
# External local package root (outside project root)
|
||||||
|
pkg = tmp_path / "ExternalPkg"
|
||||||
|
(pkg / "Editor").mkdir(parents=True)
|
||||||
|
target = pkg / "Editor" / "Some.cs"
|
||||||
|
target.write_text("// v1")
|
||||||
|
|
||||||
|
# manifest.json referencing file: dependency
|
||||||
|
manifest = root / "Packages" / "manifest.json"
|
||||||
|
manifest.write_text(
|
||||||
|
'{\n "dependencies": {\n "com.example.pkg": "file:../../ExternalPkg"\n }\n}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
inst = "Test@deadbeef"
|
||||||
|
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
|
||||||
|
s.set_project_root(inst, str(root))
|
||||||
|
|
||||||
|
# Baseline scan captures current mtimes across project + external pkg
|
||||||
|
baseline = s.update_and_get(inst)
|
||||||
|
assert baseline["external_changes_dirty"] is False
|
||||||
|
|
||||||
|
# Touch external package file and scan again -> should mark dirty
|
||||||
|
now = time.time()
|
||||||
|
os.utime(target, (now + 10.0, now + 10.0))
|
||||||
|
|
||||||
|
changed = s.update_and_get(inst)
|
||||||
|
assert changed["external_changes_dirty"] is True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
from services.registry import get_registered_tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_unity_tool_is_registered():
|
||||||
|
"""
|
||||||
|
Red test: we expect an explicit refresh tool to exist so callers can force an import/refresh/compile cycle.
|
||||||
|
"""
|
||||||
|
# Import the specific module to ensure it registers its decorator without disturbing global registry state.
|
||||||
|
import services.tools.refresh_unity # noqa: F401
|
||||||
|
|
||||||
|
names = {t.get("name") for t in get_registered_tools()}
|
||||||
|
assert "refresh_unity" in names
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from models import MCPResponse
|
||||||
|
from services.state.external_changes_scanner import external_changes_scanner
|
||||||
|
from services.state.external_changes_scanner import ExternalChangesState
|
||||||
|
|
||||||
|
from .test_helpers import DummyContext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_unity_recovers_from_retry_disconnect(monkeypatch):
|
||||||
|
"""
|
||||||
|
Option A: if Unity disconnects and the transport returns hint=retry, refresh_unity(wait_for_ready=true)
|
||||||
|
should poll readiness and then return success + clear external dirty.
|
||||||
|
"""
|
||||||
|
from services.tools.refresh_unity import refresh_unity
|
||||||
|
|
||||||
|
ctx = DummyContext()
|
||||||
|
ctx.set_state("unity_instance", "UnityMCPTests@cc8756d4cce0805a")
|
||||||
|
|
||||||
|
# Seed dirty state
|
||||||
|
inst = "UnityMCPTests@cc8756d4cce0805a"
|
||||||
|
external_changes_scanner._states[inst] = ExternalChangesState(dirty=True, dirty_since_unix_ms=1)
|
||||||
|
|
||||||
|
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||||
|
assert command_type == "refresh_unity"
|
||||||
|
return {"success": False, "error": "disconnected", "hint": "retry"}
|
||||||
|
|
||||||
|
async def fake_get_editor_state_v2(_ctx):
|
||||||
|
return MCPResponse(success=True, data={"advice": {"ready_for_tools": True}})
|
||||||
|
|
||||||
|
import services.tools.refresh_unity as refresh_mod
|
||||||
|
monkeypatch.setattr(refresh_mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||||
|
|
||||||
|
import services.resources.editor_state_v2 as esv2_mod
|
||||||
|
monkeypatch.setattr(esv2_mod, "get_editor_state_v2", fake_get_editor_state_v2)
|
||||||
|
|
||||||
|
resp = await refresh_unity(ctx, wait_for_ready=True)
|
||||||
|
payload = resp.model_dump() if hasattr(resp, "model_dump") else resp
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload.get("data", {}).get("recovered_from_disconnect") is True
|
||||||
|
|
||||||
|
# Dirty should be cleared
|
||||||
|
assert external_changes_scanner._states[inst].dirty is False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .test_helpers import DummyContext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_tests_returns_busy_when_unity_reports_already_in_progress(monkeypatch):
|
||||||
|
"""
|
||||||
|
Red test (#503): if Unity reports a test run is already in progress, the tool should return a
|
||||||
|
structured Busy response quickly (retry hint + retry_after_ms) rather than looking like a generic failure.
|
||||||
|
"""
|
||||||
|
import services.tools.run_tests as run_tests_mod
|
||||||
|
|
||||||
|
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||||
|
assert command_type == "run_tests"
|
||||||
|
# This mirrors the Unity-side exception message thrown by TestRunnerService today.
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "A Unity test run is already in progress.",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(run_tests_mod, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||||
|
|
||||||
|
result = await run_tests_mod.run_tests(ctx=DummyContext(), mode="EditMode")
|
||||||
|
payload = result.model_dump() if hasattr(result, "model_dump") else result
|
||||||
|
|
||||||
|
assert payload.get("success") is False
|
||||||
|
# Desired new behavior: provide an explicit retry hint + suggested backoff.
|
||||||
|
assert payload.get("hint") == "retry"
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert data.get("reason") in {"tests_running", "busy"}
|
||||||
|
assert isinstance(data.get("retry_after_ms"), int)
|
||||||
|
assert data.get("retry_after_ms") >= 500
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .test_helpers import DummyContext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_tests_async_forwards_params(monkeypatch):
|
||||||
|
from services.tools.test_jobs import run_tests_async
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||||
|
captured["command_type"] = command_type
|
||||||
|
captured["params"] = params
|
||||||
|
return {"success": True, "data": {"job_id": "abc123", "status": "running"}}
|
||||||
|
|
||||||
|
import services.tools.test_jobs as mod
|
||||||
|
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||||
|
|
||||||
|
resp = await run_tests_async(
|
||||||
|
DummyContext(),
|
||||||
|
mode="EditMode",
|
||||||
|
test_names="MyNamespace.MyTests.TestA",
|
||||||
|
include_details=True,
|
||||||
|
)
|
||||||
|
assert captured["command_type"] == "run_tests_async"
|
||||||
|
assert captured["params"]["mode"] == "EditMode"
|
||||||
|
assert captured["params"]["testNames"] == ["MyNamespace.MyTests.TestA"]
|
||||||
|
assert captured["params"]["includeDetails"] is True
|
||||||
|
assert resp["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_test_job_forwards_job_id(monkeypatch):
|
||||||
|
from services.tools.test_jobs import get_test_job
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||||
|
captured["command_type"] = command_type
|
||||||
|
captured["params"] = params
|
||||||
|
return {"success": True, "data": {"job_id": params["job_id"], "status": "running"}}
|
||||||
|
|
||||||
|
import services.tools.test_jobs as mod
|
||||||
|
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||||
|
|
||||||
|
resp = await get_test_job(DummyContext(), job_id="job-1")
|
||||||
|
assert captured["command_type"] == "get_test_job"
|
||||||
|
assert captured["params"]["job_id"] == "job-1"
|
||||||
|
assert resp["data"]["job_id"] == "job-1"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -809,7 +809,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcpforunityserver"
|
name = "mcpforunityserver"
|
||||||
version = "8.3.0"
|
version = "8.6.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,15 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for domain reload resilience - ensuring MCP requests succeed even during Unity domain reloads.
|
/// Tests for domain reload resilience - ensuring MCP requests succeed even during Unity domain reloads.
|
||||||
|
///
|
||||||
|
/// These tests are marked [Explicit] because they trigger script compilation which can stall
|
||||||
|
/// subsequent tests' internal coroutine waits when Unity is backgrounded. The MCP workflow
|
||||||
|
/// itself is unaffected - socket messages provide external stimulus that keeps Unity responsive.
|
||||||
|
///
|
||||||
|
/// Run these explicitly when needed, ideally with Unity foregrounded or first in the run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Category("domain_reload")]
|
[Category("domain_reload")]
|
||||||
[Explicit("Intentionally triggers script compilation/domain reload; run explicitly to avoid slowing/flaking cold-start EditMode runs.")]
|
[Explicit("Triggers compilation that can stall subsequent tests. MCP workflow unaffected - see class docs.")]
|
||||||
public class DomainReloadResilienceTests
|
public class DomainReloadResilienceTests
|
||||||
{
|
{
|
||||||
private const string TempDir = "Assets/Temp/DomainReloadTests";
|
private const string TempDir = "Assets/Temp/DomainReloadTests";
|
||||||
|
|
@ -25,14 +31,14 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
if (!AssetDatabase.IsValidFolder(TempDir))
|
if (!AssetDatabase.IsValidFolder(TempDir))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(TempDir);
|
Directory.CreateDirectory(TempDir);
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
public void TearDown()
|
public void TearDown()
|
||||||
{
|
{
|
||||||
// Clean up temp directory
|
// Clean up temp directory - this deletes any scripts we created
|
||||||
if (AssetDatabase.IsValidFolder(TempDir))
|
if (AssetDatabase.IsValidFolder(TempDir))
|
||||||
{
|
{
|
||||||
AssetDatabase.DeleteAsset(TempDir);
|
AssetDatabase.DeleteAsset(TempDir);
|
||||||
|
|
@ -48,6 +54,10 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
AssetDatabase.DeleteAsset("Assets/Temp");
|
AssetDatabase.DeleteAsset("Assets/Temp");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Force a synchronous refresh and wait for any pending compilation to finish.
|
||||||
|
// This prevents leaving compilation running that could stall subsequent tests.
|
||||||
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -72,7 +82,7 @@ public class StressTestScript : MonoBehaviour
|
||||||
|
|
||||||
// Write script file
|
// Write script file
|
||||||
File.WriteAllText(scriptPath, scriptContent);
|
File.WriteAllText(scriptPath, scriptContent);
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
|
|
||||||
Debug.Log("[DomainReloadTest] Script created, domain reload triggered");
|
Debug.Log("[DomainReloadTest] Script created, domain reload triggered");
|
||||||
|
|
||||||
|
|
@ -163,7 +173,7 @@ public class TestScript1 : MonoBehaviour
|
||||||
}";
|
}";
|
||||||
|
|
||||||
File.WriteAllText(scriptPath, scriptContent);
|
File.WriteAllText(scriptPath, scriptContent);
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
|
|
||||||
Debug.Log("[DomainReloadTest] Script created");
|
Debug.Log("[DomainReloadTest] Script created");
|
||||||
|
|
||||||
|
|
@ -211,7 +221,7 @@ public class RapidScript{i} : MonoBehaviour
|
||||||
}}";
|
}}";
|
||||||
|
|
||||||
File.WriteAllText(scriptPath, scriptContent);
|
File.WriteAllText(scriptPath, scriptContent);
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||||
|
|
||||||
Debug.Log($"[DomainReloadTest] Created script {i+1}/{scriptCount}");
|
Debug.Log($"[DomainReloadTest] Created script {i+1}/{scriptCount}");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
public class ManageScriptableObjectTests
|
public class ManageScriptableObjectTests
|
||||||
{
|
{
|
||||||
private const string TempRoot = "Assets/Temp/ManageScriptableObjectTests";
|
private const string TempRoot = "Assets/Temp/ManageScriptableObjectTests";
|
||||||
private const string NestedFolder = TempRoot + "/Nested/Deeper";
|
|
||||||
private const double UnityReadyTimeoutSeconds = 180.0;
|
private const double UnityReadyTimeoutSeconds = 180.0;
|
||||||
|
|
||||||
|
private string _runRoot;
|
||||||
|
private string _nestedFolder;
|
||||||
private string _createdAssetPath;
|
private string _createdAssetPath;
|
||||||
private string _createdGuid;
|
private string _createdGuid;
|
||||||
private string _matAPath;
|
private string _matAPath;
|
||||||
|
|
@ -27,12 +28,12 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
{
|
{
|
||||||
yield return WaitForUnityReady(UnityReadyTimeoutSeconds);
|
yield return WaitForUnityReady(UnityReadyTimeoutSeconds);
|
||||||
EnsureFolder("Assets/Temp");
|
EnsureFolder("Assets/Temp");
|
||||||
// Start from a clean slate every time (prevents intermittent setup failures).
|
// Avoid deleting/recreating the entire TempRoot each test (can trigger heavy reimport churn).
|
||||||
if (AssetDatabase.IsValidFolder(TempRoot))
|
// Instead, isolate each test in its own unique subfolder under TempRoot.
|
||||||
{
|
|
||||||
AssetDatabase.DeleteAsset(TempRoot);
|
|
||||||
}
|
|
||||||
EnsureFolder(TempRoot);
|
EnsureFolder(TempRoot);
|
||||||
|
_runRoot = $"{TempRoot}/Run_{Guid.NewGuid():N}";
|
||||||
|
EnsureFolder(_runRoot);
|
||||||
|
_nestedFolder = _runRoot + "/Nested/Deeper";
|
||||||
|
|
||||||
_createdAssetPath = null;
|
_createdAssetPath = null;
|
||||||
_createdGuid = null;
|
_createdGuid = null;
|
||||||
|
|
@ -69,9 +70,9 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
AssetDatabase.DeleteAsset(_matBPath);
|
AssetDatabase.DeleteAsset(_matBPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AssetDatabase.IsValidFolder(TempRoot))
|
if (!string.IsNullOrEmpty(_runRoot) && AssetDatabase.IsValidFolder(_runRoot))
|
||||||
{
|
{
|
||||||
AssetDatabase.DeleteAsset(TempRoot);
|
AssetDatabase.DeleteAsset(_runRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up parent Temp folder if empty
|
// Clean up parent Temp folder if empty
|
||||||
|
|
@ -89,21 +90,15 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches()
|
public void Create_CreatesNestedFolders_PlacesAssetCorrectly()
|
||||||
{
|
{
|
||||||
var create = new JObject
|
var create = new JObject
|
||||||
{
|
{
|
||||||
["action"] = "create",
|
["action"] = "create",
|
||||||
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
||||||
["folderPath"] = NestedFolder,
|
["folderPath"] = _nestedFolder,
|
||||||
["assetName"] = "My_Test_Def",
|
["assetName"] = "My_Test_Def_Placement",
|
||||||
["overwrite"] = true,
|
["overwrite"] = true,
|
||||||
["patches"] = new JArray
|
|
||||||
{
|
|
||||||
new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Hello" },
|
|
||||||
new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 42 },
|
|
||||||
new JObject { ["propertyPath"] = "nested.note", ["op"] = "set", ["value"] = "note!" }
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var raw = ManageScriptableObject.HandleCommand(create);
|
var raw = ManageScriptableObject.HandleCommand(create);
|
||||||
|
|
@ -116,11 +111,47 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
_createdGuid = data!["guid"]?.ToString();
|
_createdGuid = data!["guid"]?.ToString();
|
||||||
_createdAssetPath = data["path"]?.ToString();
|
_createdAssetPath = data["path"]?.ToString();
|
||||||
|
|
||||||
Assert.IsTrue(AssetDatabase.IsValidFolder(NestedFolder), "Nested folder should be created.");
|
Assert.IsTrue(AssetDatabase.IsValidFolder(_nestedFolder), "Nested folder should be created.");
|
||||||
Assert.IsTrue(_createdAssetPath!.StartsWith(NestedFolder, StringComparison.Ordinal), $"Asset should be created under {NestedFolder}: {_createdAssetPath}");
|
Assert.IsTrue(_createdAssetPath!.StartsWith(_nestedFolder, StringComparison.Ordinal), $"Asset should be created under {_nestedFolder}: {_createdAssetPath}");
|
||||||
Assert.IsTrue(_createdAssetPath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase), "Asset should have .asset extension.");
|
Assert.IsTrue(_createdAssetPath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase), "Asset should have .asset extension.");
|
||||||
Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response.");
|
Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response.");
|
||||||
|
|
||||||
|
var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(_createdAssetPath);
|
||||||
|
Assert.IsNotNull(asset, "Created asset should load as TestDefinition.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Create_AppliesPatches_ToCreatedAsset()
|
||||||
|
{
|
||||||
|
var create = new JObject
|
||||||
|
{
|
||||||
|
["action"] = "create",
|
||||||
|
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
||||||
|
// Patching correctness does not depend on nested folder creation; keep this lightweight.
|
||||||
|
["folderPath"] = _runRoot,
|
||||||
|
["assetName"] = "My_Test_Def_Patches",
|
||||||
|
["overwrite"] = true,
|
||||||
|
["patches"] = new JArray
|
||||||
|
{
|
||||||
|
new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Hello" },
|
||||||
|
new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 42 },
|
||||||
|
new JObject { ["propertyPath"] = "nested.note", ["op"] = "set", ["value"] = "note!" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var raw = ManageScriptableObject.HandleCommand(create);
|
||||||
|
var result = raw as JObject ?? JObject.FromObject(raw);
|
||||||
|
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||||
|
|
||||||
|
var data = result["data"] as JObject;
|
||||||
|
Assert.IsNotNull(data, "Expected data payload");
|
||||||
|
|
||||||
|
_createdGuid = data!["guid"]?.ToString();
|
||||||
|
_createdAssetPath = data["path"]?.ToString();
|
||||||
|
|
||||||
|
Assert.IsTrue(_createdAssetPath!.StartsWith(_runRoot, StringComparison.Ordinal), $"Asset should be created under {_runRoot}: {_createdAssetPath}");
|
||||||
|
Assert.IsFalse(string.IsNullOrWhiteSpace(_createdGuid), "Expected guid in response.");
|
||||||
|
|
||||||
var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(_createdAssetPath);
|
var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(_createdAssetPath);
|
||||||
Assert.IsNotNull(asset, "Created asset should load as TestDefinition.");
|
Assert.IsNotNull(asset, "Created asset should load as TestDefinition.");
|
||||||
Assert.AreEqual("Hello", asset!.DisplayName, "Private [SerializeField] string should be set via SerializedProperty.");
|
Assert.AreEqual("Hello", asset!.DisplayName, "Private [SerializeField] string should be set via SerializedProperty.");
|
||||||
|
|
@ -136,7 +167,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
{
|
{
|
||||||
["action"] = "create",
|
["action"] = "create",
|
||||||
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
||||||
["folderPath"] = TempRoot,
|
["folderPath"] = _runRoot,
|
||||||
["assetName"] = "Modify_Target",
|
["assetName"] = "Modify_Target",
|
||||||
["overwrite"] = true
|
["overwrite"] = true
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -336,3 +336,14 @@ We provide a CI job to run a Natural Language Editing suite against the Unity te
|
||||||
### Windows uv path issues
|
### Windows uv path issues
|
||||||
|
|
||||||
- On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose `uv` Install Location" to pin the Links shim.
|
- On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose `uv` Install Location" to pin the Links shim.
|
||||||
|
|
||||||
|
### Domain Reload Tests Stall When Unity is Backgrounded
|
||||||
|
|
||||||
|
Tests that trigger script compilation mid-run (e.g., `DomainReloadResilienceTests`) may stall when Unity is not the active window. This is an OS-level limitation—macOS throttles background application main threads, preventing compilation from completing.
|
||||||
|
|
||||||
|
**Workarounds:**
|
||||||
|
- Run domain reload tests with Unity foregrounded
|
||||||
|
- Run them first in the test suite (before backgrounding Unity)
|
||||||
|
- Use the `[Explicit]` attribute to exclude them from default runs
|
||||||
|
|
||||||
|
**Note:** The MCP workflow itself is unaffected—socket messages provide external stimulus that keeps Unity responsive even when backgrounded. This limitation only affects Unity's internal test coroutine waits.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Loading…
Reference in New Issue