chore: import upstream snapshot with attribution
This commit is contained in:
+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:
|
||||
Reference in New Issue
Block a user