chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7b2c3d4e5f6a7890123456789abcdef3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+899
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8e9f289147f6440dadbb78a34d893ea
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+503
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a1b2c3d4e5f6789012345678abcdef4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78f1385217c246d4a9b5c596986787b0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+264
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4d5e6c8f40564676a4afffeb2de0854
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+194
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b295bffe6b1f4006bd4aa5061932ec0f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+196
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 45fcbd8bbcd2411fbef0b27859c77465
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04b5977d361f459ea4726c6e176fa9d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+357
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+494
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 875ef370082bc42d182a9875ee7c5e15
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+364
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 88e47e156fb6a4064a2d638cf8bb8d52
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+159
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48fe9f588d61e46319110ad1c09d0aea
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+325
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5506606af081a4eaca6a5563e1a4b0dd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+304
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59d7c435f60554a719ddc9cd85f6ad3c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca5aeabe283e473ea5ae78cf60ee55f1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+234
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 299b59741ba9d4a4dafc70c3317d2e0e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+168
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0b6718bcbe94429595354c108865f67
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+155
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b019ec9ec1df4b6eb345e6f2a2c13915
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+186
@@ -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('[', ']');
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84fe06c7be1f46beab1a9374830432a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user