chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7b2c3d4e5f6a7890123456789abcdef3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,899 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace MCPForUnityTests.Editor.Services.Characterization
{
/// <summary>
/// Characterization tests for ServerManagementService public interface.
/// These tests lock down current behavior BEFORE refactoring to ensure
/// no regressions during the decomposition into focused components.
/// </summary>
[TestFixture]
public class ServerManagementServiceCharacterizationTests
{
private ServerManagementService _service;
private bool _savedUseHttpTransport;
private string _savedHttpUrl;
private string _savedHttpRemoteUrl;
private string _savedHttpTransportScope;
private bool _savedAllowLanHttpBind;
private bool _savedAllowInsecureRemoteHttp;
private bool _savedLaunchConfirmed;
[SetUp]
public void SetUp()
{
_service = new ServerManagementService();
// Save current settings
_savedLaunchConfirmed = EditorPrefs.GetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedHttpUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_savedHttpRemoteUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
_savedHttpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
_savedAllowLanHttpBind = EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
_savedAllowInsecureRemoteHttp = EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
}
[TearDown]
public void TearDown()
{
// Restore settings
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
if (!string.IsNullOrEmpty(_savedHttpUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, _savedHttpUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpBaseUrl);
}
if (!string.IsNullOrEmpty(_savedHttpRemoteUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, _savedHttpRemoteUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpRemoteBaseUrl);
}
if (!string.IsNullOrEmpty(_savedHttpTransportScope))
{
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, _savedHttpTransportScope);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpTransportScope);
}
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, _savedAllowLanHttpBind);
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, _savedAllowInsecureRemoteHttp);
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, _savedLaunchConfirmed);
// Refresh cache to reflect restored values
EditorConfigurationCache.Instance.Refresh();
}
#region StartLocalHttpServer First-Time-Confirm Gating
// A fake launcher that records the headless launch request, then returns a harmless
// no-op process (true / cmd exit) so Process.Start SUCCEEDS and exits immediately — no real
// server starts, and (unlike a non-existent path) no exception + error dialog that would
// block an interactive test run (Test Runner window / MCP bridge); -batchmode suppresses
// dialogs, but interactive runs do not.
private sealed class RecordingTerminalLauncher : ITerminalLauncher
{
public bool HeadlessCalled;
public string LastCommand;
public string LastLogPath;
public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
{
return HarmlessNoOpStartInfo();
}
public System.Diagnostics.ProcessStartInfo CreateHeadlessProcessStartInfo(string command, string logFilePath)
{
HeadlessCalled = true;
LastCommand = command;
LastLogPath = logFilePath;
return HarmlessNoOpStartInfo();
}
public string GetProjectRootPath()
{
return System.IO.Path.GetTempPath();
}
// A binary that always launches and exits 0 immediately, so Process.Start does not throw.
private static System.Diagnostics.ProcessStartInfo HarmlessNoOpStartInfo()
{
var psi = new System.Diagnostics.ProcessStartInfo
{
UseShellExecute = false,
CreateNoWindow = true
};
if (Application.platform == RuntimePlatform.WindowsEditor)
{
psi.FileName = "cmd.exe";
psi.Arguments = "/c exit";
}
else
{
psi.FileName = System.IO.File.Exists("/usr/bin/true") ? "/usr/bin/true" : "/bin/true";
}
return psi;
}
}
// A fake command builder that always yields a benign, valid command so StartLocalHttpServer
// does not bail out before reaching the confirmation gate.
private sealed class FakeCommandBuilder : IServerCommandBuilder
{
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
{
fileName = "uvx";
arguments = "run mcp-for-unity";
displayCommand = "uvx run mcp-for-unity";
error = null;
return true;
}
public string BuildUvPathFromUvx(string uvxPath) => uvxPath;
public string GetPlatformSpecificPathPrepend() => string.Empty;
public string QuoteIfNeeded(string input) =>
(!string.IsNullOrEmpty(input) && input.Contains(" ")) ? "\"" + input + "\"" : input;
}
// A fake process detector that reports no listeners (so the "port in use" branch is skipped).
private sealed class NoListenersProcessDetector : IProcessDetector
{
public bool LooksLikeMcpServerProcess(int pid) => false;
public bool TryGetProcessCommandLine(int pid, out string argsLower) { argsLower = string.Empty; return false; }
public System.Collections.Generic.List<int> GetListeningProcessIdsForPort(int port) => new System.Collections.Generic.List<int>();
public int GetCurrentProcessId() => -1;
public bool ProcessExists(int pid) => false;
public string NormalizeForMatch(string input) => (input ?? string.Empty).Replace(" ", string.Empty).ToLowerInvariant();
}
private ServerManagementService BuildServiceWithFakeLauncher(RecordingTerminalLauncher launcher)
{
return new ServerManagementService(
new NoListenersProcessDetector(),
null,
null,
new FakeCommandBuilder(),
launcher);
}
[Test]
public void StartLocalHttpServer_AlreadyConfirmed_SkipsDialogAndLaunchesHeadless()
{
// Arrange - local URL + HTTP enabled + confirmation already given.
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59998");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, true);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act - non-quiet, but confirmed: must NOT show a dialog and must launch headless.
// (The fake launcher aborts the real spawn, so the return value may be false; we assert on the launch attempt.)
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: false);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert - the headless launch path was taken (gate passed without a dialog).
Assert.IsTrue(launcher.HeadlessCalled, "Confirmed launch should reach the headless launcher without a dialog");
StringAssert.Contains("uvx run mcp-for-unity", launcher.LastCommand,
"The headless command should be the built server command");
}
[Test]
public void StartLocalHttpServer_Quiet_BypassesDialogAndDoesNotSetConfirmedFlag()
{
// Arrange - local URL + HTTP enabled + NOT yet confirmed.
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59997");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act - quiet (auto-start) path must never show a dialog and must launch headless.
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: true);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert
Assert.IsTrue(launcher.HeadlessCalled, "Quiet launch should reach the headless launcher without a dialog");
Assert.IsFalse(EditorPrefs.GetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false),
"Quiet auto-start must NOT set the first-time-confirm flag");
}
[Test]
public void StartLocalHttpServer_LaunchesIntoPerPortLaunchLog()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59996");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, true);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: false);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert - the launch log path is the per-port server-launch log.
Assert.IsTrue(launcher.HeadlessCalled, "Launch should reach the headless launcher");
StringAssert.Contains("server-launch-59996.log", launcher.LastLogPath,
"Headless launch should redirect output to the per-port launch log");
}
#endregion
#region IsLocalUrl Tests
[Test]
public void IsLocalUrl_Localhost_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "localhost should be recognized as local URL");
}
[Test]
public void IsLocalUrl_127001_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.1:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "127.0.0.1 should be recognized as local URL");
}
[Test]
public void IsLocalUrl_127002_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.2:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "127.0.0.2 should be recognized as loopback local URL");
}
[Test]
public void IsLocalUrl_0000_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "0.0.0.0 should be recognized as local URL");
}
[Test]
public void IsLocalUrl_IPv6Loopback_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://[::1]:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "::1 (IPv6 loopback) should be recognized as local URL");
}
[Test]
public void IsLocalUrl_IPv6LoopbackLongForm_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://[0:0:0:0:0:0:0:1]:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "0:0:0:0:0:0:0:1 (IPv6 loopback long-form) should be recognized as local URL");
}
[Test]
public void IsLocalUrl_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://example.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsFalse(result, "Remote URL should not be recognized as local");
}
[Test]
public void IsLocalUrl_EmptyString_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert - behavior depends on default URL handling
// Document current behavior
Assert.Pass($"IsLocalUrl returned {result} for empty URL (documents current behavior)");
}
#endregion
#region CanStartLocalServer Tests
[Test]
public void CanStartLocalServer_HttpDisabled_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server when HTTP transport is disabled");
}
[Test]
public void CanStartLocalServer_HttpEnabledLocalUrl_ReturnsTrue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsTrue(result, "Can start local server when HTTP enabled and URL is local");
}
[Test]
public void CanStartLocalServer_HttpEnabledRemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server when URL is remote");
}
[Test]
public void CanStartLocalServer_HttpEnabledZeroBind_DisallowedByDefault_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server on 0.0.0.0 unless LAN bind opt-in is enabled");
}
[Test]
public void CanStartLocalServer_HttpEnabledZeroBind_WithOptIn_ReturnsTrue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsTrue(result, "Can start local server on 0.0.0.0 when LAN bind opt-in is enabled");
}
#endregion
#region HttpEndpointUtility Security Policy Tests
[Test]
public void SaveRemoteBaseUrl_WithoutScheme_DefaultsToHttps()
{
// Arrange
EditorConfigurationCache.Instance.SetHttpTransportScope("remote");
// Act
HttpEndpointUtility.SaveRemoteBaseUrl("example.com:9000");
string normalized = HttpEndpointUtility.GetRemoteBaseUrl();
// Assert
Assert.AreEqual("https://example.com:9000", normalized);
}
[Test]
public void IsRemoteUrlAllowed_Http_DisallowedByDefault()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
// Act
bool allowed = HttpEndpointUtility.IsRemoteUrlAllowed("http://example.com:8080", out string error);
// Assert
Assert.IsFalse(allowed);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("HTTPS").IgnoreCase);
}
[Test]
public void IsRemoteUrlAllowed_Http_AllowedWithOptIn()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, true);
// Act
bool allowed = HttpEndpointUtility.IsRemoteUrlAllowed("http://example.com:8080", out string error);
// Assert
Assert.IsTrue(allowed);
Assert.IsNull(error);
}
[Test]
public void IsHttpLocalUrlAllowedForLaunch_ZeroBind_DisallowedByDefault()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, false);
// Act
bool allowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch("http://0.0.0.0:8080", out string error);
// Assert
Assert.IsFalse(allowed);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("disabled by default").IgnoreCase);
}
[Test]
public void IsHttpLocalUrlAllowedForLaunch_ZeroBind_AllowedWithOptIn()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, true);
// Act
bool allowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch("http://0.0.0.0:8080", out string error);
// Assert
Assert.IsTrue(allowed);
Assert.IsNull(error);
}
#endregion
#region TryGetLocalHttpServerCommand Tests
[Test]
public void TryGetLocalHttpServerCommand_HttpDisabled_ReturnsFalseWithError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert
Assert.IsFalse(result, "Should return false when HTTP transport is disabled");
Assert.IsNull(command, "Command should be null when failing");
Assert.IsNotNull(error, "Error message should be provided");
Assert.That(error, Does.Contain("HTTP").IgnoreCase, "Error should mention HTTP transport");
}
[Test]
public void TryGetLocalHttpServerCommand_RemoteUrl_ReturnsFalseWithError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert
Assert.IsFalse(result, "Should return false for remote URL");
Assert.IsNull(command, "Command should be null when failing");
Assert.IsNotNull(error, "Error message should be provided");
Assert.That(error, Does.Contain("local").IgnoreCase, "Error should mention local address requirement");
}
[Test]
public void TryGetLocalHttpServerCommand_LocalUrl_ReturnsCommandOrError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert - Success depends on uvx availability
if (result)
{
Assert.IsNotNull(command, "Command should be set on success");
Assert.IsNull(error, "Error should be null on success");
Assert.That(command, Does.Contain("uvx").Or.Contain("uv"), "Command should reference uvx/uv");
}
else
{
Assert.IsNotNull(error, "Error message should be provided on failure");
}
Assert.Pass($"TryGetLocalHttpServerCommand: success={result}, command={command ?? "null"}, error={error ?? "null"}");
}
#endregion
#region IsLocalHttpServerReachable Tests
[Test]
public void IsLocalHttpServerReachable_NoServer_ReturnsFalse()
{
// Arrange - Use a port that's unlikely to have a server running
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59999");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerReachable();
// Assert
Assert.IsFalse(result, "Should return false when no server is listening");
}
[Test]
public void IsLocalHttpServerReachable_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerReachable();
// Assert
Assert.IsFalse(result, "Should return false for non-local URL without attempting connection");
}
[Test]
public void IsLocalHttpServerReachable_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
// Act & Assert - Should never throw regardless of server state
Assert.DoesNotThrow(() =>
{
_service.IsLocalHttpServerReachable();
}, "IsLocalHttpServerReachable should handle all error cases gracefully");
}
#endregion
#region IsLocalHttpServerRunning Tests
[Test]
public void IsLocalHttpServerRunning_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerRunning();
// Assert
Assert.IsFalse(result, "Should return false for non-local URL");
}
[Test]
public void IsLocalHttpServerRunning_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
// Act & Assert - Should never throw regardless of server state
Assert.DoesNotThrow(() =>
{
_service.IsLocalHttpServerRunning();
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
}
#endregion
#region ClearUvxCache Tests
[Test]
public void ClearUvxCache_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
string lastLog = null;
Application.LogCallback handler = (condition, stackTrace, type) =>
{
if (condition != null && condition.Contains("uv cache"))
{
lastLog = condition;
}
};
// Act & Assert - Should not throw even if uvx is not installed
Assert.DoesNotThrow(() =>
{
LogAssert.ignoreFailingMessages = true;
Application.logMessageReceived += handler;
try
{
_service.ClearUvxCache();
}
finally
{
Application.logMessageReceived -= handler;
LogAssert.ignoreFailingMessages = false;
}
}, "ClearUvxCache should handle missing uvx gracefully");
Assert.IsNotNull(lastLog, "Expected a uv cache log message.");
StringAssert.Contains("uv cache", lastLog);
}
#endregion
#region Private Method Characterization (via reflection for documentation)
[Test]
public void NormalizeForMatch_RemovesWhitespace_ViaReflection()
{
// Arrange - Use reflection to access private static method
var method = typeof(ServerManagementService).GetMethod(
"NormalizeForMatch",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("NormalizeForMatch is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "Hello World" });
// Assert
Assert.AreEqual("helloworld", result, "Should remove whitespace and lowercase");
}
[Test]
public void NormalizeForMatch_HandlesNull_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"NormalizeForMatch",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("NormalizeForMatch is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { null });
// Assert
Assert.AreEqual(string.Empty, result, "Should return empty string for null input");
}
[Test]
public void QuoteIfNeeded_PathWithSpaces_AddsQuotes_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"QuoteIfNeeded",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("QuoteIfNeeded is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "path with spaces" });
// Assert
Assert.AreEqual("\"path with spaces\"", result, "Should wrap path with quotes");
}
[Test]
public void QuoteIfNeeded_PathWithoutSpaces_NoChange_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"QuoteIfNeeded",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("QuoteIfNeeded is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "pathwithoutspaces" });
// Assert
Assert.AreEqual("pathwithoutspaces", result, "Should not modify path without spaces");
}
[Test]
public void IsLocalUrl_Static_MatchesPublicBehavior_ViaReflection()
{
// Arrange - Access private static IsLocalUrl(string) method
var method = typeof(ServerManagementService).GetMethod(
"IsLocalUrl",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("Static IsLocalUrl is a private method - behavior documented via code review");
return;
}
// Act & Assert - Test various URLs
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://localhost:8080" }), "localhost should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://127.0.0.1:8080" }), "127.0.0.1 should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://0.0.0.0:8080" }), "0.0.0.0 should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://[::1]:8080" }), "::1 should be recognized as local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { "http://example.com:8080" }), "example.com should not be local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { "" }), "empty string should not be local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { null }), "null should not be local");
}
[Test]
public void BuildLocalProbeHosts_Localhost_IncludesIPv4AndIPv6Loopback_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"BuildLocalProbeHosts",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("BuildLocalProbeHosts is a private method - behavior documented via code review");
return;
}
// Act
var result = method.Invoke(null, new object[] { "localhost" });
Assert.IsNotNull(result);
Assert.IsInstanceOf<IEnumerable<string>>(result);
var hosts = ((IEnumerable<string>)result).ToList();
// Assert
CollectionAssert.Contains(hosts, "localhost");
CollectionAssert.Contains(hosts, "127.0.0.1");
CollectionAssert.Contains(hosts, "::1");
}
[Test]
public void BuildLocalProbeHosts_EmptyHost_DefaultsToIPv4Loopback_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"BuildLocalProbeHosts",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("BuildLocalProbeHosts is a private method - behavior documented via code review");
return;
}
// Act
var result = method.Invoke(null, new object[] { "" });
Assert.IsNotNull(result);
Assert.IsInstanceOf<IEnumerable<string>>(result);
var hosts = ((IEnumerable<string>)result).ToList();
// Assert
Assert.AreEqual(1, hosts.Count, "Empty host should resolve to a single default probe host.");
Assert.AreEqual("127.0.0.1", hosts[0]);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d8e9f289147f6440dadbb78a34d893ea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,503 @@
using System;
using System.Reflection;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services.Characterization
{
/// <summary>
/// Characterization tests for Editor Services domain.
/// These tests capture CURRENT behavior without refactoring.
/// They serve as a regression baseline for future refactoring work.
///
/// Based on analysis in: MCPForUnity/Editor/Services/Tests/CHARACTERIZATION_NOTES.md
///
/// Services covered: ServerManagementService, EditorStateCache, BridgeControlService,
/// ClientConfigurationService, MCPServiceLocator
/// </summary>
[TestFixture]
public class ServicesCharacterizationTests
{
#region Section 1: ServerManagementService - Stateless Architecture
/// <summary>
/// Current behavior: ServerManagementService is stateless - no instance fields track state.
/// All state flows through EditorPrefs (persistent) + method parameters (transient).
/// </summary>
[Test]
public void ServerManagementService_IsStateless_NoInstanceFieldsTrackingState()
{
// Verify the service can be instantiated multiple times without state issues
var service1 = new ServerManagementService();
var service2 = new ServerManagementService();
// Both instances should be equivalent (no instance state)
Assert.IsNotNull(service1);
Assert.IsNotNull(service2);
// Check that the class has minimal instance fields (primarily static or none)
var instanceFields = typeof(ServerManagementService)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
// Stateless services should have no or minimal instance fields
// This documents the current architecture
Assert.Pass($"ServerManagementService has {instanceFields.Length} instance fields - stateless design");
}
/// <summary>
/// Current behavior: Server metadata is stored in EditorPrefs for persistence
/// across domain reloads. Keys include LastLocalHttpServerPid, Port, StartedUtc, etc.
/// </summary>
[Test]
public void ServerManagementService_StoresLocalHttpServerMetadata_InEditorPrefs()
{
// Document that EditorPrefs keys exist for server tracking
// These keys are defined in EditorPrefKeys constants
var expectedKeys = new[]
{
"LastLocalHttpServerPid",
"LastLocalHttpServerPort",
"LastLocalHttpServerStartedUtc"
};
// This test documents the persistence mechanism
Assert.Pass($"Server metadata uses EditorPrefs with keys like: {string.Join(", ", expectedKeys)}");
}
/// <summary>
/// Current behavior: IsLocalHttpServerRunning uses multi-strategy detection:
/// 1. Handshake validation (pidfile + token)
/// 2. Stored PID matching (EditorPrefs with 6-hour validity)
/// 3. Heuristic process matching
/// 4. Network probe fallback
/// </summary>
[Test]
public void ServerManagementService_IsLocalHttpServerRunning_UsesMultiDetectionStrategy()
{
var service = new ServerManagementService();
// The method should not throw - it handles all edge cases
bool result = false;
Assert.DoesNotThrow(() =>
{
result = service.IsLocalHttpServerRunning();
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
// Result depends on actual server state - document the behavior
Assert.Pass($"IsLocalHttpServerRunning returned {result} using multi-strategy detection");
}
/// <summary>
/// Current behavior: IsLocalHttpServerReachable uses a fast network probe
/// (50ms TCP connection attempt) to check server availability.
/// </summary>
[Test]
public void ServerManagementService_IsLocalHttpServerReachable_UsesNetworkProbe()
{
var service = new ServerManagementService();
// Should complete quickly without hanging
bool reachable = false;
Assert.DoesNotThrow(() =>
{
reachable = service.IsLocalHttpServerReachable();
}, "Network probe should complete without hanging");
Assert.Pass($"IsLocalHttpServerReachable returned {reachable} via network probe");
}
/// <summary>
/// Current behavior: TryGetLocalHttpServerCommand builds uvx command
/// with platform-specific arguments.
/// </summary>
[Test]
public void ServerManagementService_TryGetLocalHttpServerCommand_BuildsUvxCommand()
{
var service = new ServerManagementService();
string command = null;
string error = null;
bool result = service.TryGetLocalHttpServerCommand(out command, out error);
// Command building should succeed (unless misconfigured)
if (result)
{
Assert.IsNotNull(command, "Command should be set on success");
// Document the command structure
Assert.Pass($"Built command: {command}");
}
else
{
Assert.Pass($"TryGetLocalHttpServerCommand returned false: {error ?? "unknown"}");
}
}
/// <summary>
/// Current behavior: IsLocalUrl matches loopback addresses
/// (localhost, 127.0.0.1, ::1, etc.)
/// </summary>
[Test]
public void ServerManagementService_IsLocalUrl_MatchesLoopbackAddresses()
{
// Use reflection to access static method if private, or test publicly exposed behavior
var service = new ServerManagementService();
// Test via public API behavior - local URLs should be treated specially
// This documents the expected loopback patterns
var loopbackPatterns = new[] { "localhost", "127.0.0.1", "::1", "[::1]" };
Assert.Pass($"IsLocalUrl recognizes loopback patterns: {string.Join(", ", loopbackPatterns)}");
}
/// <summary>
/// Current behavior: Process termination uses graceful-then-forced approach.
/// Unix: SIGTERM (8s grace) then SIGKILL
/// Windows: taskkill /T then /F
/// </summary>
[Test]
public void ServerManagementService_TerminateProcess_UsesGracefulThenForced_OnUnix()
{
// Document the termination strategy without actually terminating anything
var platforms = new[]
{
"Unix: SIGTERM with 8s grace, then SIGKILL",
"Windows: taskkill /T, then /F"
};
Assert.Pass($"Process termination strategies: {string.Join("; ", platforms)}");
}
/// <summary>
/// Current behavior: LooksLikeMcpServerProcess uses multi-layer validation
/// to identify MCP server processes.
/// </summary>
[Test]
public void ServerManagementService_LooksLikeMcpServerProcess_UsesMultiStrategyValidation()
{
// Document the validation strategies
var strategies = new[]
{
"Command line contains 'uvx' or 'python'",
"Command line contains 'mcp-for-unity'",
"PID args hash matching",
"Token validation"
};
Assert.Pass($"Process validation uses: {string.Join(", ", strategies)}");
}
/// <summary>
/// Current behavior: StopLocalHttpServer prefers pidfile-based approach
/// for deterministic termination.
/// </summary>
[Test]
[Explicit("Stops the MCP server - kills connection")]
public void ServerManagementService_StopLocalHttpServer_PrefersPidfileBasedApproach()
{
var service = new ServerManagementService();
// WARNING: This test calls StopLocalHttpServer() which will kill the running MCP server
// Calling stop when no server is running should not throw
Assert.DoesNotThrow(() =>
{
service.StopLocalHttpServer();
}, "StopLocalHttpServer should handle no-server case gracefully");
Assert.Pass("StopLocalHttpServer uses pidfile-based approach with fallbacks");
}
/// <summary>
/// Current behavior: PID tracking uses args hash to prevent PID reuse issues.
/// </summary>
[Test]
public void ServerManagementService_StoreLocalServerPidTracking_UsesArgHash()
{
// Document the PID tracking mechanism
var trackingElements = new[]
{
"PID value",
"Command args hash",
"Start timestamp (6-hour validity)",
"Pidfile path",
"Instance token"
};
Assert.Pass($"PID tracking includes: {string.Join(", ", trackingElements)}");
}
#endregion
#region Section 2: EditorStateCache - Thread-Safe Caching
/// <summary>
/// Current behavior: EditorStateCache is initialized via [InitializeOnLoad]
/// and uses thread-safe access patterns.
/// </summary>
[Test]
public void EditorStateCache_IsInitializedOnLoad_AndThreadSafe()
{
// EditorStateCache should already be initialized by Unity
// Check that the type exists and has InitializeOnLoad
var type = typeof(EditorStateCache);
var initAttr = type.GetCustomAttribute<InitializeOnLoadAttribute>();
Assert.IsNotNull(initAttr, "EditorStateCache should have InitializeOnLoad attribute");
}
/// <summary>
/// Current behavior: BuildSnapshot is only called when state changes,
/// using two-stage change detection to minimize expensive operations.
/// </summary>
[Test]
public void EditorStateCache_BuildSnapshot_OnlyCalledWhenStateChanges()
{
// Document the change detection stages
var stages = new[]
{
"Stage 1: Fast check (compilation edge + throttle)",
"Stage 2: Cheap capture (scene, focus, play mode)",
"Stage 3: Comparison (string/bool diff)",
"Stage 4: Expensive BuildSnapshot only if changed"
};
Assert.Pass($"Change detection: {string.Join(" -> ", stages)}");
}
/// <summary>
/// Current behavior: Snapshot schema covers multiple editor state sections.
/// </summary>
[Test]
public void EditorStateCache_SnapshotSchema_CoversEditorState()
{
// Get current snapshot to verify schema
var snapshot = EditorStateCache.GetSnapshot();
Assert.IsNotNull(snapshot, "Should be able to get current snapshot");
// Document the schema sections
var sections = new[] { "unity", "editor", "activity", "compilation", "assets", "tests", "transport" };
Assert.Pass($"Snapshot includes sections: {string.Join(", ", sections)}");
}
/// <summary>
/// Current behavior: EditorStateCache uses lock object for thread safety.
/// </summary>
[Test]
public void EditorStateCache_UsesLockObjPattern_ForThreadSafety()
{
// Verify thread-safe access pattern by checking concurrent access doesn't throw
var snapshot1 = EditorStateCache.GetSnapshot();
var snapshot2 = EditorStateCache.GetSnapshot();
Assert.IsNotNull(snapshot1);
Assert.IsNotNull(snapshot2);
Assert.Pass("EditorStateCache uses lock pattern for concurrent access safety");
}
#endregion
#region Section 3: BridgeControlService - Transport Management
/// <summary>
/// Current behavior: BridgeControlService resolves preferred mode from EditorPrefs
/// on each method call (no caching).
/// </summary>
[Test]
public void BridgeControlService_ResolvesPreferredMode_FromEditorPrefs()
{
// Document that mode is resolved dynamically
var service = MCPServiceLocator.Bridge;
Assert.IsNotNull(service, "BridgeControlService should be available via locator");
Assert.Pass("BridgeControlService reads UseHttpTransport from EditorPrefs on each call");
}
/// <summary>
/// Current behavior: StartAsync stops the other transport first
/// to ensure mutual exclusion.
/// </summary>
[Test]
public void BridgeControlService_StartAsync_StopsOtherTransport_First()
{
// Document the mutual exclusion pattern
var pattern = "StartAsync: Stop opposing transport FIRST, then start preferred";
Assert.Pass($"Transport mutual exclusion: {pattern}");
}
/// <summary>
/// Current behavior: VerifyAsync checks both ping response and handshake state.
/// </summary>
[Test]
public void BridgeControlService_VerifyAsync_ChecksBothPingAndHandshake()
{
// Document verification pattern
var checks = new[] { "Async ping", "State check", "Mode-specific validation" };
Assert.Pass($"VerifyAsync performs: {string.Join(" + ", checks)}");
}
#endregion
#region Section 4: ClientConfigurationService
/// <summary>
/// Current behavior: ConfigureAllDetectedClients runs a single-pass loop
/// over all registered clients.
/// </summary>
[Test]
public void ClientConfigurationService_ConfigureAllDetectedClients_RunsOnce()
{
var service = MCPServiceLocator.Client;
Assert.IsNotNull(service, "ClientConfigurationService should be available");
// Document the configuration pattern
var pattern = new[]
{
"Clean build artifacts once",
"Iterate all registered clients",
"Catch exceptions per client",
"Return summary with counts"
};
Assert.Pass($"Configuration loop: {string.Join(" -> ", pattern)}");
}
#endregion
#region Section 5: MCPServiceLocator - Lazy Initialization
/// <summary>
/// Current behavior: MCPServiceLocator uses lazy initialization with
/// null-coalescing operator (not Lazy<T>).
/// </summary>
[Test]
public void MCPServiceLocator_UsesLazyInitializationPattern_WithoutLocking()
{
// Access services to verify lazy initialization
var bridge1 = MCPServiceLocator.Bridge;
var bridge2 = MCPServiceLocator.Bridge;
// Same instance should be returned
Assert.AreSame(bridge1, bridge2, "Should return same instance");
// Document the race condition risk (acceptable for editor)
Assert.Pass("Uses null-coalescing lazy init - acceptable race condition for editor");
}
/// <summary>
/// Current behavior: Reset disposes and clears all services.
/// </summary>
[Test]
public void MCPServiceLocator_Reset_DisposesAndClears_AllServices()
{
// Document the reset behavior without actually calling it (would break other tests)
var resetBehavior = new[]
{
"Calls Dispose() on IDisposable services",
"Sets all fields to null",
"Used in test teardown and shutdown"
};
Assert.Pass($"Reset behavior: {string.Join(", ", resetBehavior)}");
}
/// <summary>
/// Current behavior: Register dispatches by interface type via if-else chain.
/// </summary>
[Test]
public void MCPServiceLocator_Register_DispatchesByInterface_Type()
{
// Document the registration pattern
var pattern = "Register<T>(impl) uses if-else chain for interface type dispatch";
Assert.Pass(pattern);
}
#endregion
#region Section 6: Cross-Cutting Patterns
/// <summary>
/// Current behavior: EditorStateCache and BridgeControlService maintain
/// consistent views of editor state.
/// </summary>
[Test]
public void Consistency_EditorStateCache_And_BridgeControlService()
{
var snapshot = EditorStateCache.GetSnapshot();
var bridge = MCPServiceLocator.Bridge;
Assert.IsNotNull(snapshot, "Snapshot available");
Assert.IsNotNull(bridge, "Bridge available");
Assert.Pass("Both services maintain consistent editor state views");
}
/// <summary>
/// Current behavior: MCPServiceLocator race condition is acceptable
/// because services are stateless/idempotent.
/// </summary>
[Test]
public void RaceCondition_MCPServiceLocator_DoubleInitialization_Acceptable()
{
// Document the race condition scenario
var scenario = new[]
{
"T1 accesses property, finds null",
"T2 accesses property, finds null (before T1 assignment)",
"Both create instances, last wins",
"First instance discarded (no leak - services are light)"
};
Assert.Pass($"Race scenario: {string.Join(" -> ", scenario)}");
}
/// <summary>
/// Current behavior: Configuration changes propagate via EditorPrefs reads
/// (implicit invalidation).
/// </summary>
[Test]
public void Invalidation_ConfigChanges_PropagateViaEditorPrefsReads()
{
var pattern = "No explicit cache invalidation - services re-read EditorPrefs on each call";
Assert.Pass(pattern);
}
/// <summary>
/// Current behavior: Domain initialization follows a specific load order.
/// </summary>
[Test]
public void Initialization_DomainLoad_Sequence()
{
var sequence = new[]
{
"EditorStateCache [InitializeOnLoad]",
"MCPServiceLocator services (lazy)",
"BridgeControlService (on first access)",
"Transport initialization (async)"
};
Assert.Pass($"Load sequence: {string.Join(" -> ", sequence)}");
}
/// <summary>
/// Current behavior: Configuration flows from UI to EditorPrefs to behavior.
/// </summary>
[Test]
public void Configuration_Flow_EditorPrefs_To_Behavior()
{
var flow = new[]
{
"User changes config in UI",
"EditorPrefs.SetBool/String called",
"Service method reads EditorPrefs",
"Behavior reflects new config immediately"
};
Assert.Pass($"Config flow: {string.Join(" -> ", flow)}");
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a1b2c3d4e5f6789012345678abcdef4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System.Linq;
using MCPForUnity.Editor.Services;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Services
{
// ConfigureAllDetectedClients walks the real McpClientRegistry and Configure()s every
// detected client, which would touch real user config files on a dev machine. These
// tests pass on CI (no MCP clients installed there) but would mutate real state on
// a developer's machine. Marked [Explicit] so they only run when invoked by name;
// proper isolation requires DI of the configurator list and is tracked separately.
[TestFixture]
public class ConfigureDetectedClientsTests
{
[Test]
[Explicit("Side-effect: writes real client configs on machines with MCP clients installed")]
public void Summary_ContainsOnlyInstalledClients()
{
var svc = new ClientConfigurationService();
var summary = svc.ConfigureAllDetectedClients();
int installedCount = svc.GetAllClients().Count(c => c.IsInstalled);
Assert.AreEqual(installedCount, summary.SuccessCount + summary.FailureCount,
"Only installed clients should appear in success/failure totals");
}
[Test]
[Explicit("Side-effect: writes real client configs on machines with MCP clients installed")]
public void Summary_SkippedCountTracksUninstalled()
{
var svc = new ClientConfigurationService();
var summary = svc.ConfigureAllDetectedClients();
int uninstalledCount = svc.GetAllClients().Count(c => !c.IsInstalled);
Assert.AreEqual(uninstalledCount, summary.SkippedCount);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78f1385217c246d4a9b5c596986787b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,264 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Unit tests for EditorConfigurationCache.
/// </summary>
[TestFixture]
public class EditorConfigurationCacheTests
{
private bool _originalUseHttpTransport;
private bool _originalDebugLogs;
private string _originalUvxPath;
[SetUp]
public void SetUp()
{
// Save original values
_originalUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_originalDebugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
_originalUvxPath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
// Refresh cache to ensure clean state
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Restore original values
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _originalUseHttpTransport);
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, _originalDebugLogs);
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, _originalUvxPath);
// Refresh cache
EditorConfigurationCache.Instance.Refresh();
}
#region Singleton Tests
[Test]
public void Instance_ReturnsSameInstance()
{
// Act
var instance1 = EditorConfigurationCache.Instance;
var instance2 = EditorConfigurationCache.Instance;
// Assert
Assert.AreSame(instance1, instance2, "Should return the same singleton instance");
}
[Test]
public void Instance_IsNotNull()
{
// Assert
Assert.IsNotNull(EditorConfigurationCache.Instance);
}
#endregion
#region Read Tests
[Test]
public void UseHttpTransport_ReturnsEditorPrefsValue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.UseHttpTransport);
// Arrange - change value
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsFalse(EditorConfigurationCache.Instance.UseHttpTransport);
}
[Test]
public void DebugLogs_ReturnsEditorPrefsValue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, true);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
}
[Test]
public void UvxPathOverride_ReturnsEditorPrefsValue()
{
// Arrange
string testPath = "/custom/path/to/uvx";
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, testPath);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.AreEqual(testPath, EditorConfigurationCache.Instance.UvxPathOverride);
}
#endregion
#region Write Tests
[Test]
public void SetUseHttpTransport_UpdatesCacheAndEditorPrefs()
{
// Arrange
bool initialValue = EditorConfigurationCache.Instance.UseHttpTransport;
bool newValue = !initialValue;
// Act
EditorConfigurationCache.Instance.SetUseHttpTransport(newValue);
// Assert - cache is updated
Assert.AreEqual(newValue, EditorConfigurationCache.Instance.UseHttpTransport);
// Assert - EditorPrefs is updated
Assert.AreEqual(newValue, EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, !newValue));
}
[Test]
public void SetDebugLogs_UpdatesCacheAndEditorPrefs()
{
// Act
EditorConfigurationCache.Instance.SetDebugLogs(true);
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
Assert.IsTrue(EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false));
}
[Test]
public void SetUvxPathOverride_UpdatesCacheAndEditorPrefs()
{
// Arrange
string testPath = "/test/uvx/path";
// Act
EditorConfigurationCache.Instance.SetUvxPathOverride(testPath);
// Assert
Assert.AreEqual(testPath, EditorConfigurationCache.Instance.UvxPathOverride);
Assert.AreEqual(testPath, EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty));
}
[Test]
public void SetUvxPathOverride_NullBecomesEmptyString()
{
// Act
EditorConfigurationCache.Instance.SetUvxPathOverride(null);
// Assert
Assert.AreEqual(string.Empty, EditorConfigurationCache.Instance.UvxPathOverride);
}
#endregion
#region Change Notification Tests
[Test]
public void SetUseHttpTransport_FiresOnConfigurationChanged()
{
// Arrange
string changedKey = null;
EditorConfigurationCache.Instance.OnConfigurationChanged += (key) => changedKey = key;
bool initialValue = EditorConfigurationCache.Instance.UseHttpTransport;
// Act
EditorConfigurationCache.Instance.SetUseHttpTransport(!initialValue);
// Assert
Assert.AreEqual(nameof(EditorConfigurationCache.UseHttpTransport), changedKey);
// Cleanup
EditorConfigurationCache.Instance.OnConfigurationChanged -= (key) => changedKey = key;
}
[Test]
public void SetSameValue_DoesNotFireOnConfigurationChanged()
{
// Arrange
int eventCount = 0;
EditorConfigurationCache.Instance.OnConfigurationChanged += (key) => eventCount++;
bool currentValue = EditorConfigurationCache.Instance.UseHttpTransport;
// Act - set same value
EditorConfigurationCache.Instance.SetUseHttpTransport(currentValue);
// Assert - no event fired
Assert.AreEqual(0, eventCount, "Should not fire event when value doesn't change");
// Cleanup
EditorConfigurationCache.Instance.OnConfigurationChanged -= (key) => eventCount++;
}
#endregion
#region InvalidateKey Tests
[Test]
public void InvalidateKey_RefreshesSingleValue()
{
// Arrange
EditorConfigurationCache.Instance.SetDebugLogs(false);
Assert.IsFalse(EditorConfigurationCache.Instance.DebugLogs);
// Directly modify EditorPrefs (simulating external change)
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, true);
// Act
EditorConfigurationCache.Instance.InvalidateKey(nameof(EditorConfigurationCache.DebugLogs));
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
}
[Test]
public void InvalidateKey_FiresOnConfigurationChanged()
{
// Arrange
string changedKey = null;
EditorConfigurationCache.Instance.OnConfigurationChanged += (key) => changedKey = key;
// Act
EditorConfigurationCache.Instance.InvalidateKey(nameof(EditorConfigurationCache.DebugLogs));
// Assert
Assert.AreEqual(nameof(EditorConfigurationCache.DebugLogs), changedKey);
// Cleanup
EditorConfigurationCache.Instance.OnConfigurationChanged -= (key) => changedKey = key;
}
#endregion
#region Refresh Tests
[Test]
public void Refresh_UpdatesAllCachedValues()
{
// Arrange - directly set EditorPrefs
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, true);
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, "/refreshed/path");
// Act
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsFalse(EditorConfigurationCache.Instance.UseHttpTransport);
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
Assert.AreEqual("/refreshed/path", EditorConfigurationCache.Instance.UvxPathOverride);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4d5e6c8f40564676a4afffeb2de0854
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,194 @@
using NUnit.Framework;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Tests for the auto-start tick decisions (#1229): the session latch must only be
/// written when the deferred work actually dispatches, so a domain reload that wipes
/// the pending tick can no longer consume the once-per-session auto-start.
/// TickCore is a pure decision — it never writes the latch and never spawns servers.
/// The TryBeginReconnect tests only exercise its deliberate-drop paths, which never
/// dispatch the async connect.
/// </summary>
public class HttpAutoStartHandlerTests
{
private FakeTransportClient _fakeClient;
private TransportManager _savedManager;
private bool _savedLatch;
private bool _savedConnectPending;
private bool _savedResumeFlag;
private bool _savedAutoStart;
private bool _savedUseHttpTransport;
[SetUp]
public void SetUp()
{
_savedLatch = SessionState.GetBool(HttpAutoStartHandler.SessionInitKey, false);
_savedConnectPending = SessionState.GetBool(HttpAutoStartHandler.ConnectPendingKey, false);
_savedResumeFlag = SessionState.GetBool(HttpBridgeReloadHandler.ResumeSessionKey, false);
_savedAutoStart = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedManager = MCPServiceLocator.TransportManager;
SessionState.EraseBool(HttpAutoStartHandler.SessionInitKey);
SessionState.EraseBool(HttpAutoStartHandler.ConnectPendingKey);
SessionState.EraseBool(HttpBridgeReloadHandler.ResumeSessionKey);
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, false);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorConfigurationCache.Instance.Refresh();
_fakeClient = new FakeTransportClient();
var manager = new TransportManager();
manager.Configure(() => _fakeClient, () => _fakeClient);
MCPServiceLocator.Register(manager);
}
[TearDown]
public void TearDown()
{
// Stored-false and absent are indistinguishable: every read uses GetBool(key, false).
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, _savedLatch);
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, _savedConnectPending);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, _savedResumeFlag);
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, _savedAutoStart);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
EditorConfigurationCache.Instance.Refresh();
MCPServiceLocator.Register(_savedManager);
}
private static bool LatchSet =>
SessionState.GetBool(HttpAutoStartHandler.SessionInitKey, false);
private static bool ConnectPendingSet =>
SessionState.GetBool(HttpAutoStartHandler.ConnectPendingKey, false);
[Test]
public void TickCore_EditorBusy_Defers()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.DeferBusy,
HttpAutoStartHandler.TickCore(editorBusy: true));
Assert.IsFalse(LatchSet);
}
[Test]
public void TickCore_ResumePending_YieldsToReloadHandler()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.DeferToResume,
HttpAutoStartHandler.TickCore(editorBusy: false));
Assert.IsFalse(LatchSet);
}
[Test]
public void TickCore_ResumePendingButNothingToDo_SkipsWithoutWaiting()
{
// Auto-start disabled and not latched: a pending resume must not keep the
// tick alive when the only possible outcome is Skip.
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.Skip,
HttpAutoStartHandler.TickCore(editorBusy: false));
}
[Test]
public void TickCore_AutoStartDisabled_SkipsWithoutLatch()
{
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.Skip,
HttpAutoStartHandler.TickCore(editorBusy: false));
Assert.IsFalse(LatchSet, "no latch when disabled — the pref is re-read on the next domain load");
}
[Test]
public void TickCore_AutoStartEnabled_ShouldStartWithoutWritingLatch()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.ShouldStart,
HttpAutoStartHandler.TickCore(editorBusy: false));
Assert.IsFalse(LatchSet, "the caller latches only after the start work actually dispatches");
}
[Test]
public void TickCore_Latched_Skips()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.Skip,
HttpAutoStartHandler.TickCore(editorBusy: false));
}
[Test]
public void TickCore_LatchedWithConnectPending_Reconnects()
{
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, true);
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.ShouldReconnect,
HttpAutoStartHandler.TickCore(editorBusy: false),
"a reload that killed the in-flight connect should finish connect-only, never re-spawn");
}
[Test]
public void TickCore_ResumePendingBeatsReconnect()
{
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, true);
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.DeferToResume,
HttpAutoStartHandler.TickCore(editorBusy: false),
"the reload handler owns bridge revival while a resume is pending");
}
[Test]
public void TryBeginReconnect_AutoStartDisabled_DropsPendingReconnect()
{
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.IsTrue(HttpAutoStartHandler.TryBeginReconnect());
Assert.IsFalse(ConnectPendingSet, "a deliberate drop must consume the pending marker");
}
[Test]
public void TryBeginReconnect_StdioSelected_DropsPendingReconnect()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorConfigurationCache.Instance.Refresh();
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.IsTrue(HttpAutoStartHandler.TryBeginReconnect());
Assert.IsFalse(ConnectPendingSet);
}
[Test]
public void TryBeginReconnect_BridgeAlreadyRunning_DropsPendingReconnect()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
var start = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http);
Assert.IsTrue(start.IsCompleted && start.Result, "fake bridge should start synchronously");
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.IsTrue(HttpAutoStartHandler.TryBeginReconnect());
Assert.IsFalse(ConnectPendingSet);
Assert.AreEqual(1, _fakeClient.StartCalls, "an already-running bridge must not be restarted");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b295bffe6b1f4006bd4aa5061932ec0f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,196 @@
using System;
using System.Threading.Tasks;
using NUnit.Framework;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Tests for the HTTP reload-resume flag semantics (#1229): the flag must survive
/// multi-pass compile boundaries and only be consumed on success, cancel, or exhaustion.
/// Uses fake transports and a zero-delay retry schedule so every path completes
/// synchronously (UTF 1.1 cannot run async tests).
/// </summary>
public class HttpBridgeReloadHandlerTests
{
private static readonly TimeSpan[] ZeroSchedule =
{
TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero
};
private FakeTransportClient _fakeClient;
private TransportManager _manager;
private TransportManager _savedManager;
private bool _savedResumeFlag;
private bool _savedUseHttpTransport;
[SetUp]
public void SetUp()
{
_savedResumeFlag = SessionState.GetBool(HttpBridgeReloadHandler.ResumeSessionKey, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedManager = MCPServiceLocator.TransportManager;
SessionState.EraseBool(HttpBridgeReloadHandler.ResumeSessionKey);
_fakeClient = new FakeTransportClient();
_manager = new TransportManager();
_manager.Configure(() => _fakeClient, () => _fakeClient);
MCPServiceLocator.Register(_manager);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Stored-false and absent are indistinguishable: every read uses GetBool(key, false).
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, _savedResumeFlag);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
EditorConfigurationCache.Instance.Refresh();
MCPServiceLocator.Register(_savedManager);
}
private static bool ResumeFlagSet =>
SessionState.GetBool(HttpBridgeReloadHandler.ResumeSessionKey, false);
private void StartBridge()
{
Task<bool> start = _manager.StartAsync(TransportMode.Http);
Assert.IsTrue(start.IsCompleted && start.Result, "fake bridge should start synchronously");
}
[Test]
public void BeforeReloadCore_BridgeRunning_SetsFlagAndForceStops()
{
StartBridge();
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet, "flag should be set when the bridge was running");
Assert.IsFalse(_manager.IsRunning(TransportMode.Http), "bridge should be force-stopped before reload");
}
[Test]
public void BeforeReloadCore_BridgeNotRunning_PreservesPendingFlag()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet,
"a pending resume must survive a reload boundary where the bridge is down (#1229 multi-pass compile)");
}
[Test]
public void AfterReloadCore_NoFlag_DoesNotResume()
{
Assert.IsFalse(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
}
[Test]
public void AfterReloadCore_FlagSetHttpSelected_ResumesAndKeepsFlag()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.IsTrue(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Assert.IsTrue(ResumeFlagSet, "flag is only consumed when the resume actually succeeds");
}
[Test]
public void AfterReloadCore_FlagSetStdioSelected_ClearsFlagAndSkips()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorConfigurationCache.Instance.Refresh();
Assert.IsFalse(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Assert.IsFalse(ResumeFlagSet, "switching transports cancels the pending resume");
}
[Test]
public void Resume_Success_ConnectsAndClearsFlag()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.IsTrue(_manager.IsRunning(TransportMode.Http));
Assert.AreEqual(1, _fakeClient.StartCalls);
Assert.IsFalse(ResumeFlagSet);
}
[Test]
public void Resume_Exhaustion_ClearsFlagAfterAllAttempts()
{
_fakeClient.StartResult = false;
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.AreEqual(ZeroSchedule.Length, _fakeClient.StartCalls);
Assert.IsFalse(ResumeFlagSet,
"exhaustion erases the flag so later reload boundaries don't replay the failure loop");
}
[Test]
public void Resume_FlagErasedMidLoop_StopsRetrying()
{
_fakeClient.StartResult = false;
// Simulates End Session / transport switch cancelling while a retry is in flight.
_fakeClient.OnStart = () => SessionState.EraseBool(HttpBridgeReloadHandler.ResumeSessionKey);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.AreEqual(1, _fakeClient.StartCalls, "erasing the flag must abort the retry loop");
}
[Test]
public void Resume_BridgeAlreadyRunning_ClearsFlagWithoutRestart()
{
StartBridge();
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.AreEqual(1, _fakeClient.StartCalls,
"a session established while the resume waited must not be bounced");
Assert.IsFalse(ResumeFlagSet);
}
[Test]
public void Scenario_MultiPassCompile_ResumesAtSecondBoundary()
{
// Pass 1 begins: bridge running when the first reload hits.
StartBridge();
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet);
Assert.IsFalse(_manager.IsRunning(TransportMode.Http));
// After pass 1: still compiling, so the resume tick defers — flag must not be consumed.
Assert.IsTrue(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Assert.IsTrue(ResumeFlagSet);
// Pass 2 begins with the bridge down. The old code deleted the flag at this
// boundary, which is exactly how #1229 lost the resume permanently.
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet);
// After pass 2: editor idle, resume runs and reconnects.
Assert.IsTrue(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.IsTrue(_manager.IsRunning(TransportMode.Http));
Assert.IsFalse(ResumeFlagSet);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 45fcbd8bbcd2411fbef0b27859c77465
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using System;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using UnityEngine;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class McpEditorShutdownCleanupTests
{
[Test]
public void ShouldRunCleanup_InteractiveEditor_RunsCleanup()
{
Assert.IsTrue(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: false, allowBatchEnv: null));
}
[Test]
public void ShouldRunCleanup_BatchWithoutOverride_IsNoOp()
{
// Regression for #1196/#1010: a -batchmode/CI instance must not stop the
// interactive editor's server resolved via the global pidfile+port handshake.
Assert.IsFalse(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: null));
}
[Test]
public void ShouldRunCleanup_BatchWithBlankOverride_IsNoOp()
{
// Whitespace is treated as unset, mirroring string.IsNullOrWhiteSpace in the sibling guards.
Assert.IsFalse(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: ""));
Assert.IsFalse(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: " "));
}
[Test]
public void ShouldRunCleanup_BatchWithOverride_RunsCleanup()
{
Assert.IsTrue(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: "1"));
}
[Test]
public void ShouldRunCleanup_Parameterless_MatchesEnvironment()
{
// Proves the wiring to Application.isBatchMode / UNITY_MCP_ALLOW_BATCH is correct
// without assuming how this test run was launched (GUI Test Runner vs -batchmode CI).
bool expected = !Application.isBatchMode
|| !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));
Assert.AreEqual(expected, McpEditorShutdownCleanup.ShouldRunCleanup());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04b5977d361f459ea4726c6e176fa9d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,357 @@
using System;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Constants;
namespace MCPForUnityTests.Editor.Services
{
public class PackageUpdateServiceTests
{
private PackageUpdateService _service;
private const string TestLastCheckDateKey = EditorPrefKeys.LastUpdateCheck;
private const string TestCachedVersionKey = EditorPrefKeys.LatestKnownVersion;
private const string TestAssetStoreLastCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck;
private const string TestAssetStoreCachedVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion;
[SetUp]
public void SetUp()
{
_service = new PackageUpdateService();
// Clean up any existing test data
CleanupEditorPrefs();
}
[TearDown]
public void TearDown()
{
// Clean up test data
CleanupEditorPrefs();
}
private void CleanupEditorPrefs()
{
if (EditorPrefs.HasKey(TestLastCheckDateKey))
{
EditorPrefs.DeleteKey(TestLastCheckDateKey);
}
if (EditorPrefs.HasKey(TestCachedVersionKey))
{
EditorPrefs.DeleteKey(TestCachedVersionKey);
}
if (EditorPrefs.HasKey(TestAssetStoreLastCheckDateKey))
{
EditorPrefs.DeleteKey(TestAssetStoreLastCheckDateKey);
}
if (EditorPrefs.HasKey(TestAssetStoreCachedVersionKey))
{
EditorPrefs.DeleteKey(TestAssetStoreCachedVersionKey);
}
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMajorVersionIsNewer()
{
bool result = _service.IsNewerVersion("2.0.0", "1.0.0");
Assert.IsTrue(result, "2.0.0 should be newer than 1.0.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMinorVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.2.0", "1.1.0");
Assert.IsTrue(result, "1.2.0 should be newer than 1.1.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenPatchVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.0.2", "1.0.1");
Assert.IsTrue(result, "1.0.2 should be newer than 1.0.1");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionsAreEqual()
{
bool result = _service.IsNewerVersion("1.0.0", "1.0.0");
Assert.IsFalse(result, "Same versions should return false");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionIsOlder()
{
bool result = _service.IsNewerVersion("1.0.0", "2.0.0");
Assert.IsFalse(result, "1.0.0 should not be newer than 2.0.0");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_v()
{
bool result = _service.IsNewerVersion("v2.0.0", "v1.0.0");
Assert.IsTrue(result, "Should handle 'v' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_V()
{
bool result = _service.IsNewerVersion("V2.0.0", "V1.0.0");
Assert.IsTrue(result, "Should handle 'V' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesMixedPrefixes()
{
bool result = _service.IsNewerVersion("v2.0.0", "1.0.0");
Assert.IsTrue(result, "Should handle mixed prefixes correctly");
}
[Test]
public void IsNewerVersion_ComparesCorrectly_WhenMajorDiffers()
{
bool result1 = _service.IsNewerVersion("10.0.0", "9.0.0");
bool result2 = _service.IsNewerVersion("2.0.0", "10.0.0");
Assert.IsTrue(result1, "10.0.0 should be newer than 9.0.0");
Assert.IsFalse(result2, "2.0.0 should not be newer than 10.0.0");
}
[Test]
public void IsNewerVersion_ReturnsFalse_OnInvalidVersionFormat()
{
// Service should handle errors gracefully
bool result = _service.IsNewerVersion("invalid", "1.0.0");
Assert.IsFalse(result, "Should return false for invalid version format");
}
[Test]
public void CheckForUpdate_ReturnsCachedVersion_WhenCacheIsValid()
{
// Arrange: Set up valid cache
string today = DateTime.Now.ToString("yyyy-MM-dd");
string cachedVersion = "5.5.5";
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid cache");
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached version");
Assert.IsTrue(result.UpdateAvailable, "Update should be available (5.5.5 > 5.0.0)");
}
[Test]
public void CheckForUpdate_DetectsUpdateAvailable_WhenNewerVersionCached()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "6.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.UpdateAvailable, "Should detect update is available");
Assert.AreEqual("6.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenVersionsMatch()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update needed");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenCurrentVersionIsNewer()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("6.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update when current is newer");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_IgnoresExpiredCache_AndAttemptsFreshFetch()
{
// Arrange: Set cache from yesterday (expired)
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
string cachedVersion = "4.0.0";
EditorPrefs.SetString(TestLastCheckDateKey, yesterday);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsNotNull(result, "Should return a result");
// If the check succeeded (network available), verify it didn't use the expired cache
if (result.CheckSucceeded)
{
Assert.AreNotEqual(cachedVersion, result.LatestVersion,
"Should not return expired cached version when fresh fetch succeeds");
Assert.IsNotNull(result.LatestVersion, "Should have fetched a new version");
}
else
{
// If offline, check should fail (not succeed with cached data)
Assert.IsFalse(result.UpdateAvailable,
"Should not report update available when fetch fails and cache is expired");
}
}
[Test]
public void CheckForUpdate_UsesAssetStoreCache_WhenCacheIsValid()
{
// Arrange: Set up valid Asset Store cache
string today = DateTime.Now.ToString("yyyy-MM-dd");
string cachedVersion = "9.0.1";
EditorPrefs.SetString(TestAssetStoreLastCheckDateKey, today);
EditorPrefs.SetString(TestAssetStoreCachedVersionKey, cachedVersion);
var mockService = new TestablePackageUpdateService
{
IsGitInstallationResult = false,
AssetStoreFetchResult = "9.9.9"
};
// Act
var result = mockService.CheckForUpdate("9.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid Asset Store cache");
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached Asset Store version");
Assert.IsTrue(result.UpdateAvailable, "Update should be available (9.0.1 > 9.0.0)");
Assert.IsFalse(mockService.AssetStoreFetchCalled, "Should not fetch when Asset Store cache is valid");
}
[Test]
public void CheckForUpdate_FetchesAssetStoreJson_WhenCacheExpired()
{
// Arrange: Set expired Asset Store cache and a valid Git cache to ensure separation
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestAssetStoreLastCheckDateKey, yesterday);
EditorPrefs.SetString(TestAssetStoreCachedVersionKey, "9.0.0");
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestCachedVersionKey, "99.0.0");
var mockService = new TestablePackageUpdateService
{
IsGitInstallationResult = false,
AssetStoreFetchResult = "9.1.0"
};
// Act
var result = mockService.CheckForUpdate("9.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed when fetch returns a version");
Assert.AreEqual("9.1.0", result.LatestVersion, "Should use fetched Asset Store version");
Assert.IsTrue(mockService.AssetStoreFetchCalled, "Should fetch when Asset Store cache is expired");
}
[Test]
public void CheckForUpdate_ReturnsAssetStoreFailureMessage_WhenFetchFails()
{
// Arrange
var mockService = new TestablePackageUpdateService
{
IsGitInstallationResult = false,
AssetStoreFetchResult = null
};
// Act
var result = mockService.CheckForUpdate("9.0.0");
// Assert
Assert.IsFalse(result.CheckSucceeded, "Check should fail when Asset Store fetch fails");
Assert.IsFalse(result.UpdateAvailable, "No update should be reported when fetch fails");
Assert.AreEqual("Failed to check for Asset Store updates (network issue or offline)", result.Message);
Assert.IsNull(result.LatestVersion, "Latest version should be null when fetch fails");
}
[Test]
public void ClearCache_RemovesAllCachedData()
{
// Arrange: Set up cache
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
EditorPrefs.SetString(TestAssetStoreLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestAssetStoreCachedVersionKey, "9.0.0");
// Verify cache exists
Assert.IsTrue(EditorPrefs.HasKey(TestLastCheckDateKey), "Cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestCachedVersionKey), "Cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestAssetStoreLastCheckDateKey), "Asset Store cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestAssetStoreCachedVersionKey), "Asset Store cache should exist before clearing");
// Act
_service.ClearCache();
// Assert
Assert.IsFalse(EditorPrefs.HasKey(TestLastCheckDateKey), "Date cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestCachedVersionKey), "Version cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestAssetStoreLastCheckDateKey), "Asset Store date cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestAssetStoreCachedVersionKey), "Asset Store version cache should be cleared");
}
[Test]
public void ClearCache_DoesNotThrow_WhenNoCacheExists()
{
// Ensure no cache exists
CleanupEditorPrefs();
// Act & Assert - should not throw
Assert.DoesNotThrow(() => _service.ClearCache(), "Should not throw when clearing non-existent cache");
}
}
/// <summary>
/// Testable implementation that allows forcing install type and fetch results.
/// </summary>
internal class TestablePackageUpdateService : PackageUpdateService
{
public bool IsGitInstallationResult { get; set; } = true;
public string GitFetchResult { get; set; }
public string AssetStoreFetchResult { get; set; }
public bool GitFetchCalled { get; private set; }
public bool AssetStoreFetchCalled { get; private set; }
public override bool IsGitInstallation()
{
return IsGitInstallationResult;
}
protected override string FetchLatestVersionFromGitHub(string branch)
{
GitFetchCalled = true;
return GitFetchResult;
}
protected override string FetchLatestVersionFromAssetStoreJson()
{
AssetStoreFetchCalled = true;
return AssetStoreFetchResult;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 676c3849f71a84b17b14d813774d3f74
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,176 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class PortManagerTests
{
private string _savedPortFileContent;
private string _savedLegacyFileContent;
private string _portFilePath;
private string _legacyFilePath;
[SetUp]
public void SetUp()
{
// Snapshot the on-disk port config so DiscoverNewPort tests don't
// permanently alter the running bridge's persisted port.
string dir = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
".unity-mcp");
_legacyFilePath = Path.Combine(dir, "unity-mcp-port.json");
// The hashed file uses a private helper; approximate the same hash.
// We snapshot every json file in the directory to be safe.
_portFilePath = null;
_savedPortFileContent = null;
_savedLegacyFileContent = null;
if (File.Exists(_legacyFilePath))
_savedLegacyFileContent = File.ReadAllText(_legacyFilePath);
// Find the hashed port file for this project
if (Directory.Exists(dir))
{
foreach (var f in Directory.GetFiles(dir, "unity-mcp-port-*.json"))
{
_portFilePath = f;
_savedPortFileContent = File.ReadAllText(f);
break; // one project at a time
}
}
}
[TearDown]
public void TearDown()
{
// Restore the original port files
if (_savedLegacyFileContent != null && _legacyFilePath != null)
File.WriteAllText(_legacyFilePath, _savedLegacyFileContent);
if (_savedPortFileContent != null && _portFilePath != null)
File.WriteAllText(_portFilePath, _savedPortFileContent);
}
[Test]
public void IsPortAvailable_ReturnsFalse_WhenPortIsOccupied()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
try
{
Assert.IsFalse(PortManager.IsPortAvailable(port),
"IsPortAvailable should return false for a port that is already bound");
}
finally
{
listener.Stop();
}
}
[Test]
public void IsPortAvailable_ReturnsTrue_WhenPortIsFree()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
Assert.IsTrue(PortManager.IsPortAvailable(port),
"IsPortAvailable should return true for a port that is not bound");
}
#if UNITY_EDITOR_OSX
[Test]
public void IsPortAvailable_ReturnsFalse_WhenPortHeldWithReuseAddr()
{
// Simulate what AssetImportWorkers do: bind with SO_REUSEADDR.
// IsPortAvailable must still detect this as occupied.
var holder = new TcpListener(IPAddress.Loopback, 0);
holder.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
holder.Start();
int port = ((IPEndPoint)holder.LocalEndpoint).Port;
try
{
Assert.IsFalse(PortManager.IsPortAvailable(port),
"IsPortAvailable should detect ports held with SO_REUSEADDR on macOS");
}
finally
{
holder.Stop();
}
}
#endif
[Test]
public void ShouldAbandonBusyPort_KeepsSamePort_WithinReleaseWindow()
{
// A port busy for less than the fallback window is treated as our own
// not-yet-released listener after a domain reload — keep retrying the same
// port instead of silently switching and stranding the client (#1173).
Assert.IsFalse(PortManager.ShouldAbandonBusyPort(0.0));
Assert.IsFalse(PortManager.ShouldAbandonBusyPort(
PortManager.BusyPortFallbackWindowSeconds - 0.5));
}
[Test]
public void ShouldAbandonBusyPort_FallsBack_AfterReleaseWindow()
{
// A port that stays busy past the window is a foreign occupant — only then
// does the bridge discover and switch to a new port.
Assert.IsTrue(PortManager.ShouldAbandonBusyPort(
PortManager.BusyPortFallbackWindowSeconds));
Assert.IsTrue(PortManager.ShouldAbandonBusyPort(
PortManager.BusyPortFallbackWindowSeconds + 5.0));
}
[Test]
public void DiscoverNewPort_ReturnsAvailablePort()
{
int port = PortManager.DiscoverNewPort();
Assert.Greater(port, 0, "DiscoverNewPort should return a positive port number");
Assert.IsTrue(PortManager.IsPortAvailable(port),
"The port returned by DiscoverNewPort should be available");
}
[Test]
public void DiscoverNewPort_SkipsOccupiedDefaultPort()
{
// Hold the default port (6400) so DiscoverNewPort must find an alternative
TcpListener holder = null;
try
{
holder = new TcpListener(IPAddress.Loopback, 6400);
#if UNITY_EDITOR_OSX
try { holder.Server.ExclusiveAddressUse = true; } catch { }
#endif
holder.Start();
}
catch (SocketException)
{
// Port 6400 already occupied (e.g., by the running bridge) — that's fine,
// the test still validates that DiscoverNewPort picks a different port.
holder = null;
}
try
{
int port = PortManager.DiscoverNewPort();
Assert.AreNotEqual(6400, port,
"DiscoverNewPort should not return the default port when it is occupied");
Assert.Greater(port, 0);
}
finally
{
holder?.Stop();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a04ddec79871644e991cf2ab91dc9c3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9b4824e10efbf41ef98eb4e03e39fc6f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,494 @@
using System.IO;
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for PidFileManager component.
/// </summary>
[TestFixture]
public class PidFileManagerTests
{
private PidFileManager _manager;
private string _testPidFilePath;
[SetUp]
public void SetUp()
{
_manager = new PidFileManager();
// Clear any test state
ClearTestEditorPrefs();
}
[TearDown]
public void TearDown()
{
// Clean up test files
if (!string.IsNullOrEmpty(_testPidFilePath) && File.Exists(_testPidFilePath))
{
try { File.Delete(_testPidFilePath); } catch { }
}
// Clear test state
ClearTestEditorPrefs();
}
private void ClearTestEditorPrefs()
{
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { }
}
#region GetPidFilePath Tests
[Test]
public void GetPidFilePath_ValidPort_ReturnsCorrectPath()
{
// Act
string path = _manager.GetPidFilePath(8080);
// Assert
Assert.IsNotNull(path);
Assert.That(path, Does.Contain("mcp_http_8080.pid"));
Assert.That(path, Does.Contain("MCPForUnity"));
}
[Test]
public void GetPidFilePath_DifferentPorts_ReturnsDifferentPaths()
{
// Act
string path1 = _manager.GetPidFilePath(8080);
string path2 = _manager.GetPidFilePath(9090);
// Assert
Assert.AreNotEqual(path1, path2);
}
[Test]
public void GetPidDirectory_ReturnsValidPath()
{
// Act
string dir = _manager.GetPidDirectory();
// Assert
Assert.IsNotNull(dir);
Assert.That(dir, Does.Contain("MCPForUnity"));
Assert.That(dir, Does.Contain("RunState"));
}
#endregion
#region TryReadPid Tests
[Test]
public void TryReadPid_ValidFile_ReturnsTrueWithPid()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59998);
File.WriteAllText(_testPidFilePath, "12345");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(12345, pid);
}
[Test]
public void TryReadPid_FileWithWhitespace_ParsesCorrectly()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59997);
File.WriteAllText(_testPidFilePath, " 12345 \n");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(12345, pid);
}
[Test]
public void TryReadPid_MissingFile_ReturnsFalse()
{
// Act
bool result = _manager.TryReadPid("/nonexistent/path/file.pid", out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_NullPath_ReturnsFalse()
{
// Act
bool result = _manager.TryReadPid(null, out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_EmptyPath_ReturnsFalse()
{
// Act
bool result = _manager.TryReadPid(string.Empty, out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_InvalidContent_ReturnsFalse()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59996);
File.WriteAllText(_testPidFilePath, "not a number");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_ZeroPid_ReturnsFalse()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59995);
File.WriteAllText(_testPidFilePath, "0");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsFalse(result, "Zero PID should be rejected");
}
[Test]
public void TryReadPid_NegativePid_ReturnsFalse()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59994);
File.WriteAllText(_testPidFilePath, "-1");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsFalse(result, "Negative PID should be rejected");
}
#endregion
#region TryGetPortFromPidFilePath Tests
[Test]
public void TryGetPortFromPidFilePath_ValidPath_ReturnsTrue()
{
// Arrange
string path = "/some/path/mcp_http_8080.pid";
// Act
bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(8080, port);
}
[Test]
public void TryGetPortFromPidFilePath_DifferentPort_ParsesCorrectly()
{
// Arrange
string path = "/path/to/mcp_http_9999.pid";
// Act
bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(9999, port);
}
[Test]
public void TryGetPortFromPidFilePath_NullPath_ReturnsFalse()
{
// Act
bool result = _manager.TryGetPortFromPidFilePath(null, out int port);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, port);
}
[Test]
public void TryGetPortFromPidFilePath_InvalidPrefix_ReturnsFalse()
{
// Arrange
string path = "/some/path/wrong_prefix_8080.pid";
// Act
bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
// Assert
Assert.IsFalse(result);
}
#endregion
#region Handshake Tests
[Test]
public void StoreHandshake_ValidData_StoresInEditorPrefs()
{
// Arrange
string pidFilePath = "/test/path.pid";
string instanceToken = "test-token-123";
// Act
_manager.StoreHandshake(pidFilePath, instanceToken);
bool result = _manager.TryGetHandshake(out var storedPath, out var storedToken);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(pidFilePath, storedPath);
Assert.AreEqual(instanceToken, storedToken);
}
[Test]
public void TryGetHandshake_NoHandshake_ReturnsFalse()
{
// Act
bool result = _manager.TryGetHandshake(out var pidFilePath, out var instanceToken);
// Assert
Assert.IsFalse(result);
Assert.IsNull(pidFilePath);
Assert.IsNull(instanceToken);
}
[Test]
public void StoreHandshake_NullValues_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_manager.StoreHandshake(null, null);
});
}
#endregion
#region Tracking Tests
[Test]
public void StoreTracking_ValidData_CanBeRetrieved()
{
// Arrange
int pid = 12345;
int port = 8080;
// Act
_manager.StoreTracking(pid, port);
bool result = _manager.TryGetStoredPid(port, out int storedPid);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(pid, storedPid);
}
[Test]
public void TryGetStoredPid_WrongPort_ReturnsFalse()
{
// Arrange
_manager.StoreTracking(12345, 8080);
// Act
bool result = _manager.TryGetStoredPid(9090, out int storedPid);
// Assert
Assert.IsFalse(result, "Should return false for wrong port");
}
[Test]
public void TryGetStoredPid_NoTracking_ReturnsFalse()
{
// Act
bool result = _manager.TryGetStoredPid(8080, out int storedPid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, storedPid);
}
[Test]
public void ClearTracking_RemovesAllKeys()
{
// Arrange
_manager.StoreTracking(12345, 8080, "somehash");
_manager.StoreHandshake("/path.pid", "token");
// Act
_manager.ClearTracking();
bool hasTracking = _manager.TryGetStoredPid(8080, out _);
bool hasHandshake = _manager.TryGetHandshake(out _, out _);
// Assert
Assert.IsFalse(hasTracking);
Assert.IsFalse(hasHandshake);
}
[Test]
public void GetStoredArgsHash_WithHash_ReturnsHash()
{
// Arrange
_manager.StoreTracking(12345, 8080, "testhash123");
// Act
string hash = _manager.GetStoredArgsHash();
// Assert
Assert.AreEqual("testhash123", hash);
}
[Test]
public void GetStoredArgsHash_NoHash_ReturnsEmpty()
{
// Act
string hash = _manager.GetStoredArgsHash();
// Assert
Assert.AreEqual(string.Empty, hash);
}
#endregion
#region ComputeShortHash Tests
[Test]
public void ComputeShortHash_ValidInput_Returns16CharHash()
{
// Arrange
string input = "test input string";
// Act
string hash = _manager.ComputeShortHash(input);
// Assert
Assert.IsNotNull(hash);
Assert.AreEqual(16, hash.Length);
}
[Test]
public void ComputeShortHash_SameInput_ReturnsSameHash()
{
// Arrange
string input = "consistent input";
// Act
string hash1 = _manager.ComputeShortHash(input);
string hash2 = _manager.ComputeShortHash(input);
// Assert
Assert.AreEqual(hash1, hash2);
}
[Test]
public void ComputeShortHash_DifferentInput_ReturnsDifferentHash()
{
// Act
string hash1 = _manager.ComputeShortHash("input1");
string hash2 = _manager.ComputeShortHash("input2");
// Assert
Assert.AreNotEqual(hash1, hash2);
}
[Test]
public void ComputeShortHash_NullInput_ReturnsEmpty()
{
// Act
string hash = _manager.ComputeShortHash(null);
// Assert
Assert.AreEqual(string.Empty, hash);
}
[Test]
public void ComputeShortHash_EmptyInput_ReturnsEmpty()
{
// Act
string hash = _manager.ComputeShortHash(string.Empty);
// Assert
Assert.AreEqual(string.Empty, hash);
}
#endregion
#region DeletePidFile Tests
[Test]
public void DeletePidFile_ExistingFile_DeletesFile()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59993);
File.WriteAllText(_testPidFilePath, "12345");
Assert.IsTrue(File.Exists(_testPidFilePath));
// Act
_manager.DeletePidFile(_testPidFilePath);
// Assert
Assert.IsFalse(File.Exists(_testPidFilePath));
}
[Test]
public void DeletePidFile_NonExistentFile_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_manager.DeletePidFile("/nonexistent/file.pid");
});
}
[Test]
public void DeletePidFile_NullPath_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_manager.DeletePidFile(null);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void PidFileManager_ImplementsIPidFileManager()
{
// Assert
Assert.IsInstanceOf<IPidFileManager>(_manager);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 875ef370082bc42d182a9875ee7c5e15
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,364 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for ProcessDetector component.
/// These tests execute subprocess commands (ps, lsof, tasklist, wmic) which can be slow.
/// Marked as [Explicit] to exclude from normal test runs.
/// </summary>
[TestFixture]
[Explicit]
public class ProcessDetectorTests
{
private ProcessDetector _detector;
[SetUp]
public void SetUp()
{
_detector = new ProcessDetector();
}
#region NormalizeForMatch Tests
[Test]
public void NormalizeForMatch_RemovesWhitespace()
{
// Arrange
string input = "Hello World";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("helloworld", result);
}
[Test]
public void NormalizeForMatch_LowercasesInput()
{
// Arrange
string input = "UPPERCASE";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("uppercase", result);
}
[Test]
public void NormalizeForMatch_HandlesNull()
{
// Act
string result = _detector.NormalizeForMatch(null);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void NormalizeForMatch_HandlesEmptyString()
{
// Act
string result = _detector.NormalizeForMatch(string.Empty);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void NormalizeForMatch_RemovesTabs()
{
// Arrange
string input = "hello\tworld";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("helloworld", result);
}
[Test]
public void NormalizeForMatch_RemovesNewlines()
{
// Arrange
string input = "hello\nworld\r\ntest";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("helloworldtest", result);
}
[Test]
public void NormalizeForMatch_PreservesNonWhitespace()
{
// Arrange
string input = "mcp-for-unity_test123";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("mcp-for-unity_test123", result);
}
#endregion
#region GetCurrentProcessId Tests
[Test]
public void GetCurrentProcessId_ReturnsPositiveInt()
{
// Act
int pid = _detector.GetCurrentProcessId();
// Assert
Assert.Greater(pid, 0, "Process ID should be positive");
}
[Test]
public void GetCurrentProcessId_ReturnsConsistentValue()
{
// Act
int pid1 = _detector.GetCurrentProcessId();
int pid2 = _detector.GetCurrentProcessId();
// Assert
Assert.AreEqual(pid1, pid2, "Process ID should be consistent across calls");
}
#endregion
#region ProcessExists Tests
[Test]
public void ProcessExists_CurrentProcess_ReturnsTrue()
{
// Arrange
int currentPid = _detector.GetCurrentProcessId();
// Act
bool exists = _detector.ProcessExists(currentPid);
// Assert
Assert.IsTrue(exists, "Current process should exist");
}
[Test]
public void ProcessExists_InvalidPid_ReturnsFalseOrHandlesGracefully()
{
// Act - Use a very high PID unlikely to exist
bool exists = _detector.ProcessExists(9999999);
// Assert - Should not throw, may return false or true (assumes exists if cannot verify)
Assert.Pass($"ProcessExists returned {exists} for invalid PID (handles gracefully)");
}
[Test]
public void ProcessExists_ZeroPid_HandlesGracefully()
{
// Act & Assert - Should not throw
Assert.DoesNotThrow(() =>
{
_detector.ProcessExists(0);
});
}
[Test]
public void ProcessExists_NegativePid_HandlesGracefully()
{
// Act & Assert - Should not throw
Assert.DoesNotThrow(() =>
{
_detector.ProcessExists(-1);
});
}
#endregion
#region GetListeningProcessIdsForPort Tests
[Test]
public void GetListeningProcessIdsForPort_InvalidPort_ReturnsEmpty()
{
// Act
var pids = _detector.GetListeningProcessIdsForPort(-1);
// Assert
Assert.IsNotNull(pids);
Assert.IsEmpty(pids, "Invalid port should return empty list");
}
[Test]
public void GetListeningProcessIdsForPort_UnusedPort_ReturnsEmpty()
{
// Act - Use a port that's unlikely to be in use
var pids = _detector.GetListeningProcessIdsForPort(59999);
// Assert
Assert.IsNotNull(pids);
Assert.IsEmpty(pids, "Unused port should return empty list");
}
[Test]
public void GetListeningProcessIdsForPort_ReturnsDistinctPids()
{
// Act
var pids = _detector.GetListeningProcessIdsForPort(80);
// Assert
Assert.IsNotNull(pids);
CollectionAssert.AllItemsAreUnique(pids, "PIDs should be distinct");
}
[Test]
public void GetListeningProcessIdsForPort_DoesNotThrow()
{
// Act & Assert - Should handle any port gracefully
Assert.DoesNotThrow(() =>
{
_detector.GetListeningProcessIdsForPort(8080);
});
}
#endregion
#region TryGetProcessCommandLine Tests
[Test]
public void TryGetProcessCommandLine_CurrentProcess_ReturnsResult()
{
// Arrange
int currentPid = _detector.GetCurrentProcessId();
// Act
bool result = _detector.TryGetProcessCommandLine(currentPid, out string argsLower);
// Assert - Platform dependent, but should not throw
Assert.Pass($"TryGetProcessCommandLine: success={result}, argsLower length={argsLower?.Length ?? 0}");
}
[Test]
public void TryGetProcessCommandLine_InvalidPid_ReturnsFalse()
{
// Act
bool result = _detector.TryGetProcessCommandLine(9999999, out string argsLower);
// Assert
Assert.IsFalse(result, "Invalid PID should return false");
Assert.IsEmpty(argsLower, "Args should be empty for invalid PID");
}
[Test]
public void TryGetProcessCommandLine_ReturnsNormalizedOutput()
{
// Arrange
int currentPid = _detector.GetCurrentProcessId();
// Act
bool result = _detector.TryGetProcessCommandLine(currentPid, out string argsLower);
// Assert
if (result && !string.IsNullOrEmpty(argsLower))
{
// Verify output is normalized (no whitespace, lowercase)
Assert.IsFalse(argsLower.Contains(" "), "Output should have no spaces");
Assert.AreEqual(argsLower, argsLower.ToLowerInvariant(), "Output should be lowercase");
}
Assert.Pass("Command line is properly normalized");
}
#endregion
#region LooksLikeMcpServerProcess Tests
[Test]
public void LooksLikeMcpServerProcess_CurrentProcess_ReturnsFalse()
{
// Arrange - Unity Editor process should not be an MCP server
int currentPid = _detector.GetCurrentProcessId();
// Act
bool result = _detector.LooksLikeMcpServerProcess(currentPid);
// Assert
Assert.IsFalse(result, "Unity Editor should not be identified as MCP server");
}
[Test]
public void LooksLikeMcpServerProcess_InvalidPid_ReturnsFalse()
{
// Act
bool result = _detector.LooksLikeMcpServerProcess(9999999);
// Assert
Assert.IsFalse(result, "Invalid PID should return false");
}
[Test]
public void LooksLikeMcpServerProcess_ZeroPid_ReturnsFalse()
{
// Act
bool result = _detector.LooksLikeMcpServerProcess(0);
// Assert
Assert.IsFalse(result, "Zero PID should return false");
}
[Test]
public void LooksLikeMcpServerProcess_NegativePid_ReturnsFalse()
{
// Act
bool result = _detector.LooksLikeMcpServerProcess(-1);
// Assert
Assert.IsFalse(result, "Negative PID should return false");
}
[Test]
public void LooksLikeMcpServerProcess_DoesNotThrow()
{
// Act & Assert - Should handle any PID gracefully
Assert.DoesNotThrow(() =>
{
_detector.LooksLikeMcpServerProcess(12345);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void ProcessDetector_ImplementsIProcessDetector()
{
// Assert
Assert.IsInstanceOf<IProcessDetector>(_detector);
}
[Test]
public void ProcessDetector_CanBeUsedViaInterface()
{
// Arrange
IProcessDetector detector = new ProcessDetector();
// Act & Assert - All interface methods should work
Assert.DoesNotThrow(() =>
{
detector.NormalizeForMatch("test");
detector.GetCurrentProcessId();
detector.ProcessExists(1);
detector.GetListeningProcessIdsForPort(8080);
detector.TryGetProcessCommandLine(1, out _);
detector.LooksLikeMcpServerProcess(1);
});
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 88e47e156fb6a4064a2d638cf8bb8d52
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,159 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for ProcessTerminator component.
/// Note: Most tests avoid actually terminating processes to prevent test instability.
/// Uses ProcessDetector which executes subprocess commands (ps, tasklist, etc.), so marked as [Explicit].
/// </summary>
[TestFixture]
[Explicit]
public class ProcessTerminatorTests
{
private ProcessTerminator _terminator;
private ProcessDetector _detector;
[SetUp]
public void SetUp()
{
_detector = new ProcessDetector();
_terminator = new ProcessTerminator(_detector);
}
#region Constructor Tests
[Test]
public void Constructor_NullDetector_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<System.ArgumentNullException>(() =>
{
new ProcessTerminator(null);
});
}
[Test]
public void Constructor_ValidDetector_Succeeds()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
new ProcessTerminator(_detector);
});
}
#endregion
#region Terminate Tests
[Test]
public void Terminate_InvalidPid_ReturnsFalse()
{
// Act
bool result = _terminator.Terminate(-1);
// Assert
Assert.IsFalse(result, "Invalid PID should fail to terminate");
}
[Test]
public void Terminate_ZeroPid_ReturnsFalse()
{
// Act
bool result = _terminator.Terminate(0);
// Assert
Assert.IsFalse(result, "Zero PID should fail to terminate");
}
[Test]
public void Terminate_Pid1_ReturnsFalse()
{
// PID 1 is init/launchd and must never be killed
// Act
bool result = _terminator.Terminate(1);
// Assert
Assert.IsFalse(result, "PID 1 (init/launchd) should never be terminated");
}
[Test]
public void Terminate_CurrentProcessPid_ReturnsFalse()
{
// Should never kill the Unity Editor process
// Act
int currentPid = _detector.GetCurrentProcessId();
bool result = _terminator.Terminate(currentPid);
// Assert
Assert.IsFalse(result, "Current process PID should never be terminated");
}
[Test]
public void Terminate_NonExistentPid_ReturnsFalseOrHandlesGracefully()
{
// Act - Use a very high PID unlikely to exist
bool result = _terminator.Terminate(9999999);
// Assert - Should not terminate non-existent PID
Assert.IsFalse(result, $"Terminate returned {result} for non-existent PID");
}
[Test]
public void Terminate_DoesNotThrow()
{
// Act & Assert - Should handle any PID gracefully
Assert.DoesNotThrow(() =>
{
_terminator.Terminate(int.MaxValue);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void ProcessTerminator_ImplementsIProcessTerminator()
{
// Assert
Assert.IsInstanceOf<IProcessTerminator>(_terminator);
}
[Test]
public void ProcessTerminator_CanBeUsedViaInterface()
{
// Arrange
IProcessTerminator terminator = new ProcessTerminator(_detector);
// Act & Assert - Should be callable via interface
Assert.DoesNotThrow(() =>
{
// Don't actually terminate anything
terminator.Terminate(-1);
});
}
#endregion
#region Integration Tests (with real detector)
[Test]
public void Terminate_WithRealDetector_HandlesMissingProcess()
{
// Arrange
var realDetector = new ProcessDetector();
var terminator = new ProcessTerminator(realDetector);
// Act - Try to terminate a PID that definitely doesn't exist
bool result = terminator.Terminate(int.MaxValue);
// Assert - Should return false without throwing
Assert.IsFalse(result, "Terminating non-existent process should return false");
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 48fe9f588d61e46319110ad1c09d0aea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,325 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for ServerCommandBuilder component.
/// </summary>
[TestFixture]
public class ServerCommandBuilderTests
{
private ServerCommandBuilder _builder;
private bool _savedUseHttpTransport;
private string _savedHttpUrl;
[SetUp]
public void SetUp()
{
_builder = new ServerCommandBuilder();
// Save current settings
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedHttpUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
}
[TearDown]
public void TearDown()
{
// Restore settings
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
if (!string.IsNullOrEmpty(_savedHttpUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, _savedHttpUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpBaseUrl);
}
// Refresh cache to reflect restored values
EditorConfigurationCache.Instance.Refresh();
}
#region QuoteIfNeeded Tests
[Test]
public void QuoteIfNeeded_PathWithSpaces_AddsQuotes()
{
// Arrange
string input = "path with spaces";
// Act
string result = _builder.QuoteIfNeeded(input);
// Assert
Assert.AreEqual("\"path with spaces\"", result);
}
[Test]
public void QuoteIfNeeded_PathWithoutSpaces_NoChange()
{
// Arrange
string input = "pathwithoutspaces";
// Act
string result = _builder.QuoteIfNeeded(input);
// Assert
Assert.AreEqual("pathwithoutspaces", result);
}
[Test]
public void QuoteIfNeeded_NullInput_ReturnsNull()
{
// Act
string result = _builder.QuoteIfNeeded(null);
// Assert
Assert.IsNull(result);
}
[Test]
public void QuoteIfNeeded_EmptyInput_ReturnsEmpty()
{
// Act
string result = _builder.QuoteIfNeeded(string.Empty);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void QuoteIfNeeded_AlreadyQuoted_AddsMoreQuotes()
{
// Arrange - This is intentional behavior - don't double-escape
string input = "\"already quoted\"";
// Act
string result = _builder.QuoteIfNeeded(input);
// Assert - Has spaces so gets quoted
Assert.AreEqual("\"\"already quoted\"\"", result);
}
#endregion
#region BuildUvPathFromUvx Tests
[Test]
public void BuildUvPathFromUvx_ValidPath_ConvertsCorrectly()
{
// This test uses Unix-style paths which only work correctly on non-Windows
if (UnityEngine.Application.platform == UnityEngine.RuntimePlatform.WindowsEditor)
{
Assert.Pass("Skipped on Windows - use BuildUvPathFromUvx_WindowsPath_ConvertsCorrectly instead");
return;
}
// Arrange
string uvxPath = "/usr/local/bin/uvx";
// Act
string result = _builder.BuildUvPathFromUvx(uvxPath);
// Assert
Assert.AreEqual("/usr/local/bin/uv", result);
}
[Test]
public void BuildUvPathFromUvx_WindowsPath_ConvertsCorrectly()
{
// This test only makes sense on Windows where backslash paths are native
if (UnityEngine.Application.platform != UnityEngine.RuntimePlatform.WindowsEditor)
{
Assert.Pass("Skipped on non-Windows platform");
return;
}
// Arrange
string uvxPath = @"C:\Program Files\uv\uvx.exe";
// Act
string result = _builder.BuildUvPathFromUvx(uvxPath);
// Assert
Assert.AreEqual(@"C:\Program Files\uv\uv.exe", result);
}
[Test]
public void BuildUvPathFromUvx_NullPath_ReturnsNull()
{
// Act
string result = _builder.BuildUvPathFromUvx(null);
// Assert
Assert.IsNull(result);
}
[Test]
public void BuildUvPathFromUvx_EmptyPath_ReturnsEmpty()
{
// Act
string result = _builder.BuildUvPathFromUvx(string.Empty);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void BuildUvPathFromUvx_WhitespacePath_ReturnsWhitespace()
{
// Act
string result = _builder.BuildUvPathFromUvx(" ");
// Assert
Assert.AreEqual(" ", result);
}
[Test]
public void BuildUvPathFromUvx_JustFilename_ConvertsCorrectly()
{
// Arrange
string uvxPath = "uvx";
// Act
string result = _builder.BuildUvPathFromUvx(uvxPath);
// Assert
Assert.AreEqual("uv", result);
}
#endregion
#region GetPlatformSpecificPathPrepend Tests
[Test]
public void GetPlatformSpecificPathPrepend_ReturnsNonNull()
{
// Act
string result = _builder.GetPlatformSpecificPathPrepend();
// Assert - May be null on some platforms, but should not throw
Assert.Pass($"GetPlatformSpecificPathPrepend returned: {result ?? "null"}");
}
[Test]
public void GetPlatformSpecificPathPrepend_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_builder.GetPlatformSpecificPathPrepend();
});
}
#endregion
#region TryBuildCommand Tests
[Test]
public void TryBuildCommand_HttpDisabled_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
// Act
bool result = _builder.TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
// Assert
Assert.IsFalse(result);
Assert.IsNull(fileName);
Assert.IsNull(arguments);
Assert.IsNull(displayCommand);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("HTTP").IgnoreCase);
}
[Test]
public void TryBuildCommand_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
// Act
bool result = _builder.TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
// Assert
Assert.IsFalse(result);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("local").IgnoreCase);
}
[Test]
public void TryBuildCommand_LocalUrl_ReturnsCommandOrError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
// Act
bool result = _builder.TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
// Assert - Success depends on uvx availability
if (result)
{
Assert.IsNotNull(fileName, "fileName should be set on success");
Assert.IsNotNull(arguments, "arguments should be set on success");
Assert.IsNotNull(displayCommand, "displayCommand should be set on success");
Assert.IsNull(error, "error should be null on success");
Assert.That(displayCommand, Does.Contain("uvx").Or.Contain("uv"));
}
else
{
Assert.IsNotNull(error, "error message should be provided on failure");
}
Assert.Pass($"TryBuildCommand: success={result}, error={error ?? "null"}");
}
[Test]
public void TryBuildCommand_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_builder.TryBuildCommand(out _, out _, out _, out _);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void ServerCommandBuilder_ImplementsIServerCommandBuilder()
{
// Assert
Assert.IsInstanceOf<IServerCommandBuilder>(_builder);
}
[Test]
public void ServerCommandBuilder_CanBeUsedViaInterface()
{
// Arrange
IServerCommandBuilder builder = new ServerCommandBuilder();
// Act & Assert - All interface methods should work
Assert.DoesNotThrow(() =>
{
builder.QuoteIfNeeded("test");
builder.BuildUvPathFromUvx("uvx");
builder.GetPlatformSpecificPathPrepend();
builder.TryBuildCommand(out _, out _, out _, out _);
});
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5506606af081a4eaca6a5563e1a4b0dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,304 @@
using System;
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for TerminalLauncher component.
/// Note: Tests avoid actually launching terminals to prevent test instability.
/// </summary>
[TestFixture]
public class TerminalLauncherTests
{
private TerminalLauncher _launcher;
[SetUp]
public void SetUp()
{
_launcher = new TerminalLauncher();
}
#region GetProjectRootPath Tests
[Test]
public void GetProjectRootPath_ReturnsNonEmpty()
{
// Act
string path = _launcher.GetProjectRootPath();
// Assert
Assert.IsNotNull(path);
Assert.IsNotEmpty(path);
}
[Test]
public void GetProjectRootPath_ReturnsValidDirectory()
{
// Act
string path = _launcher.GetProjectRootPath();
// Assert
Assert.IsTrue(System.IO.Directory.Exists(path), $"Project root path should exist: {path}");
}
[Test]
public void GetProjectRootPath_DoesNotContainAssets()
{
// Act
string path = _launcher.GetProjectRootPath();
// Assert
Assert.IsFalse(path.EndsWith("Assets"), "Project root should not end with Assets");
}
#endregion
#region CreateTerminalProcessStartInfo Tests
[Test]
public void CreateTerminalProcessStartInfo_EmptyCommand_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
{
_launcher.CreateTerminalProcessStartInfo(string.Empty);
});
}
[Test]
public void CreateTerminalProcessStartInfo_NullCommand_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
{
_launcher.CreateTerminalProcessStartInfo(null);
});
}
[Test]
public void CreateTerminalProcessStartInfo_WhitespaceCommand_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
{
_launcher.CreateTerminalProcessStartInfo(" ");
});
}
[Test]
public void CreateTerminalProcessStartInfo_ValidCommand_ReturnsStartInfo()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo hello");
// Assert
Assert.IsNotNull(startInfo);
Assert.IsNotNull(startInfo.FileName);
Assert.IsNotEmpty(startInfo.FileName);
}
[Test]
public void CreateTerminalProcessStartInfo_ValidCommand_SetsUseShellExecuteFalse()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo hello");
// Assert
Assert.IsFalse(startInfo.UseShellExecute, "UseShellExecute should be false");
}
[Test]
public void CreateTerminalProcessStartInfo_ValidCommand_SetsCreateNoWindowTrue()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo hello");
// Assert
Assert.IsTrue(startInfo.CreateNoWindow, "CreateNoWindow should be true");
}
[Test]
public void CreateTerminalProcessStartInfo_CommandWithNewlines_StripsNewlines()
{
// Act - Should not throw
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo\nhello\r\nworld");
// Assert
Assert.IsNotNull(startInfo);
}
[Test]
public void CreateTerminalProcessStartInfo_LongCommand_HandlesGracefully()
{
// Arrange
string longCommand = new string('a', 1000);
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo(longCommand);
// Assert
Assert.IsNotNull(startInfo);
}
[Test]
public void CreateTerminalProcessStartInfo_SpecialCharacters_HandlesGracefully()
{
// Arrange
string command = "echo \"hello world\" && echo 'test' | cat";
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo(command);
// Assert
Assert.IsNotNull(startInfo);
}
#endregion
#region CreateHeadlessProcessStartInfo Tests
[Test]
public void CreateHeadlessProcessStartInfo_EmptyCommand_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
_launcher.CreateHeadlessProcessStartInfo(string.Empty, "/tmp/log.txt"));
}
[Test]
public void CreateHeadlessProcessStartInfo_EmptyLogPath_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
_launcher.CreateHeadlessProcessStartInfo("echo hello", string.Empty));
}
[Test]
public void CreateHeadlessProcessStartInfo_IsHiddenNoWindow()
{
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo hello", LogPath());
Assert.IsFalse(startInfo.UseShellExecute, "UseShellExecute should be false for headless launch");
Assert.IsTrue(startInfo.CreateNoWindow, "CreateNoWindow should be true for headless launch");
Assert.AreEqual(System.Diagnostics.ProcessWindowStyle.Hidden, startInfo.WindowStyle,
"WindowStyle should be Hidden for headless launch");
}
[Test]
public void CreateHeadlessProcessStartInfo_DoesNotOpenTerminal()
{
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo hello", LogPath());
// Must NOT route through Terminal.app / start / a terminal emulator.
#if UNITY_EDITOR_WIN
Assert.AreEqual("cmd.exe", startInfo.FileName, "Windows headless should run via cmd.exe");
StringAssert.DoesNotContain("start ", startInfo.Arguments, "Windows headless must not use 'start' (new window)");
#else
Assert.AreEqual("/bin/bash", startInfo.FileName, "macOS/Linux headless should run via /bin/bash");
StringAssert.DoesNotContain("open", startInfo.Arguments, "macOS headless must not use 'open -a Terminal'");
StringAssert.DoesNotContain("Terminal", startInfo.Arguments, "macOS headless must not open Terminal.app");
#endif
}
[Test]
public void CreateHeadlessProcessStartInfo_RedirectsOutputToLogFile()
{
string logPath = LogPath();
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo hello", logPath);
// The redirect to the log file is part of the shell payload.
StringAssert.Contains(logPath, startInfo.Arguments, "Arguments should reference the log file path");
StringAssert.Contains("2>&1", startInfo.Arguments, "stderr should be redirected to stdout (and the log)");
StringAssert.Contains(">>", startInfo.Arguments, "output should be appended to the log via >>");
}
[Test]
public void CreateHeadlessProcessStartInfo_LogPathWithSpaces_IsQuoted()
{
// A log path containing spaces must remain a single token.
string logPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Mcp Logs", "server launch.log");
var startInfo = _launcher.CreateHeadlessProcessStartInfo("uvx run-server", logPath);
StringAssert.Contains(logPath, startInfo.Arguments, "Arguments should contain the full spaced log path");
#if UNITY_EDITOR_WIN
StringAssert.Contains($"\"{logPath}\"", startInfo.Arguments, "Windows should double-quote a spaced log path");
#else
StringAssert.Contains($"'{logPath}'", startInfo.Arguments, "macOS/Linux should single-quote a spaced log path");
#endif
}
[Test]
public void CreateHeadlessProcessStartInfo_CommandWithSpaces_Preserved()
{
string command = "/path with spaces/uvx --no-cache run mcp-for-unity";
var startInfo = _launcher.CreateHeadlessProcessStartInfo(command, LogPath());
StringAssert.Contains(command, startInfo.Arguments, "The command (incl. spaces) should be preserved in Arguments");
}
[Test]
public void CreateHeadlessProcessStartInfo_StripsNewlines()
{
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo\nhello\r\nworld", LogPath());
Assert.IsNotNull(startInfo);
StringAssert.DoesNotContain("\n", startInfo.Arguments);
StringAssert.DoesNotContain("\r", startInfo.Arguments);
}
private static string LogPath()
{
return System.IO.Path.Combine(System.IO.Path.GetTempPath(), "mcp-headless-test.log");
}
#endregion
#region Interface Implementation Tests
[Test]
public void TerminalLauncher_ImplementsITerminalLauncher()
{
// Assert
Assert.IsInstanceOf<ITerminalLauncher>(_launcher);
}
[Test]
public void TerminalLauncher_CanBeUsedViaInterface()
{
// Arrange
ITerminalLauncher launcher = new TerminalLauncher();
// Act & Assert
Assert.DoesNotThrow(() =>
{
launcher.GetProjectRootPath();
launcher.CreateTerminalProcessStartInfo("test");
});
}
#endregion
#region Platform-Specific Behavior Tests
[Test]
public void CreateTerminalProcessStartInfo_ReturnsAppropriateTerminal()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo test");
// Assert - Platform-specific
#if UNITY_EDITOR_OSX
Assert.AreEqual("/usr/bin/open", startInfo.FileName, "macOS should use 'open'");
#elif UNITY_EDITOR_WIN
Assert.AreEqual("cmd.exe", startInfo.FileName, "Windows should use 'cmd.exe'");
#else
// Linux uses detected terminal
Assert.IsNotNull(startInfo.FileName, "Linux should have a terminal command");
#endif
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59d7c435f60554a719ddc9cd85f6ad3c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using System.Reflection;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class StartupConfigRewriteTests
{
[Test]
public void StartupConfigRewrite_TypeExists()
{
var t = System.Type.GetType("MCPForUnity.Editor.Services.StartupConfigRewrite, MCPForUnity.Editor");
Assert.IsNotNull(t, "StartupConfigRewrite type must exist");
Assert.IsTrue(t.IsPublic, "StartupConfigRewrite must be public so the [InitializeOnLoad] attribute fires");
}
[Test]
public void StartupConfigRewrite_HasInitializeOnLoad()
{
var t = System.Type.GetType("MCPForUnity.Editor.Services.StartupConfigRewrite, MCPForUnity.Editor");
Assert.IsNotNull(t);
object[] attrs = t.GetCustomAttributes(typeof(InitializeOnLoadAttribute), inherit: false);
Assert.AreEqual(1, attrs.Length, "Class must be decorated with [InitializeOnLoad]");
}
[Test]
public void StartupConfigRewrite_RunOncePerSession_GuardKey()
{
var t = System.Type.GetType("MCPForUnity.Editor.Services.StartupConfigRewrite, MCPForUnity.Editor");
var keyField = t?.GetField("SESSION_GUARD_KEY",
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
Assert.IsNotNull(keyField);
string val = (string)keyField.GetValue(null);
StringAssert.StartsWith("MCPForUnity.", val);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca5aeabe283e473ea5ae78cf60ee55f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,234 @@
using System;
using System.Collections;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Tests that StdioBridgeHost correctly handles client reconnection scenarios.
/// After an abrupt client disconnect, a new client must be able to connect and
/// have its commands processed — this was broken by the zombie state bug (#785).
/// </summary>
[TestFixture]
public class StdioBridgeReconnectTests
{
private const int ConnectTimeoutMs = 5000;
private const int ReadTimeoutMs = 10000;
[UnityTest]
public IEnumerator NewClient_AfterAbruptDisconnect_CanSendAndReceiveCommands()
{
if (!StdioBridgeHost.IsRunning)
{
Assert.Ignore("StdioBridgeHost is not running; skipping reconnect test.");
yield break;
}
int port = StdioBridgeHost.GetCurrentPort();
// --- First client: connect, verify ping/pong, then abruptly close ---
using (var client1 = new TcpClient())
{
Assert.IsTrue(client1.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs),
"First client connect timed out");
client1.ReceiveTimeout = ReadTimeoutMs;
var stream1 = client1.GetStream();
string handshake1 = ReadLine(stream1, ReadTimeoutMs);
Assert.That(handshake1, Does.Contain("FRAMING=1"), "First client should receive handshake");
// Send a framed ping
SendFrame(stream1, Encoding.UTF8.GetBytes("ping"));
byte[] pongBytes = ReadFrame(stream1, ReadTimeoutMs);
string pong1 = Encoding.UTF8.GetString(pongBytes);
Assert.That(pong1, Does.Contain("pong"), "First client should get pong response");
// Abrupt close — simulates server crash / domain reload disconnect
client1.Client.LingerState = new LingerOption(true, 0);
client1.Close();
}
// Wait a few frames for cleanup
for (int i = 0; i < 10; i++)
yield return null;
// --- Second client: connect and verify commands still work ---
using (var client2 = new TcpClient())
{
Assert.IsTrue(client2.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs),
"Second client connect timed out");
client2.ReceiveTimeout = ReadTimeoutMs;
var stream2 = client2.GetStream();
string handshake2 = ReadLine(stream2, ReadTimeoutMs);
Assert.That(handshake2, Does.Contain("FRAMING=1"), "Second client should receive handshake");
// Send a framed ping — this is the critical check that would fail
// if the bridge is in zombie state.
SendFrame(stream2, Encoding.UTF8.GetBytes("ping"));
byte[] pongBytes2 = ReadFrame(stream2, ReadTimeoutMs);
string pong2 = Encoding.UTF8.GetString(pongBytes2);
Assert.That(pong2, Does.Contain("pong"), "Second client should get pong response after reconnect");
client2.Close();
}
}
[UnityTest]
public IEnumerator NewClient_WhileOldClientStillConnected_ClosesStaleClient()
{
if (!StdioBridgeHost.IsRunning)
{
Assert.Ignore("StdioBridgeHost is not running; skipping reconnect test.");
yield break;
}
int port = StdioBridgeHost.GetCurrentPort();
// --- First client: connect and verify handshake (but don't close) ---
var client1 = new TcpClient();
try
{
Assert.IsTrue(client1.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs),
"First client connect timed out");
client1.ReceiveTimeout = ReadTimeoutMs;
var stream1 = client1.GetStream();
string handshake1 = ReadLine(stream1, ReadTimeoutMs);
Assert.That(handshake1, Does.Contain("FRAMING=1"), "First client should receive handshake");
// Verify ping works on first client
SendFrame(stream1, Encoding.UTF8.GetBytes("ping"));
byte[] pong1Bytes = ReadFrame(stream1, ReadTimeoutMs);
Assert.That(Encoding.UTF8.GetString(pong1Bytes), Does.Contain("pong"));
// --- Second client: connect while first is still open ---
using (var client2 = new TcpClient())
{
Assert.IsTrue(client2.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs),
"Second client connect timed out");
client2.ReceiveTimeout = ReadTimeoutMs;
var stream2 = client2.GetStream();
string handshake2 = ReadLine(stream2, ReadTimeoutMs);
Assert.That(handshake2, Does.Contain("FRAMING=1"), "Second client should receive handshake");
// Stale-client cleanup runs synchronously in HandleClientAsync before
// the read loop, so by the time we read the handshake it's already done.
// No yield needed — yielding here creates a window for the MCP Python
// server to reconnect and close our test client as stale.
SendFrame(stream2, Encoding.UTF8.GetBytes("ping"));
byte[] pong2Bytes = ReadFrame(stream2, ReadTimeoutMs);
Assert.That(Encoding.UTF8.GetString(pong2Bytes), Does.Contain("pong"),
"Second client should get pong after stale client cleanup");
client2.Close();
}
// First client should now be disconnected by the bridge.
// A read attempt should throw or return 0 bytes.
yield return null;
bool firstClientDisconnected = false;
try
{
SendFrame(stream1, Encoding.UTF8.GetBytes("ping"));
ReadFrame(stream1, 2000);
}
catch
{
firstClientDisconnected = true;
}
Assert.IsTrue(firstClientDisconnected, "First client should be disconnected after second client connects");
}
finally
{
try { client1.Close(); } catch { }
}
}
#region Frame protocol helpers
private static string ReadLine(NetworkStream stream, int timeoutMs)
{
var sb = new StringBuilder();
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
stream.ReadTimeout = timeoutMs;
while (DateTime.UtcNow < deadline)
{
int b = stream.ReadByte();
if (b < 0)
throw new IOException("Connection closed while reading line");
if (b == '\n')
return sb.ToString();
sb.Append((char)b);
}
throw new TimeoutException("Timed out reading line from stream");
}
private static void SendFrame(NetworkStream stream, byte[] payload)
{
byte[] header = new byte[8];
ulong len = (ulong)payload.LongLength;
header[0] = (byte)(len >> 56);
header[1] = (byte)(len >> 48);
header[2] = (byte)(len >> 40);
header[3] = (byte)(len >> 32);
header[4] = (byte)(len >> 24);
header[5] = (byte)(len >> 16);
header[6] = (byte)(len >> 8);
header[7] = (byte)(len);
stream.Write(header, 0, 8);
stream.Write(payload, 0, payload.Length);
stream.Flush();
}
private static byte[] ReadFrame(NetworkStream stream, int timeoutMs)
{
stream.ReadTimeout = timeoutMs;
byte[] header = ReadExact(stream, 8, timeoutMs);
ulong payloadLen =
((ulong)header[0] << 56) | ((ulong)header[1] << 48) |
((ulong)header[2] << 40) | ((ulong)header[3] << 32) |
((ulong)header[4] << 24) | ((ulong)header[5] << 16) |
((ulong)header[6] << 8) | header[7];
if (payloadLen == 0 || payloadLen > 16 * 1024 * 1024)
throw new IOException($"Invalid frame length: {payloadLen}");
return ReadExact(stream, (int)payloadLen, timeoutMs);
}
private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs)
{
byte[] buffer = new byte[count];
int offset = 0;
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (offset < count)
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException($"Timed out reading {count} bytes (got {offset})");
int remaining = count - offset;
int read = stream.Read(buffer, offset, remaining);
if (read == 0)
throw new IOException("Connection closed before reading expected bytes");
offset += read;
}
return buffer;
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 299b59741ba9d4a4dafc70c3317d2e0e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
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");
// Flush cleaned state to SessionState so synthetic jobs don't survive domain reloads.
// The persist test writes to SessionState; without this, the stub job would be
// restored on the next [InitializeOnLoadMethod] and pollute later test runs.
_persistMethod.Invoke(null, new object[] { true });
}
[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()
{
// 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");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e0b6718bcbe94429595354c108865f67
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,155 @@
using System.Linq;
using NUnit.Framework;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnity.Editor.Tests.EditMode.Services
{
[TestFixture]
public class ToolDiscoveryServiceTests
{
private const string TestToolName = "test_tool_for_testing";
[SetUp]
public void SetUp()
{
// Clean up any test preferences
string testKey = EditorPrefKeys.ToolEnabledPrefix + TestToolName;
if (EditorPrefs.HasKey(testKey))
{
EditorPrefs.DeleteKey(testKey);
}
}
[TearDown]
public void TearDown()
{
// Clean up test preferences after each test
string testKey = EditorPrefKeys.ToolEnabledPrefix + TestToolName;
if (EditorPrefs.HasKey(testKey))
{
EditorPrefs.DeleteKey(testKey);
}
}
[Test]
public void SetToolEnabled_WritesToEditorPrefs()
{
// Arrange
var service = new ToolDiscoveryService();
// Act
service.SetToolEnabled(TestToolName, false);
// Assert
string key = EditorPrefKeys.ToolEnabledPrefix + TestToolName;
Assert.IsTrue(EditorPrefs.HasKey(key), "Preference key should exist after SetToolEnabled");
Assert.IsFalse(EditorPrefs.GetBool(key, true), "Preference should be set to false");
}
[Test]
public void IsToolEnabled_ReturnsFalse_WhenToolDoesNotExist()
{
// Arrange - Ensure no preference exists
string key = EditorPrefKeys.ToolEnabledPrefix + TestToolName;
if (EditorPrefs.HasKey(key))
{
EditorPrefs.DeleteKey(key);
}
var service = new ToolDiscoveryService();
// Act - For a non-existent tool, IsToolEnabled should return false
// (since metadata.AutoRegister defaults to false for non-existent tools)
bool result = service.IsToolEnabled(TestToolName);
// Assert - Non-existent tools return false (no metadata found)
Assert.IsFalse(result, "Non-existent tool should return false");
}
[Test]
public void IsToolEnabled_ReturnsStoredValue_WhenPreferenceExists()
{
// Arrange
string key = EditorPrefKeys.ToolEnabledPrefix + TestToolName;
EditorPrefs.SetBool(key, false); // Store false value
var service = new ToolDiscoveryService();
// Act
bool result = service.IsToolEnabled(TestToolName);
// Assert
Assert.IsFalse(result, "Should return the stored preference value (false)");
}
[Test]
public void IsToolEnabled_ReturnsTrue_WhenPreferenceSetToTrue()
{
// Arrange
string key = EditorPrefKeys.ToolEnabledPrefix + TestToolName;
EditorPrefs.SetBool(key, true);
var service = new ToolDiscoveryService();
// Act
bool result = service.IsToolEnabled(TestToolName);
// Assert
Assert.IsTrue(result, "Should return the stored preference value (true)");
}
[Test]
public void ToolToggle_PersistsAcrossServiceInstances()
{
// Arrange
var service1 = new ToolDiscoveryService();
service1.SetToolEnabled(TestToolName, false);
// Act - Create a new service instance
var service2 = new ToolDiscoveryService();
bool result = service2.IsToolEnabled(TestToolName);
// Assert - The disabled state should persist
Assert.IsFalse(result, "Tool state should persist across service instances");
}
[Test]
public void DiscoverAllTools_DoesNotOverrideStoredFalse_ForBuiltInAutoRegisterFalseTool()
{
// Arrange
var service = new ToolDiscoveryService();
var builtInTool = service.DiscoverAllTools()
.FirstOrDefault(tool => tool.IsBuiltIn && !tool.AutoRegister);
Assert.IsNotNull(builtInTool, "Expected at least one built-in tool with AutoRegister=false.");
string key = EditorPrefKeys.ToolEnabledPrefix + builtInTool.Name;
bool hadOriginalKey = EditorPrefs.HasKey(key);
bool originalValue = hadOriginalKey && EditorPrefs.GetBool(key, true);
try
{
EditorPrefs.SetBool(key, false);
service.InvalidateCache();
// Act
service.DiscoverAllTools();
bool enabled = service.IsToolEnabled(builtInTool.Name);
// Assert
Assert.IsFalse(enabled, $"Built-in tool '{builtInTool.Name}' should remain disabled when preference is false.");
}
finally
{
if (hadOriginalKey)
{
EditorPrefs.SetBool(key, originalValue);
}
else
{
EditorPrefs.DeleteKey(key);
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5f3b1c9b5dc24a52ae7053af3e2135ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,66 @@
using System.Threading.Tasks;
using NUnit.Framework;
using MCPForUnity.Editor.Services.Transport;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Pins TransportManager.StartAsync's coalescing contract: concurrent starts for the
/// same mode share one in-flight attempt instead of racing — a second StartAsync would
/// otherwise tear down the first connection mid-handshake (manual Connect vs the
/// reload-resume/auto-start loops).
/// </summary>
public class TransportManagerTests
{
private sealed class PendingTransportClient : IMcpTransportClient
{
public readonly TaskCompletionSource<bool> Pending = new TaskCompletionSource<bool>();
public int StartCalls;
public bool IsConnected => false;
public string TransportName => "http";
public TransportState State { get; } = TransportState.Disconnected("http");
public Task<bool> StartAsync()
{
StartCalls++;
return Pending.Task;
}
public Task StopAsync() => Task.CompletedTask;
public Task<bool> VerifyAsync() => Task.FromResult(false);
public Task ReregisterToolsAsync() => Task.CompletedTask;
}
[Test]
public void StartAsync_ConcurrentCallsSameMode_CoalesceIntoOneAttempt()
{
var client = new PendingTransportClient();
var manager = new TransportManager();
manager.Configure(() => client, () => client);
Task<bool> first = manager.StartAsync(TransportMode.Http);
Task<bool> second = manager.StartAsync(TransportMode.Http);
Assert.AreEqual(1, client.StartCalls, "concurrent starts must share one client attempt");
Assert.AreSame(first, second, "the in-flight task is returned to concurrent callers");
client.Pending.SetResult(true); // let the shared attempt finish
}
[Test]
public void StartAsync_AfterCompletedStart_StartsFresh()
{
var client = new FakeTransportClient();
var manager = new TransportManager();
manager.Configure(() => client, () => client);
Task<bool> first = manager.StartAsync(TransportMode.Http);
Assert.IsTrue(first.IsCompleted && first.Result, "fake start should complete synchronously");
Task<bool> second = manager.StartAsync(TransportMode.Http);
Assert.AreEqual(2, client.StartCalls, "a completed start must not block later restarts");
Assert.IsTrue(second.IsCompleted && second.Result);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b019ec9ec1df4b6eb345e6f2a2c13915
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using MCPForUnity.Editor.Services.Transport.Transports;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class WebSocketTransportClientTests
{
private const string CandidateBuilderMethodName = "BuildConnectionCandidateUris";
private const string WebSocketTransportClientTypeName = "MCPForUnity.Editor.Services.Transport.Transports.WebSocketTransportClient";
private static readonly MethodInfo BuildConnectionCandidateUrisMethod = ResolveCandidateBuilderMethod();
[Test]
public void BuildConnectionCandidateUris_NullEndpoint_ReturnsEmptyList()
{
// Act
List<Uri> candidates = InvokeBuildConnectionCandidateUris(null);
// Assert
Assert.IsNotNull(candidates);
Assert.AreEqual(0, candidates.Count);
}
[Test]
public void BuildConnectionCandidateUris_NonLocalhost_ReturnsOriginalOnly()
{
// Arrange
var endpoint = new Uri("ws://127.0.0.1:8080/hub/plugin");
// Act
List<Uri> candidates = InvokeBuildConnectionCandidateUris(endpoint);
// Assert
Assert.AreEqual(1, candidates.Count);
Assert.AreEqual(endpoint, candidates[0]);
}
[Test]
public void BuildConnectionCandidateUris_Localhost_AddsIPv4AndIPv6Fallbacks()
{
// Arrange
var endpoint = new Uri("ws://localhost:8080/hub/plugin");
// Act
List<Uri> candidates = InvokeBuildConnectionCandidateUris(endpoint);
// Assert
Assert.AreEqual(3, candidates.Count);
CollectionAssert.AreEqual(
new[] { "localhost", "127.0.0.1", "::1" },
candidates.Select(uri => NormalizeHostForComparison(uri.Host)).ToArray());
int uniqueCount = candidates
.Select(uri => uri.AbsoluteUri)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
Assert.AreEqual(candidates.Count, uniqueCount, "Fallback list should not contain duplicate endpoints.");
}
[Test]
public void BuildConnectionCandidateUris_LocalhostFallbacks_PreserveSchemePortPathAndQuery()
{
// Arrange
var endpoint = new Uri("wss://localhost:9443/custom/path?mode=test");
// Act
List<Uri> candidates = InvokeBuildConnectionCandidateUris(endpoint);
// Assert
Assert.AreEqual(3, candidates.Count);
foreach (Uri candidate in candidates)
{
Assert.AreEqual("wss", candidate.Scheme);
Assert.AreEqual(9443, candidate.Port);
Assert.AreEqual("/custom/path", candidate.AbsolutePath);
Assert.AreEqual("?mode=test", candidate.Query);
}
}
private static List<Uri> InvokeBuildConnectionCandidateUris(Uri endpoint)
{
if (BuildConnectionCandidateUrisMethod == null)
{
Assert.Fail(BuildMissingMethodDiagnostic());
}
var result = BuildConnectionCandidateUrisMethod.Invoke(null, new object[] { endpoint });
Assert.IsNotNull(result);
Assert.IsInstanceOf<List<Uri>>(result);
return (List<Uri>)result;
}
private static MethodInfo ResolveCandidateBuilderMethod()
{
MethodInfo direct = GetCandidateBuilderMethod(typeof(WebSocketTransportClient));
if (direct != null)
{
return direct;
}
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type candidateType = assembly.GetType(WebSocketTransportClientTypeName);
if (candidateType == null)
{
continue;
}
MethodInfo method = GetCandidateBuilderMethod(candidateType);
if (method != null)
{
return method;
}
}
return null;
}
private static MethodInfo GetCandidateBuilderMethod(Type type)
{
const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static;
MethodInfo direct = type.GetMethod(
CandidateBuilderMethodName,
flags,
binder: null,
types: new[] { typeof(Uri) },
modifiers: null);
if (direct != null)
{
return direct;
}
// Fallback for environments where signature binding can differ between loaded copies.
return type.GetMethods(flags).FirstOrDefault(method =>
{
if (!string.Equals(method.Name, CandidateBuilderMethodName, StringComparison.Ordinal))
{
return false;
}
ParameterInfo[] parameters = method.GetParameters();
return parameters.Length == 1 && parameters[0].ParameterType == typeof(Uri);
});
}
private static string BuildMissingMethodDiagnostic()
{
var sb = new StringBuilder();
sb.Append("Expected private candidate builder method to exist. Searched loaded assemblies for ")
.Append(WebSocketTransportClientTypeName)
.Append('.')
.Append(CandidateBuilderMethodName)
.Append(". Loaded candidate types:");
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type candidateType = assembly.GetType(WebSocketTransportClientTypeName);
if (candidateType == null)
{
continue;
}
sb.Append("\n- ")
.Append(assembly.FullName)
.Append(" @ ")
.Append(string.IsNullOrEmpty(assembly.Location) ? "<dynamic>" : assembly.Location);
}
return sb.ToString();
}
private static string NormalizeHostForComparison(string host)
{
if (string.IsNullOrEmpty(host))
{
return host;
}
return host.Trim('[', ']');
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 84fe06c7be1f46beab1a9374830432a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: