chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6a74c5895837a475695408d1a7373098
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 189741066b9f4be5a32fd0ef5e9e0547
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,166 @@
using System;
using System.IO;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the real TripoAdapter through FakeHttpTransport and pumps the job state machine
/// deterministically (no editor loop, no network). Import is stubbed so no real model bytes
/// are needed; the end-to-end test writes a tiny file under a throwaway Assets subfolder and
/// cleans it up.
/// </summary>
public class AssetGenJobManagerTests
{
private const string EnvVar = "MCPFORUNITY_TRIPO_API_KEY";
private const string Secret = "tsk_e2e_secret_value";
private const string TestFolder = "Assets/Generated/__assetgen_jobtest";
private FakeHttpTransport _fake;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, Secret);
_fake = new FakeHttpTransport();
AssetGenJobManager.TransportOverrideForTests = _fake;
AssetGenJobManager.PollIntervalSeconds = 0;
// Stub import: mark the asset path without touching AssetDatabase.
AssetGenJobManager.ImportOverrideForTests = (job, path) => { job.AssetPath = path; return job; };
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, null);
try
{
string abs = Path.Combine(ProjectRoot(), TestFolder);
if (Directory.Exists(abs)) Directory.Delete(abs, true);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { /* ignore */ }
}
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
private static ModelGenRequest Req() => new ModelGenRequest
{
Provider = "tripo",
Mode = "text",
Prompt = "a low-poly oak tree",
Format = "glb",
TargetSize = 1f,
Name = "jobtest",
OutputFolder = TestFolder,
};
private static void Pump(string jobId)
{
int guard = 0;
while (!AssetGenJobManager.TryAdvanceForTests(jobId) && guard++ < 50) { }
Assert.Less(guard, 50, "state machine did not reach a terminal state");
}
[Test]
public void EndToEnd_ReachesDone_WithAssetPath()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
if (spec.Url.Contains("/openapi/task/"))
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"https://cdn.example.com/model.glb\"}}}");
return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } }; // download
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Done, job.State);
Assert.IsNotNull(job.AssetPath);
StringAssert.EndsWith("jobtest.glb", job.AssetPath);
Assert.AreEqual(1f, job.Progress);
}
[Test]
public void FailedPoll_FailsJob_AndLeaksNoKey()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
return Json("{\"code\":0,\"data\":{\"status\":\"failed\",\"error\":\"out of credits\"}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
Assert.IsNotEmpty(job.Error);
string serialized = JsonConvert.SerializeObject(job);
StringAssert.DoesNotContain(Secret, serialized);
}
[Test]
public void FileSchemeDownloadUrl_Rejected_FailsJob()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
// Poll succeeds but hands back a malicious local-file URL as the model.
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"file:///etc/passwd\"}}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("http", job.Error.ToLowerInvariant());
}
[Test]
public void Cancel_BeforeRun_MarksCanceled()
{
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Assert.IsTrue(AssetGenJobManager.Cancel(job.JobId));
AssetGenJobManager.TryAdvanceForTests(job.JobId);
Assert.AreEqual(AssetGenJobState.Canceled, job.State);
}
[Test]
public void MissingKey_FailsImmediately()
{
Environment.SetEnvironmentVariable(EnvVar, null); // remove the env key
// Ensure no stored key resolves for tripo by routing through an empty temp store.
string dir = Path.Combine(Path.GetTempPath(), "mcp_jobmgr_nokey_" + Guid.NewGuid().ToString("N"));
MCPForUnity.Editor.Security.SecureKeyStore.OverrideForTests(new MCPForUnity.Editor.Security.EncryptedFileKeyStore(dir));
try
{
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("No API key", job.Error);
}
finally
{
MCPForUnity.Editor.Security.SecureKeyStore.ResetForTests();
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 532c8eaebae24a2985bdd0d498296b35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,83 @@
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Phase 0 scaffold: non-secret AssetGen preferences round-trip through EditorPrefs
/// with sensible defaults. (API keys are NOT covered here — they live in the secure store.)
/// </summary>
public class AssetGenPrefsTests
{
private static readonly string[] Keys =
{
EditorPrefKeys.AssetGenSelectedModelProvider,
EditorPrefKeys.AssetGenSelectedImageProvider,
EditorPrefKeys.AssetGenDefaultFormat,
EditorPrefKeys.AssetGenOutputRoot,
EditorPrefKeys.AssetGenAutoNormalize,
EditorPrefKeys.AssetGenProviderEnabledPrefix + "tripo",
};
private void Clear()
{
foreach (string k in Keys) EditorPrefs.DeleteKey(k);
}
[SetUp]
public void SetUp() => Clear();
[TearDown]
public void TearDown() => Clear();
[Test]
public void Defaults_AreSensible_WhenUnset()
{
Assert.AreEqual("tripo", AssetGenPrefs.ModelProvider);
Assert.AreEqual("fal", AssetGenPrefs.ImageProvider);
Assert.AreEqual("Assets/Generated", AssetGenPrefs.OutputRoot);
Assert.IsTrue(AssetGenPrefs.AutoNormalize);
Assert.IsFalse(AssetGenPrefs.IsProviderEnabled("tripo"));
}
[Test]
public void RoundTrip_PersistsValues()
{
AssetGenPrefs.ModelProvider = "meshy";
AssetGenPrefs.OutputRoot = "Assets/Foo";
AssetGenPrefs.AutoNormalize = false;
AssetGenPrefs.SetProviderEnabled("tripo", true);
Assert.AreEqual("meshy", AssetGenPrefs.ModelProvider);
Assert.AreEqual("Assets/Foo", AssetGenPrefs.OutputRoot);
Assert.IsFalse(AssetGenPrefs.AutoNormalize);
Assert.IsTrue(AssetGenPrefs.IsProviderEnabled("tripo"));
}
[Test]
public void OutputRoot_FallsBackToDefault_WhenClearedToEmpty()
{
AssetGenPrefs.OutputRoot = "Assets/Foo";
AssetGenPrefs.OutputRoot = "";
Assert.AreEqual("Assets/Generated", AssetGenPrefs.OutputRoot);
}
[Test]
public void AssetPathNormalization_RejectsTraversalOutsideAssets()
{
Assert.IsFalse(AssetGenPaths.TryGetAssetsRelativePath("Assets/../ProjectSettings", out _));
}
[Test]
public void AssetPathNormalization_AcceptsAbsolutePathInsideAssets()
{
string abs = System.IO.Path.Combine(Application.dataPath, "Refs/ref.png");
Assert.IsTrue(AssetGenPaths.TryGetAssetsRelativePath(abs, out string rel));
Assert.AreEqual("Assets/Refs/ref.png", rel);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ae32dac98164b77a73a380b0ebaf4f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Covers the provider factory/registry: Tripo resolves to a real adapter, unimplemented
/// providers throw, and <c>List()</c> reports a key-free <c>Configured</c> bool that honors
/// the env override (<c>MCPFORUNITY_TRIPO_API_KEY</c>) without ever exposing a key value.
/// </summary>
public class AssetGenProvidersTests
{
[Test]
public void Model_Tripo_ReturnsAdapter()
{
IModelProviderAdapter adapter = AssetGenProviders.Model("tripo");
Assert.IsNotNull(adapter);
Assert.AreEqual("tripo", adapter.Id);
}
[Test]
public void Model_Unimplemented_Throws()
{
Assert.Throws<NotSupportedException>(() => AssetGenProviders.Model("hunyuan"));
}
[Test]
public void List_IncludesTripo_ConfiguredIsBool()
{
const string envName = "MCPFORUNITY_TRIPO_API_KEY";
string original = Environment.GetEnvironmentVariable(envName);
string tempDir = Path.Combine(Path.GetTempPath(), "mcp_assetgen_providers_" + Guid.NewGuid().ToString("N"));
try
{
// Deterministic empty baseline that still consults the env-override layer,
// so this test never depends on the dev machine's real key store.
SecureKeyStore.OverrideForTests(new EnvOverlayKeyStore(new EncryptedFileKeyStore(tempDir)));
Environment.SetEnvironmentVariable(envName, null);
ProviderInfo tripo = FindTripo();
Assert.IsNotNull(tripo);
Assert.AreEqual("model", tripo.Kind);
Assert.IsFalse(tripo.Configured, "no key/env should report not configured");
Environment.SetEnvironmentVariable(envName, "tsk_env_override_value");
tripo = FindTripo();
Assert.IsTrue(tripo.Configured, "env present should flip Configured to true");
}
finally
{
Environment.SetEnvironmentVariable(envName, original);
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { /* ignore */ }
}
}
private static ProviderInfo FindTripo()
{
foreach (ProviderInfo p in AssetGenProviders.List())
{
if (p.Id == "tripo") return p;
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59685def5a534586985c4971cc7c5190
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Covers the pure detection core (path list + an exists predicate) deterministically, without
/// depending on whether the test machine actually has Blender installed.
/// </summary>
public class BlenderDetectionTests
{
[Test]
public void DetectIn_ReturnsTrue_WhenACandidateExists()
{
var candidates = new List<string> { "/x/blender", "/y/blender" };
Assert.IsTrue(BlenderDetection.DetectIn(candidates, p => p == "/y/blender"));
}
[Test]
public void DetectIn_ReturnsFalse_WhenNoCandidateExists()
{
var candidates = new List<string> { "/x/blender", "/y/blender" };
Assert.IsFalse(BlenderDetection.DetectIn(candidates, _ => false));
}
[Test]
public void DetectIn_IgnoresNullOrEmptyCandidates()
{
var candidates = new List<string> { null, "", "/real/blender" };
Assert.IsTrue(BlenderDetection.DetectIn(candidates, p => p == "/real/blender"));
}
[Test]
public void CandidatePaths_AreNonEmpty()
{
CollectionAssert.IsNotEmpty(new List<string>(BlenderDetection.CandidatePaths()));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c066ef0e1704050a8baa05f984f51b3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
using System;
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class EnvKeyOverrideTests
{
private const string Var = "MCPFORUNITY_TRIPO_API_KEY";
[TearDown]
public void TearDown() => Environment.SetEnvironmentVariable(Var, null);
[Test]
public void EnvVarName_UpperWithApiKeySuffix()
{
Assert.AreEqual("MCPFORUNITY_TRIPO_API_KEY", EnvKeyOverride.EnvVarName("tripo"));
Assert.AreEqual("MCPFORUNITY_OPENROUTER_API_KEY", EnvKeyOverride.EnvVarName("openrouter"));
}
[Test]
public void TryGet_ReturnsValue_WhenEnvSet()
{
Environment.SetEnvironmentVariable(Var, "tsk_env_value_123");
Assert.IsTrue(EnvKeyOverride.TryGet("tripo", out string v));
Assert.AreEqual("tsk_env_value_123", v);
}
[Test]
public void TryGet_ReturnsFalse_WhenEnvMissing()
{
Environment.SetEnvironmentVariable(Var, null);
Assert.IsFalse(EnvKeyOverride.TryGet("tripo", out string v));
Assert.IsNull(v);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db5c682df8fd473aae015b4e11718bd7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class FalAdapterTests
{
private const string Resp = "https://queue.fal.run/fal-ai/flux-2/requests/r1";
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
private static ImageGenRequest Req() => new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat" };
[Test]
public void Submit_PostsModelEndpoint_WithKeyHeader_ReturnsResponseUrl()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(Resp, pid);
HttpRequestSpec sent = fake.RecordedRequests[0];
Assert.AreEqual("POST", sent.Method);
StringAssert.Contains("fal-ai/flux-2", sent.Url);
Assert.IsTrue(sent.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Key ", sent.Headers["Authorization"]);
}
[Test]
public void Submit_WithDimensions_IncludesImageSize()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat", Width = 512, Height = 768 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_size", sent);
StringAssert.Contains("512", sent);
StringAssert.Contains("768", sent);
}
[Test]
public void Submit_ImageMode_UsesEditEndpoint_WithImageUrlsArray()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "make it night", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/edit", rec.Url);
string body = System.Text.Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("image_urls", body);
StringAssert.Contains("https://ex.com/in.png", body);
}
[Test]
public void Poll_Completed_FetchesResult_ReturnsImageUrl()
{
var fake = new FakeHttpTransport
{
Handler = spec =>
{
if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}");
return Json("{\"images\":[{\"url\":\"https://cdn.example.com/img.png\"}]}");
}
};
var adapter = new FalAdapter();
ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
Assert.AreEqual("https://cdn.example.com/img.png", pr.DownloadUrl);
}
[Test]
public void Submit_ImageMode_OmitsImageSize_EvenWithDimensions()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "edit", ImageUrl = "https://ex.com/in.png", Width = 512, Height = 512 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string body = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.DoesNotContain("image_size", body); // /edit derives size from the source image
}
[Test]
public void Submit_ImageMode_FallbackResponseUrl_UsesBaseModelPath()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"request_id\":\"r1\"}") }; // no response_url
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "edit", ImageUrl = "https://ex.com/in.png" };
string pid = adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("/fal-ai/flux-2/requests/r1", pid);
StringAssert.DoesNotContain("/edit/requests", pid);
}
[Test]
public void Poll_InProgress_ReturnsRunning()
{
var fake = new FakeHttpTransport { Handler = spec => Json("{\"status\":\"IN_PROGRESS\"}") };
var adapter = new FalAdapter();
ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, pr.State);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f27682f34bb041f69ce0495155ccdc73
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,177 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class GenerateImageTests
{
private const string TestFolder = "Assets/Generated/__assetgen_imgtest";
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null);
Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_imghandler_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport();
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { }
try
{
string dp = Application.dataPath.Replace('\\', '/');
string abs = Path.Combine(dp.Substring(0, dp.Length - "Assets".Length), TestFolder);
if (Directory.Exists(abs)) Directory.Delete(abs, true);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(GenerateImage.HandleCommand(p)));
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
[Test]
public void Generate_WithKey_ReturnsPendingJobId()
{
_store.Set("fal", "falkey");
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
Assert.IsFalse(string.IsNullOrEmpty((string)gen["data"]["job_id"]));
}
[Test]
public void Generate_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
_store.Set("fal", "falkey");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_PathOutsideAssets_ReturnsError()
{
_store.Set("fal", "falkey");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = tmp, ["prompt"] = "edit it" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Assets", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_ImageMode_ProjectLocalPath_Accepted_ReturnsPending()
{
_store.Set("fal", "falkey");
string rel = WriteProjectFile(TestFolder + "/ref.png", new byte[] { 137, 80, 78, 71 });
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = rel, ["prompt"] = "edit it" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
[Test]
public void Generate_ImageMode_UnsupportedExtension_ReturnsError()
{
_store.Set("fal", "falkey");
string rel = WriteProjectFile(TestFolder + "/bad.tga", new byte[] { 0, 0, 2 });
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = rel, ["prompt"] = "x" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Unsupported", (string)resp["error"]);
}
[Test]
public void ListProviders_ImageOnly()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("fal", s);
StringAssert.Contains("openrouter", s);
StringAssert.DoesNotContain("tripo", s); // model providers excluded
}
[Test]
public void Generate_UnknownProvider_ReturnsError()
{
_store.Set("bogus", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "bogus", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void OpenRouterInline_EndToEnd_ReachesDone()
{
_store.Set("openrouter", "orkey");
byte[] png = { 137, 80, 78, 71, 13, 10, 26, 10 }; // PNG magic; bytes only need to be written, not validated
string b64 = Convert.ToBase64String(png);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
{
Handler = spec => new HttpResult
{
Status = 200,
IsSuccess = true,
Text = "{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + b64 + "\"}}]}}]}"
}
};
AssetGenJobManager.PollIntervalSeconds = 0;
AssetGenJobManager.ImportOverrideForTests = (job, path) => { job.AssetPath = path; return job; };
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat", Name = "imgtest", OutputFolder = TestFolder };
AssetGenJob job = AssetGenJobManager.StartImageGeneration(req);
int guard = 0;
while (!AssetGenJobManager.TryAdvanceForTests(job.JobId) && guard++ < 50) { }
Assert.Less(guard, 50);
Assert.AreEqual(AssetGenJobState.Done, job.State);
StringAssert.EndsWith("imgtest.png", job.AssetPath);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dfab9b71ed52491d9cb539269c3f1670
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,165 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class GenerateModelHandlerTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_TRIPO_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_handler_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport();
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { }
try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_model_handler"), true); } catch { }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(GenerateModel.HandleCommand(p)));
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
[Test]
public void Generate_WithKey_ReturnsPendingJobId_AndStatusReportsState()
{
_store.Set("tripo", "handlerkey123");
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
string jobId = (string)gen["data"]["job_id"];
Assert.IsFalse(string.IsNullOrEmpty(jobId));
JObject st = Call(new JObject { ["action"] = "status", ["job_id"] = jobId });
Assert.AreEqual("pending", (string)st["_mcp_status"]);
Assert.IsNotNull(st["data"]["state"]);
}
[Test]
public void Generate_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_UnimplementedProvider_ReturnsError()
{
_store.Set("hunyuan", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "hunyuan", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
// Use a provider that supports local images (Meshy) so we exercise the file-existence
// check; Tripo short-circuits on any local path (covered separately).
_store.Set("meshy", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_PathOutsideAssets_ReturnsError()
{
_store.Set("meshy", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Assets", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_ImageMode_ProjectLocalPath_Accepted_ReturnsPending()
{
_store.Set("meshy", "k");
string rel = WriteProjectFile("Assets/Generated/__assetgen_model_handler/ref.png", new byte[] { 137, 80, 78, 71 });
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = rel });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
[Test]
public void Generate_ImageMode_Tripo_LocalPath_ReturnsErrorSynchronously()
{
_store.Set("tripo", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_tripoimg_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual(false, (bool)resp["success"]); // synchronous error, not a fake 'pending'
StringAssert.Contains("Tripo", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_TextMode_RequiresPrompt()
{
_store.Set("tripo", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("prompt", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void ListProviders_IncludesTripoModel()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("tripo", s);
StringAssert.Contains("model", s);
}
[Test]
public void UnknownAction_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "frobnicate" });
Assert.AreEqual(false, (bool)resp["success"]);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7db2353f825a4ab7af5df4b6393309df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,110 @@
using System;
using System.IO;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the import_model_file handler with on-disk fixture files (no network, no provider).
/// Covers missing source, unsupported extension, and a real OBJ import that yields an asset GUID.
/// </summary>
public class ImportModelFileHandlerTests
{
private string _tempDir;
private const string TestFolder = "Assets/__import_model_file_test";
[SetUp]
public void SetUp()
{
_tempDir = Path.Combine(Path.GetTempPath(), "mcp_imf_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
[TearDown]
public void TearDown()
{
if (AssetDatabase.IsValidFolder(TestFolder))
AssetDatabase.DeleteAsset(TestFolder);
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { /* ignore */ }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(ImportModelFile.HandleCommand(p)));
private string WriteCubeObj()
{
string path = Path.Combine(_tempDir, "cube.obj");
File.WriteAllText(path,
"o Cube\n" +
"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n" +
"f 1 2 3 4\nf 5 6 7 8\nf 1 2 6 5\nf 2 3 7 6\nf 3 4 8 7\nf 4 1 5 8\n");
return path;
}
[Test]
public void MissingSource_ReturnsError()
{
JObject resp = Call(new JObject { ["sourcePath"] = Path.Combine(_tempDir, "nope.obj") });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void UnsupportedExtension_ReturnsError()
{
string txt = Path.Combine(_tempDir, "readme.txt");
File.WriteAllText(txt, "hi");
JObject resp = Call(new JObject { ["sourcePath"] = txt });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("unsupported", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void ImportsObj_ReturnsAssetPathAndGuid()
{
string obj = WriteCubeObj();
JObject resp = Call(new JObject
{
["sourcePath"] = obj,
["name"] = "TestCube",
["outputFolder"] = TestFolder,
});
Assert.AreEqual(true, (bool)resp["success"], resp.ToString());
string assetPath = (string)resp["data"]["asset_path"];
StringAssert.StartsWith(TestFolder, assetPath);
Assert.IsFalse(string.IsNullOrEmpty((string)resp["data"]["asset_guid"]));
Assert.IsTrue(File.Exists(assetPath), "imported file should exist under Assets");
}
[Test]
public void OutputFolderTraversal_ReturnsError_AndDoesNotStageOutsideAssets()
{
string obj = WriteCubeObj();
string escapeFolder = "Library/__import_model_file_escape_" + Guid.NewGuid().ToString("N");
string escapedAbs = Path.Combine(Path.GetDirectoryName(Application.dataPath), escapeFolder, "Escaped.obj");
try
{
JObject resp = Call(new JObject
{
["sourcePath"] = obj,
["name"] = "Escaped",
["outputFolder"] = "Assets/../" + escapeFolder,
});
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("output_folder", ((string)resp["error"]).ToLowerInvariant());
Assert.IsFalse(File.Exists(escapedAbs), "handler must not stage files outside Assets");
}
finally
{
string dir = Path.GetDirectoryName(escapedAbs);
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 17fa9724d8324cbf8cc1c7f4c73e020f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,119 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the import_model handler with a fake transport and a temp key store (no network).
/// Covers search (key present/absent), import (returns a pending job_id), and the
/// marketplace-only list_providers shape.
/// </summary>
public class ImportModelHandlerTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_SKETCHFAB_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_import_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
ImportModel.TransportOverrideForTests = null;
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
ImportModel.TransportOverrideForTests = null;
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { /* ignore */ }
}
private static HttpResult Json(string body)
=> new HttpResult { Status = 200, IsSuccess = true, Text = body, Body = Encoding.UTF8.GetBytes(body) };
// HandleCommand is async; the fake transport completes synchronously so awaiting here
// never blocks on the editor loop (unlike the live UnityWebRequest path).
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(ImportModel.HandleCommand(p).GetAwaiter().GetResult()));
[Test]
public void HandleCommand_IsAsync()
{
// Regression: search/preview hit UnityWebRequest, whose completion is pumped by the
// editor loop. A synchronous handler that blocks on .GetResult() deadlocks the main
// thread and freezes the editor. The handler must be async so the request can finish
// on a later tick. See the import_model editor-freeze investigation.
var ret = typeof(ImportModel)
.GetMethod(nameof(ImportModel.HandleCommand))
.ReturnType;
Assert.IsTrue(typeof(Task).IsAssignableFrom(ret),
"ImportModel.HandleCommand must return Task (async) to avoid blocking the Unity main thread.");
}
[Test]
public void Search_WithKey_ReturnsResults()
{
_store.Set("sketchfab", "sfkey123");
ImportModel.TransportOverrideForTests = new FakeHttpTransport
{
Handler = _ => Json("{\"results\":[{\"uid\":\"u1\",\"name\":\"Castle\"}]}")
};
JObject resp = Call(new JObject { ["action"] = "search", ["query"] = "castle" });
Assert.AreEqual(true, (bool)resp["success"]);
StringAssert.Contains("u1", resp.ToString());
}
[Test]
public void Search_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "search", ["query"] = "castle" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Import_WithKey_ReturnsPendingJobId()
{
_store.Set("sketchfab", "sfkey123");
// The job manager resolves the download URL through its own transport seam.
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
{
Handler = _ => Json("{\"gltf\":{\"url\":\"https://dl.sketchfab.com/u1/file.zip\"}}")
};
JObject resp = Call(new JObject { ["action"] = "import", ["uid"] = "u1", ["name"] = "castle" });
Assert.AreEqual("pending", (string)resp["_mcp_status"]);
string jobId = (string)resp["data"]["job_id"];
Assert.IsFalse(string.IsNullOrEmpty(jobId));
}
[Test]
public void ListProviders_IncludesSketchfabOnly()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("sketchfab", s);
StringAssert.Contains("marketplace", s);
StringAssert.DoesNotContain("tripo", s);
StringAssert.DoesNotContain("\"fal\"", s);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 52eb341abadd457dacc294f4624307c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,210 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Meshy adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, Bearer scheme), the task id field, and
/// poll-state mapping. The bearer secret is never asserted beyond the "Bearer " prefix.
/// </summary>
public class MeshyAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTextEndpoint_WithBearerHeader_AndReturnsResultId()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"task_meshy_1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a brass lantern", Format = "glb" };
string taskId = adapter.SubmitAsync(req, "msy_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("task_meshy_1", taskId);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/openapi/v2/text-to-3d", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a brass lantern", body);
StringAssert.Contains("preview", body);
}
[Test]
public void Poll_Succeeded_ReturnsGlbUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"status\":\"SUCCEEDED\",\"progress\":100," +
"\"model_urls\":{\"glb\":\"https://assets.meshy.ai/model.glb\",\"fbx\":\"https://assets.meshy.ai/model.fbx\"}}")
};
var adapter = new MeshyAdapter();
// Texture=false -> single-phase (no refine), so a SUCCEEDED preview surfaces directly.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Format = "glb", Texture = false };
adapter.SubmitAsync(req, "k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://assets.meshy.ai/model.glb", res.DownloadUrl);
Assert.AreEqual(1f, res.Progress, 0.001f);
}
[Test]
public void Submit_NoTaskIdInResponse_ErrorIncludesBody()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"message\":\"quota exceeded\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false };
var ex = Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult());
StringAssert.Contains("quota exceeded", ex.Message);
}
[Test]
public void Poll_InProgress_ReturnsRunning_WithProgress()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\",\"progress\":37}") };
var adapter = new MeshyAdapter();
// Submit single-phase (Texture=false) so progress is reported raw (not split across refine).
adapter.SubmitAsync(
new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false },
"k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.37f, res.Progress, 0.001f);
}
[Test]
public void Submit_ImageMode_PollsV1ImageEndpoint()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"img1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/i.glb\"}}")
};
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImageUrl = "https://ex.com/ref.png", Format = "glb" };
string id = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("img1", id);
StringAssert.Contains("/openapi/v1/image-to-3d", submitFake.RecordedRequests[0].Url);
ProviderPollResult res = adapter.PollAsync(id, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
// Image tasks must be polled at the v1 image endpoint, not the v2 text endpoint.
StringAssert.Contains("/openapi/v1/image-to-3d/img1", pollFake.RecordedRequests[0].Url);
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://m/i.glb", res.DownloadUrl);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_meshy_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImagePath = rel, Format = "glb" };
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/openapi/v1/image-to-3d", rec.Url);
StringAssert.Contains("data:image/png;base64,", Encoding.UTF8.GetString(rec.Body));
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_meshy_adapter"), true); } catch { } }
}
[Test]
public void TextWithTexture_PreviewSucceeded_SubmitsRefine_ThenReturnsTexturedModel()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"prev1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = spec => spec.Method == "POST"
? Json("{\"result\":\"refine1\"}") // refine submit
: Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/refined.glb\"}}")
};
var adapter = new MeshyAdapter();
// Texture defaults to true -> two-phase preview+refine.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a chair", Format = "glb" };
string previewId = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("prev1", previewId);
// Poll #1: preview SUCCEEDED -> adapter submits a refine task and reports Running.
ProviderPollResult p1 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, p1.State);
bool refinePosted = false;
foreach (HttpRequestSpec r in pollFake.RecordedRequests)
{
if (r.Method == "POST" && r.Body != null)
{
string b = Encoding.UTF8.GetString(r.Body);
if (b.Contains("refine") && b.Contains("prev1")) refinePosted = true;
}
}
Assert.IsTrue(refinePosted, "expected a refine POST carrying the preview_task_id");
// Poll #2: the refine task SUCCEEDED -> textured model url surfaced.
ProviderPollResult p2 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, p2.State);
Assert.AreEqual("https://m/refined.glb", p2.DownloadUrl);
}
[Test]
public void Poll_Failed_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"FAILED\",\"progress\":0,\"task_error\":{\"message\":\"render error\"}}")
};
var adapter = new MeshyAdapter();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 47843d3832064cf783a4ee52735f6b3e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,62 @@
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Import;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Validates the import pipeline's guard paths without fabricating model bytes. Real FBX/GLB
/// import is left to the user's licensed editor (manual verification checklist).
/// Assumes glTFast is NOT installed in the test project (it is an optional dependency).
/// </summary>
public class ModelImportPipelineTests
{
private static AssetGenJob Job(string format) => new AssetGenJob
{
JobId = "test",
Kind = "model",
Provider = "tripo",
State = AssetGenJobState.Importing,
Format = format,
TargetSize = 1f,
};
[Test]
public void Glb_WithoutGltfast_FailsWithActionableMessage()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("glb"), "Assets/Generated/Models/__fake_nonexistent.glb");
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("glTFast", result.Error);
}
[Test]
public void PathOutsideAssets_Fails()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("fbx"), "/tmp/somewhere/model.fbx");
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("Assets", result.Error);
}
[Test]
public void NullPath_Fails()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("fbx"), null);
Assert.AreEqual(AssetGenJobState.Failed, result.State);
}
[TestCase("generic", ModelImporterAnimationType.Generic)]
[TestCase("Generic", ModelImporterAnimationType.Generic)]
[TestCase("humanoid", ModelImporterAnimationType.Human)]
[TestCase("human", ModelImporterAnimationType.Human)]
[TestCase(" LEGACY ", ModelImporterAnimationType.Legacy)]
[TestCase("none", ModelImporterAnimationType.None)]
[TestCase("", ModelImporterAnimationType.None)]
[TestCase(null, ModelImporterAnimationType.None)]
[TestCase("nonsense", ModelImporterAnimationType.None)]
public void ParseAnimationType_MapsRigMode(string input, ModelImporterAnimationType expected)
{
Assert.AreEqual(expected, ModelImportPipeline.ParseAnimationType(input));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 68e84150afa841cfa8e0a7329037859c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class OpenRouterAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
[Test]
public void Submit_Then_Poll_ReturnsInlineImageBytes()
{
byte[] expected = { 1, 2, 3, 4 };
string b64 = Convert.ToBase64String(expected);
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + b64 + "\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat" };
string pid = adapter.SubmitAsync(req, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("ready", pid);
// Submit posts to chat/completions with a Bearer key.
HttpRequestSpec sent = fake.RecordedRequests[0];
Assert.AreEqual("POST", sent.Method);
StringAssert.Contains("chat/completions", sent.Url);
StringAssert.StartsWith("Bearer ", sent.Headers["Authorization"]);
ProviderPollResult pr = adapter.PollAsync(pid, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
CollectionAssert.AreEqual(expected, pr.InlineData);
}
[Test]
public void Submit_ImageMode_IncludesReferenceImageInBody()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "make it watercolor", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("https://ex.com/in.png", sent);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_openrouter_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "watercolor", ImagePath = rel };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("data:image/png;base64,", sent);
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_openrouter_adapter"), true); } catch { } }
}
[Test]
public void Submit_NoImage_PollFails()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"content\":\"sorry, no image\"}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat" };
adapter.SubmitAsync(req, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
ProviderPollResult pr = adapter.PollAsync("ready", "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, pr.State);
Assert.IsNotEmpty(pr.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dc263c722db149f6979a08d4e7e376ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,114 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Import;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Verifies <see cref="SafeZipExtractor"/> unpacks normal entries and rejects Zip-Slip
/// traversal entries. Builds tiny zips in memory (no fixtures, no network).
/// </summary>
public class SafeZipExtractorTests
{
private string _work;
[SetUp]
public void SetUp()
{
_work = Path.Combine(Path.GetTempPath(), "mcp_zip_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_work);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch { /* ignore */ }
}
private string MakeZip(string entryName, string content)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
ZipArchiveEntry entry = archive.CreateEntry(entryName);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
private string MakeMultiZip(params (string name, string content)[] entries)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var (name, content) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(name);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
[Test]
public void Allowlist_SkipsDisallowedEntries()
{
// A hostile marketplace archive: a valid model plus an editor script + a managed dll.
string zip = MakeMultiZip(
("teapot.obj", "o teapot"),
("Editor/Hack.cs", "// [InitializeOnLoad] arbitrary code"),
("plugins/Evil.dll", "MZ..."));
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest, new System.Collections.Generic.HashSet<string> { ".obj" });
Assert.IsTrue(File.Exists(Path.Combine(dest, "teapot.obj")), "allowed model must be written");
Assert.IsFalse(File.Exists(Path.Combine(dest, "Editor", "Hack.cs")), "disallowed .cs must be skipped");
Assert.IsFalse(File.Exists(Path.Combine(dest, "plugins", "Evil.dll")), "disallowed .dll must be skipped");
}
[Test]
public void NormalEntry_Extracts()
{
string zip = MakeZip("models/teapot.obj", "o teapot");
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest);
string extracted = Path.Combine(dest, "models", "teapot.obj");
Assert.IsTrue(File.Exists(extracted), "expected extracted file to exist");
Assert.AreEqual("o teapot", File.ReadAllText(extracted));
}
[Test]
public void TraversalEntry_Throws()
{
string zip = MakeZip("../evil.txt", "pwned");
string dest = Path.Combine(_work, "out");
Assert.Throws<IOException>(() => SafeZipExtractor.ExtractTo(zip, dest));
string escaped = Path.GetFullPath(Path.Combine(_work, "evil.txt"));
Assert.IsFalse(File.Exists(escaped), "traversal target must not be written");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0808d9aabe914ea29b323640caf3f998
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class SecretRedactorTests
{
[Test]
public void Scrub_RedactsBearerToken()
{
string s = SecretRedactor.Scrub("Authorization: Bearer tsk_abc123XYZ");
StringAssert.DoesNotContain("tsk_abc123XYZ", s);
StringAssert.Contains("REDACTED", s);
}
[Test]
public void Scrub_RedactsKeyAndTokenSchemes()
{
StringAssert.DoesNotContain("falkey123456", SecretRedactor.Scrub("Authorization: Key falkey123456"));
StringAssert.DoesNotContain("sktoken987654", SecretRedactor.Scrub("Authorization: Token sktoken987654"));
}
[Test]
public void Scrub_WithExplicitSecret_RedactsThatValue()
{
string s = SecretRedactor.Scrub("error: key sk-supersecret rejected", "sk-supersecret");
StringAssert.DoesNotContain("sk-supersecret", s);
}
[Test]
public void Scrub_LeavesNormalTextAlone()
{
Assert.AreEqual("hello world", SecretRedactor.Scrub("hello world"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ed1cc6ea39d48dcaad4fbbf372c979c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,92 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Targets the CI-safe EncryptedFileKeyStore directly against a throwaway temp dir
/// (never the user's real store). Proves round-trip, deletion, overwrite, multi-secret
/// blobs, and that the on-disk file is actually encrypted.
/// </summary>
public class SecureKeyStoreTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
_dir = Path.Combine(Path.GetTempPath(), "mcp_assetgen_keys_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { /* ignore */ }
}
[Test]
public void SetThenGet_RoundTrips()
{
_store.Set("tripo", "tsk_secret_value_123");
Assert.IsTrue(_store.Has("tripo"));
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual("tsk_secret_value_123", v);
}
[Test]
public void Get_Missing_ReturnsFalse()
{
Assert.IsFalse(_store.Has("meshy"));
Assert.IsFalse(_store.TryGet("meshy", out string v));
Assert.IsNull(v);
}
[Test]
public void Delete_RemovesKey()
{
_store.Set("fal", "k-123456");
_store.Delete("fal");
Assert.IsFalse(_store.Has("fal"));
Assert.IsFalse(_store.TryGet("fal", out _));
}
[Test]
public void Overwrite_ReplacesValue()
{
_store.Set("tripo", "first_value");
_store.Set("tripo", "second_value");
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual("second_value", v);
}
[Test]
public void MultiSecret_JsonBlob_RoundTrips()
{
string blob = "{\"secretId\":\"AKID123\",\"secretKey\":\"SK456\"}";
_store.Set("tripo", blob);
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual(blob, v);
}
[Test]
public void Set_Empty_DeletesKey()
{
_store.Set("tripo", "abc");
_store.Set("tripo", "");
Assert.IsFalse(_store.Has("tripo"));
}
[Test]
public void Ciphertext_OnDisk_DoesNotContainPlaintext()
{
_store.Set("tripo", "PLAINTEXT_SECRET_XYZ");
string file = Path.Combine(_dir, "key_tripo.bin");
Assert.IsTrue(File.Exists(file));
StringAssert.DoesNotContain("PLAINTEXT_SECRET_XYZ", File.ReadAllText(file));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa8115bc72ce42a9aadd51bf567135df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Sketchfab marketplace adapter against a <see cref="FakeHttpTransport"/>.
/// Asserts the "Token" auth scheme and that the download endpoint's gltf.url is parsed into
/// the archive URL the job manager will fetch. The secret is never asserted beyond the scheme.
/// </summary>
public class SketchfabAdapterTests
{
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Search_GetsSearchEndpoint_WithTokenHeader_ReturnsRawJson()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[{\"uid\":\"abc\"}]}") };
var adapter = new SketchfabAdapter();
string raw = adapter.SearchAsync("castle", null, true, null, null, "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("\"uid\":\"abc\"", raw);
HttpRequestSpec rec = http.RecordedRequests[0];
Assert.AreEqual("GET", rec.Method);
StringAssert.Contains("/v3/search", rec.Url);
StringAssert.Contains("downloadable=true", rec.Url);
StringAssert.Contains("q=castle", rec.Url);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void Search_ForwardsCategoriesCountCursorAndDownloadableFlag()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[],\"cursors\":{\"next\":\"2\"}}") };
var adapter = new SketchfabAdapter();
adapter.SearchAsync("castle", "architecture", false, 12, "2", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
string url = http.RecordedRequests[0].Url;
StringAssert.Contains("categories=architecture", url);
StringAssert.Contains("count=12", url);
StringAssert.Contains("cursor=2", url);
StringAssert.Contains("downloadable=false", url);
}
[Test]
public void ResolveDownloadUrl_ParsesGltfUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"gltf\":{\"url\":\"https://dl.sketchfab.com/models/abc/file.zip?sig=x\"}}")
};
var adapter = new SketchfabAdapter();
string url = adapter.ResolveDownloadUrlAsync("abc", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("https://dl.sketchfab.com/models/abc/file.zip?sig=x", url);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/v3/models/abc/download", rec.Url);
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void ResolveDownloadUrl_MissingGltf_Throws()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"usdz\":{\"url\":\"https://x/y.usdz\"}}") };
var adapter = new SketchfabAdapter();
Assert.Throws<System.Exception>(() =>
adapter.ResolveDownloadUrlAsync("abc", "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e0da413b01b2437390f8f7b7b9d20c44
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,136 @@
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Tripo adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, auth scheme, body) and poll-state mapping.
/// The bearer secret is never asserted beyond the "Bearer " scheme prefix.
/// </summary>
public class TripoAdapterTests
{
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTaskEndpoint_WithBearerHeader_AndReturnsTaskId()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"abc123\"}}")
};
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "text", Prompt = "a red dragon" };
string taskId = adapter.SubmitAsync(req, "tsk_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("abc123", taskId);
Assert.AreEqual(1, http.RecordedRequests.Count);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/v2/openapi/task", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a red dragon", body);
StringAssert.Contains("text_to_model", body);
}
[Test]
public void Poll_MapsSuccess_WithDownloadUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100," +
"\"output\":{\"pbr_model\":\"https://cdn.tripo3d.ai/model.glb\"}}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "tsk_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://cdn.tripo3d.ai/model.glb", res.DownloadUrl);
}
[Test]
public void Poll_MapsRunning_WithProgress()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"status\":\"running\",\"progress\":42}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "k", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.42f, res.Progress, 0.001f);
}
[Test]
public void Poll_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"status\":\"failed\",\"progress\":0}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "k", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
[Test]
public void Submit_ImageMode_UsesImageToModel()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"img1\"}}")
};
var adapter = new TripoAdapter();
var req = new ModelGenRequest
{
Provider = "tripo",
Mode = "image",
ImageUrl = "https://example.com/in.png"
};
adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult();
string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body);
StringAssert.Contains("image_to_model", body);
StringAssert.Contains("https://example.com/in.png", body);
}
[Test]
public void Submit_ImageMode_LocalPathOnly_Throws()
{
// Tripo can't take a local image inline (no data-URI support, upload not wired) — it must
// fail clearly rather than silently falling back to text mode.
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "image", ImagePath = "/tmp/whatever.png" };
Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", new FakeHttpTransport(), CancellationToken.None).GetAwaiter().GetResult());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79b6a1e9658e417d960212be835c0c84
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 57176dc46eea4bbba356c3959cfeb33f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,76 @@
using System;
using System.IO;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Models;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Clients
{
/// <summary>
/// Covers the regression where JsonFileMcpConfigurator.CheckStatus only recognized
/// the "url" property and missed "serverUrl" (Antigravity/Windsurf) and "httpUrl"
/// (Gemini CLI), so clients configured via HTTP looked unconfigured and got rewritten
/// every startup by the auto-rewrite path.
/// </summary>
[TestFixture]
public class CheckStatusUrlPropertyTests
{
private string _tempDir;
[SetUp]
public void SetUp()
{
_tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { }
}
[Test]
public void CheckStatus_DetectsHttp_WhenUrlProperty()
=> AssertHttpDetected("url");
[Test]
public void CheckStatus_DetectsHttp_WhenServerUrlProperty()
=> AssertHttpDetected("serverUrl");
[Test]
public void CheckStatus_DetectsHttp_WhenHttpUrlProperty()
=> AssertHttpDetected("httpUrl");
private void AssertHttpDetected(string urlProperty)
{
string configPath = Path.Combine(_tempDir, $"{urlProperty}.json");
File.WriteAllText(configPath,
"{\"mcpServers\":{\"unityMCP\":{\"" + urlProperty + "\":\"http://localhost:65535/mcp\"}}}");
var client = new McpClient
{
name = "Fake",
windowsConfigPath = configPath,
macConfigPath = configPath,
linuxConfigPath = configPath,
HttpUrlProperty = urlProperty,
};
var configurator = new FakeJsonConfigurator(client);
configurator.CheckStatus(attemptAutoRewrite: false);
Assert.AreNotEqual(ConfiguredTransport.Unknown, client.configuredTransport,
$"CheckStatus must recognize the '{urlProperty}' property as an HTTP URL");
Assert.That(
client.configuredTransport == ConfiguredTransport.Http
|| client.configuredTransport == ConfiguredTransport.HttpRemote,
$"Expected HTTP/HttpRemote transport for '{urlProperty}', got {client.configuredTransport}");
}
private sealed class FakeJsonConfigurator : JsonFileMcpConfigurator
{
public FakeJsonConfigurator(McpClient client) : base(client) { }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9b5bc8d0b36d477e8239edff10911bba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
using System.IO;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Clients.Configurators;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Clients
{
[TestFixture]
public class IsInstalledTests
{
[Test]
public void IMcpClientConfigurator_ExposesIsInstalled()
{
var prop = typeof(IMcpClientConfigurator).GetProperty("IsInstalled");
Assert.IsNotNull(prop, "IMcpClientConfigurator must expose an IsInstalled property");
Assert.AreEqual(typeof(bool), prop.PropertyType);
}
[Test]
public void JsonClient_NotInstalled_WhenParentDirMissing()
{
var cursor = new CursorConfigurator();
string parent = Path.GetDirectoryName(cursor.GetConfigPath());
if (parent == null || !Directory.Exists(parent))
{
Assert.IsFalse(cursor.IsInstalled,
"Cursor parent dir does not exist on this machine, IsInstalled must be false");
}
else
{
Assert.IsTrue(cursor.IsInstalled,
"Cursor parent dir exists, IsInstalled must be true");
}
}
[Test]
public void JsonClient_Installed_WhenParentDirExists()
{
var claude = new ClaudeDesktopConfigurator();
string parent = Path.GetDirectoryName(claude.GetConfigPath());
bool expected = parent != null && Directory.Exists(parent);
Assert.AreEqual(expected, claude.IsInstalled);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 15e00d05358545a5955aa1c291653289
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System.Linq;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Models;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Clients
{
[TestFixture]
public class SupportedTransportsTests
{
[Test]
public void IMcpClientConfigurator_ExposesSupportedTransports()
{
var prop = typeof(IMcpClientConfigurator).GetProperty("SupportedTransports");
Assert.IsNotNull(prop, "Must expose SupportedTransports");
}
[Test]
public void ClaudeDesktop_SupportsStdioOnly()
{
var claude = new ClaudeDesktopConfigurator();
CollectionAssert.Contains(claude.SupportedTransports.ToList(), ConfiguredTransport.Stdio);
CollectionAssert.DoesNotContain(claude.SupportedTransports.ToList(), ConfiguredTransport.Http);
}
[Test]
public void Cursor_SupportsBothTransports()
{
var cursor = new CursorConfigurator();
var list = cursor.SupportedTransports.ToList();
CollectionAssert.Contains(list, ConfiguredTransport.Stdio);
CollectionAssert.Contains(list, ConfiguredTransport.Http);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 26823824aa5cae0caa9488e9ad49bcf1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d539787bf8f6a426e94bfffb32a36d4f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Helpers
{
public class AssetPathUtilityOfflineTests
{
private bool _originalForceRefresh;
[SetUp]
public void SetUp()
{
_originalForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
}
[TearDown]
public void TearDown()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalForceRefresh);
}
[Test]
public void ShouldUseUvxOffline_WhenForceRefreshEnabled_ReturnsFalse()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, true);
Assert.IsFalse(AssetPathUtility.ShouldUseUvxOffline());
}
[Test]
public void ShouldUseUvxOffline_DoesNotThrow()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
Assert.DoesNotThrow(() => AssetPathUtility.ShouldUseUvxOffline());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 39ea6f0fc573340d689bf01ef5510153
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,156 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using EditorConfigCache = MCPForUnity.Editor.Services.EditorConfigurationCache;
namespace MCPForUnityTests.Editor.Helpers
{
// Per-client MCP config-format coverage.
//
// Issue #1120: Kilo Code v7.0.33+ moved its MCP config from the VS Code extension's
// globalStorage/mcp_settings.json (mcpServers / type:"http" or "streamableHttp" / disabled)
// to a CLI-style kilo.jsonc under ~/.config/kilo whose schema (https://app.kilo.ai/config.json)
// uses an "mcp" container, type:"remote" for HTTP servers, and an "enabled" flag. Writing the
// old format left the server showing as "stdio" + disabled. These tests pin the new Kilo format
// while guarding that Cline keeps "streamableHttp" and generic clients keep plain "http".
public class ClientConfigFormatTests
{
private const string UseHttpTransportPrefKey = EditorPrefKeys.UseHttpTransport;
private bool _hadHttpTransport;
private bool _originalHttpTransport;
[SetUp]
public void SetUp()
{
_hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey);
_originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
// Force HTTP transport so the remote/streamableHttp branch is exercised.
EditorPrefs.SetBool(UseHttpTransportPrefKey, true);
EditorConfigCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
if (_hadHttpTransport)
EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport);
else
EditorPrefs.DeleteKey(UseHttpTransportPrefKey);
EditorConfigCache.Instance.Refresh();
}
[Test]
public void KiloCodeConfigurator_DeclaresNewKiloFormat()
{
var client = new KiloCodeConfigurator().Client;
Assert.AreEqual("mcp", client.ServerContainerKey,
"Kilo Code's new schema nests servers under an \"mcp\" container, not \"mcpServers\"");
Assert.AreEqual("remote", client.HttpTypeValue,
"Kilo Code's new schema uses type:\"remote\" for HTTP servers");
Assert.AreEqual("local", client.StdioTypeValue,
"Kilo Code's new schema uses type:\"local\" for stdio servers");
Assert.AreEqual("https://app.kilo.ai/config.json", client.SchemaUrl,
"Kilo Code config should declare the kilo.jsonc $schema");
Assert.IsTrue(client.DefaultUnityFields.ContainsKey("enabled"),
"Kilo Code uses an \"enabled\" flag instead of \"disabled\"");
Assert.AreEqual(true, client.DefaultUnityFields["enabled"],
"Kilo Code must default enabled:true so the server is active");
Assert.IsFalse(client.DefaultUnityFields.ContainsKey("disabled"),
"Kilo Code's new schema must not write the legacy \"disabled\" field");
foreach (var path in new[] { client.windowsConfigPath, client.macConfigPath, client.linuxConfigPath })
{
Assert.AreEqual("kilo.jsonc", System.IO.Path.GetFileName(path),
"Kilo Code config must target kilo.jsonc, not mcp_settings.json");
}
}
[Test]
public void BuildManualConfigJson_ForKiloCode_UsesMcpContainerRemoteTypeAndEnabled()
{
var client = new KiloCodeConfigurator().Client;
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
Assert.AreEqual("https://app.kilo.ai/config.json", (string)root["$schema"],
"Kilo Code config should include the kilo.jsonc $schema at the root");
Assert.IsNull(root["mcpServers"], "Kilo Code must not use the legacy \"mcpServers\" container");
var unity = (JObject)root.SelectToken("mcp.unityMCP");
Assert.NotNull(unity, "Expected mcp.unityMCP node");
Assert.AreEqual("remote", (string)unity["type"],
"Kilo Code HTTP config must use type:remote, not type:http/streamableHttp");
Assert.AreEqual(true, (bool)unity["enabled"],
"Kilo Code config must set enabled:true");
Assert.IsNull(unity["disabled"], "Kilo Code must not write the legacy disabled field");
Assert.IsNotNull(unity["url"], "HTTP transport should set a url");
Assert.IsNull(unity["command"], "HTTP transport should not include a command");
}
[Test]
public void ApplyUnityServerToExistingConfig_ForKiloCode_RewritesStaleStdioToRemote()
{
var client = new KiloCodeConfigurator().Client;
// Simulate a stale local (stdio) entry that Kilo would have shown as the wrong transport.
var root = new JObject
{
["mcp"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = "uvx",
["args"] = new JArray("unity-mcp-server"),
["type"] = "local"
}
}
};
var result = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(root, uvPath: null, client);
Assert.AreEqual("https://app.kilo.ai/config.json", (string)result["$schema"],
"Rewrite should add the kilo.jsonc $schema when missing");
var unity = (JObject)result.SelectToken("mcp.unityMCP");
Assert.NotNull(unity, "Expected mcp.unityMCP node");
Assert.AreEqual("remote", (string)unity["type"],
"Existing config should be rewritten to type:remote for Kilo Code");
Assert.AreEqual(true, (bool)unity["enabled"], "Existing config should gain enabled:true");
Assert.IsNull(unity["command"], "HTTP transport should remove the stale stdio command");
}
[Test]
public void BuildManualConfigJson_ForCline_StillUsesStreamableHttpInMcpServers()
{
var client = new ClineConfigurator().Client;
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
Assert.IsNull(root["$schema"], "Cline must not write a $schema");
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("streamableHttp", (string)unity["type"],
"Cline must keep type:streamableHttp after the name-check was replaced with a flag");
}
[Test]
public void BuildManualConfigJson_ForGenericHttpClient_UsesPlainHttpType()
{
// A client without a type override must continue to receive the generic type:http.
var client = new McpClient { name = "Cursor" };
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("http", (string)unity["type"],
"Clients without HttpTypeValue should keep the generic type:http");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3e8c1a47f2d4e9b8a6c5d0f1e2a3b4c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,595 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.External.Tommy;
using MCPForUnity.Editor.Services;
using System.IO;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Helpers
{
public class CodexConfigHelperTests
{
/// <summary>
/// Validates that a TOML args array contains the expected uvx structure:
/// --from, a mcpforunityserver reference, mcp-for-unity package name,
/// and optionally --prerelease/explicit (only for prerelease builds).
/// </summary>
private static void AssertValidUvxArgs(TomlArray args)
{
var argValues = new List<string>();
foreach (TomlNode child in args.Children)
argValues.Add((child as TomlString).Value);
Assert.IsTrue(argValues.Contains("--from"), "Args should contain --from");
Assert.IsTrue(argValues.Any(a => a.Contains("mcpforunityserver")), "Args should contain PyPI package reference");
Assert.IsTrue(argValues.Contains("mcp-for-unity"), "Args should contain package name");
// Prerelease builds include --prerelease explicit before --from
int fromIndex = argValues.IndexOf("--from");
int prereleaseIndex = argValues.IndexOf("--prerelease");
if (prereleaseIndex >= 0)
{
Assert.IsTrue(prereleaseIndex < fromIndex, "--prerelease should come before --from");
Assert.AreEqual("explicit", argValues[prereleaseIndex + 1], "--prerelease should be followed by explicit");
}
}
/// <summary>
/// Mock platform service for testing
/// </summary>
private class MockPlatformService : IPlatformService
{
private readonly bool _isWindows;
private readonly string _systemRoot;
public MockPlatformService(bool isWindows, string systemRoot = "C:\\Windows")
{
_isWindows = isWindows;
_systemRoot = systemRoot;
}
public bool IsWindows() => _isWindows;
public string GetSystemRoot() => _isWindows ? _systemRoot : null;
}
private bool _hadGitOverride;
private string _originalGitOverride;
private bool _hadHttpTransport;
private bool _originalHttpTransport;
private bool _hadDevForceRefresh;
private bool _originalDevForceRefresh;
private IPlatformService _originalPlatformService;
[OneTimeSetUp]
public void OneTimeSetUp()
{
_hadGitOverride = EditorPrefs.HasKey(EditorPrefKeys.GitUrlOverride);
_originalGitOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
_hadHttpTransport = EditorPrefs.HasKey(EditorPrefKeys.UseHttpTransport);
_originalHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_hadDevForceRefresh = EditorPrefs.HasKey(EditorPrefKeys.DevModeForceServerRefresh);
_originalDevForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_originalPlatformService = MCPServiceLocator.Platform;
}
[SetUp]
public void SetUp()
{
// Ensure per-test deterministic Git URL (ignore developer overrides)
EditorPrefs.DeleteKey(EditorPrefKeys.GitUrlOverride);
// Default to stdio mode for existing tests unless specified otherwise
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Ensure deterministic uvx args ordering for these tests regardless of editor settings
// (dev-mode inserts --no-cache/--refresh, which changes the first args).
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
// Refresh the cache so it picks up the test's pref values
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// IMPORTANT:
// These tests can be executed while an MCP session is active (e.g., when running tests via MCP).
// MCPServiceLocator.Reset() disposes the bridge + transport manager, which can kill the MCP connection
// mid-run. Instead, restore only what this fixture mutates.
// To avoid leaking global state to other tests/fixtures, restore the original platform service
// instance captured before this fixture started running.
if (_originalPlatformService != null)
{
MCPServiceLocator.Register<IPlatformService>(_originalPlatformService);
}
else
{
MCPServiceLocator.Register<IPlatformService>(new PlatformService());
}
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
if (_hadGitOverride)
{
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, _originalGitOverride);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.GitUrlOverride);
}
if (_hadHttpTransport)
{
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _originalHttpTransport);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.UseHttpTransport);
}
if (_hadDevForceRefresh)
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalDevForceRefresh);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.DevModeForceServerRefresh);
}
}
[Test]
public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server\"",
"args = [\"mcp-for-unity\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should detect server definition");
Assert.AreEqual("uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx\"",
"args = [",
" \"mcp-for-unity\",",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx\"",
"args = [",
" \"mcp-for-unity\", # package name",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should tolerate comments within the array block");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_HeaderWithComment_StillDetected()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP] # annotated header",
"command = \"uvx\"",
"args = [\"mcp-for-unity\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should recognize section headers even with inline comments");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = 'uvx'",
"args = ['mcp-for-unity']"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void BuildCodexServerBlock_OnWindows_IncludesSystemRootEnv()
{
// This test verifies that Windows-specific environment configuration is included in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock Windows platform
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: true));
string uvPath = "C:\\Program Files\\uv\\uv.exe";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env.SystemRoot is present on Windows
bool hasEnv = unityMcp.TryGetNode("env", out var envNode);
Assert.IsTrue(hasEnv, "Windows config should contain env table");
Assert.IsInstanceOf<TomlTable>(envNode, "env should be a table");
var env = envNode as TomlTable;
Assert.IsTrue(env.TryGetNode("SystemRoot", out var systemRootNode), "env should contain SystemRoot");
Assert.IsInstanceOf<TomlString>(systemRootNode, "SystemRoot should be a string");
var systemRoot = (systemRootNode as TomlString).Value;
Assert.AreEqual("C:\\Windows", systemRoot, "SystemRoot should be C:\\Windows");
}
[Test]
public void BuildCodexServerBlock_OnNonWindows_ExcludesEnv()
{
// This test verifies that non-Windows platforms don't include env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock non-Windows platform (e.g., macOS/Linux)
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: false));
string uvPath = "/usr/local/bin/uv";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env is NOT present on non-Windows platforms
bool hasEnv = unityMcp.TryGetNode("env", out _);
Assert.IsFalse(hasEnv, "Non-Windows config should not contain env table");
}
[Test]
public void UpsertCodexServerBlock_OnWindows_IncludesSystemRootEnv()
{
// This test verifies the fix for https://github.com/CoplayDev/unity-mcp/issues/315
// Ensures that upsert operations also include Windows-specific env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock Windows platform
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: true, systemRoot: "C:\\Windows"));
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "C:\\path\\to\\uv.exe";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env.SystemRoot is present on Windows
bool hasEnv = unityMcp.TryGetNode("env", out var envNode);
Assert.IsTrue(hasEnv, "Windows config should contain env table");
Assert.IsInstanceOf<TomlTable>(envNode, "env should be a table");
var env = envNode as TomlTable;
Assert.IsTrue(env.TryGetNode("SystemRoot", out var systemRootNode), "env should contain SystemRoot");
Assert.IsInstanceOf<TomlString>(systemRootNode, "SystemRoot should be a string");
var systemRoot = (systemRootNode as TomlString).Value;
Assert.AreEqual("C:\\Windows", systemRoot, "SystemRoot should be C:\\Windows");
}
[Test]
public void UpsertCodexServerBlock_OnNonWindows_ExcludesEnv()
{
// This test verifies that upsert operations on non-Windows platforms don't include env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock non-Windows platform (e.g., macOS/Linux)
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: false));
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "/usr/local/bin/uv";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env is NOT present on non-Windows platforms
bool hasEnv = unityMcp.TryGetNode("env", out _);
Assert.IsFalse(hasEnv, "Non-Windows config should not contain env table");
}
[Test]
public void BuildCodexServerBlock_HttpMode_GeneratesUrlField()
{
// This test verifies HTTP transport mode generates url field instead of command/args
// Force HTTP mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
string uvPath = "C:\\Program Files\\uv\\uv.exe";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
// Verify features.rmcp_client is enabled for HTTP transport
Assert.IsTrue(parsed.TryGetNode("features", out var featuresNode), "HTTP mode should include features table");
Assert.IsInstanceOf<TomlTable>(featuresNode, "features should be a table");
var features = featuresNode as TomlTable;
Assert.IsTrue(features.TryGetNode("rmcp_client", out var rmcpNode), "features should include rmcp_client flag");
Assert.IsInstanceOf<TomlBoolean>(rmcpNode, "rmcp_client should be a boolean");
Assert.IsTrue((rmcpNode as TomlBoolean).Value, "rmcp_client should be true");
// Verify url field is present
Assert.IsTrue(unityMcp.TryGetNode("url", out var urlNode), "unityMCP should contain url in HTTP mode");
Assert.IsInstanceOf<TomlString>(urlNode, "url should be a string");
var url = (urlNode as TomlString).Value;
Assert.IsTrue(url.Contains("http"), "URL should be an HTTP endpoint");
Assert.IsTrue(url.Contains("/mcp"), "URL should contain /mcp path");
// Verify command and args are NOT present in HTTP mode
Assert.IsFalse(unityMcp.TryGetNode("command", out _), "HTTP mode should not contain command field");
Assert.IsFalse(unityMcp.TryGetNode("args", out _), "HTTP mode should not contain args field");
Assert.IsFalse(unityMcp.TryGetNode("env", out _), "HTTP mode should not contain env field");
}
[Test]
public void TryParseCodexServer_HttpMode_ParsesUrlSuccessfully()
{
// This test verifies HTTP mode parsing with url field
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"url = \"http://localhost:8080/mcp/v1/rpc\""
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args, out string url);
Assert.IsTrue(result, "Parser should accept HTTP mode with url field");
Assert.IsNull(command, "Command should be null in HTTP mode");
Assert.IsNull(args, "Args should be null in HTTP mode");
Assert.AreEqual("http://localhost:8080/mcp/v1/rpc", url, "URL should be parsed correctly");
}
[Test]
public void UpsertCodexServerBlock_HttpMode_GeneratesUrlField()
{
// This test verifies HTTP mode upsert generates url field
// Force HTTP mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "C:\\path\\to\\uv.exe";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
// Verify features.rmcp_client is enabled for HTTP transport
Assert.IsTrue(parsed.TryGetNode("features", out var featuresNode), "HTTP mode should include features table");
Assert.IsInstanceOf<TomlTable>(featuresNode, "features should be a table");
var features = featuresNode as TomlTable;
Assert.IsTrue(features.TryGetNode("rmcp_client", out var rmcpNode), "features should include rmcp_client flag");
Assert.IsInstanceOf<TomlBoolean>(rmcpNode, "rmcp_client should be a boolean");
Assert.IsTrue((rmcpNode as TomlBoolean).Value, "rmcp_client should be true");
// Verify url field is present
Assert.IsTrue(unityMcp.TryGetNode("url", out var urlNode), "unityMCP should contain url in HTTP mode");
Assert.IsInstanceOf<TomlString>(urlNode, "url should be a string");
var url = (urlNode as TomlString).Value;
Assert.IsTrue(url.Contains("http"), "URL should be an HTTP endpoint");
// Verify command and args are NOT present in HTTP mode
Assert.IsFalse(unityMcp.TryGetNode("command", out _), "HTTP mode should not contain command field");
Assert.IsFalse(unityMcp.TryGetNode("args", out _), "HTTP mode should not contain args field");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 013424dea29744a98b3dc01618f4e95e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System.Linq;
using MCPForUnity.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for Matrix4x4Converter to ensure it safely serializes matrices
/// without accessing dangerous computed properties (lossyScale, rotation).
/// Regression test for https://github.com/CoplayDev/unity-mcp/issues/478
/// </summary>
public class Matrix4x4ConverterTests
{
private JsonSerializerSettings _settings;
[SetUp]
public void SetUp()
{
_settings = new JsonSerializerSettings
{
Converters = { new Matrix4x4Converter() }
};
}
[Test]
public void Serialize_IdentityMatrix_ReturnsCorrectJson()
{
var matrix = Matrix4x4.identity;
var json = JsonConvert.SerializeObject(matrix, _settings);
Assert.That(json, Does.Contain("\"m00\":1"));
Assert.That(json, Does.Contain("\"m11\":1"));
Assert.That(json, Does.Contain("\"m22\":1"));
Assert.That(json, Does.Contain("\"m33\":1"));
Assert.That(json, Does.Contain("\"m01\":0"));
}
[Test]
public void Deserialize_IdentityMatrix_ReturnsIdentity()
{
var original = Matrix4x4.identity;
var json = JsonConvert.SerializeObject(original, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
Assert.That(result, Is.EqualTo(original));
}
[Test]
public void Serialize_TranslationMatrix_PreservesValues()
{
var matrix = Matrix4x4.Translate(new Vector3(10, 20, 30));
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
Assert.That(result.m03, Is.EqualTo(10f));
Assert.That(result.m13, Is.EqualTo(20f));
Assert.That(result.m23, Is.EqualTo(30f));
}
[Test]
public void Serialize_DegenerateMatrix_DoesNotCrashAndRoundtrips()
{
// This is the key test - a degenerate matrix that would crash
// if we accessed lossyScale or rotation properties
var matrix = new Matrix4x4();
matrix.m00 = 0; matrix.m11 = 0; matrix.m22 = 0; // Degenerate - determinant = 0
// This should NOT throw or crash - the old code would fail here
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
// Verify JSON only contains raw mXY properties
var jo = JObject.Parse(json);
var expectedProps = new[]
{
"m00", "m01", "m02", "m03",
"m10", "m11", "m12", "m13",
"m20", "m21", "m22", "m23",
"m30", "m31", "m32", "m33"
};
CollectionAssert.AreEquivalent(expectedProps, jo.Properties().Select(p => p.Name).ToArray());
// Verify values roundtrip correctly (all zeros for degenerate matrix)
Assert.That(result.m00, Is.EqualTo(0f));
Assert.That(result.m11, Is.EqualTo(0f));
Assert.That(result.m22, Is.EqualTo(0f));
Assert.That(result, Is.EqualTo(matrix));
}
[Test]
public void Serialize_NonTRSMatrix_DoesNotCrash()
{
// Projection matrices are NOT valid TRS matrices
// Accessing lossyScale/rotation on them causes ValidTRS() assertion
var matrix = Matrix4x4.Perspective(60f, 1.77f, 0.1f, 1000f);
// Verify it's not a valid TRS matrix
Assert.That(matrix.ValidTRS(), Is.False, "Test requires non-TRS matrix");
// This should NOT throw - the fix ensures we never access computed properties
Assert.DoesNotThrow(() =>
{
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
});
}
[Test]
public void Deserialize_NullToken_ReturnsZeroMatrix()
{
var json = "null";
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
// Returns zero matrix (consistent with missing field defaults of 0f)
Assert.That(result, Is.EqualTo(new Matrix4x4()));
}
[Test]
public void Serialize_DoesNotContainDangerousProperties()
{
var matrix = Matrix4x4.TRS(Vector3.one, Quaternion.identity, Vector3.one);
var json = JsonConvert.SerializeObject(matrix, _settings);
// Ensure we're not serializing the dangerous computed properties
Assert.That(json, Does.Not.Contain("lossyScale"));
Assert.That(json, Does.Not.Contain("rotation"));
Assert.That(json, Does.Not.Contain("inverse"));
Assert.That(json, Does.Not.Contain("transpose"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d2c5dd202b4944675ae606581676a24a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,209 @@
using System.Collections.Generic;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for the standard Pagination classes.
/// </summary>
public class PaginationTests
{
#region PaginationRequest Tests
[Test]
public void PaginationRequest_FromParams_ParsesPageSizeSnakeCase()
{
var p = new JObject { ["page_size"] = 25 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(25, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_ParsesPageSizeCamelCase()
{
var p = new JObject { ["pageSize"] = 30 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(30, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_ParsesCursor()
{
var p = new JObject { ["cursor"] = 50 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(50, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_ConvertsPageNumberToCursor()
{
// page_number is 1-based, should convert to 0-based cursor
var p = new JObject { ["page_number"] = 3, ["page_size"] = 10 };
var req = PaginationRequest.FromParams(p);
// Page 3 with page size 10 means items 20-29, so cursor should be 20
Assert.AreEqual(20, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_CursorTakesPrecedenceOverPageNumber()
{
// If both cursor and page_number are specified, cursor should win
var p = new JObject { ["cursor"] = 100, ["page_number"] = 1 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(100, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_UsesDefaultsForNullParams()
{
var req = PaginationRequest.FromParams(null);
Assert.AreEqual(50, req.PageSize);
Assert.AreEqual(0, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_UsesDefaultsForEmptyParams()
{
var req = PaginationRequest.FromParams(new JObject());
Assert.AreEqual(50, req.PageSize);
Assert.AreEqual(0, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_AcceptsCustomDefaultPageSize()
{
var req = PaginationRequest.FromParams(new JObject(), defaultPageSize: 100);
Assert.AreEqual(100, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_HandleStringValues()
{
// Some clients might send string values
var p = new JObject { ["page_size"] = "15", ["cursor"] = "5" };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(15, req.PageSize);
Assert.AreEqual(5, req.Cursor);
}
#endregion
#region PaginationResponse Tests
[Test]
public void PaginationResponse_Create_ReturnsCorrectPageOfItems()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(3, response.Items.Count);
Assert.AreEqual(new List<int> { 1, 2, 3 }, response.Items);
}
[Test]
public void PaginationResponse_Create_ReturnsCorrectMiddlePage()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(3, response.Items.Count);
Assert.AreEqual(new List<int> { 4, 5, 6 }, response.Items);
}
[Test]
public void PaginationResponse_Create_HandlesLastPage()
{
var allItems = new List<int> { 1, 2, 3, 4, 5 };
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(2, response.Items.Count);
Assert.AreEqual(new List<int> { 4, 5 }, response.Items);
Assert.IsNull(response.NextCursor);
Assert.IsFalse(response.HasMore);
}
[Test]
public void PaginationResponse_HasMore_TrueWhenNextCursorSet()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6 };
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.IsTrue(response.HasMore);
Assert.AreEqual(3, response.NextCursor);
}
[Test]
public void PaginationResponse_HasMore_FalseWhenNoMoreItems()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.IsFalse(response.HasMore);
Assert.IsNull(response.NextCursor);
}
[Test]
public void PaginationResponse_Create_SetsCorrectTotalCount()
{
var allItems = new List<string> { "a", "b", "c", "d", "e" };
var request = new PaginationRequest { PageSize = 2, Cursor = 0 };
var response = PaginationResponse<string>.Create(allItems, request);
Assert.AreEqual(5, response.TotalCount);
}
[Test]
public void PaginationResponse_Create_HandlesEmptyList()
{
var allItems = new List<int>();
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Items.Count);
Assert.AreEqual(0, response.TotalCount);
Assert.IsNull(response.NextCursor);
Assert.IsFalse(response.HasMore);
}
[Test]
public void PaginationResponse_Create_ClampsCursorToValidRange()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = 100 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Items.Count);
Assert.AreEqual(3, response.Cursor); // Clamped to totalCount
}
[Test]
public void PaginationResponse_Create_HandlesNegativeCursor()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = -5 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Cursor); // Clamped to 0
Assert.AreEqual(3, response.Items.Count);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6d0177f4432b41c6bf7e0013cd5a2f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,469 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for the ToolParams parameter validation wrapper.
/// </summary>
public class ToolParamsTests
{
#region Constructor Tests
[Test]
public void ToolParams_Constructor_ThrowsOnNullParams()
{
Assert.Throws<System.ArgumentNullException>(() => new ToolParams(null));
}
[Test]
public void ToolParams_Constructor_AcceptsEmptyJObject()
{
Assert.DoesNotThrow(() => new ToolParams(new JObject()));
}
#endregion
#region GetRequired Tests
[Test]
public void GetRequired_ExistingParameter_ReturnsSuccess()
{
var json = new JObject { ["action"] = "create" };
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual("create", result.Value);
Assert.IsNull(result.ErrorMessage);
}
[Test]
public void GetRequired_MissingParameter_ReturnsError()
{
var json = new JObject();
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.That(result.ErrorMessage, Does.Contain("'action' parameter is required"));
}
[Test]
public void GetRequired_EmptyStringParameter_ReturnsError()
{
var json = new JObject { ["action"] = "" };
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsFalse(result.IsSuccess);
Assert.That(result.ErrorMessage, Does.Contain("'action' parameter is required"));
}
[Test]
public void GetRequired_CustomErrorMessage_ReturnsCustomError()
{
var json = new JObject();
var p = new ToolParams(json);
var result = p.GetRequired("action", "Custom error message");
Assert.IsFalse(result.IsSuccess);
Assert.AreEqual("Custom error message", result.ErrorMessage);
}
#endregion
#region Get Tests
[Test]
public void Get_ExistingParameter_ReturnsValue()
{
var json = new JObject { ["name"] = "TestObject" };
var p = new ToolParams(json);
var value = p.Get("name");
Assert.AreEqual("TestObject", value);
}
[Test]
public void Get_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.Get("name");
Assert.IsNull(value);
}
[Test]
public void Get_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.Get("name", "DefaultName");
Assert.AreEqual("DefaultName", value);
}
#endregion
#region Snake/Camel Case Fallback Tests
[Test]
public void Get_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["search_method"] = "by_name" };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var value = p.Get("searchMethod");
Assert.AreEqual("by_name", value);
}
[Test]
public void Get_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["searchMethod"] = "by_name" };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var value = p.Get("search_method");
Assert.AreEqual("by_name", value);
}
[Test]
public void Get_ExactMatchTakesPrecedence()
{
// If both snake_case and camelCase exist, exact match wins
var json = new JObject
{
["search_method"] = "snake",
["searchMethod"] = "camel"
};
var p = new ToolParams(json);
Assert.AreEqual("snake", p.Get("search_method"));
Assert.AreEqual("camel", p.Get("searchMethod"));
}
#endregion
#region GetInt Tests
[Test]
public void GetInt_ValidInteger_ReturnsValue()
{
var json = new JObject { ["count"] = "10" };
var p = new ToolParams(json);
var value = p.GetInt("count");
Assert.AreEqual(10, value);
}
[Test]
public void GetInt_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetInt("count");
Assert.IsNull(value);
}
[Test]
public void GetInt_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetInt("count", 5);
Assert.AreEqual(5, value);
}
[Test]
public void GetInt_InvalidInteger_ReturnsDefault()
{
var json = new JObject { ["count"] = "not_a_number" };
var p = new ToolParams(json);
var value = p.GetInt("count", 5);
Assert.AreEqual(5, value);
}
#endregion
#region GetFloat Tests
[Test]
public void GetFloat_ValidFloat_ReturnsValue()
{
var json = new JObject { ["scale"] = "2.5" };
var p = new ToolParams(json);
var value = p.GetFloat("scale");
Assert.AreEqual(2.5f, value);
}
[Test]
public void GetFloat_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetFloat("scale");
Assert.IsNull(value);
}
[Test]
public void GetFloat_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetFloat("scale", 1.0f);
Assert.AreEqual(1.0f, value);
}
#endregion
#region GetBool Tests
[Test]
public void GetBool_TrueBoolean_ReturnsTrue()
{
var json = new JObject { ["enabled"] = true };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsTrue(value);
}
[Test]
public void GetBool_FalseBoolean_ReturnsFalse()
{
var json = new JObject { ["enabled"] = false };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsFalse(value);
}
[Test]
public void GetBool_MissingParameter_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetBool("enabled", true);
Assert.IsTrue(value);
}
[Test]
public void GetBool_StringTrue_ReturnsTrue()
{
var json = new JObject { ["enabled"] = "true" };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsTrue(value);
}
[Test]
public void GetBool_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["include_inactive"] = true };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var value = p.GetBool("includeInactive");
Assert.IsTrue(value);
}
[Test]
public void GetBool_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["includeInactive"] = true };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var value = p.GetBool("include_inactive");
Assert.IsTrue(value);
}
#endregion
#region Has Tests
[Test]
public void Has_ExistingParameter_ReturnsTrue()
{
var json = new JObject { ["key"] = "value" };
var p = new ToolParams(json);
Assert.IsTrue(p.Has("key"));
}
[Test]
public void Has_MissingParameter_ReturnsFalse()
{
var json = new JObject();
var p = new ToolParams(json);
Assert.IsFalse(p.Has("key"));
}
[Test]
public void Has_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["search_term"] = "Player" };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
Assert.IsTrue(p.Has("searchTerm"));
}
[Test]
public void Has_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["searchTerm"] = "Player" };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
Assert.IsTrue(p.Has("search_term"));
}
#endregion
#region GetRaw Tests
[Test]
public void GetRaw_ComplexObject_ReturnsJToken()
{
var json = new JObject { ["data"] = new JObject { ["nested"] = "value" } };
var p = new ToolParams(json);
var raw = p.GetRaw("data");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual("value", raw["nested"]?.ToString());
}
[Test]
public void GetRaw_Array_ReturnsJToken()
{
var json = new JObject { ["items"] = new JArray { "a", "b", "c" } };
var p = new ToolParams(json);
var raw = p.GetRaw("items");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JArray>(raw);
Assert.AreEqual(3, ((JArray)raw).Count);
}
[Test]
public void GetRaw_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["component_properties"] = new JObject { ["mass"] = 1.5 } };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var raw = p.GetRaw("componentProperties");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual(1.5, raw["mass"]?.Value<double>());
}
[Test]
public void GetRaw_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["componentProperties"] = new JObject { ["mass"] = 1.5 } };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var raw = p.GetRaw("component_properties");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual(1.5, raw["mass"]?.Value<double>());
}
#endregion
#region Result<T> Tests
[Test]
public void Result_Success_IsSuccessTrue()
{
var result = Result<string>.Success("value");
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual("value", result.Value);
Assert.IsNull(result.ErrorMessage);
}
[Test]
public void Result_Error_IsSuccessFalse()
{
var result = Result<string>.Error("error message");
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.AreEqual("error message", result.ErrorMessage);
}
[Test]
public void Result_GetOrError_Success_ReturnsNull()
{
var result = Result<string>.Success("value");
var error = result.GetOrError(out var value);
Assert.IsNull(error);
Assert.AreEqual("value", value);
}
[Test]
public void Result_GetOrError_Error_ReturnsErrorResponse()
{
var result = Result<string>.Error("error message");
var error = result.GetOrError(out var value);
Assert.IsNotNull(error);
Assert.IsInstanceOf<ErrorResponse>(error);
Assert.IsNull(value);
var errorResponse = error as ErrorResponse;
Assert.AreEqual("error message", errorResponse.error);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8207a28487246e8a3c5f28481581354
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,475 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using EditorConfigCache = MCPForUnity.Editor.Services.EditorConfigurationCache;
namespace MCPForUnityTests.Editor.Helpers
{
public class WriteToConfigTests
{
private const string UseHttpTransportPrefKey = EditorPrefKeys.UseHttpTransport;
private const string HttpUrlPrefKey = EditorPrefKeys.HttpBaseUrl;
private string _tempRoot;
private string _fakeUvPath;
private string _serverSrcDir;
// Save/restore original pref values (must happen BEFORE Assert.Ignore since TearDown still runs)
private bool _hadHttpTransport;
private bool _originalHttpTransport;
private bool _hadHttpUrl;
private string _originalHttpUrl;
[SetUp]
public void SetUp()
{
// Save original pref values FIRST - TearDown runs even when test is ignored!
_hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey);
_originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
_hadHttpUrl = EditorPrefs.HasKey(HttpUrlPrefKey);
_originalHttpUrl = EditorPrefs.GetString(HttpUrlPrefKey, "");
// Tests are designed for Linux/macOS runners. Skip on Windows due to ProcessStartInfo
// restrictions when UseShellExecute=false for .cmd/.bat scripts.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Assert.Ignore("WriteToConfig tests are skipped on Windows (CI runs linux).\n" +
"ValidateUvBinarySafe requires launching an actual exe on Windows.");
}
_tempRoot = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
// Create a fake uv executable that prints a valid version string
_fakeUvPath = Path.Combine(_tempRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.cmd" : "uv");
File.WriteAllText(_fakeUvPath, "#!/bin/sh\n\necho 'uv 9.9.9'\n");
TryChmodX(_fakeUvPath);
// Create a fake server directory with server.py
_serverSrcDir = Path.Combine(_tempRoot, "server-src");
Directory.CreateDirectory(_serverSrcDir);
File.WriteAllText(Path.Combine(_serverSrcDir, "server.py"), "# dummy server\n");
// Point the editor to our server dir (so ResolveServerSrc() uses this)
EditorPrefs.SetString(EditorPrefKeys.ServerSrc, _serverSrcDir);
// Ensure no lock is enabled
EditorPrefs.SetBool(EditorPrefKeys.LockCursorConfig, false);
// Disable auto-registration to avoid hitting user configs during tests
EditorPrefs.SetBool(EditorPrefKeys.AutoRegisterEnabled, false);
// Force HTTP transport defaults so expectations match current behavior
EditorPrefs.SetBool(UseHttpTransportPrefKey, true);
EditorPrefs.SetString(HttpUrlPrefKey, "http://localhost:8080");
EditorConfigCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Clean up editor preferences set during SetUp
EditorPrefs.DeleteKey(EditorPrefKeys.ServerSrc);
EditorPrefs.DeleteKey(EditorPrefKeys.LockCursorConfig);
EditorPrefs.DeleteKey(EditorPrefKeys.AutoRegisterEnabled);
// Restore original pref values (don't delete if user had them set!)
if (_hadHttpTransport)
EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport);
else
EditorPrefs.DeleteKey(UseHttpTransportPrefKey);
if (_hadHttpUrl)
EditorPrefs.SetString(HttpUrlPrefKey, _originalHttpUrl);
else
EditorPrefs.DeleteKey(HttpUrlPrefKey);
// Remove temp files
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }
}
// --- Tests ---
[Test]
public void AddsDisabledFalseAndServerUrl_ForWindsurf()
{
var configPath = Path.Combine(_tempRoot, "windsurf.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "Windsurf configs should not include an env block");
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing");
AssertTransportConfiguration(unity, client);
}
[Test]
public void AddsEnvAndDisabledFalse_ForKiro()
{
var configPath = Path.Combine(_tempRoot, "kiro.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient
{
name = "Kiro",
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.NotNull(unity["env"], "env should be present for all clients");
Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object");
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Kiro when missing");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForCursor()
{
var configPath = Path.Combine(_tempRoot, "cursor.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "Cursor" };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "env should not be added for non-Windsurf/Kiro clients");
Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForVSCode()
{
var configPath = Path.Combine(_tempRoot, "vscode.json");
WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "VSCode", IsVsCodeLayout = true };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("servers.unityMCP");
Assert.NotNull(unity, "Expected servers.unityMCP node");
Assert.IsNull(unity["env"], "env should not be added for VSCode client");
Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForTrae()
{
var configPath = Path.Combine(_tempRoot, "trae.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "Trae" };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "env should not be added for Trae client");
Assert.IsNull(unity["disabled"], "disabled should not be added for Trae client");
AssertTransportConfiguration(unity, client);
}
[Test]
public void ClaudeDesktop_UsesAbsoluteUvPath_WhenOverrideProvided()
{
var configPath = Path.Combine(_tempRoot, "claude-desktop.json");
WriteInitialConfig(configPath, isVSCode: false, command: "uvx", directory: "/old/path");
WithTransportPreference(false, () =>
{
MCPServiceLocator.Paths.SetUvxPathOverride(_fakeUvPath);
try
{
var client = new McpClient
{
name = "Claude Desktop",
SupportsHttpTransport = false,
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual(_fakeUvPath, (string)unity["command"], "Claude Desktop should use absolute uvx path");
Assert.IsNull(unity["env"], "Claude Desktop config should not include env block when not required");
AssertTransportConfiguration(unity, client);
}
finally
{
MCPServiceLocator.Paths.ClearUvxPathOverride();
}
});
}
[Test]
public void PreservesExistingEnvAndDisabled_ForKiro()
{
var configPath = Path.Combine(_tempRoot, "preserve.json");
// Existing config with env and disabled=true should be preserved
var json = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = _fakeUvPath,
["args"] = new JArray("run", "--directory", "/old/path", "server.py"),
["env"] = new JObject { ["FOO"] = "bar" },
["disabled"] = true
}
}
};
File.WriteAllText(configPath, json.ToString());
var client = new McpClient
{
name = "Kiro",
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("bar", (string)unity["env"]!["FOO"], "Existing env should be preserved");
Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved");
AssertTransportConfiguration(unity, client);
}
[Test]
public void RemovesEnvBlock_ForWindsurf()
{
var configPath = Path.Combine(_tempRoot, "windsurf-env.json");
var json = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = _fakeUvPath,
["args"] = new JArray("run", "--directory", "/old/path", "server.py"),
["env"] = new JObject { ["SHOULD"] = "be removed" },
["disabled"] = true
}
}
};
File.WriteAllText(configPath, json.ToString());
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "Windsurf config should strip any existing env block");
Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved");
AssertTransportConfiguration(unity, client);
}
[Test]
public void UsesStdioTransport_ForNonVSCodeClients_WhenPreferenceDisabled()
{
var configPath = Path.Combine(_tempRoot, "stdio-non-vscode.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
WithTransportPreference(false, () =>
{
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
AssertTransportConfiguration(unity, client);
});
}
[Test]
public void UsesStdioTransport_ForVSCode_WhenPreferenceDisabled()
{
var configPath = Path.Combine(_tempRoot, "stdio-vscode.json");
WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path");
WithTransportPreference(false, () =>
{
var client = new McpClient { name = "VSCode", IsVsCodeLayout = true };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("servers.unityMCP");
Assert.NotNull(unity, "Expected servers.unityMCP node");
AssertTransportConfiguration(unity, client);
});
}
// --- Helpers ---
private static void TryChmodX(string path)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "/bin/chmod",
Arguments = "+x \"" + path + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var p = Process.Start(psi);
p?.WaitForExit(2000);
}
catch { /* best-effort on non-Unix */ }
}
private static void WriteInitialConfig(string configPath, bool isVSCode, string command, string directory)
{
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
JObject root;
if (isVSCode)
{
root = new JObject
{
["servers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = command,
["args"] = new JArray("run", "--directory", directory, "server.py"),
["type"] = "stdio"
}
}
};
}
else
{
root = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = command,
["args"] = new JArray("run", "--directory", directory, "server.py")
}
}
};
}
File.WriteAllText(configPath, root.ToString());
}
private static void InvokeWriteToConfig(string configPath, McpClient client)
{
var result = McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
Assert.AreEqual("Configured successfully", result, "WriteMcpConfiguration should return success");
}
private static void AssertTransportConfiguration(JObject unity, McpClient client)
{
bool useHttp = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
bool isWindsurf = string.Equals(client.HttpUrlProperty, "serverUrl", StringComparison.OrdinalIgnoreCase);
if (useHttp)
{
string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
if (isWindsurf)
{
Assert.AreEqual(expectedUrl, (string)unity["serverUrl"],
"Windsurf should advertise HTTP using serverUrl");
Assert.IsNull(unity["url"], "Windsurf configs should not use the url property");
}
else
{
Assert.AreEqual(expectedUrl, (string)unity["url"],
"HTTP transport should set url to the MCP endpoint");
Assert.IsNull(unity["serverUrl"], "serverUrl should be reserved for Windsurf");
}
Assert.IsNull(unity["command"], "HTTP transport should remove command");
Assert.IsNull(unity["args"], "HTTP transport should remove args");
// "type" is now included for all clients (standard MCP protocol field).
Assert.AreEqual("http", (string)unity["type"],
"All entries should advertise HTTP transport type");
}
else
{
Assert.IsNull(unity["url"], "stdio transport should not include a url");
Assert.IsNull(unity["serverUrl"], "stdio transport should not include a serverUrl");
string command = (string)unity["command"];
Assert.False(string.IsNullOrEmpty(command), "stdio transport should include a command");
var args = (unity["args"] as JArray)?.ToObject<string[]>();
Assert.NotNull(args, "stdio transport should include args array");
int transportIndex = Array.IndexOf(args, "--transport");
Assert.GreaterOrEqual(transportIndex, 0, "args should include --transport flag");
Assert.Less(transportIndex + 1, args.Length,
"--transport flag should be followed by a mode value");
Assert.AreEqual("stdio", args[transportIndex + 1],
"--transport should be followed by stdio mode");
// "type" is now included for all clients (standard MCP protocol field).
Assert.AreEqual("stdio", (string)unity["type"],
"All entries should advertise stdio transport type");
}
}
private static void WithTransportPreference(bool useHttp, Action action)
{
bool original = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
EditorPrefs.SetBool(UseHttpTransportPrefKey, useHttp);
EditorConfigCache.Instance.Refresh();
try
{
action();
}
finally
{
EditorPrefs.SetBool(UseHttpTransportPrefKey, original);
EditorConfigCache.Instance.Refresh();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3fc4210e7cbef4479b2cb9498b1580a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
{
"name": "MCPForUnityTests.EditMode",
"rootNamespace": "",
"references": [
"MCPForUnity.Editor",
"MCPForUnity.Runtime",
"TestAsmdef",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll",
"Newtonsoft.Json.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 32956d76430ba4ea4a794f28ee983d16
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using System;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor
{
/// <summary>
/// Locks the strict override contract of Claude CLI resolution after discovery was
/// consolidated into ExecPath.ResolveClaude(): a valid override is honored, and an
/// override that points at a missing file returns null rather than falling back to
/// auto-discovery.
/// </summary>
public class PathResolverClaudeCliTests
{
private const string Key = EditorPrefKeys.ClaudeCliPathOverride;
private bool _hadOverride;
private string _savedOverride;
[SetUp]
public void SetUp()
{
_savedOverride = EditorPrefs.GetString(Key, string.Empty);
_hadOverride = !string.IsNullOrEmpty(_savedOverride);
}
[TearDown]
public void TearDown()
{
if (_hadOverride) EditorPrefs.SetString(Key, _savedOverride);
else EditorPrefs.DeleteKey(Key);
}
[Test]
public void GetClaudeCliPath_ValidOverride_ReturnsOverride()
{
string stub = Path.Combine(Path.GetTempPath(), "mcp_claude_stub_" + Guid.NewGuid().ToString("N"));
File.WriteAllText(stub, "stub");
try
{
EditorPrefs.SetString(Key, stub);
var resolver = new PathResolverService();
Assert.AreEqual(stub, resolver.GetClaudeCliPath());
Assert.IsTrue(resolver.IsClaudeCliDetected());
}
finally
{
File.Delete(stub);
}
}
[Test]
public void GetClaudeCliPath_InvalidOverride_ReturnsNull_WithoutDiscoveryFallback()
{
string missing = Path.Combine(Path.GetTempPath(), "mcp_claude_missing_" + Guid.NewGuid().ToString("N"));
EditorPrefs.SetString(Key, missing);
var resolver = new PathResolverService();
Assert.IsNull(resolver.GetClaudeCliPath());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 762a862b7c1c4f3b9526dbff1c7e153a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 552680d3640c64564b19677d789515c3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Resources.MenuItems;
using System;
using System.Linq;
namespace MCPForUnityTests.Editor.Resources.MenuItems
{
public class GetMenuItemsTests
{
private static JObject ToJO(object o) => JObject.FromObject(o);
[Test]
public void NoSearch_ReturnsSuccessAndArray()
{
var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false });
var jo = ToJO(res);
Assert.IsTrue((bool)jo["success"], "Expected success true");
Assert.IsNotNull(jo["data"], "Expected data field present");
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
// Validate list is sorted ascending when there are multiple items
var arr = (JArray)jo["data"];
if (arr.Count >= 2)
{
var original = arr.Select(t => (string)t).ToList();
var sorted = original.OrderBy(s => s, StringComparer.Ordinal).ToList();
CollectionAssert.AreEqual(sorted, original, "Expected menu items to be sorted ascending");
}
}
[Test]
public void SearchNoMatch_ReturnsEmpty()
{
var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "___unlikely___term___" });
var jo = ToJO(res);
Assert.IsTrue((bool)jo["success"], "Expected success true");
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
Assert.AreEqual(0, jo["data"].Count(), "Expected no results for unlikely search term");
}
[Test]
public void SearchMatchesExistingItem_ReturnsContainingItem()
{
// Get the full list first
var listRes = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false });
var listJo = ToJO(listRes);
if (listJo["data"] is JArray arr && arr.Count > 0)
{
var first = (string)arr[0];
// Use a mid-substring (case-insensitive) to avoid edge cases
var term = first.Length > 4 ? first.Substring(1, Math.Min(3, first.Length - 2)) : first;
term = term.ToLowerInvariant();
var res = GetMenuItems.HandleCommand(new JObject { ["search"] = term, ["refresh"] = false });
var jo = ToJO(res);
Assert.IsTrue((bool)jo["success"], "Expected success true");
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
// Expect at least the original item to be present
var names = ((JArray)jo["data"]).Select(t => (string)t).ToList();
CollectionAssert.Contains(names, first, "Expected search results to include the sampled item");
}
else
{
Assert.Pass("No menu items available to perform a content-based search assertion.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dbae8d670978f4a2bb525d7da9ed9f34
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a7b66499ec8924852a539d5cc4378c0d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7b2c3d4e5f6a7890123456789abcdef3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,899 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace MCPForUnityTests.Editor.Services.Characterization
{
/// <summary>
/// Characterization tests for ServerManagementService public interface.
/// These tests lock down current behavior BEFORE refactoring to ensure
/// no regressions during the decomposition into focused components.
/// </summary>
[TestFixture]
public class ServerManagementServiceCharacterizationTests
{
private ServerManagementService _service;
private bool _savedUseHttpTransport;
private string _savedHttpUrl;
private string _savedHttpRemoteUrl;
private string _savedHttpTransportScope;
private bool _savedAllowLanHttpBind;
private bool _savedAllowInsecureRemoteHttp;
private bool _savedLaunchConfirmed;
[SetUp]
public void SetUp()
{
_service = new ServerManagementService();
// Save current settings
_savedLaunchConfirmed = EditorPrefs.GetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedHttpUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_savedHttpRemoteUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
_savedHttpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
_savedAllowLanHttpBind = EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
_savedAllowInsecureRemoteHttp = EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
}
[TearDown]
public void TearDown()
{
// Restore settings
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
if (!string.IsNullOrEmpty(_savedHttpUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, _savedHttpUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpBaseUrl);
}
if (!string.IsNullOrEmpty(_savedHttpRemoteUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, _savedHttpRemoteUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpRemoteBaseUrl);
}
if (!string.IsNullOrEmpty(_savedHttpTransportScope))
{
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, _savedHttpTransportScope);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpTransportScope);
}
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, _savedAllowLanHttpBind);
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, _savedAllowInsecureRemoteHttp);
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, _savedLaunchConfirmed);
// Refresh cache to reflect restored values
EditorConfigurationCache.Instance.Refresh();
}
#region StartLocalHttpServer First-Time-Confirm Gating
// A fake launcher that records the headless launch request, then returns a harmless
// no-op process (true / cmd exit) so Process.Start SUCCEEDS and exits immediately — no real
// server starts, and (unlike a non-existent path) no exception + error dialog that would
// block an interactive test run (Test Runner window / MCP bridge); -batchmode suppresses
// dialogs, but interactive runs do not.
private sealed class RecordingTerminalLauncher : ITerminalLauncher
{
public bool HeadlessCalled;
public string LastCommand;
public string LastLogPath;
public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
{
return HarmlessNoOpStartInfo();
}
public System.Diagnostics.ProcessStartInfo CreateHeadlessProcessStartInfo(string command, string logFilePath)
{
HeadlessCalled = true;
LastCommand = command;
LastLogPath = logFilePath;
return HarmlessNoOpStartInfo();
}
public string GetProjectRootPath()
{
return System.IO.Path.GetTempPath();
}
// A binary that always launches and exits 0 immediately, so Process.Start does not throw.
private static System.Diagnostics.ProcessStartInfo HarmlessNoOpStartInfo()
{
var psi = new System.Diagnostics.ProcessStartInfo
{
UseShellExecute = false,
CreateNoWindow = true
};
if (Application.platform == RuntimePlatform.WindowsEditor)
{
psi.FileName = "cmd.exe";
psi.Arguments = "/c exit";
}
else
{
psi.FileName = System.IO.File.Exists("/usr/bin/true") ? "/usr/bin/true" : "/bin/true";
}
return psi;
}
}
// A fake command builder that always yields a benign, valid command so StartLocalHttpServer
// does not bail out before reaching the confirmation gate.
private sealed class FakeCommandBuilder : IServerCommandBuilder
{
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
{
fileName = "uvx";
arguments = "run mcp-for-unity";
displayCommand = "uvx run mcp-for-unity";
error = null;
return true;
}
public string BuildUvPathFromUvx(string uvxPath) => uvxPath;
public string GetPlatformSpecificPathPrepend() => string.Empty;
public string QuoteIfNeeded(string input) =>
(!string.IsNullOrEmpty(input) && input.Contains(" ")) ? "\"" + input + "\"" : input;
}
// A fake process detector that reports no listeners (so the "port in use" branch is skipped).
private sealed class NoListenersProcessDetector : IProcessDetector
{
public bool LooksLikeMcpServerProcess(int pid) => false;
public bool TryGetProcessCommandLine(int pid, out string argsLower) { argsLower = string.Empty; return false; }
public System.Collections.Generic.List<int> GetListeningProcessIdsForPort(int port) => new System.Collections.Generic.List<int>();
public int GetCurrentProcessId() => -1;
public bool ProcessExists(int pid) => false;
public string NormalizeForMatch(string input) => (input ?? string.Empty).Replace(" ", string.Empty).ToLowerInvariant();
}
private ServerManagementService BuildServiceWithFakeLauncher(RecordingTerminalLauncher launcher)
{
return new ServerManagementService(
new NoListenersProcessDetector(),
null,
null,
new FakeCommandBuilder(),
launcher);
}
[Test]
public void StartLocalHttpServer_AlreadyConfirmed_SkipsDialogAndLaunchesHeadless()
{
// Arrange - local URL + HTTP enabled + confirmation already given.
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59998");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, true);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act - non-quiet, but confirmed: must NOT show a dialog and must launch headless.
// (The fake launcher aborts the real spawn, so the return value may be false; we assert on the launch attempt.)
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: false);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert - the headless launch path was taken (gate passed without a dialog).
Assert.IsTrue(launcher.HeadlessCalled, "Confirmed launch should reach the headless launcher without a dialog");
StringAssert.Contains("uvx run mcp-for-unity", launcher.LastCommand,
"The headless command should be the built server command");
}
[Test]
public void StartLocalHttpServer_Quiet_BypassesDialogAndDoesNotSetConfirmedFlag()
{
// Arrange - local URL + HTTP enabled + NOT yet confirmed.
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59997");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act - quiet (auto-start) path must never show a dialog and must launch headless.
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: true);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert
Assert.IsTrue(launcher.HeadlessCalled, "Quiet launch should reach the headless launcher without a dialog");
Assert.IsFalse(EditorPrefs.GetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false),
"Quiet auto-start must NOT set the first-time-confirm flag");
}
[Test]
public void StartLocalHttpServer_LaunchesIntoPerPortLaunchLog()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59996");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, true);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: false);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert - the launch log path is the per-port server-launch log.
Assert.IsTrue(launcher.HeadlessCalled, "Launch should reach the headless launcher");
StringAssert.Contains("server-launch-59996.log", launcher.LastLogPath,
"Headless launch should redirect output to the per-port launch log");
}
#endregion
#region IsLocalUrl Tests
[Test]
public void IsLocalUrl_Localhost_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "localhost should be recognized as local URL");
}
[Test]
public void IsLocalUrl_127001_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.1:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "127.0.0.1 should be recognized as local URL");
}
[Test]
public void IsLocalUrl_127002_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.2:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "127.0.0.2 should be recognized as loopback local URL");
}
[Test]
public void IsLocalUrl_0000_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "0.0.0.0 should be recognized as local URL");
}
[Test]
public void IsLocalUrl_IPv6Loopback_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://[::1]:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "::1 (IPv6 loopback) should be recognized as local URL");
}
[Test]
public void IsLocalUrl_IPv6LoopbackLongForm_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://[0:0:0:0:0:0:0:1]:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "0:0:0:0:0:0:0:1 (IPv6 loopback long-form) should be recognized as local URL");
}
[Test]
public void IsLocalUrl_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://example.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsFalse(result, "Remote URL should not be recognized as local");
}
[Test]
public void IsLocalUrl_EmptyString_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert - behavior depends on default URL handling
// Document current behavior
Assert.Pass($"IsLocalUrl returned {result} for empty URL (documents current behavior)");
}
#endregion
#region CanStartLocalServer Tests
[Test]
public void CanStartLocalServer_HttpDisabled_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server when HTTP transport is disabled");
}
[Test]
public void CanStartLocalServer_HttpEnabledLocalUrl_ReturnsTrue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsTrue(result, "Can start local server when HTTP enabled and URL is local");
}
[Test]
public void CanStartLocalServer_HttpEnabledRemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server when URL is remote");
}
[Test]
public void CanStartLocalServer_HttpEnabledZeroBind_DisallowedByDefault_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server on 0.0.0.0 unless LAN bind opt-in is enabled");
}
[Test]
public void CanStartLocalServer_HttpEnabledZeroBind_WithOptIn_ReturnsTrue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsTrue(result, "Can start local server on 0.0.0.0 when LAN bind opt-in is enabled");
}
#endregion
#region HttpEndpointUtility Security Policy Tests
[Test]
public void SaveRemoteBaseUrl_WithoutScheme_DefaultsToHttps()
{
// Arrange
EditorConfigurationCache.Instance.SetHttpTransportScope("remote");
// Act
HttpEndpointUtility.SaveRemoteBaseUrl("example.com:9000");
string normalized = HttpEndpointUtility.GetRemoteBaseUrl();
// Assert
Assert.AreEqual("https://example.com:9000", normalized);
}
[Test]
public void IsRemoteUrlAllowed_Http_DisallowedByDefault()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
// Act
bool allowed = HttpEndpointUtility.IsRemoteUrlAllowed("http://example.com:8080", out string error);
// Assert
Assert.IsFalse(allowed);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("HTTPS").IgnoreCase);
}
[Test]
public void IsRemoteUrlAllowed_Http_AllowedWithOptIn()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, true);
// Act
bool allowed = HttpEndpointUtility.IsRemoteUrlAllowed("http://example.com:8080", out string error);
// Assert
Assert.IsTrue(allowed);
Assert.IsNull(error);
}
[Test]
public void IsHttpLocalUrlAllowedForLaunch_ZeroBind_DisallowedByDefault()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, false);
// Act
bool allowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch("http://0.0.0.0:8080", out string error);
// Assert
Assert.IsFalse(allowed);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("disabled by default").IgnoreCase);
}
[Test]
public void IsHttpLocalUrlAllowedForLaunch_ZeroBind_AllowedWithOptIn()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, true);
// Act
bool allowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch("http://0.0.0.0:8080", out string error);
// Assert
Assert.IsTrue(allowed);
Assert.IsNull(error);
}
#endregion
#region TryGetLocalHttpServerCommand Tests
[Test]
public void TryGetLocalHttpServerCommand_HttpDisabled_ReturnsFalseWithError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert
Assert.IsFalse(result, "Should return false when HTTP transport is disabled");
Assert.IsNull(command, "Command should be null when failing");
Assert.IsNotNull(error, "Error message should be provided");
Assert.That(error, Does.Contain("HTTP").IgnoreCase, "Error should mention HTTP transport");
}
[Test]
public void TryGetLocalHttpServerCommand_RemoteUrl_ReturnsFalseWithError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert
Assert.IsFalse(result, "Should return false for remote URL");
Assert.IsNull(command, "Command should be null when failing");
Assert.IsNotNull(error, "Error message should be provided");
Assert.That(error, Does.Contain("local").IgnoreCase, "Error should mention local address requirement");
}
[Test]
public void TryGetLocalHttpServerCommand_LocalUrl_ReturnsCommandOrError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert - Success depends on uvx availability
if (result)
{
Assert.IsNotNull(command, "Command should be set on success");
Assert.IsNull(error, "Error should be null on success");
Assert.That(command, Does.Contain("uvx").Or.Contain("uv"), "Command should reference uvx/uv");
}
else
{
Assert.IsNotNull(error, "Error message should be provided on failure");
}
Assert.Pass($"TryGetLocalHttpServerCommand: success={result}, command={command ?? "null"}, error={error ?? "null"}");
}
#endregion
#region IsLocalHttpServerReachable Tests
[Test]
public void IsLocalHttpServerReachable_NoServer_ReturnsFalse()
{
// Arrange - Use a port that's unlikely to have a server running
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59999");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerReachable();
// Assert
Assert.IsFalse(result, "Should return false when no server is listening");
}
[Test]
public void IsLocalHttpServerReachable_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerReachable();
// Assert
Assert.IsFalse(result, "Should return false for non-local URL without attempting connection");
}
[Test]
public void IsLocalHttpServerReachable_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
// Act & Assert - Should never throw regardless of server state
Assert.DoesNotThrow(() =>
{
_service.IsLocalHttpServerReachable();
}, "IsLocalHttpServerReachable should handle all error cases gracefully");
}
#endregion
#region IsLocalHttpServerRunning Tests
[Test]
public void IsLocalHttpServerRunning_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerRunning();
// Assert
Assert.IsFalse(result, "Should return false for non-local URL");
}
[Test]
public void IsLocalHttpServerRunning_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
// Act & Assert - Should never throw regardless of server state
Assert.DoesNotThrow(() =>
{
_service.IsLocalHttpServerRunning();
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
}
#endregion
#region ClearUvxCache Tests
[Test]
public void ClearUvxCache_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
string lastLog = null;
Application.LogCallback handler = (condition, stackTrace, type) =>
{
if (condition != null && condition.Contains("uv cache"))
{
lastLog = condition;
}
};
// Act & Assert - Should not throw even if uvx is not installed
Assert.DoesNotThrow(() =>
{
LogAssert.ignoreFailingMessages = true;
Application.logMessageReceived += handler;
try
{
_service.ClearUvxCache();
}
finally
{
Application.logMessageReceived -= handler;
LogAssert.ignoreFailingMessages = false;
}
}, "ClearUvxCache should handle missing uvx gracefully");
Assert.IsNotNull(lastLog, "Expected a uv cache log message.");
StringAssert.Contains("uv cache", lastLog);
}
#endregion
#region Private Method Characterization (via reflection for documentation)
[Test]
public void NormalizeForMatch_RemovesWhitespace_ViaReflection()
{
// Arrange - Use reflection to access private static method
var method = typeof(ServerManagementService).GetMethod(
"NormalizeForMatch",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("NormalizeForMatch is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "Hello World" });
// Assert
Assert.AreEqual("helloworld", result, "Should remove whitespace and lowercase");
}
[Test]
public void NormalizeForMatch_HandlesNull_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"NormalizeForMatch",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("NormalizeForMatch is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { null });
// Assert
Assert.AreEqual(string.Empty, result, "Should return empty string for null input");
}
[Test]
public void QuoteIfNeeded_PathWithSpaces_AddsQuotes_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"QuoteIfNeeded",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("QuoteIfNeeded is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "path with spaces" });
// Assert
Assert.AreEqual("\"path with spaces\"", result, "Should wrap path with quotes");
}
[Test]
public void QuoteIfNeeded_PathWithoutSpaces_NoChange_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"QuoteIfNeeded",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("QuoteIfNeeded is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "pathwithoutspaces" });
// Assert
Assert.AreEqual("pathwithoutspaces", result, "Should not modify path without spaces");
}
[Test]
public void IsLocalUrl_Static_MatchesPublicBehavior_ViaReflection()
{
// Arrange - Access private static IsLocalUrl(string) method
var method = typeof(ServerManagementService).GetMethod(
"IsLocalUrl",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("Static IsLocalUrl is a private method - behavior documented via code review");
return;
}
// Act & Assert - Test various URLs
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://localhost:8080" }), "localhost should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://127.0.0.1:8080" }), "127.0.0.1 should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://0.0.0.0:8080" }), "0.0.0.0 should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://[::1]:8080" }), "::1 should be recognized as local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { "http://example.com:8080" }), "example.com should not be local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { "" }), "empty string should not be local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { null }), "null should not be local");
}
[Test]
public void BuildLocalProbeHosts_Localhost_IncludesIPv4AndIPv6Loopback_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"BuildLocalProbeHosts",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("BuildLocalProbeHosts is a private method - behavior documented via code review");
return;
}
// Act
var result = method.Invoke(null, new object[] { "localhost" });
Assert.IsNotNull(result);
Assert.IsInstanceOf<IEnumerable<string>>(result);
var hosts = ((IEnumerable<string>)result).ToList();
// Assert
CollectionAssert.Contains(hosts, "localhost");
CollectionAssert.Contains(hosts, "127.0.0.1");
CollectionAssert.Contains(hosts, "::1");
}
[Test]
public void BuildLocalProbeHosts_EmptyHost_DefaultsToIPv4Loopback_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"BuildLocalProbeHosts",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("BuildLocalProbeHosts is a private method - behavior documented via code review");
return;
}
// Act
var result = method.Invoke(null, new object[] { "" });
Assert.IsNotNull(result);
Assert.IsInstanceOf<IEnumerable<string>>(result);
var hosts = ((IEnumerable<string>)result).ToList();
// Assert
Assert.AreEqual(1, hosts.Count, "Empty host should resolve to a single default probe host.");
Assert.AreEqual("127.0.0.1", hosts[0]);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d8e9f289147f6440dadbb78a34d893ea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,503 @@
using System;
using System.Reflection;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services.Characterization
{
/// <summary>
/// Characterization tests for Editor Services domain.
/// These tests capture CURRENT behavior without refactoring.
/// They serve as a regression baseline for future refactoring work.
///
/// Based on analysis in: MCPForUnity/Editor/Services/Tests/CHARACTERIZATION_NOTES.md
///
/// Services covered: ServerManagementService, EditorStateCache, BridgeControlService,
/// ClientConfigurationService, MCPServiceLocator
/// </summary>
[TestFixture]
public class ServicesCharacterizationTests
{
#region Section 1: ServerManagementService - Stateless Architecture
/// <summary>
/// Current behavior: ServerManagementService is stateless - no instance fields track state.
/// All state flows through EditorPrefs (persistent) + method parameters (transient).
/// </summary>
[Test]
public void ServerManagementService_IsStateless_NoInstanceFieldsTrackingState()
{
// Verify the service can be instantiated multiple times without state issues
var service1 = new ServerManagementService();
var service2 = new ServerManagementService();
// Both instances should be equivalent (no instance state)
Assert.IsNotNull(service1);
Assert.IsNotNull(service2);
// Check that the class has minimal instance fields (primarily static or none)
var instanceFields = typeof(ServerManagementService)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
// Stateless services should have no or minimal instance fields
// This documents the current architecture
Assert.Pass($"ServerManagementService has {instanceFields.Length} instance fields - stateless design");
}
/// <summary>
/// Current behavior: Server metadata is stored in EditorPrefs for persistence
/// across domain reloads. Keys include LastLocalHttpServerPid, Port, StartedUtc, etc.
/// </summary>
[Test]
public void ServerManagementService_StoresLocalHttpServerMetadata_InEditorPrefs()
{
// Document that EditorPrefs keys exist for server tracking
// These keys are defined in EditorPrefKeys constants
var expectedKeys = new[]
{
"LastLocalHttpServerPid",
"LastLocalHttpServerPort",
"LastLocalHttpServerStartedUtc"
};
// This test documents the persistence mechanism
Assert.Pass($"Server metadata uses EditorPrefs with keys like: {string.Join(", ", expectedKeys)}");
}
/// <summary>
/// Current behavior: IsLocalHttpServerRunning uses multi-strategy detection:
/// 1. Handshake validation (pidfile + token)
/// 2. Stored PID matching (EditorPrefs with 6-hour validity)
/// 3. Heuristic process matching
/// 4. Network probe fallback
/// </summary>
[Test]
public void ServerManagementService_IsLocalHttpServerRunning_UsesMultiDetectionStrategy()
{
var service = new ServerManagementService();
// The method should not throw - it handles all edge cases
bool result = false;
Assert.DoesNotThrow(() =>
{
result = service.IsLocalHttpServerRunning();
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
// Result depends on actual server state - document the behavior
Assert.Pass($"IsLocalHttpServerRunning returned {result} using multi-strategy detection");
}
/// <summary>
/// Current behavior: IsLocalHttpServerReachable uses a fast network probe
/// (50ms TCP connection attempt) to check server availability.
/// </summary>
[Test]
public void ServerManagementService_IsLocalHttpServerReachable_UsesNetworkProbe()
{
var service = new ServerManagementService();
// Should complete quickly without hanging
bool reachable = false;
Assert.DoesNotThrow(() =>
{
reachable = service.IsLocalHttpServerReachable();
}, "Network probe should complete without hanging");
Assert.Pass($"IsLocalHttpServerReachable returned {reachable} via network probe");
}
/// <summary>
/// Current behavior: TryGetLocalHttpServerCommand builds uvx command
/// with platform-specific arguments.
/// </summary>
[Test]
public void ServerManagementService_TryGetLocalHttpServerCommand_BuildsUvxCommand()
{
var service = new ServerManagementService();
string command = null;
string error = null;
bool result = service.TryGetLocalHttpServerCommand(out command, out error);
// Command building should succeed (unless misconfigured)
if (result)
{
Assert.IsNotNull(command, "Command should be set on success");
// Document the command structure
Assert.Pass($"Built command: {command}");
}
else
{
Assert.Pass($"TryGetLocalHttpServerCommand returned false: {error ?? "unknown"}");
}
}
/// <summary>
/// Current behavior: IsLocalUrl matches loopback addresses
/// (localhost, 127.0.0.1, ::1, etc.)
/// </summary>
[Test]
public void ServerManagementService_IsLocalUrl_MatchesLoopbackAddresses()
{
// Use reflection to access static method if private, or test publicly exposed behavior
var service = new ServerManagementService();
// Test via public API behavior - local URLs should be treated specially
// This documents the expected loopback patterns
var loopbackPatterns = new[] { "localhost", "127.0.0.1", "::1", "[::1]" };
Assert.Pass($"IsLocalUrl recognizes loopback patterns: {string.Join(", ", loopbackPatterns)}");
}
/// <summary>
/// Current behavior: Process termination uses graceful-then-forced approach.
/// Unix: SIGTERM (8s grace) then SIGKILL
/// Windows: taskkill /T then /F
/// </summary>
[Test]
public void ServerManagementService_TerminateProcess_UsesGracefulThenForced_OnUnix()
{
// Document the termination strategy without actually terminating anything
var platforms = new[]
{
"Unix: SIGTERM with 8s grace, then SIGKILL",
"Windows: taskkill /T, then /F"
};
Assert.Pass($"Process termination strategies: {string.Join("; ", platforms)}");
}
/// <summary>
/// Current behavior: LooksLikeMcpServerProcess uses multi-layer validation
/// to identify MCP server processes.
/// </summary>
[Test]
public void ServerManagementService_LooksLikeMcpServerProcess_UsesMultiStrategyValidation()
{
// Document the validation strategies
var strategies = new[]
{
"Command line contains 'uvx' or 'python'",
"Command line contains 'mcp-for-unity'",
"PID args hash matching",
"Token validation"
};
Assert.Pass($"Process validation uses: {string.Join(", ", strategies)}");
}
/// <summary>
/// Current behavior: StopLocalHttpServer prefers pidfile-based approach
/// for deterministic termination.
/// </summary>
[Test]
[Explicit("Stops the MCP server - kills connection")]
public void ServerManagementService_StopLocalHttpServer_PrefersPidfileBasedApproach()
{
var service = new ServerManagementService();
// WARNING: This test calls StopLocalHttpServer() which will kill the running MCP server
// Calling stop when no server is running should not throw
Assert.DoesNotThrow(() =>
{
service.StopLocalHttpServer();
}, "StopLocalHttpServer should handle no-server case gracefully");
Assert.Pass("StopLocalHttpServer uses pidfile-based approach with fallbacks");
}
/// <summary>
/// Current behavior: PID tracking uses args hash to prevent PID reuse issues.
/// </summary>
[Test]
public void ServerManagementService_StoreLocalServerPidTracking_UsesArgHash()
{
// Document the PID tracking mechanism
var trackingElements = new[]
{
"PID value",
"Command args hash",
"Start timestamp (6-hour validity)",
"Pidfile path",
"Instance token"
};
Assert.Pass($"PID tracking includes: {string.Join(", ", trackingElements)}");
}
#endregion
#region Section 2: EditorStateCache - Thread-Safe Caching
/// <summary>
/// Current behavior: EditorStateCache is initialized via [InitializeOnLoad]
/// and uses thread-safe access patterns.
/// </summary>
[Test]
public void EditorStateCache_IsInitializedOnLoad_AndThreadSafe()
{
// EditorStateCache should already be initialized by Unity
// Check that the type exists and has InitializeOnLoad
var type = typeof(EditorStateCache);
var initAttr = type.GetCustomAttribute<InitializeOnLoadAttribute>();
Assert.IsNotNull(initAttr, "EditorStateCache should have InitializeOnLoad attribute");
}
/// <summary>
/// Current behavior: BuildSnapshot is only called when state changes,
/// using two-stage change detection to minimize expensive operations.
/// </summary>
[Test]
public void EditorStateCache_BuildSnapshot_OnlyCalledWhenStateChanges()
{
// Document the change detection stages
var stages = new[]
{
"Stage 1: Fast check (compilation edge + throttle)",
"Stage 2: Cheap capture (scene, focus, play mode)",
"Stage 3: Comparison (string/bool diff)",
"Stage 4: Expensive BuildSnapshot only if changed"
};
Assert.Pass($"Change detection: {string.Join(" -> ", stages)}");
}
/// <summary>
/// Current behavior: Snapshot schema covers multiple editor state sections.
/// </summary>
[Test]
public void EditorStateCache_SnapshotSchema_CoversEditorState()
{
// Get current snapshot to verify schema
var snapshot = EditorStateCache.GetSnapshot();
Assert.IsNotNull(snapshot, "Should be able to get current snapshot");
// Document the schema sections
var sections = new[] { "unity", "editor", "activity", "compilation", "assets", "tests", "transport" };
Assert.Pass($"Snapshot includes sections: {string.Join(", ", sections)}");
}
/// <summary>
/// Current behavior: EditorStateCache uses lock object for thread safety.
/// </summary>
[Test]
public void EditorStateCache_UsesLockObjPattern_ForThreadSafety()
{
// Verify thread-safe access pattern by checking concurrent access doesn't throw
var snapshot1 = EditorStateCache.GetSnapshot();
var snapshot2 = EditorStateCache.GetSnapshot();
Assert.IsNotNull(snapshot1);
Assert.IsNotNull(snapshot2);
Assert.Pass("EditorStateCache uses lock pattern for concurrent access safety");
}
#endregion
#region Section 3: BridgeControlService - Transport Management
/// <summary>
/// Current behavior: BridgeControlService resolves preferred mode from EditorPrefs
/// on each method call (no caching).
/// </summary>
[Test]
public void BridgeControlService_ResolvesPreferredMode_FromEditorPrefs()
{
// Document that mode is resolved dynamically
var service = MCPServiceLocator.Bridge;
Assert.IsNotNull(service, "BridgeControlService should be available via locator");
Assert.Pass("BridgeControlService reads UseHttpTransport from EditorPrefs on each call");
}
/// <summary>
/// Current behavior: StartAsync stops the other transport first
/// to ensure mutual exclusion.
/// </summary>
[Test]
public void BridgeControlService_StartAsync_StopsOtherTransport_First()
{
// Document the mutual exclusion pattern
var pattern = "StartAsync: Stop opposing transport FIRST, then start preferred";
Assert.Pass($"Transport mutual exclusion: {pattern}");
}
/// <summary>
/// Current behavior: VerifyAsync checks both ping response and handshake state.
/// </summary>
[Test]
public void BridgeControlService_VerifyAsync_ChecksBothPingAndHandshake()
{
// Document verification pattern
var checks = new[] { "Async ping", "State check", "Mode-specific validation" };
Assert.Pass($"VerifyAsync performs: {string.Join(" + ", checks)}");
}
#endregion
#region Section 4: ClientConfigurationService
/// <summary>
/// Current behavior: ConfigureAllDetectedClients runs a single-pass loop
/// over all registered clients.
/// </summary>
[Test]
public void ClientConfigurationService_ConfigureAllDetectedClients_RunsOnce()
{
var service = MCPServiceLocator.Client;
Assert.IsNotNull(service, "ClientConfigurationService should be available");
// Document the configuration pattern
var pattern = new[]
{
"Clean build artifacts once",
"Iterate all registered clients",
"Catch exceptions per client",
"Return summary with counts"
};
Assert.Pass($"Configuration loop: {string.Join(" -> ", pattern)}");
}
#endregion
#region Section 5: MCPServiceLocator - Lazy Initialization
/// <summary>
/// Current behavior: MCPServiceLocator uses lazy initialization with
/// null-coalescing operator (not Lazy<T>).
/// </summary>
[Test]
public void MCPServiceLocator_UsesLazyInitializationPattern_WithoutLocking()
{
// Access services to verify lazy initialization
var bridge1 = MCPServiceLocator.Bridge;
var bridge2 = MCPServiceLocator.Bridge;
// Same instance should be returned
Assert.AreSame(bridge1, bridge2, "Should return same instance");
// Document the race condition risk (acceptable for editor)
Assert.Pass("Uses null-coalescing lazy init - acceptable race condition for editor");
}
/// <summary>
/// Current behavior: Reset disposes and clears all services.
/// </summary>
[Test]
public void MCPServiceLocator_Reset_DisposesAndClears_AllServices()
{
// Document the reset behavior without actually calling it (would break other tests)
var resetBehavior = new[]
{
"Calls Dispose() on IDisposable services",
"Sets all fields to null",
"Used in test teardown and shutdown"
};
Assert.Pass($"Reset behavior: {string.Join(", ", resetBehavior)}");
}
/// <summary>
/// Current behavior: Register dispatches by interface type via if-else chain.
/// </summary>
[Test]
public void MCPServiceLocator_Register_DispatchesByInterface_Type()
{
// Document the registration pattern
var pattern = "Register<T>(impl) uses if-else chain for interface type dispatch";
Assert.Pass(pattern);
}
#endregion
#region Section 6: Cross-Cutting Patterns
/// <summary>
/// Current behavior: EditorStateCache and BridgeControlService maintain
/// consistent views of editor state.
/// </summary>
[Test]
public void Consistency_EditorStateCache_And_BridgeControlService()
{
var snapshot = EditorStateCache.GetSnapshot();
var bridge = MCPServiceLocator.Bridge;
Assert.IsNotNull(snapshot, "Snapshot available");
Assert.IsNotNull(bridge, "Bridge available");
Assert.Pass("Both services maintain consistent editor state views");
}
/// <summary>
/// Current behavior: MCPServiceLocator race condition is acceptable
/// because services are stateless/idempotent.
/// </summary>
[Test]
public void RaceCondition_MCPServiceLocator_DoubleInitialization_Acceptable()
{
// Document the race condition scenario
var scenario = new[]
{
"T1 accesses property, finds null",
"T2 accesses property, finds null (before T1 assignment)",
"Both create instances, last wins",
"First instance discarded (no leak - services are light)"
};
Assert.Pass($"Race scenario: {string.Join(" -> ", scenario)}");
}
/// <summary>
/// Current behavior: Configuration changes propagate via EditorPrefs reads
/// (implicit invalidation).
/// </summary>
[Test]
public void Invalidation_ConfigChanges_PropagateViaEditorPrefsReads()
{
var pattern = "No explicit cache invalidation - services re-read EditorPrefs on each call";
Assert.Pass(pattern);
}
/// <summary>
/// Current behavior: Domain initialization follows a specific load order.
/// </summary>
[Test]
public void Initialization_DomainLoad_Sequence()
{
var sequence = new[]
{
"EditorStateCache [InitializeOnLoad]",
"MCPServiceLocator services (lazy)",
"BridgeControlService (on first access)",
"Transport initialization (async)"
};
Assert.Pass($"Load sequence: {string.Join(" -> ", sequence)}");
}
/// <summary>
/// Current behavior: Configuration flows from UI to EditorPrefs to behavior.
/// </summary>
[Test]
public void Configuration_Flow_EditorPrefs_To_Behavior()
{
var flow = new[]
{
"User changes config in UI",
"EditorPrefs.SetBool/String called",
"Service method reads EditorPrefs",
"Behavior reflects new config immediately"
};
Assert.Pass($"Config flow: {string.Join(" -> ", flow)}");
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a1b2c3d4e5f6789012345678abcdef4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System.Linq;
using MCPForUnity.Editor.Services;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Services
{
// ConfigureAllDetectedClients walks the real McpClientRegistry and Configure()s every
// detected client, which would touch real user config files on a dev machine. These
// tests pass on CI (no MCP clients installed there) but would mutate real state on
// a developer's machine. Marked [Explicit] so they only run when invoked by name;
// proper isolation requires DI of the configurator list and is tracked separately.
[TestFixture]
public class ConfigureDetectedClientsTests
{
[Test]
[Explicit("Side-effect: writes real client configs on machines with MCP clients installed")]
public void Summary_ContainsOnlyInstalledClients()
{
var svc = new ClientConfigurationService();
var summary = svc.ConfigureAllDetectedClients();
int installedCount = svc.GetAllClients().Count(c => c.IsInstalled);
Assert.AreEqual(installedCount, summary.SuccessCount + summary.FailureCount,
"Only installed clients should appear in success/failure totals");
}
[Test]
[Explicit("Side-effect: writes real client configs on machines with MCP clients installed")]
public void Summary_SkippedCountTracksUninstalled()
{
var svc = new ClientConfigurationService();
var summary = svc.ConfigureAllDetectedClients();
int uninstalledCount = svc.GetAllClients().Count(c => !c.IsInstalled);
Assert.AreEqual(uninstalledCount, summary.SkippedCount);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78f1385217c246d4a9b5c596986787b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,264 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Unit tests for EditorConfigurationCache.
/// </summary>
[TestFixture]
public class EditorConfigurationCacheTests
{
private bool _originalUseHttpTransport;
private bool _originalDebugLogs;
private string _originalUvxPath;
[SetUp]
public void SetUp()
{
// Save original values
_originalUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_originalDebugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
_originalUvxPath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
// Refresh cache to ensure clean state
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Restore original values
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _originalUseHttpTransport);
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, _originalDebugLogs);
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, _originalUvxPath);
// Refresh cache
EditorConfigurationCache.Instance.Refresh();
}
#region Singleton Tests
[Test]
public void Instance_ReturnsSameInstance()
{
// Act
var instance1 = EditorConfigurationCache.Instance;
var instance2 = EditorConfigurationCache.Instance;
// Assert
Assert.AreSame(instance1, instance2, "Should return the same singleton instance");
}
[Test]
public void Instance_IsNotNull()
{
// Assert
Assert.IsNotNull(EditorConfigurationCache.Instance);
}
#endregion
#region Read Tests
[Test]
public void UseHttpTransport_ReturnsEditorPrefsValue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.UseHttpTransport);
// Arrange - change value
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsFalse(EditorConfigurationCache.Instance.UseHttpTransport);
}
[Test]
public void DebugLogs_ReturnsEditorPrefsValue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, true);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
}
[Test]
public void UvxPathOverride_ReturnsEditorPrefsValue()
{
// Arrange
string testPath = "/custom/path/to/uvx";
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, testPath);
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.AreEqual(testPath, EditorConfigurationCache.Instance.UvxPathOverride);
}
#endregion
#region Write Tests
[Test]
public void SetUseHttpTransport_UpdatesCacheAndEditorPrefs()
{
// Arrange
bool initialValue = EditorConfigurationCache.Instance.UseHttpTransport;
bool newValue = !initialValue;
// Act
EditorConfigurationCache.Instance.SetUseHttpTransport(newValue);
// Assert - cache is updated
Assert.AreEqual(newValue, EditorConfigurationCache.Instance.UseHttpTransport);
// Assert - EditorPrefs is updated
Assert.AreEqual(newValue, EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, !newValue));
}
[Test]
public void SetDebugLogs_UpdatesCacheAndEditorPrefs()
{
// Act
EditorConfigurationCache.Instance.SetDebugLogs(true);
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
Assert.IsTrue(EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false));
}
[Test]
public void SetUvxPathOverride_UpdatesCacheAndEditorPrefs()
{
// Arrange
string testPath = "/test/uvx/path";
// Act
EditorConfigurationCache.Instance.SetUvxPathOverride(testPath);
// Assert
Assert.AreEqual(testPath, EditorConfigurationCache.Instance.UvxPathOverride);
Assert.AreEqual(testPath, EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty));
}
[Test]
public void SetUvxPathOverride_NullBecomesEmptyString()
{
// Act
EditorConfigurationCache.Instance.SetUvxPathOverride(null);
// Assert
Assert.AreEqual(string.Empty, EditorConfigurationCache.Instance.UvxPathOverride);
}
#endregion
#region Change Notification Tests
[Test]
public void SetUseHttpTransport_FiresOnConfigurationChanged()
{
// Arrange
string changedKey = null;
EditorConfigurationCache.Instance.OnConfigurationChanged += (key) => changedKey = key;
bool initialValue = EditorConfigurationCache.Instance.UseHttpTransport;
// Act
EditorConfigurationCache.Instance.SetUseHttpTransport(!initialValue);
// Assert
Assert.AreEqual(nameof(EditorConfigurationCache.UseHttpTransport), changedKey);
// Cleanup
EditorConfigurationCache.Instance.OnConfigurationChanged -= (key) => changedKey = key;
}
[Test]
public void SetSameValue_DoesNotFireOnConfigurationChanged()
{
// Arrange
int eventCount = 0;
EditorConfigurationCache.Instance.OnConfigurationChanged += (key) => eventCount++;
bool currentValue = EditorConfigurationCache.Instance.UseHttpTransport;
// Act - set same value
EditorConfigurationCache.Instance.SetUseHttpTransport(currentValue);
// Assert - no event fired
Assert.AreEqual(0, eventCount, "Should not fire event when value doesn't change");
// Cleanup
EditorConfigurationCache.Instance.OnConfigurationChanged -= (key) => eventCount++;
}
#endregion
#region InvalidateKey Tests
[Test]
public void InvalidateKey_RefreshesSingleValue()
{
// Arrange
EditorConfigurationCache.Instance.SetDebugLogs(false);
Assert.IsFalse(EditorConfigurationCache.Instance.DebugLogs);
// Directly modify EditorPrefs (simulating external change)
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, true);
// Act
EditorConfigurationCache.Instance.InvalidateKey(nameof(EditorConfigurationCache.DebugLogs));
// Assert
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
}
[Test]
public void InvalidateKey_FiresOnConfigurationChanged()
{
// Arrange
string changedKey = null;
EditorConfigurationCache.Instance.OnConfigurationChanged += (key) => changedKey = key;
// Act
EditorConfigurationCache.Instance.InvalidateKey(nameof(EditorConfigurationCache.DebugLogs));
// Assert
Assert.AreEqual(nameof(EditorConfigurationCache.DebugLogs), changedKey);
// Cleanup
EditorConfigurationCache.Instance.OnConfigurationChanged -= (key) => changedKey = key;
}
#endregion
#region Refresh Tests
[Test]
public void Refresh_UpdatesAllCachedValues()
{
// Arrange - directly set EditorPrefs
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, true);
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, "/refreshed/path");
// Act
EditorConfigurationCache.Instance.Refresh();
// Assert
Assert.IsFalse(EditorConfigurationCache.Instance.UseHttpTransport);
Assert.IsTrue(EditorConfigurationCache.Instance.DebugLogs);
Assert.AreEqual("/refreshed/path", EditorConfigurationCache.Instance.UvxPathOverride);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4d5e6c8f40564676a4afffeb2de0854
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,194 @@
using NUnit.Framework;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Tests for the auto-start tick decisions (#1229): the session latch must only be
/// written when the deferred work actually dispatches, so a domain reload that wipes
/// the pending tick can no longer consume the once-per-session auto-start.
/// TickCore is a pure decision — it never writes the latch and never spawns servers.
/// The TryBeginReconnect tests only exercise its deliberate-drop paths, which never
/// dispatch the async connect.
/// </summary>
public class HttpAutoStartHandlerTests
{
private FakeTransportClient _fakeClient;
private TransportManager _savedManager;
private bool _savedLatch;
private bool _savedConnectPending;
private bool _savedResumeFlag;
private bool _savedAutoStart;
private bool _savedUseHttpTransport;
[SetUp]
public void SetUp()
{
_savedLatch = SessionState.GetBool(HttpAutoStartHandler.SessionInitKey, false);
_savedConnectPending = SessionState.GetBool(HttpAutoStartHandler.ConnectPendingKey, false);
_savedResumeFlag = SessionState.GetBool(HttpBridgeReloadHandler.ResumeSessionKey, false);
_savedAutoStart = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedManager = MCPServiceLocator.TransportManager;
SessionState.EraseBool(HttpAutoStartHandler.SessionInitKey);
SessionState.EraseBool(HttpAutoStartHandler.ConnectPendingKey);
SessionState.EraseBool(HttpBridgeReloadHandler.ResumeSessionKey);
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, false);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorConfigurationCache.Instance.Refresh();
_fakeClient = new FakeTransportClient();
var manager = new TransportManager();
manager.Configure(() => _fakeClient, () => _fakeClient);
MCPServiceLocator.Register(manager);
}
[TearDown]
public void TearDown()
{
// Stored-false and absent are indistinguishable: every read uses GetBool(key, false).
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, _savedLatch);
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, _savedConnectPending);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, _savedResumeFlag);
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, _savedAutoStart);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
EditorConfigurationCache.Instance.Refresh();
MCPServiceLocator.Register(_savedManager);
}
private static bool LatchSet =>
SessionState.GetBool(HttpAutoStartHandler.SessionInitKey, false);
private static bool ConnectPendingSet =>
SessionState.GetBool(HttpAutoStartHandler.ConnectPendingKey, false);
[Test]
public void TickCore_EditorBusy_Defers()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.DeferBusy,
HttpAutoStartHandler.TickCore(editorBusy: true));
Assert.IsFalse(LatchSet);
}
[Test]
public void TickCore_ResumePending_YieldsToReloadHandler()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.DeferToResume,
HttpAutoStartHandler.TickCore(editorBusy: false));
Assert.IsFalse(LatchSet);
}
[Test]
public void TickCore_ResumePendingButNothingToDo_SkipsWithoutWaiting()
{
// Auto-start disabled and not latched: a pending resume must not keep the
// tick alive when the only possible outcome is Skip.
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.Skip,
HttpAutoStartHandler.TickCore(editorBusy: false));
}
[Test]
public void TickCore_AutoStartDisabled_SkipsWithoutLatch()
{
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.Skip,
HttpAutoStartHandler.TickCore(editorBusy: false));
Assert.IsFalse(LatchSet, "no latch when disabled — the pref is re-read on the next domain load");
}
[Test]
public void TickCore_AutoStartEnabled_ShouldStartWithoutWritingLatch()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.ShouldStart,
HttpAutoStartHandler.TickCore(editorBusy: false));
Assert.IsFalse(LatchSet, "the caller latches only after the start work actually dispatches");
}
[Test]
public void TickCore_Latched_Skips()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.Skip,
HttpAutoStartHandler.TickCore(editorBusy: false));
}
[Test]
public void TickCore_LatchedWithConnectPending_Reconnects()
{
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, true);
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.ShouldReconnect,
HttpAutoStartHandler.TickCore(editorBusy: false),
"a reload that killed the in-flight connect should finish connect-only, never re-spawn");
}
[Test]
public void TickCore_ResumePendingBeatsReconnect()
{
SessionState.SetBool(HttpAutoStartHandler.SessionInitKey, true);
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.AreEqual(
HttpAutoStartHandler.TickDecision.DeferToResume,
HttpAutoStartHandler.TickCore(editorBusy: false),
"the reload handler owns bridge revival while a resume is pending");
}
[Test]
public void TryBeginReconnect_AutoStartDisabled_DropsPendingReconnect()
{
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.IsTrue(HttpAutoStartHandler.TryBeginReconnect());
Assert.IsFalse(ConnectPendingSet, "a deliberate drop must consume the pending marker");
}
[Test]
public void TryBeginReconnect_StdioSelected_DropsPendingReconnect()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorConfigurationCache.Instance.Refresh();
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.IsTrue(HttpAutoStartHandler.TryBeginReconnect());
Assert.IsFalse(ConnectPendingSet);
}
[Test]
public void TryBeginReconnect_BridgeAlreadyRunning_DropsPendingReconnect()
{
EditorPrefs.SetBool(EditorPrefKeys.AutoStartOnLoad, true);
var start = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http);
Assert.IsTrue(start.IsCompleted && start.Result, "fake bridge should start synchronously");
SessionState.SetBool(HttpAutoStartHandler.ConnectPendingKey, true);
Assert.IsTrue(HttpAutoStartHandler.TryBeginReconnect());
Assert.IsFalse(ConnectPendingSet);
Assert.AreEqual(1, _fakeClient.StartCalls, "an already-running bridge must not be restarted");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b295bffe6b1f4006bd4aa5061932ec0f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,196 @@
using System;
using System.Threading.Tasks;
using NUnit.Framework;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Transport;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
/// <summary>
/// Tests for the HTTP reload-resume flag semantics (#1229): the flag must survive
/// multi-pass compile boundaries and only be consumed on success, cancel, or exhaustion.
/// Uses fake transports and a zero-delay retry schedule so every path completes
/// synchronously (UTF 1.1 cannot run async tests).
/// </summary>
public class HttpBridgeReloadHandlerTests
{
private static readonly TimeSpan[] ZeroSchedule =
{
TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero
};
private FakeTransportClient _fakeClient;
private TransportManager _manager;
private TransportManager _savedManager;
private bool _savedResumeFlag;
private bool _savedUseHttpTransport;
[SetUp]
public void SetUp()
{
_savedResumeFlag = SessionState.GetBool(HttpBridgeReloadHandler.ResumeSessionKey, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedManager = MCPServiceLocator.TransportManager;
SessionState.EraseBool(HttpBridgeReloadHandler.ResumeSessionKey);
_fakeClient = new FakeTransportClient();
_manager = new TransportManager();
_manager.Configure(() => _fakeClient, () => _fakeClient);
MCPServiceLocator.Register(_manager);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Stored-false and absent are indistinguishable: every read uses GetBool(key, false).
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, _savedResumeFlag);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
EditorConfigurationCache.Instance.Refresh();
MCPServiceLocator.Register(_savedManager);
}
private static bool ResumeFlagSet =>
SessionState.GetBool(HttpBridgeReloadHandler.ResumeSessionKey, false);
private void StartBridge()
{
Task<bool> start = _manager.StartAsync(TransportMode.Http);
Assert.IsTrue(start.IsCompleted && start.Result, "fake bridge should start synchronously");
}
[Test]
public void BeforeReloadCore_BridgeRunning_SetsFlagAndForceStops()
{
StartBridge();
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet, "flag should be set when the bridge was running");
Assert.IsFalse(_manager.IsRunning(TransportMode.Http), "bridge should be force-stopped before reload");
}
[Test]
public void BeforeReloadCore_BridgeNotRunning_PreservesPendingFlag()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet,
"a pending resume must survive a reload boundary where the bridge is down (#1229 multi-pass compile)");
}
[Test]
public void AfterReloadCore_NoFlag_DoesNotResume()
{
Assert.IsFalse(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
}
[Test]
public void AfterReloadCore_FlagSetHttpSelected_ResumesAndKeepsFlag()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Assert.IsTrue(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Assert.IsTrue(ResumeFlagSet, "flag is only consumed when the resume actually succeeds");
}
[Test]
public void AfterReloadCore_FlagSetStdioSelected_ClearsFlagAndSkips()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorConfigurationCache.Instance.Refresh();
Assert.IsFalse(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Assert.IsFalse(ResumeFlagSet, "switching transports cancels the pending resume");
}
[Test]
public void Resume_Success_ConnectsAndClearsFlag()
{
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.IsTrue(_manager.IsRunning(TransportMode.Http));
Assert.AreEqual(1, _fakeClient.StartCalls);
Assert.IsFalse(ResumeFlagSet);
}
[Test]
public void Resume_Exhaustion_ClearsFlagAfterAllAttempts()
{
_fakeClient.StartResult = false;
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.AreEqual(ZeroSchedule.Length, _fakeClient.StartCalls);
Assert.IsFalse(ResumeFlagSet,
"exhaustion erases the flag so later reload boundaries don't replay the failure loop");
}
[Test]
public void Resume_FlagErasedMidLoop_StopsRetrying()
{
_fakeClient.StartResult = false;
// Simulates End Session / transport switch cancelling while a retry is in flight.
_fakeClient.OnStart = () => SessionState.EraseBool(HttpBridgeReloadHandler.ResumeSessionKey);
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.AreEqual(1, _fakeClient.StartCalls, "erasing the flag must abort the retry loop");
}
[Test]
public void Resume_BridgeAlreadyRunning_ClearsFlagWithoutRestart()
{
StartBridge();
SessionState.SetBool(HttpBridgeReloadHandler.ResumeSessionKey, true);
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.AreEqual(1, _fakeClient.StartCalls,
"a session established while the resume waited must not be bounced");
Assert.IsFalse(ResumeFlagSet);
}
[Test]
public void Scenario_MultiPassCompile_ResumesAtSecondBoundary()
{
// Pass 1 begins: bridge running when the first reload hits.
StartBridge();
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet);
Assert.IsFalse(_manager.IsRunning(TransportMode.Http));
// After pass 1: still compiling, so the resume tick defers — flag must not be consumed.
Assert.IsTrue(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Assert.IsTrue(ResumeFlagSet);
// Pass 2 begins with the bridge down. The old code deleted the flag at this
// boundary, which is exactly how #1229 lost the resume permanently.
HttpBridgeReloadHandler.OnBeforeAssemblyReloadCore(_manager);
Assert.IsTrue(ResumeFlagSet);
// After pass 2: editor idle, resume runs and reconnects.
Assert.IsTrue(HttpBridgeReloadHandler.OnAfterAssemblyReloadCore());
Task resume = HttpBridgeReloadHandler.ResumeHttpWithRetriesAsync(ZeroSchedule);
Assert.IsTrue(resume.IsCompleted, "resume should complete synchronously with fakes");
Assert.IsTrue(_manager.IsRunning(TransportMode.Http));
Assert.IsFalse(ResumeFlagSet);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 45fcbd8bbcd2411fbef0b27859c77465
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using System;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using UnityEngine;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class McpEditorShutdownCleanupTests
{
[Test]
public void ShouldRunCleanup_InteractiveEditor_RunsCleanup()
{
Assert.IsTrue(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: false, allowBatchEnv: null));
}
[Test]
public void ShouldRunCleanup_BatchWithoutOverride_IsNoOp()
{
// Regression for #1196/#1010: a -batchmode/CI instance must not stop the
// interactive editor's server resolved via the global pidfile+port handshake.
Assert.IsFalse(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: null));
}
[Test]
public void ShouldRunCleanup_BatchWithBlankOverride_IsNoOp()
{
// Whitespace is treated as unset, mirroring string.IsNullOrWhiteSpace in the sibling guards.
Assert.IsFalse(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: ""));
Assert.IsFalse(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: " "));
}
[Test]
public void ShouldRunCleanup_BatchWithOverride_RunsCleanup()
{
Assert.IsTrue(McpEditorShutdownCleanup.ShouldRunCleanup(isBatchMode: true, allowBatchEnv: "1"));
}
[Test]
public void ShouldRunCleanup_Parameterless_MatchesEnvironment()
{
// Proves the wiring to Application.isBatchMode / UNITY_MCP_ALLOW_BATCH is correct
// without assuming how this test run was launched (GUI Test Runner vs -batchmode CI).
bool expected = !Application.isBatchMode
|| !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));
Assert.AreEqual(expected, McpEditorShutdownCleanup.ShouldRunCleanup());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04b5977d361f459ea4726c6e176fa9d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,357 @@
using System;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Constants;
namespace MCPForUnityTests.Editor.Services
{
public class PackageUpdateServiceTests
{
private PackageUpdateService _service;
private const string TestLastCheckDateKey = EditorPrefKeys.LastUpdateCheck;
private const string TestCachedVersionKey = EditorPrefKeys.LatestKnownVersion;
private const string TestAssetStoreLastCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck;
private const string TestAssetStoreCachedVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion;
[SetUp]
public void SetUp()
{
_service = new PackageUpdateService();
// Clean up any existing test data
CleanupEditorPrefs();
}
[TearDown]
public void TearDown()
{
// Clean up test data
CleanupEditorPrefs();
}
private void CleanupEditorPrefs()
{
if (EditorPrefs.HasKey(TestLastCheckDateKey))
{
EditorPrefs.DeleteKey(TestLastCheckDateKey);
}
if (EditorPrefs.HasKey(TestCachedVersionKey))
{
EditorPrefs.DeleteKey(TestCachedVersionKey);
}
if (EditorPrefs.HasKey(TestAssetStoreLastCheckDateKey))
{
EditorPrefs.DeleteKey(TestAssetStoreLastCheckDateKey);
}
if (EditorPrefs.HasKey(TestAssetStoreCachedVersionKey))
{
EditorPrefs.DeleteKey(TestAssetStoreCachedVersionKey);
}
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMajorVersionIsNewer()
{
bool result = _service.IsNewerVersion("2.0.0", "1.0.0");
Assert.IsTrue(result, "2.0.0 should be newer than 1.0.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMinorVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.2.0", "1.1.0");
Assert.IsTrue(result, "1.2.0 should be newer than 1.1.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenPatchVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.0.2", "1.0.1");
Assert.IsTrue(result, "1.0.2 should be newer than 1.0.1");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionsAreEqual()
{
bool result = _service.IsNewerVersion("1.0.0", "1.0.0");
Assert.IsFalse(result, "Same versions should return false");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionIsOlder()
{
bool result = _service.IsNewerVersion("1.0.0", "2.0.0");
Assert.IsFalse(result, "1.0.0 should not be newer than 2.0.0");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_v()
{
bool result = _service.IsNewerVersion("v2.0.0", "v1.0.0");
Assert.IsTrue(result, "Should handle 'v' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_V()
{
bool result = _service.IsNewerVersion("V2.0.0", "V1.0.0");
Assert.IsTrue(result, "Should handle 'V' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesMixedPrefixes()
{
bool result = _service.IsNewerVersion("v2.0.0", "1.0.0");
Assert.IsTrue(result, "Should handle mixed prefixes correctly");
}
[Test]
public void IsNewerVersion_ComparesCorrectly_WhenMajorDiffers()
{
bool result1 = _service.IsNewerVersion("10.0.0", "9.0.0");
bool result2 = _service.IsNewerVersion("2.0.0", "10.0.0");
Assert.IsTrue(result1, "10.0.0 should be newer than 9.0.0");
Assert.IsFalse(result2, "2.0.0 should not be newer than 10.0.0");
}
[Test]
public void IsNewerVersion_ReturnsFalse_OnInvalidVersionFormat()
{
// Service should handle errors gracefully
bool result = _service.IsNewerVersion("invalid", "1.0.0");
Assert.IsFalse(result, "Should return false for invalid version format");
}
[Test]
public void CheckForUpdate_ReturnsCachedVersion_WhenCacheIsValid()
{
// Arrange: Set up valid cache
string today = DateTime.Now.ToString("yyyy-MM-dd");
string cachedVersion = "5.5.5";
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid cache");
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached version");
Assert.IsTrue(result.UpdateAvailable, "Update should be available (5.5.5 > 5.0.0)");
}
[Test]
public void CheckForUpdate_DetectsUpdateAvailable_WhenNewerVersionCached()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "6.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.UpdateAvailable, "Should detect update is available");
Assert.AreEqual("6.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenVersionsMatch()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update needed");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenCurrentVersionIsNewer()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("6.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update when current is newer");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_IgnoresExpiredCache_AndAttemptsFreshFetch()
{
// Arrange: Set cache from yesterday (expired)
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
string cachedVersion = "4.0.0";
EditorPrefs.SetString(TestLastCheckDateKey, yesterday);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsNotNull(result, "Should return a result");
// If the check succeeded (network available), verify it didn't use the expired cache
if (result.CheckSucceeded)
{
Assert.AreNotEqual(cachedVersion, result.LatestVersion,
"Should not return expired cached version when fresh fetch succeeds");
Assert.IsNotNull(result.LatestVersion, "Should have fetched a new version");
}
else
{
// If offline, check should fail (not succeed with cached data)
Assert.IsFalse(result.UpdateAvailable,
"Should not report update available when fetch fails and cache is expired");
}
}
[Test]
public void CheckForUpdate_UsesAssetStoreCache_WhenCacheIsValid()
{
// Arrange: Set up valid Asset Store cache
string today = DateTime.Now.ToString("yyyy-MM-dd");
string cachedVersion = "9.0.1";
EditorPrefs.SetString(TestAssetStoreLastCheckDateKey, today);
EditorPrefs.SetString(TestAssetStoreCachedVersionKey, cachedVersion);
var mockService = new TestablePackageUpdateService
{
IsGitInstallationResult = false,
AssetStoreFetchResult = "9.9.9"
};
// Act
var result = mockService.CheckForUpdate("9.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid Asset Store cache");
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached Asset Store version");
Assert.IsTrue(result.UpdateAvailable, "Update should be available (9.0.1 > 9.0.0)");
Assert.IsFalse(mockService.AssetStoreFetchCalled, "Should not fetch when Asset Store cache is valid");
}
[Test]
public void CheckForUpdate_FetchesAssetStoreJson_WhenCacheExpired()
{
// Arrange: Set expired Asset Store cache and a valid Git cache to ensure separation
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestAssetStoreLastCheckDateKey, yesterday);
EditorPrefs.SetString(TestAssetStoreCachedVersionKey, "9.0.0");
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestCachedVersionKey, "99.0.0");
var mockService = new TestablePackageUpdateService
{
IsGitInstallationResult = false,
AssetStoreFetchResult = "9.1.0"
};
// Act
var result = mockService.CheckForUpdate("9.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed when fetch returns a version");
Assert.AreEqual("9.1.0", result.LatestVersion, "Should use fetched Asset Store version");
Assert.IsTrue(mockService.AssetStoreFetchCalled, "Should fetch when Asset Store cache is expired");
}
[Test]
public void CheckForUpdate_ReturnsAssetStoreFailureMessage_WhenFetchFails()
{
// Arrange
var mockService = new TestablePackageUpdateService
{
IsGitInstallationResult = false,
AssetStoreFetchResult = null
};
// Act
var result = mockService.CheckForUpdate("9.0.0");
// Assert
Assert.IsFalse(result.CheckSucceeded, "Check should fail when Asset Store fetch fails");
Assert.IsFalse(result.UpdateAvailable, "No update should be reported when fetch fails");
Assert.AreEqual("Failed to check for Asset Store updates (network issue or offline)", result.Message);
Assert.IsNull(result.LatestVersion, "Latest version should be null when fetch fails");
}
[Test]
public void ClearCache_RemovesAllCachedData()
{
// Arrange: Set up cache
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
EditorPrefs.SetString(TestAssetStoreLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestAssetStoreCachedVersionKey, "9.0.0");
// Verify cache exists
Assert.IsTrue(EditorPrefs.HasKey(TestLastCheckDateKey), "Cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestCachedVersionKey), "Cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestAssetStoreLastCheckDateKey), "Asset Store cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestAssetStoreCachedVersionKey), "Asset Store cache should exist before clearing");
// Act
_service.ClearCache();
// Assert
Assert.IsFalse(EditorPrefs.HasKey(TestLastCheckDateKey), "Date cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestCachedVersionKey), "Version cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestAssetStoreLastCheckDateKey), "Asset Store date cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestAssetStoreCachedVersionKey), "Asset Store version cache should be cleared");
}
[Test]
public void ClearCache_DoesNotThrow_WhenNoCacheExists()
{
// Ensure no cache exists
CleanupEditorPrefs();
// Act & Assert - should not throw
Assert.DoesNotThrow(() => _service.ClearCache(), "Should not throw when clearing non-existent cache");
}
}
/// <summary>
/// Testable implementation that allows forcing install type and fetch results.
/// </summary>
internal class TestablePackageUpdateService : PackageUpdateService
{
public bool IsGitInstallationResult { get; set; } = true;
public string GitFetchResult { get; set; }
public string AssetStoreFetchResult { get; set; }
public bool GitFetchCalled { get; private set; }
public bool AssetStoreFetchCalled { get; private set; }
public override bool IsGitInstallation()
{
return IsGitInstallationResult;
}
protected override string FetchLatestVersionFromGitHub(string branch)
{
GitFetchCalled = true;
return GitFetchResult;
}
protected override string FetchLatestVersionFromAssetStoreJson()
{
AssetStoreFetchCalled = true;
return AssetStoreFetchResult;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 676c3849f71a84b17b14d813774d3f74
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,176 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class PortManagerTests
{
private string _savedPortFileContent;
private string _savedLegacyFileContent;
private string _portFilePath;
private string _legacyFilePath;
[SetUp]
public void SetUp()
{
// Snapshot the on-disk port config so DiscoverNewPort tests don't
// permanently alter the running bridge's persisted port.
string dir = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
".unity-mcp");
_legacyFilePath = Path.Combine(dir, "unity-mcp-port.json");
// The hashed file uses a private helper; approximate the same hash.
// We snapshot every json file in the directory to be safe.
_portFilePath = null;
_savedPortFileContent = null;
_savedLegacyFileContent = null;
if (File.Exists(_legacyFilePath))
_savedLegacyFileContent = File.ReadAllText(_legacyFilePath);
// Find the hashed port file for this project
if (Directory.Exists(dir))
{
foreach (var f in Directory.GetFiles(dir, "unity-mcp-port-*.json"))
{
_portFilePath = f;
_savedPortFileContent = File.ReadAllText(f);
break; // one project at a time
}
}
}
[TearDown]
public void TearDown()
{
// Restore the original port files
if (_savedLegacyFileContent != null && _legacyFilePath != null)
File.WriteAllText(_legacyFilePath, _savedLegacyFileContent);
if (_savedPortFileContent != null && _portFilePath != null)
File.WriteAllText(_portFilePath, _savedPortFileContent);
}
[Test]
public void IsPortAvailable_ReturnsFalse_WhenPortIsOccupied()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
try
{
Assert.IsFalse(PortManager.IsPortAvailable(port),
"IsPortAvailable should return false for a port that is already bound");
}
finally
{
listener.Stop();
}
}
[Test]
public void IsPortAvailable_ReturnsTrue_WhenPortIsFree()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
Assert.IsTrue(PortManager.IsPortAvailable(port),
"IsPortAvailable should return true for a port that is not bound");
}
#if UNITY_EDITOR_OSX
[Test]
public void IsPortAvailable_ReturnsFalse_WhenPortHeldWithReuseAddr()
{
// Simulate what AssetImportWorkers do: bind with SO_REUSEADDR.
// IsPortAvailable must still detect this as occupied.
var holder = new TcpListener(IPAddress.Loopback, 0);
holder.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
holder.Start();
int port = ((IPEndPoint)holder.LocalEndpoint).Port;
try
{
Assert.IsFalse(PortManager.IsPortAvailable(port),
"IsPortAvailable should detect ports held with SO_REUSEADDR on macOS");
}
finally
{
holder.Stop();
}
}
#endif
[Test]
public void ShouldAbandonBusyPort_KeepsSamePort_WithinReleaseWindow()
{
// A port busy for less than the fallback window is treated as our own
// not-yet-released listener after a domain reload — keep retrying the same
// port instead of silently switching and stranding the client (#1173).
Assert.IsFalse(PortManager.ShouldAbandonBusyPort(0.0));
Assert.IsFalse(PortManager.ShouldAbandonBusyPort(
PortManager.BusyPortFallbackWindowSeconds - 0.5));
}
[Test]
public void ShouldAbandonBusyPort_FallsBack_AfterReleaseWindow()
{
// A port that stays busy past the window is a foreign occupant — only then
// does the bridge discover and switch to a new port.
Assert.IsTrue(PortManager.ShouldAbandonBusyPort(
PortManager.BusyPortFallbackWindowSeconds));
Assert.IsTrue(PortManager.ShouldAbandonBusyPort(
PortManager.BusyPortFallbackWindowSeconds + 5.0));
}
[Test]
public void DiscoverNewPort_ReturnsAvailablePort()
{
int port = PortManager.DiscoverNewPort();
Assert.Greater(port, 0, "DiscoverNewPort should return a positive port number");
Assert.IsTrue(PortManager.IsPortAvailable(port),
"The port returned by DiscoverNewPort should be available");
}
[Test]
public void DiscoverNewPort_SkipsOccupiedDefaultPort()
{
// Hold the default port (6400) so DiscoverNewPort must find an alternative
TcpListener holder = null;
try
{
holder = new TcpListener(IPAddress.Loopback, 6400);
#if UNITY_EDITOR_OSX
try { holder.Server.ExclusiveAddressUse = true; } catch { }
#endif
holder.Start();
}
catch (SocketException)
{
// Port 6400 already occupied (e.g., by the running bridge) — that's fine,
// the test still validates that DiscoverNewPort picks a different port.
holder = null;
}
try
{
int port = PortManager.DiscoverNewPort();
Assert.AreNotEqual(6400, port,
"DiscoverNewPort should not return the default port when it is occupied");
Assert.Greater(port, 0);
}
finally
{
holder?.Stop();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a04ddec79871644e991cf2ab91dc9c3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9b4824e10efbf41ef98eb4e03e39fc6f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,494 @@
using System.IO;
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for PidFileManager component.
/// </summary>
[TestFixture]
public class PidFileManagerTests
{
private PidFileManager _manager;
private string _testPidFilePath;
[SetUp]
public void SetUp()
{
_manager = new PidFileManager();
// Clear any test state
ClearTestEditorPrefs();
}
[TearDown]
public void TearDown()
{
// Clean up test files
if (!string.IsNullOrEmpty(_testPidFilePath) && File.Exists(_testPidFilePath))
{
try { File.Delete(_testPidFilePath); } catch { }
}
// Clear test state
ClearTestEditorPrefs();
}
private void ClearTestEditorPrefs()
{
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { }
}
#region GetPidFilePath Tests
[Test]
public void GetPidFilePath_ValidPort_ReturnsCorrectPath()
{
// Act
string path = _manager.GetPidFilePath(8080);
// Assert
Assert.IsNotNull(path);
Assert.That(path, Does.Contain("mcp_http_8080.pid"));
Assert.That(path, Does.Contain("MCPForUnity"));
}
[Test]
public void GetPidFilePath_DifferentPorts_ReturnsDifferentPaths()
{
// Act
string path1 = _manager.GetPidFilePath(8080);
string path2 = _manager.GetPidFilePath(9090);
// Assert
Assert.AreNotEqual(path1, path2);
}
[Test]
public void GetPidDirectory_ReturnsValidPath()
{
// Act
string dir = _manager.GetPidDirectory();
// Assert
Assert.IsNotNull(dir);
Assert.That(dir, Does.Contain("MCPForUnity"));
Assert.That(dir, Does.Contain("RunState"));
}
#endregion
#region TryReadPid Tests
[Test]
public void TryReadPid_ValidFile_ReturnsTrueWithPid()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59998);
File.WriteAllText(_testPidFilePath, "12345");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(12345, pid);
}
[Test]
public void TryReadPid_FileWithWhitespace_ParsesCorrectly()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59997);
File.WriteAllText(_testPidFilePath, " 12345 \n");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(12345, pid);
}
[Test]
public void TryReadPid_MissingFile_ReturnsFalse()
{
// Act
bool result = _manager.TryReadPid("/nonexistent/path/file.pid", out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_NullPath_ReturnsFalse()
{
// Act
bool result = _manager.TryReadPid(null, out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_EmptyPath_ReturnsFalse()
{
// Act
bool result = _manager.TryReadPid(string.Empty, out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_InvalidContent_ReturnsFalse()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59996);
File.WriteAllText(_testPidFilePath, "not a number");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, pid);
}
[Test]
public void TryReadPid_ZeroPid_ReturnsFalse()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59995);
File.WriteAllText(_testPidFilePath, "0");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsFalse(result, "Zero PID should be rejected");
}
[Test]
public void TryReadPid_NegativePid_ReturnsFalse()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59994);
File.WriteAllText(_testPidFilePath, "-1");
// Act
bool result = _manager.TryReadPid(_testPidFilePath, out int pid);
// Assert
Assert.IsFalse(result, "Negative PID should be rejected");
}
#endregion
#region TryGetPortFromPidFilePath Tests
[Test]
public void TryGetPortFromPidFilePath_ValidPath_ReturnsTrue()
{
// Arrange
string path = "/some/path/mcp_http_8080.pid";
// Act
bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(8080, port);
}
[Test]
public void TryGetPortFromPidFilePath_DifferentPort_ParsesCorrectly()
{
// Arrange
string path = "/path/to/mcp_http_9999.pid";
// Act
bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(9999, port);
}
[Test]
public void TryGetPortFromPidFilePath_NullPath_ReturnsFalse()
{
// Act
bool result = _manager.TryGetPortFromPidFilePath(null, out int port);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, port);
}
[Test]
public void TryGetPortFromPidFilePath_InvalidPrefix_ReturnsFalse()
{
// Arrange
string path = "/some/path/wrong_prefix_8080.pid";
// Act
bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
// Assert
Assert.IsFalse(result);
}
#endregion
#region Handshake Tests
[Test]
public void StoreHandshake_ValidData_StoresInEditorPrefs()
{
// Arrange
string pidFilePath = "/test/path.pid";
string instanceToken = "test-token-123";
// Act
_manager.StoreHandshake(pidFilePath, instanceToken);
bool result = _manager.TryGetHandshake(out var storedPath, out var storedToken);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(pidFilePath, storedPath);
Assert.AreEqual(instanceToken, storedToken);
}
[Test]
public void TryGetHandshake_NoHandshake_ReturnsFalse()
{
// Act
bool result = _manager.TryGetHandshake(out var pidFilePath, out var instanceToken);
// Assert
Assert.IsFalse(result);
Assert.IsNull(pidFilePath);
Assert.IsNull(instanceToken);
}
[Test]
public void StoreHandshake_NullValues_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_manager.StoreHandshake(null, null);
});
}
#endregion
#region Tracking Tests
[Test]
public void StoreTracking_ValidData_CanBeRetrieved()
{
// Arrange
int pid = 12345;
int port = 8080;
// Act
_manager.StoreTracking(pid, port);
bool result = _manager.TryGetStoredPid(port, out int storedPid);
// Assert
Assert.IsTrue(result);
Assert.AreEqual(pid, storedPid);
}
[Test]
public void TryGetStoredPid_WrongPort_ReturnsFalse()
{
// Arrange
_manager.StoreTracking(12345, 8080);
// Act
bool result = _manager.TryGetStoredPid(9090, out int storedPid);
// Assert
Assert.IsFalse(result, "Should return false for wrong port");
}
[Test]
public void TryGetStoredPid_NoTracking_ReturnsFalse()
{
// Act
bool result = _manager.TryGetStoredPid(8080, out int storedPid);
// Assert
Assert.IsFalse(result);
Assert.AreEqual(0, storedPid);
}
[Test]
public void ClearTracking_RemovesAllKeys()
{
// Arrange
_manager.StoreTracking(12345, 8080, "somehash");
_manager.StoreHandshake("/path.pid", "token");
// Act
_manager.ClearTracking();
bool hasTracking = _manager.TryGetStoredPid(8080, out _);
bool hasHandshake = _manager.TryGetHandshake(out _, out _);
// Assert
Assert.IsFalse(hasTracking);
Assert.IsFalse(hasHandshake);
}
[Test]
public void GetStoredArgsHash_WithHash_ReturnsHash()
{
// Arrange
_manager.StoreTracking(12345, 8080, "testhash123");
// Act
string hash = _manager.GetStoredArgsHash();
// Assert
Assert.AreEqual("testhash123", hash);
}
[Test]
public void GetStoredArgsHash_NoHash_ReturnsEmpty()
{
// Act
string hash = _manager.GetStoredArgsHash();
// Assert
Assert.AreEqual(string.Empty, hash);
}
#endregion
#region ComputeShortHash Tests
[Test]
public void ComputeShortHash_ValidInput_Returns16CharHash()
{
// Arrange
string input = "test input string";
// Act
string hash = _manager.ComputeShortHash(input);
// Assert
Assert.IsNotNull(hash);
Assert.AreEqual(16, hash.Length);
}
[Test]
public void ComputeShortHash_SameInput_ReturnsSameHash()
{
// Arrange
string input = "consistent input";
// Act
string hash1 = _manager.ComputeShortHash(input);
string hash2 = _manager.ComputeShortHash(input);
// Assert
Assert.AreEqual(hash1, hash2);
}
[Test]
public void ComputeShortHash_DifferentInput_ReturnsDifferentHash()
{
// Act
string hash1 = _manager.ComputeShortHash("input1");
string hash2 = _manager.ComputeShortHash("input2");
// Assert
Assert.AreNotEqual(hash1, hash2);
}
[Test]
public void ComputeShortHash_NullInput_ReturnsEmpty()
{
// Act
string hash = _manager.ComputeShortHash(null);
// Assert
Assert.AreEqual(string.Empty, hash);
}
[Test]
public void ComputeShortHash_EmptyInput_ReturnsEmpty()
{
// Act
string hash = _manager.ComputeShortHash(string.Empty);
// Assert
Assert.AreEqual(string.Empty, hash);
}
#endregion
#region DeletePidFile Tests
[Test]
public void DeletePidFile_ExistingFile_DeletesFile()
{
// Arrange
_testPidFilePath = _manager.GetPidFilePath(59993);
File.WriteAllText(_testPidFilePath, "12345");
Assert.IsTrue(File.Exists(_testPidFilePath));
// Act
_manager.DeletePidFile(_testPidFilePath);
// Assert
Assert.IsFalse(File.Exists(_testPidFilePath));
}
[Test]
public void DeletePidFile_NonExistentFile_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_manager.DeletePidFile("/nonexistent/file.pid");
});
}
[Test]
public void DeletePidFile_NullPath_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_manager.DeletePidFile(null);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void PidFileManager_ImplementsIPidFileManager()
{
// Assert
Assert.IsInstanceOf<IPidFileManager>(_manager);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 875ef370082bc42d182a9875ee7c5e15
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,364 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for ProcessDetector component.
/// These tests execute subprocess commands (ps, lsof, tasklist, wmic) which can be slow.
/// Marked as [Explicit] to exclude from normal test runs.
/// </summary>
[TestFixture]
[Explicit]
public class ProcessDetectorTests
{
private ProcessDetector _detector;
[SetUp]
public void SetUp()
{
_detector = new ProcessDetector();
}
#region NormalizeForMatch Tests
[Test]
public void NormalizeForMatch_RemovesWhitespace()
{
// Arrange
string input = "Hello World";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("helloworld", result);
}
[Test]
public void NormalizeForMatch_LowercasesInput()
{
// Arrange
string input = "UPPERCASE";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("uppercase", result);
}
[Test]
public void NormalizeForMatch_HandlesNull()
{
// Act
string result = _detector.NormalizeForMatch(null);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void NormalizeForMatch_HandlesEmptyString()
{
// Act
string result = _detector.NormalizeForMatch(string.Empty);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void NormalizeForMatch_RemovesTabs()
{
// Arrange
string input = "hello\tworld";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("helloworld", result);
}
[Test]
public void NormalizeForMatch_RemovesNewlines()
{
// Arrange
string input = "hello\nworld\r\ntest";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("helloworldtest", result);
}
[Test]
public void NormalizeForMatch_PreservesNonWhitespace()
{
// Arrange
string input = "mcp-for-unity_test123";
// Act
string result = _detector.NormalizeForMatch(input);
// Assert
Assert.AreEqual("mcp-for-unity_test123", result);
}
#endregion
#region GetCurrentProcessId Tests
[Test]
public void GetCurrentProcessId_ReturnsPositiveInt()
{
// Act
int pid = _detector.GetCurrentProcessId();
// Assert
Assert.Greater(pid, 0, "Process ID should be positive");
}
[Test]
public void GetCurrentProcessId_ReturnsConsistentValue()
{
// Act
int pid1 = _detector.GetCurrentProcessId();
int pid2 = _detector.GetCurrentProcessId();
// Assert
Assert.AreEqual(pid1, pid2, "Process ID should be consistent across calls");
}
#endregion
#region ProcessExists Tests
[Test]
public void ProcessExists_CurrentProcess_ReturnsTrue()
{
// Arrange
int currentPid = _detector.GetCurrentProcessId();
// Act
bool exists = _detector.ProcessExists(currentPid);
// Assert
Assert.IsTrue(exists, "Current process should exist");
}
[Test]
public void ProcessExists_InvalidPid_ReturnsFalseOrHandlesGracefully()
{
// Act - Use a very high PID unlikely to exist
bool exists = _detector.ProcessExists(9999999);
// Assert - Should not throw, may return false or true (assumes exists if cannot verify)
Assert.Pass($"ProcessExists returned {exists} for invalid PID (handles gracefully)");
}
[Test]
public void ProcessExists_ZeroPid_HandlesGracefully()
{
// Act & Assert - Should not throw
Assert.DoesNotThrow(() =>
{
_detector.ProcessExists(0);
});
}
[Test]
public void ProcessExists_NegativePid_HandlesGracefully()
{
// Act & Assert - Should not throw
Assert.DoesNotThrow(() =>
{
_detector.ProcessExists(-1);
});
}
#endregion
#region GetListeningProcessIdsForPort Tests
[Test]
public void GetListeningProcessIdsForPort_InvalidPort_ReturnsEmpty()
{
// Act
var pids = _detector.GetListeningProcessIdsForPort(-1);
// Assert
Assert.IsNotNull(pids);
Assert.IsEmpty(pids, "Invalid port should return empty list");
}
[Test]
public void GetListeningProcessIdsForPort_UnusedPort_ReturnsEmpty()
{
// Act - Use a port that's unlikely to be in use
var pids = _detector.GetListeningProcessIdsForPort(59999);
// Assert
Assert.IsNotNull(pids);
Assert.IsEmpty(pids, "Unused port should return empty list");
}
[Test]
public void GetListeningProcessIdsForPort_ReturnsDistinctPids()
{
// Act
var pids = _detector.GetListeningProcessIdsForPort(80);
// Assert
Assert.IsNotNull(pids);
CollectionAssert.AllItemsAreUnique(pids, "PIDs should be distinct");
}
[Test]
public void GetListeningProcessIdsForPort_DoesNotThrow()
{
// Act & Assert - Should handle any port gracefully
Assert.DoesNotThrow(() =>
{
_detector.GetListeningProcessIdsForPort(8080);
});
}
#endregion
#region TryGetProcessCommandLine Tests
[Test]
public void TryGetProcessCommandLine_CurrentProcess_ReturnsResult()
{
// Arrange
int currentPid = _detector.GetCurrentProcessId();
// Act
bool result = _detector.TryGetProcessCommandLine(currentPid, out string argsLower);
// Assert - Platform dependent, but should not throw
Assert.Pass($"TryGetProcessCommandLine: success={result}, argsLower length={argsLower?.Length ?? 0}");
}
[Test]
public void TryGetProcessCommandLine_InvalidPid_ReturnsFalse()
{
// Act
bool result = _detector.TryGetProcessCommandLine(9999999, out string argsLower);
// Assert
Assert.IsFalse(result, "Invalid PID should return false");
Assert.IsEmpty(argsLower, "Args should be empty for invalid PID");
}
[Test]
public void TryGetProcessCommandLine_ReturnsNormalizedOutput()
{
// Arrange
int currentPid = _detector.GetCurrentProcessId();
// Act
bool result = _detector.TryGetProcessCommandLine(currentPid, out string argsLower);
// Assert
if (result && !string.IsNullOrEmpty(argsLower))
{
// Verify output is normalized (no whitespace, lowercase)
Assert.IsFalse(argsLower.Contains(" "), "Output should have no spaces");
Assert.AreEqual(argsLower, argsLower.ToLowerInvariant(), "Output should be lowercase");
}
Assert.Pass("Command line is properly normalized");
}
#endregion
#region LooksLikeMcpServerProcess Tests
[Test]
public void LooksLikeMcpServerProcess_CurrentProcess_ReturnsFalse()
{
// Arrange - Unity Editor process should not be an MCP server
int currentPid = _detector.GetCurrentProcessId();
// Act
bool result = _detector.LooksLikeMcpServerProcess(currentPid);
// Assert
Assert.IsFalse(result, "Unity Editor should not be identified as MCP server");
}
[Test]
public void LooksLikeMcpServerProcess_InvalidPid_ReturnsFalse()
{
// Act
bool result = _detector.LooksLikeMcpServerProcess(9999999);
// Assert
Assert.IsFalse(result, "Invalid PID should return false");
}
[Test]
public void LooksLikeMcpServerProcess_ZeroPid_ReturnsFalse()
{
// Act
bool result = _detector.LooksLikeMcpServerProcess(0);
// Assert
Assert.IsFalse(result, "Zero PID should return false");
}
[Test]
public void LooksLikeMcpServerProcess_NegativePid_ReturnsFalse()
{
// Act
bool result = _detector.LooksLikeMcpServerProcess(-1);
// Assert
Assert.IsFalse(result, "Negative PID should return false");
}
[Test]
public void LooksLikeMcpServerProcess_DoesNotThrow()
{
// Act & Assert - Should handle any PID gracefully
Assert.DoesNotThrow(() =>
{
_detector.LooksLikeMcpServerProcess(12345);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void ProcessDetector_ImplementsIProcessDetector()
{
// Assert
Assert.IsInstanceOf<IProcessDetector>(_detector);
}
[Test]
public void ProcessDetector_CanBeUsedViaInterface()
{
// Arrange
IProcessDetector detector = new ProcessDetector();
// Act & Assert - All interface methods should work
Assert.DoesNotThrow(() =>
{
detector.NormalizeForMatch("test");
detector.GetCurrentProcessId();
detector.ProcessExists(1);
detector.GetListeningProcessIdsForPort(8080);
detector.TryGetProcessCommandLine(1, out _);
detector.LooksLikeMcpServerProcess(1);
});
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 88e47e156fb6a4064a2d638cf8bb8d52
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,159 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for ProcessTerminator component.
/// Note: Most tests avoid actually terminating processes to prevent test instability.
/// Uses ProcessDetector which executes subprocess commands (ps, tasklist, etc.), so marked as [Explicit].
/// </summary>
[TestFixture]
[Explicit]
public class ProcessTerminatorTests
{
private ProcessTerminator _terminator;
private ProcessDetector _detector;
[SetUp]
public void SetUp()
{
_detector = new ProcessDetector();
_terminator = new ProcessTerminator(_detector);
}
#region Constructor Tests
[Test]
public void Constructor_NullDetector_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<System.ArgumentNullException>(() =>
{
new ProcessTerminator(null);
});
}
[Test]
public void Constructor_ValidDetector_Succeeds()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
new ProcessTerminator(_detector);
});
}
#endregion
#region Terminate Tests
[Test]
public void Terminate_InvalidPid_ReturnsFalse()
{
// Act
bool result = _terminator.Terminate(-1);
// Assert
Assert.IsFalse(result, "Invalid PID should fail to terminate");
}
[Test]
public void Terminate_ZeroPid_ReturnsFalse()
{
// Act
bool result = _terminator.Terminate(0);
// Assert
Assert.IsFalse(result, "Zero PID should fail to terminate");
}
[Test]
public void Terminate_Pid1_ReturnsFalse()
{
// PID 1 is init/launchd and must never be killed
// Act
bool result = _terminator.Terminate(1);
// Assert
Assert.IsFalse(result, "PID 1 (init/launchd) should never be terminated");
}
[Test]
public void Terminate_CurrentProcessPid_ReturnsFalse()
{
// Should never kill the Unity Editor process
// Act
int currentPid = _detector.GetCurrentProcessId();
bool result = _terminator.Terminate(currentPid);
// Assert
Assert.IsFalse(result, "Current process PID should never be terminated");
}
[Test]
public void Terminate_NonExistentPid_ReturnsFalseOrHandlesGracefully()
{
// Act - Use a very high PID unlikely to exist
bool result = _terminator.Terminate(9999999);
// Assert - Should not terminate non-existent PID
Assert.IsFalse(result, $"Terminate returned {result} for non-existent PID");
}
[Test]
public void Terminate_DoesNotThrow()
{
// Act & Assert - Should handle any PID gracefully
Assert.DoesNotThrow(() =>
{
_terminator.Terminate(int.MaxValue);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void ProcessTerminator_ImplementsIProcessTerminator()
{
// Assert
Assert.IsInstanceOf<IProcessTerminator>(_terminator);
}
[Test]
public void ProcessTerminator_CanBeUsedViaInterface()
{
// Arrange
IProcessTerminator terminator = new ProcessTerminator(_detector);
// Act & Assert - Should be callable via interface
Assert.DoesNotThrow(() =>
{
// Don't actually terminate anything
terminator.Terminate(-1);
});
}
#endregion
#region Integration Tests (with real detector)
[Test]
public void Terminate_WithRealDetector_HandlesMissingProcess()
{
// Arrange
var realDetector = new ProcessDetector();
var terminator = new ProcessTerminator(realDetector);
// Act - Try to terminate a PID that definitely doesn't exist
bool result = terminator.Terminate(int.MaxValue);
// Assert - Should return false without throwing
Assert.IsFalse(result, "Terminating non-existent process should return false");
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 48fe9f588d61e46319110ad1c09d0aea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,325 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for ServerCommandBuilder component.
/// </summary>
[TestFixture]
public class ServerCommandBuilderTests
{
private ServerCommandBuilder _builder;
private bool _savedUseHttpTransport;
private string _savedHttpUrl;
[SetUp]
public void SetUp()
{
_builder = new ServerCommandBuilder();
// Save current settings
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedHttpUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
}
[TearDown]
public void TearDown()
{
// Restore settings
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
if (!string.IsNullOrEmpty(_savedHttpUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, _savedHttpUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpBaseUrl);
}
// Refresh cache to reflect restored values
EditorConfigurationCache.Instance.Refresh();
}
#region QuoteIfNeeded Tests
[Test]
public void QuoteIfNeeded_PathWithSpaces_AddsQuotes()
{
// Arrange
string input = "path with spaces";
// Act
string result = _builder.QuoteIfNeeded(input);
// Assert
Assert.AreEqual("\"path with spaces\"", result);
}
[Test]
public void QuoteIfNeeded_PathWithoutSpaces_NoChange()
{
// Arrange
string input = "pathwithoutspaces";
// Act
string result = _builder.QuoteIfNeeded(input);
// Assert
Assert.AreEqual("pathwithoutspaces", result);
}
[Test]
public void QuoteIfNeeded_NullInput_ReturnsNull()
{
// Act
string result = _builder.QuoteIfNeeded(null);
// Assert
Assert.IsNull(result);
}
[Test]
public void QuoteIfNeeded_EmptyInput_ReturnsEmpty()
{
// Act
string result = _builder.QuoteIfNeeded(string.Empty);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void QuoteIfNeeded_AlreadyQuoted_AddsMoreQuotes()
{
// Arrange - This is intentional behavior - don't double-escape
string input = "\"already quoted\"";
// Act
string result = _builder.QuoteIfNeeded(input);
// Assert - Has spaces so gets quoted
Assert.AreEqual("\"\"already quoted\"\"", result);
}
#endregion
#region BuildUvPathFromUvx Tests
[Test]
public void BuildUvPathFromUvx_ValidPath_ConvertsCorrectly()
{
// This test uses Unix-style paths which only work correctly on non-Windows
if (UnityEngine.Application.platform == UnityEngine.RuntimePlatform.WindowsEditor)
{
Assert.Pass("Skipped on Windows - use BuildUvPathFromUvx_WindowsPath_ConvertsCorrectly instead");
return;
}
// Arrange
string uvxPath = "/usr/local/bin/uvx";
// Act
string result = _builder.BuildUvPathFromUvx(uvxPath);
// Assert
Assert.AreEqual("/usr/local/bin/uv", result);
}
[Test]
public void BuildUvPathFromUvx_WindowsPath_ConvertsCorrectly()
{
// This test only makes sense on Windows where backslash paths are native
if (UnityEngine.Application.platform != UnityEngine.RuntimePlatform.WindowsEditor)
{
Assert.Pass("Skipped on non-Windows platform");
return;
}
// Arrange
string uvxPath = @"C:\Program Files\uv\uvx.exe";
// Act
string result = _builder.BuildUvPathFromUvx(uvxPath);
// Assert
Assert.AreEqual(@"C:\Program Files\uv\uv.exe", result);
}
[Test]
public void BuildUvPathFromUvx_NullPath_ReturnsNull()
{
// Act
string result = _builder.BuildUvPathFromUvx(null);
// Assert
Assert.IsNull(result);
}
[Test]
public void BuildUvPathFromUvx_EmptyPath_ReturnsEmpty()
{
// Act
string result = _builder.BuildUvPathFromUvx(string.Empty);
// Assert
Assert.AreEqual(string.Empty, result);
}
[Test]
public void BuildUvPathFromUvx_WhitespacePath_ReturnsWhitespace()
{
// Act
string result = _builder.BuildUvPathFromUvx(" ");
// Assert
Assert.AreEqual(" ", result);
}
[Test]
public void BuildUvPathFromUvx_JustFilename_ConvertsCorrectly()
{
// Arrange
string uvxPath = "uvx";
// Act
string result = _builder.BuildUvPathFromUvx(uvxPath);
// Assert
Assert.AreEqual("uv", result);
}
#endregion
#region GetPlatformSpecificPathPrepend Tests
[Test]
public void GetPlatformSpecificPathPrepend_ReturnsNonNull()
{
// Act
string result = _builder.GetPlatformSpecificPathPrepend();
// Assert - May be null on some platforms, but should not throw
Assert.Pass($"GetPlatformSpecificPathPrepend returned: {result ?? "null"}");
}
[Test]
public void GetPlatformSpecificPathPrepend_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_builder.GetPlatformSpecificPathPrepend();
});
}
#endregion
#region TryBuildCommand Tests
[Test]
public void TryBuildCommand_HttpDisabled_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
// Act
bool result = _builder.TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
// Assert
Assert.IsFalse(result);
Assert.IsNull(fileName);
Assert.IsNull(arguments);
Assert.IsNull(displayCommand);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("HTTP").IgnoreCase);
}
[Test]
public void TryBuildCommand_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
// Act
bool result = _builder.TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
// Assert
Assert.IsFalse(result);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("local").IgnoreCase);
}
[Test]
public void TryBuildCommand_LocalUrl_ReturnsCommandOrError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
// Act
bool result = _builder.TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
// Assert - Success depends on uvx availability
if (result)
{
Assert.IsNotNull(fileName, "fileName should be set on success");
Assert.IsNotNull(arguments, "arguments should be set on success");
Assert.IsNotNull(displayCommand, "displayCommand should be set on success");
Assert.IsNull(error, "error should be null on success");
Assert.That(displayCommand, Does.Contain("uvx").Or.Contain("uv"));
}
else
{
Assert.IsNotNull(error, "error message should be provided on failure");
}
Assert.Pass($"TryBuildCommand: success={result}, error={error ?? "null"}");
}
[Test]
public void TryBuildCommand_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_builder.TryBuildCommand(out _, out _, out _, out _);
});
}
#endregion
#region Interface Implementation Tests
[Test]
public void ServerCommandBuilder_ImplementsIServerCommandBuilder()
{
// Assert
Assert.IsInstanceOf<IServerCommandBuilder>(_builder);
}
[Test]
public void ServerCommandBuilder_CanBeUsedViaInterface()
{
// Arrange
IServerCommandBuilder builder = new ServerCommandBuilder();
// Act & Assert - All interface methods should work
Assert.DoesNotThrow(() =>
{
builder.QuoteIfNeeded("test");
builder.BuildUvPathFromUvx("uvx");
builder.GetPlatformSpecificPathPrepend();
builder.TryBuildCommand(out _, out _, out _, out _);
});
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5506606af081a4eaca6a5563e1a4b0dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,304 @@
using System;
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
namespace MCPForUnityTests.Editor.Services.Server
{
/// <summary>
/// Unit tests for TerminalLauncher component.
/// Note: Tests avoid actually launching terminals to prevent test instability.
/// </summary>
[TestFixture]
public class TerminalLauncherTests
{
private TerminalLauncher _launcher;
[SetUp]
public void SetUp()
{
_launcher = new TerminalLauncher();
}
#region GetProjectRootPath Tests
[Test]
public void GetProjectRootPath_ReturnsNonEmpty()
{
// Act
string path = _launcher.GetProjectRootPath();
// Assert
Assert.IsNotNull(path);
Assert.IsNotEmpty(path);
}
[Test]
public void GetProjectRootPath_ReturnsValidDirectory()
{
// Act
string path = _launcher.GetProjectRootPath();
// Assert
Assert.IsTrue(System.IO.Directory.Exists(path), $"Project root path should exist: {path}");
}
[Test]
public void GetProjectRootPath_DoesNotContainAssets()
{
// Act
string path = _launcher.GetProjectRootPath();
// Assert
Assert.IsFalse(path.EndsWith("Assets"), "Project root should not end with Assets");
}
#endregion
#region CreateTerminalProcessStartInfo Tests
[Test]
public void CreateTerminalProcessStartInfo_EmptyCommand_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
{
_launcher.CreateTerminalProcessStartInfo(string.Empty);
});
}
[Test]
public void CreateTerminalProcessStartInfo_NullCommand_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
{
_launcher.CreateTerminalProcessStartInfo(null);
});
}
[Test]
public void CreateTerminalProcessStartInfo_WhitespaceCommand_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
{
_launcher.CreateTerminalProcessStartInfo(" ");
});
}
[Test]
public void CreateTerminalProcessStartInfo_ValidCommand_ReturnsStartInfo()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo hello");
// Assert
Assert.IsNotNull(startInfo);
Assert.IsNotNull(startInfo.FileName);
Assert.IsNotEmpty(startInfo.FileName);
}
[Test]
public void CreateTerminalProcessStartInfo_ValidCommand_SetsUseShellExecuteFalse()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo hello");
// Assert
Assert.IsFalse(startInfo.UseShellExecute, "UseShellExecute should be false");
}
[Test]
public void CreateTerminalProcessStartInfo_ValidCommand_SetsCreateNoWindowTrue()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo hello");
// Assert
Assert.IsTrue(startInfo.CreateNoWindow, "CreateNoWindow should be true");
}
[Test]
public void CreateTerminalProcessStartInfo_CommandWithNewlines_StripsNewlines()
{
// Act - Should not throw
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo\nhello\r\nworld");
// Assert
Assert.IsNotNull(startInfo);
}
[Test]
public void CreateTerminalProcessStartInfo_LongCommand_HandlesGracefully()
{
// Arrange
string longCommand = new string('a', 1000);
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo(longCommand);
// Assert
Assert.IsNotNull(startInfo);
}
[Test]
public void CreateTerminalProcessStartInfo_SpecialCharacters_HandlesGracefully()
{
// Arrange
string command = "echo \"hello world\" && echo 'test' | cat";
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo(command);
// Assert
Assert.IsNotNull(startInfo);
}
#endregion
#region CreateHeadlessProcessStartInfo Tests
[Test]
public void CreateHeadlessProcessStartInfo_EmptyCommand_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
_launcher.CreateHeadlessProcessStartInfo(string.Empty, "/tmp/log.txt"));
}
[Test]
public void CreateHeadlessProcessStartInfo_EmptyLogPath_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
_launcher.CreateHeadlessProcessStartInfo("echo hello", string.Empty));
}
[Test]
public void CreateHeadlessProcessStartInfo_IsHiddenNoWindow()
{
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo hello", LogPath());
Assert.IsFalse(startInfo.UseShellExecute, "UseShellExecute should be false for headless launch");
Assert.IsTrue(startInfo.CreateNoWindow, "CreateNoWindow should be true for headless launch");
Assert.AreEqual(System.Diagnostics.ProcessWindowStyle.Hidden, startInfo.WindowStyle,
"WindowStyle should be Hidden for headless launch");
}
[Test]
public void CreateHeadlessProcessStartInfo_DoesNotOpenTerminal()
{
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo hello", LogPath());
// Must NOT route through Terminal.app / start / a terminal emulator.
#if UNITY_EDITOR_WIN
Assert.AreEqual("cmd.exe", startInfo.FileName, "Windows headless should run via cmd.exe");
StringAssert.DoesNotContain("start ", startInfo.Arguments, "Windows headless must not use 'start' (new window)");
#else
Assert.AreEqual("/bin/bash", startInfo.FileName, "macOS/Linux headless should run via /bin/bash");
StringAssert.DoesNotContain("open", startInfo.Arguments, "macOS headless must not use 'open -a Terminal'");
StringAssert.DoesNotContain("Terminal", startInfo.Arguments, "macOS headless must not open Terminal.app");
#endif
}
[Test]
public void CreateHeadlessProcessStartInfo_RedirectsOutputToLogFile()
{
string logPath = LogPath();
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo hello", logPath);
// The redirect to the log file is part of the shell payload.
StringAssert.Contains(logPath, startInfo.Arguments, "Arguments should reference the log file path");
StringAssert.Contains("2>&1", startInfo.Arguments, "stderr should be redirected to stdout (and the log)");
StringAssert.Contains(">>", startInfo.Arguments, "output should be appended to the log via >>");
}
[Test]
public void CreateHeadlessProcessStartInfo_LogPathWithSpaces_IsQuoted()
{
// A log path containing spaces must remain a single token.
string logPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Mcp Logs", "server launch.log");
var startInfo = _launcher.CreateHeadlessProcessStartInfo("uvx run-server", logPath);
StringAssert.Contains(logPath, startInfo.Arguments, "Arguments should contain the full spaced log path");
#if UNITY_EDITOR_WIN
StringAssert.Contains($"\"{logPath}\"", startInfo.Arguments, "Windows should double-quote a spaced log path");
#else
StringAssert.Contains($"'{logPath}'", startInfo.Arguments, "macOS/Linux should single-quote a spaced log path");
#endif
}
[Test]
public void CreateHeadlessProcessStartInfo_CommandWithSpaces_Preserved()
{
string command = "/path with spaces/uvx --no-cache run mcp-for-unity";
var startInfo = _launcher.CreateHeadlessProcessStartInfo(command, LogPath());
StringAssert.Contains(command, startInfo.Arguments, "The command (incl. spaces) should be preserved in Arguments");
}
[Test]
public void CreateHeadlessProcessStartInfo_StripsNewlines()
{
var startInfo = _launcher.CreateHeadlessProcessStartInfo("echo\nhello\r\nworld", LogPath());
Assert.IsNotNull(startInfo);
StringAssert.DoesNotContain("\n", startInfo.Arguments);
StringAssert.DoesNotContain("\r", startInfo.Arguments);
}
private static string LogPath()
{
return System.IO.Path.Combine(System.IO.Path.GetTempPath(), "mcp-headless-test.log");
}
#endregion
#region Interface Implementation Tests
[Test]
public void TerminalLauncher_ImplementsITerminalLauncher()
{
// Assert
Assert.IsInstanceOf<ITerminalLauncher>(_launcher);
}
[Test]
public void TerminalLauncher_CanBeUsedViaInterface()
{
// Arrange
ITerminalLauncher launcher = new TerminalLauncher();
// Act & Assert
Assert.DoesNotThrow(() =>
{
launcher.GetProjectRootPath();
launcher.CreateTerminalProcessStartInfo("test");
});
}
#endregion
#region Platform-Specific Behavior Tests
[Test]
public void CreateTerminalProcessStartInfo_ReturnsAppropriateTerminal()
{
// Act
var startInfo = _launcher.CreateTerminalProcessStartInfo("echo test");
// Assert - Platform-specific
#if UNITY_EDITOR_OSX
Assert.AreEqual("/usr/bin/open", startInfo.FileName, "macOS should use 'open'");
#elif UNITY_EDITOR_WIN
Assert.AreEqual("cmd.exe", startInfo.FileName, "Windows should use 'cmd.exe'");
#else
// Linux uses detected terminal
Assert.IsNotNull(startInfo.FileName, "Linux should have a terminal command");
#endif
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59d7c435f60554a719ddc9cd85f6ad3c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using System.Reflection;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services
{
[TestFixture]
public class StartupConfigRewriteTests
{
[Test]
public void StartupConfigRewrite_TypeExists()
{
var t = System.Type.GetType("MCPForUnity.Editor.Services.StartupConfigRewrite, MCPForUnity.Editor");
Assert.IsNotNull(t, "StartupConfigRewrite type must exist");
Assert.IsTrue(t.IsPublic, "StartupConfigRewrite must be public so the [InitializeOnLoad] attribute fires");
}
[Test]
public void StartupConfigRewrite_HasInitializeOnLoad()
{
var t = System.Type.GetType("MCPForUnity.Editor.Services.StartupConfigRewrite, MCPForUnity.Editor");
Assert.IsNotNull(t);
object[] attrs = t.GetCustomAttributes(typeof(InitializeOnLoadAttribute), inherit: false);
Assert.AreEqual(1, attrs.Length, "Class must be decorated with [InitializeOnLoad]");
}
[Test]
public void StartupConfigRewrite_RunOncePerSession_GuardKey()
{
var t = System.Type.GetType("MCPForUnity.Editor.Services.StartupConfigRewrite, MCPForUnity.Editor");
var keyField = t?.GetField("SESSION_GUARD_KEY",
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
Assert.IsNotNull(keyField);
string val = (string)keyField.GetValue(null);
StringAssert.StartsWith("MCPForUnity.", val);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca5aeabe283e473ea5ae78cf60ee55f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More