Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions MCPForUnity/Editor/Services/TestJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ internal sealed class TestJob
public List<TestJobFailure> FailuresSoFar { get; set; }
public string Error { get; set; }
public TestRunResult Result { get; set; }
public long InitTimeoutMs { get; set; }
}

/// <summary>
Expand All @@ -50,7 +51,8 @@ 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 long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail
private const long DefaultInitializationTimeoutMs = 15_000; // 15 seconds default; override per-job via run_tests init_timeout param
private const long MaxInitializationTimeoutMs = 600_000; // 10 minutes hard cap
private const int MaxJobsToKeep = 10;
private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead

Expand Down Expand Up @@ -139,6 +141,7 @@ private sealed class PersistedJob
public long? last_finished_unix_ms { get; set; }
public List<TestJobFailure> failures_so_far { get; set; }
public string error { get; set; }
public long init_timeout_ms { get; set; }
}

private static TestJobStatus ParseStatus(string status)
Expand Down Expand Up @@ -201,6 +204,7 @@ private static void TryRestoreFromSessionState()
LastFinishedUnixMs = pj.last_finished_unix_ms,
FailuresSoFar = pj.failures_so_far ?? new List<TestJobFailure>(),
Error = pj.error,
InitTimeoutMs = pj.init_timeout_ms,
// Intentionally not persisted to avoid ballooning SessionState.
Result = null
};
Expand Down Expand Up @@ -273,7 +277,8 @@ private static void PersistToSessionState(bool force = false)
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
error = j.Error,
init_timeout_ms = j.InitTimeoutMs
})
.ToList();

Expand All @@ -294,8 +299,12 @@ private static void PersistToSessionState(bool force = false)
}
}

public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null)
public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null, long initTimeoutMs = 0)
{
// Clamp to valid range: non-positive values mean "use default", cap at 10 minutes
if (initTimeoutMs < 0) initTimeoutMs = 0;
if (initTimeoutMs > MaxInitializationTimeoutMs) initTimeoutMs = MaxInitializationTimeoutMs;

string jobId = Guid.NewGuid().ToString("N");
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string modeStr = mode.ToString();
Expand All @@ -316,7 +325,8 @@ public static string StartJob(TestMode mode, TestFilterOptions filterOptions = n
LastFinishedUnixMs = null,
FailuresSoFar = new List<TestJobFailure>(),
Error = null,
Result = null
Result = null,
InitTimeoutMs = initTimeoutMs
};

// Single lock scope for check-and-set to avoid TOCTOU race
Expand Down Expand Up @@ -491,9 +501,10 @@ internal static TestJob GetJob(string jobId)
if (job.Status == TestJobStatus.Running && job.TotalTests == null)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs)
long initTimeout = job.InitTimeoutMs > 0 ? job.InitTimeoutMs : DefaultInitializationTimeoutMs;
if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > initTimeout)
{
McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing");
McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {initTimeout}ms, auto-failing");
job.Status = TestJobStatus.Failed;
job.Error = "Test job failed to initialize (tests did not start within timeout)";
job.FinishedUnixMs = now;
Expand Down
3 changes: 2 additions & 1 deletion MCPForUnity/Editor/Tools/RunTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public static Task<object> HandleCommand(JObject @params)
bool includeFailedTests = p.GetBool("includeFailedTests");

var filterOptions = GetFilterOptions(@params);
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
long initTimeoutMs = p.GetInt("initTimeout") ?? 0;
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions, initTimeoutMs);

return Task.FromResult<object>(new SuccessResponse("Test job started.", new
{
Expand Down
8 changes: 8 additions & 0 deletions Server/src/services/tools/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,13 @@ async def run_tests(
"Include details for failed/skipped tests only (default: false)"] = False,
include_details: Annotated[bool,
"Include details for all tests (default: false)"] = False,
init_timeout: Annotated[int | None,
"Initialization timeout in milliseconds. PlayMode tests may need longer "
"due to domain reload (default: 15000). Recommended: 120000 for PlayMode."] = None,
) -> RunTestsStartResponse | MCPResponse:
if init_timeout is not None and init_timeout <= 0:
return MCPResponse(success=False, error="init_timeout must be a positive integer (milliseconds) or None")

unity_instance = await get_unity_instance_from_context(ctx)

gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
Expand Down Expand Up @@ -197,6 +203,8 @@ def _coerce_string_list(value) -> list[str] | None:
params["includeFailedTests"] = True
if include_details:
params["includeDetails"] = True
if init_timeout is not None and init_timeout > 0:
params["initTimeout"] = init_timeout

response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
Expand Down
60 changes: 60 additions & 0 deletions Server/tests/integration/test_run_tests_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,66 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p
assert resp.data.job_id == "abc123"


@pytest.mark.asyncio
async def test_run_tests_forwards_init_timeout(monkeypatch):
from services.tools.run_tests import run_tests

captured = {}

async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "PlayMode"}}

