/*
┌──────────────────────────────────────────────────────────────────┐
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
│ Copyright (c) 2025 Ivan Murzak │
│ Licensed under the Apache License, Version 2.0. │
│ See the LICENSE file in the project root for more information. │
└──────────────────────────────────────────────────────────────────┘
*/
#nullable enable
#if UNITY_6000_5_OR_NEWER
using System.Collections;
using System.IO;
using com.IvanMurzak.Unity.MCP.Editor.API;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace com.IvanMurzak.Unity.MCP.Editor.Tests
{
///
/// Thread-safety coverage for MCP tools — regression for
/// #633.
///
/// For each tool we want to guarantee the tool's body handles invocation from any
/// thread (the MCP server dispatches tool calls from a SignalR background thread,
/// so tools that touch Unity APIs must marshal back to the main thread via
/// MainThread.Instance.Run). Forgetting to do so caused the regression in
/// #632 /
/// #637
/// (console-clear-logs).
///
/// Excluded tools: package-add/package-remove (trigger domain
/// reload), tests-run (recursive hang), system tools (ping,
/// unity-skill-*). See inline comments for rationale.
///
/// Two helpers from are used:
///
/// - — happy path; asserts the tool
/// succeeds on both threads. Used when crafting a valid input is practical.
/// - — lenient mode; tolerates
/// business-level errors and only fails the test if Unity surfaces a
/// "can only be called from the main thread" exception. Used for tools
/// that need elaborate setup (SerializedMember, prefab stages, etc.) or
/// whose happy path would mutate the project in risky ways
/// (package-add/package-remove, tests-run, etc.).
///
///
public class ToolThreadSafetyTests : BaseTest
{
const string TmpFolderName = "BgThreadTests_TMP";
const string TmpFolder = "Assets/" + TmpFolderName;
[TearDown]
public void BgTearDown()
{
if (AssetDatabase.IsValidFolder(TmpFolder))
AssetDatabase.DeleteAsset(TmpFolder);
}
// ================================================================
// NO-ARG / READ-ONLY tools (happy path, empty JSON)
// ================================================================
[UnityTest] public IEnumerator ToolList_BothThreads()
=> RunToolBothThreads(Tool_Tool.ToolListId, "{}");
[UnityTest] public IEnumerator GameObjectComponentListAll_BothThreads()
=> RunToolBothThreads(Tool_GameObject.ComponentListToolId, @"{""pageSize"":3}");
[UnityTest] public IEnumerator SceneListOpened_BothThreads()
=> RunToolBothThreads(Tool_Scene.SceneListOpenedToolId, "{}");
[UnityTest] public IEnumerator SceneGetData_BothThreads()
=> RunToolBothThreads(Tool_Scene.SceneGetDataToolId, "{}");
[UnityTest] public IEnumerator EditorApplicationGetState_BothThreads()
=> RunToolBothThreads(Tool_Editor.EditorApplicationGetStateToolId, "{}");
[UnityTest] public IEnumerator EditorSelectionGet_BothThreads()
=> RunToolBothThreads(Tool_Editor_Selection.EditorSelectionGetToolId, "{}");
[UnityTest] public IEnumerator ConsoleGetLogs_BothThreads()
=> RunToolBothThreads(Tool_Console.ConsoleGetLogsToolId, "{}");
[UnityTest]
public IEnumerator ConsoleClearLogs_BothThreads()
{
// Regression for #632/#637: ClearLogs calls Debug.ClearDeveloperConsole() which
// MUST be invoked on the main thread.
yield return RunToolBothThreads(Tool_Console.ConsoleClearLogsToolId, "{}");
}
[UnityTest] public IEnumerator AssetsFind_BothThreads()
=> RunToolBothThreads(Tool_Assets.AssetsFindToolId, @"{""filter"":""t:Scene"",""maxResults"":3}");
[UnityTest] public IEnumerator AssetsFindBuiltIn_BothThreads()
=> RunToolBothThreads(Tool_Assets.AssetsFindBuiltInToolId, @"{""maxResults"":3}");
[UnityTest] public IEnumerator AssetsShaderListAll_BothThreads()
=> RunToolBothThreads(Tool_Assets_Shader.AssetsShaderListAllToolId, "{}");
[UnityTest] public IEnumerator AssetsRefresh_BothThreads()
=> RunToolBothThreads(Tool_Assets.AssetsRefreshToolId, "{}");
[UnityTest] public IEnumerator PackageList_BothThreads()
=> RunToolBothThreads(Tool_Package.PackageListToolId, "{}");
[UnityTest] public IEnumerator TypeGetJsonSchema_BothThreads()
=> RunToolBothThreads(Tool_Type.TypeGetJsonSchemaToolId, @"{""typeName"":""UnityEngine.Vector3""}");
// ================================================================
// GAMEOBJECT tools — happy path, per-call setup
// ================================================================
[UnityTest]
public IEnumerator GameObjectCreate_BothThreads()
{
yield return RunToolMainThreadCoop(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""bg_main""}");
Assert.IsNotNull(GameObject.Find("bg_main"));
yield return RunToolFromBackgroundThread(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""bg_bg""}");
Assert.IsNotNull(GameObject.Find("bg_bg"));
}
[UnityTest]
public IEnumerator GameObjectFind_BothThreads()
{
var go = new GameObject("bg_find");
var id = go.GetEntityId();
yield return RunToolBothThreads(Tool_GameObject.GameObjectFindToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{id}}}}}");
}
[UnityTest]
public IEnumerator GameObjectDestroy_BothThreads()
{
var a = new GameObject("bg_destroy_a").GetEntityId();
yield return RunToolMainThreadCoop(Tool_GameObject.GameObjectDestroyToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{a}}}}}");
var b = new GameObject("bg_destroy_b").GetEntityId();
yield return RunToolFromBackgroundThread(Tool_GameObject.GameObjectDestroyToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{b}}}}}");
}
[UnityTest]
public IEnumerator GameObjectDuplicate_BothThreads()
{
var a = new GameObject("bg_dup_a").GetEntityId();
yield return RunToolMainThreadCoop(Tool_GameObject.GameObjectDuplicateToolId,
$@"{{""gameObjectRefs"":[{{""instanceID"":{a}}}]}}");
var b = new GameObject("bg_dup_b").GetEntityId();
yield return RunToolFromBackgroundThread(Tool_GameObject.GameObjectDuplicateToolId,
$@"{{""gameObjectRefs"":[{{""instanceID"":{b}}}]}}");
}
[UnityTest]
public IEnumerator GameObjectSetParent_BothThreads()
{
var parent = new GameObject("bg_parent").GetEntityId();
var child1 = new GameObject("bg_child_a").GetEntityId();
yield return RunToolMainThreadCoop(Tool_GameObject.GameObjectSetParentToolId,
$@"{{""gameObjectRefs"":[{{""instanceID"":{child1}}}],""parentGameObjectRef"":{{""instanceID"":{parent}}}}}");
var child2 = new GameObject("bg_child_b").GetEntityId();
yield return RunToolFromBackgroundThread(Tool_GameObject.GameObjectSetParentToolId,
$@"{{""gameObjectRefs"":[{{""instanceID"":{child2}}}],""parentGameObjectRef"":{{""instanceID"":{parent}}}}}");
}
[UnityTest]
public IEnumerator GameObjectComponentAdd_BothThreads()
{
var a = new GameObject("bg_addc_a").GetEntityId();
yield return RunToolMainThreadCoop(Tool_GameObject.GameObjectComponentAddToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{a}}},""componentNames"":[""UnityEngine.BoxCollider""]}}");
var b = new GameObject("bg_addc_b").GetEntityId();
yield return RunToolFromBackgroundThread(Tool_GameObject.GameObjectComponentAddToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{b}}},""componentNames"":[""UnityEngine.SphereCollider""]}}");
}
[UnityTest]
public IEnumerator GameObjectComponentGet_BothThreads()
{
var go = new GameObject("bg_getc");
var id = go.GetEntityId();
// Transform lives at component index 0 on every GameObject.
yield return RunToolBothThreads(Tool_GameObject.GameObjectComponentGetToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{id}}},""componentRef"":{{""index"":0}}}}");
}
[UnityTest]
public IEnumerator GameObjectComponentDestroy_BothThreads()
{
var a = new GameObject("bg_destc_a");
a.AddComponent();
var idA = a.GetEntityId();
yield return RunToolMainThreadCoop(Tool_GameObject.GameObjectComponentDestroyToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{idA}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}");
var b = new GameObject("bg_destc_b");
b.AddComponent();
var idB = b.GetEntityId();
yield return RunToolFromBackgroundThread(Tool_GameObject.GameObjectComponentDestroyToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{idB}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.SphereCollider""}}]}}");
}
[UnityTest]
public IEnumerator GameObjectModify_ThreadSafetyOnly()
{
// gameobject-modify requires a SerializedMember tree — we cover thread safety
// only (business errors from an empty diff are tolerated).
var id = new GameObject("bg_modify").GetEntityId();
yield return RunToolExpectNoThreadViolation(Tool_GameObject.GameObjectModifyToolId,
$@"{{""gameObjectRefs"":[{{""instanceID"":{id}}}],""gameObjectDiffs"":[{{""name"":""root""}}]}}");
}
[UnityTest]
public IEnumerator GameObjectComponentModify_ThreadSafetyOnly()
{
var go = new GameObject("bg_cmodify");
go.AddComponent();
var id = go.GetEntityId();
yield return RunToolExpectNoThreadViolation(Tool_GameObject.GameObjectComponentModifyToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{id}}},""componentRef"":{{""typeName"":""UnityEngine.BoxCollider""}},""componentDiff"":{{""name"":""root""}}}}");
}
// ================================================================
// OBJECT tools
// ================================================================
[UnityTest]
public IEnumerator ObjectGetData_BothThreads()
{
var id = new GameObject("bg_obj").GetEntityId();
yield return RunToolBothThreads(Tool_Object.ObjectGetDataToolId,
$@"{{""objectRef"":{{""instanceID"":{id}}}}}");
}
[UnityTest]
public IEnumerator ObjectModify_ThreadSafetyOnly()
{
var id = new GameObject("bg_objmod").GetEntityId();
yield return RunToolExpectNoThreadViolation(Tool_Object.ObjectModifyToolId,
$@"{{""objectRef"":{{""instanceID"":{id}}},""objectDiff"":{{""name"":""root""}}}}");
}
// ================================================================
// EDITOR tools
// ================================================================
[UnityTest]
public IEnumerator EditorSelectionSet_BothThreads()
{
var id = new GameObject("bg_select").GetEntityId();
yield return RunToolBothThreads(Tool_Editor_Selection.EditorSelectionSetToolId,
$@"{{""select"":[{{""instanceID"":{id}}}]}}");
}
[UnityTest]
public IEnumerator EditorApplicationSetState_BothThreads()
{
// No-op toggle: current state is not-playing, not-paused.
yield return RunToolBothThreads(Tool_Editor.EditorApplicationSetStateToolId,
@"{""isPlaying"":false,""isPaused"":false}");
}
// ================================================================
// ASSETS tools
// ================================================================
[UnityTest]
public IEnumerator AssetsCreateFolder_BothThreads()
{
EnsureTmpFolder();
yield return RunToolMainThreadCoop(Tool_Assets.AssetsCreateFolderToolId,
$@"{{""inputs"":[{{""parentFolderPath"":""{TmpFolder}"",""newFolderName"":""a""}}]}}");
yield return RunToolFromBackgroundThread(Tool_Assets.AssetsCreateFolderToolId,
$@"{{""inputs"":[{{""parentFolderPath"":""{TmpFolder}"",""newFolderName"":""b""}}]}}");
}
[UnityTest]
public IEnumerator AssetsGetData_BothThreads()
{
var assetPath = CreateTmpTextAsset("get_data.txt", "hello");
yield return RunToolBothThreads(Tool_Assets.AssetsGetDataToolId,
$@"{{""assetRef"":{{""assetPath"":""{assetPath}""}}}}");
}
[UnityTest]
public IEnumerator AssetsModify_ThreadSafetyOnly()
{
var assetPath = CreateTmpTextAsset("modify.txt", "hello");
yield return RunToolExpectNoThreadViolation(Tool_Assets.AssetsModifyToolId,
$@"{{""assetRef"":{{""assetPath"":""{assetPath}""}},""content"":{{""name"":""root""}}}}");
}
[UnityTest]
public IEnumerator AssetsMaterialCreate_BothThreads()
{
EnsureTmpFolder();
yield return RunToolMainThreadCoop(Tool_Assets.AssetsMaterialCreateToolId,
$@"{{""assetPath"":""{TmpFolder}/mat_main.mat"",""shaderName"":""Standard""}}");
yield return RunToolFromBackgroundThread(Tool_Assets.AssetsMaterialCreateToolId,
$@"{{""assetPath"":""{TmpFolder}/mat_bg.mat"",""shaderName"":""Standard""}}");
}
[UnityTest]
public IEnumerator AssetsShaderGetData_BothThreads()
{
// Find any shader asset in the project to query.
var shaderPath = FindFirstShaderPath();
if (shaderPath == null)
{
Assert.Ignore("No shader asset available in project.");
yield break;
}
yield return RunToolBothThreads(Tool_Assets_Shader.AssetsShaderGetDataToolId,
$@"{{""assetRef"":{{""assetPath"":""{shaderPath}""}}}}");
}
[UnityTest]
public IEnumerator AssetsCopy_BothThreads()
{
var src = CreateTmpTextAsset("copy_src.txt", "source");
yield return RunToolMainThreadCoop(Tool_Assets.AssetsCopyToolId,
$@"{{""sourcePaths"":[""{src}""],""destinationPaths"":[""{TmpFolder}/copy_main.txt""]}}");
yield return RunToolFromBackgroundThread(Tool_Assets.AssetsCopyToolId,
$@"{{""sourcePaths"":[""{src}""],""destinationPaths"":[""{TmpFolder}/copy_bg.txt""]}}");
}
[UnityTest]
public IEnumerator AssetsMove_BothThreads()
{
var src1 = CreateTmpTextAsset("move_a.txt", "a");
yield return RunToolMainThreadCoop(Tool_Assets.AssetsMoveToolId,
$@"{{""sourcePaths"":[""{src1}""],""destinationPaths"":[""{TmpFolder}/move_a_done.txt""]}}");
var src2 = CreateTmpTextAsset("move_b.txt", "b");
yield return RunToolFromBackgroundThread(Tool_Assets.AssetsMoveToolId,
$@"{{""sourcePaths"":[""{src2}""],""destinationPaths"":[""{TmpFolder}/move_b_done.txt""]}}");
}
[UnityTest]
public IEnumerator AssetsDelete_BothThreads()
{
var src1 = CreateTmpTextAsset("del_a.txt", "a");
yield return RunToolMainThreadCoop(Tool_Assets.AssetsDeleteToolId, $@"{{""paths"":[""{src1}""]}}");
var src2 = CreateTmpTextAsset("del_b.txt", "b");
yield return RunToolFromBackgroundThread(Tool_Assets.AssetsDeleteToolId,
$@"{{""paths"":[""{src2}""]}}");
}
// ---------- Prefabs ----------
[UnityTest]
public IEnumerator AssetsPrefabCreate_BothThreads()
{
EnsureTmpFolder();
var idA = new GameObject("bg_prefab_a").GetEntityId();
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabCreateToolId,
$@"{{""prefabAssetPath"":""{TmpFolder}/prefab_a.prefab"",""gameObjectRef"":{{""instanceID"":{idA}}}}}");
var idB = new GameObject("bg_prefab_b").GetEntityId();
yield return RunToolFromBackgroundThread(Tool_Assets_Prefab.AssetsPrefabCreateToolId,
$@"{{""prefabAssetPath"":""{TmpFolder}/prefab_b.prefab"",""gameObjectRef"":{{""instanceID"":{idB}}}}}");
}
[UnityTest]
public IEnumerator AssetsPrefabInstantiate_BothThreads()
{
EnsureTmpFolder();
var seedId = new GameObject("bg_seed_prefab").GetEntityId();
var prefabPath = $"{TmpFolder}/seed.prefab";
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabCreateToolId,
$@"{{""prefabAssetPath"":""{prefabPath}"",""gameObjectRef"":{{""instanceID"":{seedId}}}}}");
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabInstantiateToolId,
$@"{{""prefabAssetPath"":""{prefabPath}"",""gameObjectPath"":""bg_inst_main""}}");
yield return RunToolFromBackgroundThread(Tool_Assets_Prefab.AssetsPrefabInstantiateToolId,
$@"{{""prefabAssetPath"":""{prefabPath}"",""gameObjectPath"":""bg_inst_bg""}}");
}
[UnityTest]
public IEnumerator AssetsPrefabOpenSaveClose_BothThreads()
{
EnsureTmpFolder();
// Create a prefab asset from a scene GameObject.
var seedA = new GameObject("bg_prefab_osc_a").GetEntityId();
var prefabPathA = $"{TmpFolder}/osc_a.prefab";
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabCreateToolId,
$@"{{""prefabAssetPath"":""{prefabPathA}"",""gameObjectRef"":{{""instanceID"":{seedA}}}}}");
// Instantiate it so we get a prefab instance to open.
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabInstantiateToolId,
$@"{{""prefabAssetPath"":""{prefabPathA}"",""gameObjectPath"":""bg_osc_inst_a""}}");
var instanceA = GameObject.Find("bg_osc_inst_a");
Assert.IsNotNull(instanceA, "Prefab instance not found after instantiate.");
// Open → Save → Close (main thread).
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabOpenToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{instanceA!.GetEntityId()}}}}}");
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabSaveToolId, "{}");
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabCloseToolId, @"{""save"":false}");
// Same sequence from a background thread.
var seedB = new GameObject("bg_prefab_osc_b").GetEntityId();
var prefabPathB = $"{TmpFolder}/osc_b.prefab";
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabCreateToolId,
$@"{{""prefabAssetPath"":""{prefabPathB}"",""gameObjectRef"":{{""instanceID"":{seedB}}}}}");
yield return RunToolMainThreadCoop(Tool_Assets_Prefab.AssetsPrefabInstantiateToolId,
$@"{{""prefabAssetPath"":""{prefabPathB}"",""gameObjectPath"":""bg_osc_inst_b""}}");
var instanceB = GameObject.Find("bg_osc_inst_b");
Assert.IsNotNull(instanceB, "Prefab instance not found after instantiate.");
yield return RunToolFromBackgroundThread(Tool_Assets_Prefab.AssetsPrefabOpenToolId,
$@"{{""gameObjectRef"":{{""instanceID"":{instanceB!.GetEntityId()}}}}}");
yield return RunToolFromBackgroundThread(Tool_Assets_Prefab.AssetsPrefabSaveToolId, "{}");
yield return RunToolFromBackgroundThread(Tool_Assets_Prefab.AssetsPrefabCloseToolId, @"{""save"":false}");
}
// ================================================================
// SCENE tools
// ================================================================
[UnityTest]
public IEnumerator SceneCreate_BothThreads()
{
EnsureTmpFolder();
var startingActive = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
yield return RunToolMainThreadCoop(Tool_Scene.SceneCreateToolId,
$@"{{""path"":""{TmpFolder}/scene_main.unity""}}");
yield return RunToolFromBackgroundThread(Tool_Scene.SceneCreateToolId,
$@"{{""path"":""{TmpFolder}/scene_bg.unity""}}");
// Restore a baseline active scene if the test clobbered it.
if (startingActive.IsValid() && UnityEngine.SceneManagement.SceneManager.GetActiveScene() != startingActive)
UnityEngine.SceneManagement.SceneManager.SetActiveScene(startingActive);
}
[UnityTest]
public IEnumerator SceneOpenSaveSetActive_BothThreads()
{
EnsureTmpFolder();
var scenePath = $"{TmpFolder}/reopen.unity";
yield return RunToolMainThreadCoop(Tool_Scene.SceneCreateToolId, $@"{{""path"":""{scenePath}""}}");
// open (main + bg)
yield return RunToolMainThreadCoop(Tool_Scene.SceneOpenToolId,
$@"{{""sceneRef"":{{""assetPath"":""{scenePath}""}}}}");
yield return RunToolFromBackgroundThread(Tool_Scene.SceneOpenToolId,
$@"{{""sceneRef"":{{""assetPath"":""{scenePath}""}}}}");
// set-active (both)
yield return RunToolBothThreads(Tool_Scene.SceneSetActiveToolId,
$@"{{""sceneRef"":{{""assetPath"":""{scenePath}""}}}}");
// save (both)
yield return RunToolBothThreads(Tool_Scene.SceneSaveToolId, "{}");
}
[UnityTest]
public IEnumerator SceneUnload_BothThreads()
{
EnsureTmpFolder();
// Create a saved scene so we can open additional scenes additively.
var basePath = $"{TmpFolder}/unload_base.unity";
yield return RunToolMainThreadCoop(Tool_Scene.SceneCreateToolId,
$@"{{""path"":""{basePath}""}}");
// Create two extra scenes additively (allowed now because base is saved).
var mainPath = $"{TmpFolder}/unload_main.unity";
var bgPath = $"{TmpFolder}/unload_bg.unity";
yield return RunToolMainThreadCoop(Tool_Scene.SceneCreateToolId,
$@"{{""path"":""{mainPath}"",""newSceneMode"":""Additive""}}");
yield return RunToolMainThreadCoop(Tool_Scene.SceneCreateToolId,
$@"{{""path"":""{bgPath}"",""newSceneMode"":""Additive""}}");
// Unload each additive scene from both threads (tool takes scene name, not path).
yield return RunToolMainThreadCoop(Tool_Scene.SceneUnloadToolId,
@"{""name"":""unload_main""}");
yield return RunToolFromBackgroundThread(Tool_Scene.SceneUnloadToolId,
@"{""name"":""unload_bg""}");
}
// ================================================================
// SCRIPT tools
// ================================================================
[UnityTest]
public IEnumerator ScriptUpdateOrCreate_ReadDelete_BothThreads()
{
EnsureTmpFolder();
var pathA = $"{TmpFolder}/S_a.cs";
var pathB = $"{TmpFolder}/S_b.cs";
const string SrcA = "public class BgSa { public static void M() {} }";
const string SrcB = "public class BgSb { public static void M() {} }";
// update-or-create
yield return RunToolMainThreadCoop(Tool_Script.ScriptUpdateOrCreateToolId,
$@"{{""filePath"":""{pathA}"",""content"":{JsonString(SrcA)}}}");
yield return RunToolFromBackgroundThread(Tool_Script.ScriptUpdateOrCreateToolId,
$@"{{""filePath"":""{pathB}"",""content"":{JsonString(SrcB)}}}");
// read (main + bg)
yield return RunToolBothThreads(Tool_Script.ScriptReadToolId,
$@"{{""filePath"":""{pathA}""}}",
$@"{{""filePath"":""{pathB}""}}");
// delete
yield return RunToolMainThreadCoop(Tool_Script.ScriptDeleteToolId, $@"{{""files"":[""{pathA}""]}}");
yield return RunToolFromBackgroundThread(Tool_Script.ScriptDeleteToolId,
$@"{{""files"":[""{pathB}""]}}");
}
[UnityTest]
public IEnumerator ScriptExecute_BothThreads()
{
const string Code = "public class Script { public static int Main() { return 42; } }";
yield return RunToolBothThreads(Tool_Script.ScriptExecuteToolId,
$@"{{""csharpCode"":{JsonString(Code)},""className"":""Script"",""methodName"":""Main""}}");
}
// ================================================================
// TOOL CONTROL
// ================================================================
[UnityTest]
public IEnumerator ToolSetEnabledState_BothThreads()
{
// Enable unity-tool-list twice (idempotent, already enabled) from both threads.
yield return RunToolBothThreads(Tool_Tool.ToolSetEnabledStateId,
@"{""tools"":[{""name"":""unity-tool-list"",""enabled"":true}]}");
}
// ================================================================
// REFLECTION (complex MethodRef schema → thread-safety only)
// ================================================================
[UnityTest]
public IEnumerator ReflectionMethodFind_ThreadSafetyOnly()
{
yield return RunToolExpectNoThreadViolation(Tool_Reflection.ReflectionMethodFindToolId,
@"{""filter"":{""Namespace"":""UnityEngine"",""TypeName"":""Transform"",""MethodName"":""LookAt""}}");
}
[UnityTest]
public IEnumerator ReflectionMethodCall_BothThreads()
{
// Call a static method on a test-scoped GameObject's Transform via instance method.
var go = new GameObject("bg_reflect");
var id = go.GetEntityId();
yield return RunToolBothThreads(Tool_Reflection.ReflectionMethodCallToolId,
$@"{{""filter"":{{""Namespace"":""UnityEngine"",""TypeName"":""GameObject"",""MethodName"":""Find"",""InputParameters"":[{{""TypeName"":""System.String"",""Name"":""name""}}]}},""inputParameters"":[{{""typeName"":""System.String"",""name"":""name"",""value"":""bg_reflect""}}]}}");
}
// ================================================================
// PACKAGE MANAGER — package-add / package-remove intentionally NOT
// exercised here: both return status "Processing" and kick off a real
// Unity PackageManager resolution on a background task, which in turn
// forces a domain reload. Under the test runner that hangs the whole
// batch-mode run. Their thread-safety is already covered by the fact
// that their entry path is a synchronous parameter check.
// ================================================================
[UnityTest]
public IEnumerator PackageSearch_ThreadSafetyOnly()
{
// Registry round-trip is slow and flaky under test; thread-safety only.
yield return RunToolExpectNoThreadViolation(Tool_Package.PackageSearchToolId,
@"{""query"":""com.unity.textmeshpro"",""maxResults"":1,""offlineMode"":true}");
}
// ================================================================
// SCREENSHOTS — not guaranteed to have a camera/view in batchmode.
// ================================================================
[UnityTest]
public IEnumerator ScreenshotCamera_BothThreads()
{
new GameObject("TestCamera").AddComponent();
yield return RunToolBothThreads(Tool_Screenshot.ScreenshotCameraToolId, @"{""width"":64,""height"":64}");
}
[UnityTest]
public IEnumerator ScreenshotGameView_ThreadSafetyOnly()
=> RunToolExpectNoThreadViolation(Tool_Screenshot.ScreenshotGameViewToolId, "{}");
[UnityTest]
public IEnumerator ScreenshotSceneView_ThreadSafetyOnly()
=> RunToolExpectNoThreadViolation(Tool_Screenshot.ScreenshotSceneViewToolId, @"{""width"":64,""height"":64}");
// ================================================================
// TESTS — recursive; skip deliberately.
// ================================================================
// tests-run intentionally has no test here: executing it from within the test
// runner causes recursion and a hang. Its thread safety is implicit because the
// test runner itself schedules the invocation onto the main thread.
// ================================================================
// Helpers
// ================================================================
static void EnsureTmpFolder()
{
if (!AssetDatabase.IsValidFolder(TmpFolder))
AssetDatabase.CreateFolder("Assets", TmpFolderName);
}
static string CreateTmpTextAsset(string fileName, string contents)
{
EnsureTmpFolder();
var path = $"{TmpFolder}/{fileName}";
File.WriteAllText(path, contents);
AssetDatabase.ImportAsset(path);
return path;
}
static string? FindFirstShaderPath()
{
// Prefer the built-in Standard shader.
var standard = Shader.Find("Standard");
if (standard != null)
{
var p = AssetDatabase.GetAssetPath(standard);
if (!string.IsNullOrEmpty(p))
return p;
}
var guids = AssetDatabase.FindAssets("t:Shader");
foreach (var guid in guids)
{
var p = AssetDatabase.GUIDToAssetPath(guid);
if (!string.IsNullOrEmpty(p))
return p;
}
return null;
}
/// Returns a JSON string literal (quoted, backslash-escaped).
static string JsonString(string raw)
=> System.Text.Json.JsonSerializer.Serialize(raw);
}
}
#endif