diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml
index 632a1f8ac..191c161b1 100644
--- a/.github/workflows/unity-tests.yml
+++ b/.github/workflows/unity-tests.yml
@@ -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:
diff --git a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs
new file mode 100644
index 000000000..33ac97058
--- /dev/null
+++ b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs
@@ -0,0 +1,29 @@
+using System;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Services;
+using Newtonsoft.Json.Linq;
+
+namespace MCPForUnity.Editor.Resources.Editor
+{
+ ///
+ /// Provides a cached, v2 readiness snapshot. This is designed to remain responsive even when Unity is busy.
+ ///
+ [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}");
+ }
+ }
+ }
+}
+
+
diff --git a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta
new file mode 100644
index 000000000..e776994fb
--- /dev/null
+++ b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5514ec4eb8a294a55892a13194e250e8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs
new file mode 100644
index 000000000..97557841e
--- /dev/null
+++ b/MCPForUnity/Editor/Services/EditorStateCache.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+ }
+ }
+}
+
+
diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs.meta b/MCPForUnity/Editor/Services/EditorStateCache.cs.meta
new file mode 100644
index 000000000..21c5d014e
--- /dev/null
+++ b/MCPForUnity/Editor/Services/EditorStateCache.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: aa7909967ce3c48c493181c978782a54
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs
new file mode 100644
index 000000000..d5399cf5e
--- /dev/null
+++ b/MCPForUnity/Editor/Services/TestJobManager.cs
@@ -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 FailuresSoFar { get; set; }
+ public string Error { get; set; }
+ public TestRunResult Result { get; set; }
+ }
+
+ ///
+ /// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs.
+ ///
+ 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 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 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 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(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(),
+ 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()).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(),
+ 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 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();
+ 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();
+ 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 failures)
+ {
+ if (failures == null || failures.Count == 0)
+ {
+ return Array.Empty