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:
|
||||
- 2021.3.45f2
|
||||
steps:
|
||||
# Checkout
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
# Cache
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.projectPath }}/Library
|
||||
|
|
@ -40,7 +38,20 @@ jobs:
|
|||
Library-${{ matrix.projectPath }}-
|
||||
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
|
||||
uses: game-ci/unity-test-runner@v4
|
||||
id: tests
|
||||
|
|
@ -53,7 +64,6 @@ jobs:
|
|||
unityVersion: ${{ matrix.unityVersion }}
|
||||
testMode: ${{ matrix.testMode }}
|
||||
|
||||
# Upload test results
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
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();
|
||||
_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
|
||||
{
|
||||
|
|
@ -115,6 +117,8 @@ namespace MCPForUnity.Editor.Services
|
|||
}
|
||||
catch
|
||||
{
|
||||
// Ensure the status is cleared if we failed to start the run.
|
||||
TestRunStatus.MarkFinished();
|
||||
if (adjustedPlayModeOptions)
|
||||
{
|
||||
RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions);
|
||||
|
|
@ -163,6 +167,20 @@ namespace MCPForUnity.Editor.Services
|
|||
public void RunStarted(ITestAdaptor testsToRun)
|
||||
{
|
||||
_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)
|
||||
|
|
@ -175,11 +193,27 @@ namespace MCPForUnity.Editor.Services
|
|||
var payload = TestRunResult.Create(result, _leafResults);
|
||||
_runCompletionSource.TrySetResult(payload);
|
||||
_runCompletionSource = null;
|
||||
TestRunStatus.MarkFinished();
|
||||
TestJobManager.OnRunFinished();
|
||||
TestJobManager.FinalizeCurrentJobFromRunFinished(payload);
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -192,11 +226,72 @@ namespace MCPForUnity.Editor.Services
|
|||
if (!result.HasChildren)
|
||||
{
|
||||
_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
|
||||
|
||||
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(
|
||||
out bool originalEnterPlayModeOptionsEnabled,
|
||||
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)))
|
||||
{
|
||||
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))
|
||||
|
|
@ -869,7 +869,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
if (!Directory.Exists(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);
|
||||
AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Refresh asset database to recognize the new folder
|
||||
Debug.Log(
|
||||
$"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
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(
|
||||
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
|
||||
new { path = relativePath }
|
||||
|
|
@ -362,7 +362,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
if (saved)
|
||||
{
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
return new SuccessResponse(
|
||||
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
|
||||
new { path = finalPath, name = currentScene.name }
|
||||
|
|
@ -408,7 +408,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
result = ScreenshotUtility.CaptureFromCameraToAssetsFolder(cam, fileName, resolvedSuperSize, ensureUniqueFileName: true);
|
||||
}
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
string message = $"Screenshot captured to '{result.AssetsRelativePath}' (full: {result.FullPath}).";
|
||||
|
||||
|
|
|
|||
|
|
@ -978,7 +978,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
bool deleted = AssetDatabase.MoveAssetToTrash(relativePath);
|
||||
if (deleted)
|
||||
{
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
return new SuccessResponse(
|
||||
$"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.",
|
||||
new { deleted = true }
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
Directory.CreateDirectory(fullPathDir);
|
||||
// Refresh AssetDatabase to recognize new folders
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -174,7 +174,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
|
||||
AssetDatabase.ImportAsset(relativePath);
|
||||
AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity recognizes the new shader
|
||||
return new SuccessResponse(
|
||||
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
|
||||
new { path = relativePath }
|
||||
|
|
@ -242,7 +242,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
|
||||
AssetDatabase.ImportAsset(relativePath);
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
return new SuccessResponse(
|
||||
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
|
||||
new { path = relativePath }
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs
|
|||
if (!Directory.Exists(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"
|
||||
// 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)
|
||||
{
|
||||
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++)
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -16,9 +16,9 @@
|
|||
|
||||
**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_shader`: Performs shader CRUD operations (create, read, modify, delete).
|
||||
* `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_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`.
|
||||
|
|
@ -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):
|
||||
```
|
||||
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
|
||||
|
|
@ -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:
|
||||
|
||||
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`).
|
||||
3. Click **Start Local HTTP Server**. Unity spawns a new operating-system terminal running `uv ... server.py --transport http`.
|
||||
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 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.
|
||||
|
||||
> 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:
|
||||
|
||||
```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.
|
||||
|
|
@ -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.
|
||||
- Restart Unity.
|
||||
- **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:
|
||||
- **Windows:** `%USERPROFILE%\AppData\Local\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 transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
|
|
@ -47,6 +48,12 @@ async def manage_asset(
|
|||
) -> dict[str, Any]:
|
||||
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]:
|
||||
try:
|
||||
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.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
|
|
@ -92,6 +93,10 @@ async def manage_gameobject(
|
|||
# Removed session_state import
|
||||
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:
|
||||
return {
|
||||
"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 transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
|
|
@ -40,6 +41,9 @@ async def manage_scene(
|
|||
# Get active instance from session state
|
||||
# Removed session_state import
|
||||
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:
|
||||
coerced_build_index = coerce_int(build_index, 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 transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
class RunTestsSummary(BaseModel):
|
||||
|
|
@ -42,7 +43,7 @@ class RunTestsResponse(MCPResponse):
|
|||
|
||||
|
||||
@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(
|
||||
ctx: Context,
|
||||
|
|
@ -54,9 +55,13 @@ async def run_tests(
|
|||
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,
|
||||
) -> RunTestsResponse:
|
||||
) -> RunTestsResponse | 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
|
||||
|
||||
# Coerce string or list to list of strings
|
||||
def _coerce_string_list(value) -> list[str] | None:
|
||||
if value is None:
|
||||
|
|
@ -97,5 +102,19 @@ async def run_tests(
|
|||
params["includeDetails"] = True
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
name = "mcpforunityserver"
|
||||
version = "8.3.0"
|
||||
version = "8.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
[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
|
||||
{
|
||||
private const string TempDir = "Assets/Temp/DomainReloadTests";
|
||||
|
|
@ -25,14 +31,14 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
if (!AssetDatabase.IsValidFolder(TempDir))
|
||||
{
|
||||
Directory.CreateDirectory(TempDir);
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Clean up temp directory
|
||||
// Clean up temp directory - this deletes any scripts we created
|
||||
if (AssetDatabase.IsValidFolder(TempDir))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(TempDir);
|
||||
|
|
@ -48,6 +54,10 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
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>
|
||||
|
|
@ -72,7 +82,7 @@ public class StressTestScript : MonoBehaviour
|
|||
|
||||
// Write script file
|
||||
File.WriteAllText(scriptPath, scriptContent);
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
Debug.Log("[DomainReloadTest] Script created, domain reload triggered");
|
||||
|
||||
|
|
@ -163,7 +173,7 @@ public class TestScript1 : MonoBehaviour
|
|||
}";
|
||||
|
||||
File.WriteAllText(scriptPath, scriptContent);
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
Debug.Log("[DomainReloadTest] Script created");
|
||||
|
||||
|
|
@ -211,7 +221,7 @@ public class RapidScript{i} : MonoBehaviour
|
|||
}}";
|
||||
|
||||
File.WriteAllText(scriptPath, scriptContent);
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
Debug.Log($"[DomainReloadTest] Created script {i+1}/{scriptCount}");
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
public class ManageScriptableObjectTests
|
||||
{
|
||||
private const string TempRoot = "Assets/Temp/ManageScriptableObjectTests";
|
||||
private const string NestedFolder = TempRoot + "/Nested/Deeper";
|
||||
private const double UnityReadyTimeoutSeconds = 180.0;
|
||||
|
||||
private string _runRoot;
|
||||
private string _nestedFolder;
|
||||
private string _createdAssetPath;
|
||||
private string _createdGuid;
|
||||
private string _matAPath;
|
||||
|
|
@ -27,12 +28,12 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
{
|
||||
yield return WaitForUnityReady(UnityReadyTimeoutSeconds);
|
||||
EnsureFolder("Assets/Temp");
|
||||
// Start from a clean slate every time (prevents intermittent setup failures).
|
||||
if (AssetDatabase.IsValidFolder(TempRoot))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(TempRoot);
|
||||
}
|
||||
// Avoid deleting/recreating the entire TempRoot each test (can trigger heavy reimport churn).
|
||||
// Instead, isolate each test in its own unique subfolder under TempRoot.
|
||||
EnsureFolder(TempRoot);
|
||||
_runRoot = $"{TempRoot}/Run_{Guid.NewGuid():N}";
|
||||
EnsureFolder(_runRoot);
|
||||
_nestedFolder = _runRoot + "/Nested/Deeper";
|
||||
|
||||
_createdAssetPath = null;
|
||||
_createdGuid = null;
|
||||
|
|
@ -69,9 +70,9 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
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
|
||||
|
|
@ -89,21 +90,15 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches()
|
||||
public void Create_CreatesNestedFolders_PlacesAssetCorrectly()
|
||||
{
|
||||
var create = new JObject
|
||||
{
|
||||
["action"] = "create",
|
||||
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
||||
["folderPath"] = NestedFolder,
|
||||
["assetName"] = "My_Test_Def",
|
||||
["folderPath"] = _nestedFolder,
|
||||
["assetName"] = "My_Test_Def_Placement",
|
||||
["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);
|
||||
|
|
@ -116,11 +111,47 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
_createdGuid = data!["guid"]?.ToString();
|
||||
_createdAssetPath = data["path"]?.ToString();
|
||||
|
||||
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(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.EndsWith(".asset", StringComparison.OrdinalIgnoreCase), "Asset should have .asset extension.");
|
||||
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);
|
||||
Assert.IsNotNull(asset, "Created asset should load as TestDefinition.");
|
||||
Assert.AreEqual("Hello", asset!.DisplayName, "Private [SerializeField] string should be set via SerializedProperty.");
|
||||
|
|
@ -136,7 +167,7 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
{
|
||||
["action"] = "create",
|
||||
["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
|
||||
["folderPath"] = TempRoot,
|
||||
["folderPath"] = _runRoot,
|
||||
["assetName"] = "Modify_Target",
|
||||
["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
|
||||
|
||||
- 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