import services.tools.run_tests as mod
monkeypatch.setattr(
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)

resp = await run_tests(
DummyContext(),
mode="PlayMode",
init_timeout=120000,
)
assert captured["params"]["initTimeout"] == 120000
assert resp.success is True


@pytest.mark.asyncio
async def test_run_tests_omits_init_timeout_when_none(monkeypatch):
from services.tools.run_tests import run_tests

captured = {}

async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}}

import services.tools.run_tests as mod
monkeypatch.setattr(
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)

resp = await run_tests(DummyContext(), mode="EditMode")
assert "initTimeout" not in captured["params"]
assert resp.success is True


@pytest.mark.asyncio
async def test_run_tests_rejects_negative_init_timeout():
from services.tools.run_tests import run_tests

resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=-1)
assert resp.success is False
assert "init_timeout" in resp.error


@pytest.mark.asyncio
async def test_run_tests_rejects_zero_init_timeout():
from services.tools.run_tests import run_tests

resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=0)
assert resp.success is False
assert "init_timeout" in resp.error


@pytest.mark.asyncio
async def test_get_test_job_forwards_job_id(monkeypatch):
from services.tools.run_tests import get_test_job
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Services;

namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Tests for TestJobManager's per-job InitTimeoutMs feature.
/// Uses reflection to manipulate internal state since StartJob triggers a real test run.
/// </summary>
public class TestJobManagerInitTimeoutTests
{
private FieldInfo _jobsField;
private FieldInfo _currentJobIdField;
private MethodInfo _getJobMethod;
private MethodInfo _persistMethod;
private MethodInfo _restoreMethod;
private Type _testJobType;

private string _originalJobId;

[SetUp]
public void SetUp()
{
var asm = typeof(MCPServiceLocator).Assembly;
var managerType = asm.GetType("MCPForUnity.Editor.Services.TestJobManager");
Assert.NotNull(managerType, "Could not find TestJobManager");

_testJobType = asm.GetType("MCPForUnity.Editor.Services.TestJob");
Assert.NotNull(_testJobType, "Could not find TestJob");

_jobsField = managerType.GetField("Jobs", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(_jobsField, "Could not find Jobs field");

_currentJobIdField = managerType.GetField("_currentJobId", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(_currentJobIdField, "Could not find _currentJobId field");

_getJobMethod = managerType.GetMethod("GetJob", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(_getJobMethod, "Could not find GetJob method");

_persistMethod = managerType.GetMethod("PersistToSessionState", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(_persistMethod, "Could not find PersistToSessionState method");

_restoreMethod = managerType.GetMethod("TryRestoreFromSessionState", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(_restoreMethod, "Could not find TryRestoreFromSessionState method");

// Snapshot original state
_originalJobId = _currentJobIdField.GetValue(null) as string;
// We'll restore _currentJobId in TearDown; Jobs dictionary is shared static state
}

[TearDown]
public void TearDown()
{
// Restore original state
_currentJobIdField.SetValue(null, _originalJobId);
// Clean up any test jobs we inserted
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
jobs?.Remove("test-init-timeout-job");
jobs?.Remove("test-init-timeout-default");
jobs?.Remove("test-init-timeout-persist");
}

[Test]
public void GetJob_WithCustomInitTimeout_UsesPerJobTimeout()
{
// Arrange: insert a job with a custom init timeout and a start time far enough in the
// past to exceed the default 15s but within the custom 120s.
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

var job = Activator.CreateInstance(_testJobType);
_testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-job");
_testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running);
_testJobType.GetProperty("Mode").SetValue(job, "PlayMode");
_testJobType.GetProperty("StartedUnixMs").SetValue(job, now - 30_000); // 30s ago
_testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now - 30_000);
_testJobType.GetProperty("TotalTests").SetValue(job, null); // Not initialized yet
_testJobType.GetProperty("InitTimeoutMs").SetValue(job, 120_000L); // 120s custom timeout
_testJobType.GetProperty("FailuresSoFar").SetValue(job, new List<TestJobFailure>());

jobs["test-init-timeout-job"] = job;
_currentJobIdField.SetValue(null, "test-init-timeout-job");

// Act: GetJob should NOT auto-fail because 30s < 120s custom timeout
var result = _getJobMethod.Invoke(null, new object[] { "test-init-timeout-job" });

// Assert: job should still be running
var status = (TestJobStatus)_testJobType.GetProperty("Status").GetValue(result);
Assert.AreEqual(TestJobStatus.Running, status,
"Job with 120s custom timeout should not auto-fail after 30s");
}

[Test]
public void GetJob_WithDefaultTimeout_AutoFailsAfter15Seconds()
{
// Guard: GetJob skips the auto-fail path while compiling/updating, which would
// make this test pass for the wrong reason (job stays Running due to compile guard).
Assume.That(EditorApplication.isCompiling, Is.False, "Skipping: editor is compiling");
Assume.That(EditorApplication.isUpdating, Is.False, "Skipping: editor is updating");

// Arrange: insert a job with InitTimeoutMs=0 (use default) and start time 20s ago
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

var job = Activator.CreateInstance(_testJobType);
_testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-default");
_testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running);
_testJobType.GetProperty("Mode").SetValue(job, "EditMode");
_testJobType.GetProperty("StartedUnixMs").SetValue(job, now - 20_000); // 20s ago
_testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now - 20_000);
_testJobType.GetProperty("TotalTests").SetValue(job, null);
_testJobType.GetProperty("InitTimeoutMs").SetValue(job, 0L); // Use default
_testJobType.GetProperty("FailuresSoFar").SetValue(job, new List<TestJobFailure>());

jobs["test-init-timeout-default"] = job;
_currentJobIdField.SetValue(null, "test-init-timeout-default");

// Act: GetJob should auto-fail because 20s > 15s default
var result = _getJobMethod.Invoke(null, new object[] { "test-init-timeout-default" });

// Assert: job should be failed
var status = (TestJobStatus)_testJobType.GetProperty("Status").GetValue(result);
Assert.AreEqual(TestJobStatus.Failed, status,
"Job with default timeout should auto-fail after 20s");
}

[Test]
public void InitTimeoutMs_SurvivesPersistAndRestore()
{
// Arrange: insert a job with custom InitTimeoutMs
var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

var job = Activator.CreateInstance(_testJobType);
_testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-persist");
_testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running);
_testJobType.GetProperty("Mode").SetValue(job, "PlayMode");
_testJobType.GetProperty("StartedUnixMs").SetValue(job, now);
_testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now);
_testJobType.GetProperty("TotalTests").SetValue(job, null);
_testJobType.GetProperty("InitTimeoutMs").SetValue(job, 90_000L);
_testJobType.GetProperty("FailuresSoFar").SetValue(job, new List<TestJobFailure>());

jobs["test-init-timeout-persist"] = job;
_currentJobIdField.SetValue(null, "test-init-timeout-persist");

// Act: persist then restore (simulates domain reload)
_persistMethod.Invoke(null, new object[] { true });
// Clear in-memory state
jobs.Remove("test-init-timeout-persist");
_currentJobIdField.SetValue(null, null);
// Restore from SessionState
_restoreMethod.Invoke(null, null);

// Assert: restored job should have the same InitTimeoutMs
var restoredJobs = _jobsField.GetValue(null) as System.Collections.IDictionary;
Assert.IsTrue(restoredJobs.Contains("test-init-timeout-persist"),
"Job should be restored from SessionState");

var restoredJob = restoredJobs["test-init-timeout-persist"];
var restoredTimeout = (long)_testJobType.GetProperty("InitTimeoutMs").GetValue(restoredJob);
Assert.AreEqual(90_000L, restoredTimeout,
"InitTimeoutMs should survive persist/restore cycle");
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/development/README-DEV-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ uv run python -m cli.main editor tests --failed-only

```
run_tests(mode="EditMode")
run_tests(mode="PlayMode", init_timeout=120000) # PlayMode 由于域重载可能需要更长的初始化时间
get_test_job(job_id="<id>", wait_timeout=60)
```

Expand Down
1 change: 1 addition & 0 deletions docs/development/README-DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ uv run python -m cli.main editor tests --failed-only

```
run_tests(mode="EditMode")
run_tests(mode="PlayMode", init_timeout=120000) # PlayMode may need longer init due to domain reload
get_test_job(job_id="<id>", wait_timeout=60)
```

Expand Down