diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index b65cf21ea..c4169da5b 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -27,6 +27,7 @@ public static string NormalizeSeparators(string path) /// /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". + /// Also protects against path traversal attacks using "../" sequences. /// public static string SanitizeAssetPath(string path) { @@ -36,6 +37,15 @@ public static string SanitizeAssetPath(string path) } path = NormalizeSeparators(path); + + // Check for path traversal sequences + if (path.Contains("..")) + { + McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'"); + return null; + } + + // Ensure path starts with Assets/ if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { return "Assets/" + path.TrimStart('/'); @@ -44,6 +54,45 @@ public static string SanitizeAssetPath(string path) return path; } + /// + /// Checks if a given asset path is valid and safe (no traversal, within Assets folder). + /// + /// True if the path is valid, false otherwise. + public static bool IsValidAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + // Normalize for comparison + string normalized = NormalizeSeparators(path); + + // Must start with Assets/ + if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Must not contain traversal sequences + if (normalized.Contains("..")) + { + return false; + } + + // Must not contain invalid path characters + char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' }; + foreach (char c in invalidChars) + { + if (normalized.IndexOf(c) >= 0) + { + return false; + } + } + + return true; + } + /// /// Gets the MCP for Unity package root path. /// Works for registry Package Manager, local Package Manager, and Asset Store installations. diff --git a/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs new file mode 100644 index 000000000..88397fb6e --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity Prefab assets. + /// + public static class PrefabUtilityHelper + { + /// + /// Gets the GUID for a prefab asset path. + /// + /// The Unity asset path (e.g., "Assets/Prefabs/MyPrefab.prefab") + /// The GUID string, or null if the path is invalid. + public static string GetPrefabGUID(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) + { + return null; + } + + try + { + return AssetDatabase.AssetPathToGUID(assetPath); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get GUID for asset path '{assetPath}': {ex.Message}"); + return null; + } + } + + /// + /// Gets variant information if the prefab is a variant. + /// + /// The prefab GameObject to check. + /// A tuple containing (isVariant, parentPath, parentGuid). + public static (bool isVariant, string parentPath, string parentGuid) GetVariantInfo(GameObject prefabAsset) + { + if (prefabAsset == null) + { + return (false, null, null); + } + + try + { + PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset); + if (assetType != PrefabAssetType.Variant) + { + return (false, null, null); + } + + GameObject parentAsset = PrefabUtility.GetCorrespondingObjectFromSource(prefabAsset); + if (parentAsset == null) + { + return (true, null, null); + } + + string parentPath = AssetDatabase.GetAssetPath(parentAsset); + string parentGuid = GetPrefabGUID(parentPath); + + return (true, parentPath, parentGuid); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get variant info for '{prefabAsset.name}': {ex.Message}"); + return (false, null, null); + } + } + + /// + /// Gets the list of component type names on a GameObject. + /// + /// The GameObject to inspect. + /// A list of component type full names. + public static List GetComponentTypeNames(GameObject obj) + { + var typeNames = new List(); + + if (obj == null) + { + return typeNames; + } + + try + { + var components = obj.GetComponents(); + foreach (var component in components) + { + if (component != null) + { + typeNames.Add(component.GetType().FullName); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get component types for '{obj.name}': {ex.Message}"); + } + + return typeNames; + } + + /// + /// Recursively counts all children in the hierarchy. + /// + /// The root transform to count from. + /// Total number of children in the hierarchy. + public static int CountChildrenRecursive(Transform transform) + { + if (transform == null) + { + return 0; + } + + int count = transform.childCount; + for (int i = 0; i < transform.childCount; i++) + { + count += CountChildrenRecursive(transform.GetChild(i)); + } + return count; + } + + /// + /// Gets the source prefab path for a nested prefab instance. + /// + /// The GameObject to check. + /// The asset path of the source prefab, or null if not a nested prefab. + public static string GetNestedPrefabPath(GameObject gameObject) + { + if (gameObject == null || !PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + { + return null; + } + + try + { + var sourcePrefab = PrefabUtility.GetCorrespondingObjectFromSource(gameObject); + if (sourcePrefab != null) + { + return AssetDatabase.GetAssetPath(sourcePrefab); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get nested prefab path for '{gameObject.name}': {ex.Message}"); + } + + return null; + } + + /// + /// Gets the nesting depth of a prefab instance within the prefab hierarchy. + /// Returns 0 for main prefab root, 1 for first-level nested, 2 for second-level, etc. + /// Returns -1 for non-prefab-root objects. + /// + /// The GameObject to analyze. + /// The root transform of the main prefab asset. + /// Nesting depth (0=main root, 1+=nested), or -1 if not a prefab root. + public static int GetPrefabNestingDepth(GameObject gameObject, Transform mainPrefabRoot) + { + if (gameObject == null) + return -1; + + // Main prefab root + if (gameObject.transform == mainPrefabRoot) + return 0; + + // Not a prefab instance root + if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + return -1; + + // Calculate depth by walking up the hierarchy + int depth = 0; + Transform current = gameObject.transform; + + while (current != null && current != mainPrefabRoot) + { + if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject)) + { + depth++; + } + current = current.parent; + } + + return depth; + } + + /// + /// Gets the parent prefab path for a nested prefab instance. + /// Returns null for main prefab root or non-prefab objects. + /// + /// The GameObject to analyze. + /// The root transform of the main prefab asset. + /// The asset path of the parent prefab, or null if none. + public static string GetParentPrefabPath(GameObject gameObject, Transform mainPrefabRoot) + { + if (gameObject == null || gameObject.transform == mainPrefabRoot) + return null; + + if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + return null; + + // Walk up the hierarchy to find the parent prefab instance + Transform current = gameObject.transform.parent; + + while (current != null && current != mainPrefabRoot) + { + if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject)) + { + return GetNestedPrefabPath(current.gameObject); + } + current = current.parent; + } + + // Parent is the main prefab root - get its asset path + if (mainPrefabRoot != null) + { + return AssetDatabase.GetAssetPath(mainPrefabRoot.gameObject); + } + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs index bfa83faac..b817b90b0 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -5,6 +5,7 @@ using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; +using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; @@ -239,6 +240,18 @@ internal static object Handle(JObject @params, JToken targetToken, string search } EditorUtility.SetDirty(targetGo); + + // Mark the appropriate scene as dirty (handles both regular scenes and prefab stages) + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + { + EditorSceneManager.MarkSceneDirty(prefabStage.scene); + } + else + { + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + } + return new SuccessResponse( $"GameObject '{targetGo.name}' modified successfully.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 39ed057e0..4223a2561 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; @@ -15,7 +16,14 @@ namespace MCPForUnity.Editor.Tools.Prefabs /// public static class ManagePrefabs { - private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; + // Action constants + private const string ACTION_OPEN_STAGE = "open_stage"; + private const string ACTION_CLOSE_STAGE = "close_stage"; + private const string ACTION_SAVE_OPEN_STAGE = "save_open_stage"; + private const string ACTION_CREATE_FROM_GAMEOBJECT = "create_from_gameobject"; + private const string ACTION_GET_INFO = "get_info"; + private const string ACTION_GET_HIERARCHY = "get_hierarchy"; + private const string SupportedActions = ACTION_OPEN_STAGE + ", " + ACTION_CLOSE_STAGE + ", " + ACTION_SAVE_OPEN_STAGE + ", " + ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY; public static object HandleCommand(JObject @params) { @@ -34,14 +42,18 @@ public static object HandleCommand(JObject @params) { switch (action) { - case "open_stage": + case ACTION_OPEN_STAGE: return OpenStage(@params); - case "close_stage": + case ACTION_CLOSE_STAGE: return CloseStage(@params); - case "save_open_stage": - return SaveOpenStage(); - case "create_from_gameobject": + case ACTION_SAVE_OPEN_STAGE: + return SaveOpenStage(@params); + case ACTION_CREATE_FROM_GAMEOBJECT: return CreatePrefabFromGameObject(@params); + case ACTION_GET_INFO: + return GetInfo(@params); + case ACTION_GET_HIERARCHY: + return GetHierarchy(@params); default: return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); } @@ -53,6 +65,9 @@ public static object HandleCommand(JObject @params) } } + /// + /// Opens a prefab in prefab mode for editing. + /// private static object OpenStage(JObject @params) { string prefabPath = @params["prefabPath"]?.ToString(); @@ -62,18 +77,16 @@ private static object OpenStage(JObject @params) } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + if (string.IsNullOrEmpty(sanitizedPath)) + { + return new ErrorResponse($"Invalid prefab path: '{prefabPath}'."); + } GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); } - string modeValue = @params["mode"]?.ToString(); - if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) - { - return new ErrorResponse("Only PrefabStage mode 'InIsolation' is supported at this time."); - } - PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); if (stage == null) { @@ -83,6 +96,9 @@ private static object OpenStage(JObject @params) return new SuccessResponse($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); } + /// + /// Closes the currently open prefab stage, optionally saving first. + /// private static object CloseStage(JObject @params) { PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); @@ -91,18 +107,30 @@ private static object CloseStage(JObject @params) return new SuccessResponse("No prefab stage was open."); } + string assetPath = stage.assetPath; bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; + if (saveBeforeClose && stage.scene.isDirty) { - SaveStagePrefab(stage); - AssetDatabase.SaveAssets(); + try + { + SaveAndRefreshStage(stage); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to save prefab before closing: {e.Message}"); + } } StageUtility.GoToMainStage(); - return new SuccessResponse($"Closed prefab stage for '{stage.assetPath}'."); + return new SuccessResponse($"Closed prefab stage for '{assetPath}'."); } - private static object SaveOpenStage() + /// + /// Saves changes to the currently open prefab stage. + /// Supports a 'force' parameter for automated workflows where isDirty may not be set. + /// + private static object SaveOpenStage(JObject @params) { PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) @@ -110,107 +138,327 @@ private static object SaveOpenStage() return new ErrorResponse("No prefab stage is currently open."); } - SaveStagePrefab(stage); - AssetDatabase.SaveAssets(); - return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); + if (!ValidatePrefabStageForSave(stage)) + { + return new ErrorResponse("Prefab stage validation failed. Cannot save."); + } + + // Check for force parameter (useful for automated workflows) + bool force = @params?["force"]?.ToObject() ?? false; + + // Check if there are actual changes to save + bool wasDirty = stage.scene.isDirty; + if (!wasDirty && !force) + { + return new SuccessResponse($"Prefab stage for '{stage.assetPath}' has no unsaved changes.", SerializeStage(stage)); + } + + try + { + SaveAndRefreshStage(stage, force); + return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to save prefab: {e.Message}"); + } } - private static void SaveStagePrefab(PrefabStage stage) + #region Prefab Save Operations + + /// + /// Saves the prefab stage and refreshes the asset database. + /// Uses PrefabUtility.SaveAsPrefabAsset for reliable prefab saving without dialogs. + /// + /// The prefab stage to save. + /// If true, marks the prefab dirty before saving to ensure changes are captured. + private static void SaveAndRefreshStage(PrefabStage stage, bool force = false) { - if (stage?.prefabContentsRoot == null) + if (stage == null) + { + throw new ArgumentNullException(nameof(stage), "Prefab stage cannot be null."); + } + + if (stage.prefabContentsRoot == null) { throw new InvalidOperationException("Cannot save prefab stage without a prefab root."); } - bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath); - if (!saved) + if (string.IsNullOrEmpty(stage.assetPath)) + { + throw new InvalidOperationException("Prefab stage has invalid asset path."); + } + + // When force=true, mark the prefab root dirty to ensure changes are saved + // This is useful for automated workflows where isDirty may not be set correctly + if (force) { - throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'."); + EditorUtility.SetDirty(stage.prefabContentsRoot); + EditorSceneManager.MarkSceneDirty(stage.scene); } + + // Mark all children as dirty to ensure their changes are captured + foreach (Transform child in stage.prefabContentsRoot.GetComponentsInChildren(true)) + { + if (child != stage.prefabContentsRoot.transform) + { + EditorUtility.SetDirty(child.gameObject); + } + } + + // Use PrefabUtility.SaveAsPrefabAsset which saves without dialogs + // This is more reliable for automated workflows than EditorSceneManager.SaveScene + bool success; + PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath, out success); + + if (!success) + { + throw new InvalidOperationException($"Failed to save prefab asset for '{stage.assetPath}'."); + } + + // Ensure changes are persisted to disk + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + McpLog.Info($"[ManagePrefabs] Successfully saved prefab '{stage.assetPath}'."); } - private static object CreatePrefabFromGameObject(JObject @params) + /// + /// Validates prefab stage before saving. + /// + private static bool ValidatePrefabStageForSave(PrefabStage stage) { - string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); - if (string.IsNullOrEmpty(targetName)) + if (stage == null) { - return new ErrorResponse("'target' parameter is required for create_from_gameobject."); + McpLog.Warn("[ManagePrefabs] No prefab stage is open."); + return false; } - bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; - GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); - if (sourceObject == null) + if (stage.prefabContentsRoot == null) { - return new ErrorResponse($"GameObject '{targetName}' not found in the active scene."); + McpLog.Error($"[ManagePrefabs] Prefab stage '{stage.assetPath}' has no root object."); + return false; } - if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) + if (string.IsNullOrEmpty(stage.assetPath)) { - return new ErrorResponse( - $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." - ); + McpLog.Error("[ManagePrefabs] Prefab stage has invalid asset path."); + return false; } - PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); - if (status != PrefabInstanceStatus.NotAPrefab) + return true; + } + + #endregion + + #region Create Prefab from GameObject + + /// + /// Creates a prefab asset from a GameObject in the scene. + /// + private static object CreatePrefabFromGameObject(JObject @params) + { + // 1. Validate and parse parameters + var validation = ValidateCreatePrefabParams(@params); + if (!validation.isValid) { - return new ErrorResponse( - $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." - ); + return new ErrorResponse(validation.errorMessage); } - string requestedPath = @params["prefabPath"]?.ToString(); - if (string.IsNullOrWhiteSpace(requestedPath)) + string targetName = validation.targetName; + string finalPath = validation.finalPath; + bool includeInactive = validation.includeInactive; + bool replaceExisting = validation.replaceExisting; + bool unlinkIfInstance = validation.unlinkIfInstance; + + // 2. Find the source object + GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); + if (sourceObject == null) { - return new ErrorResponse("'prefabPath' parameter is required for create_from_gameobject."); + return new ErrorResponse($"GameObject '{targetName}' not found in the active scene or prefab stage{(includeInactive ? " (including inactive objects)" : "")}."); } - string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); - if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + // 3. Validate source object state + var objectValidation = ValidateSourceObjectForPrefab(sourceObject, unlinkIfInstance); + if (!objectValidation.isValid) { - sanitizedPath += ".prefab"; + return new ErrorResponse(objectValidation.errorMessage); } - bool allowOverwrite = @params["allowOverwrite"]?.ToObject() ?? false; - string finalPath = sanitizedPath; + // 4. Check for path conflicts and track if file will be replaced + bool fileExistedAtPath = AssetDatabase.LoadAssetAtPath(finalPath) != null; - if (!allowOverwrite && AssetDatabase.LoadAssetAtPath(finalPath) != null) + if (!replaceExisting && fileExistedAtPath) { finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); + McpLog.Info($"[ManagePrefabs] Generated unique path: {finalPath}"); } + // 5. Ensure directory exists EnsureAssetDirectoryExists(finalPath); + // 6. Unlink from existing prefab if needed + if (unlinkIfInstance && objectValidation.shouldUnlink) + { + try + { + // UnpackPrefabInstance requires the prefab instance root, not a child object + GameObject rootToUnlink = PrefabUtility.GetOutermostPrefabInstanceRoot(sourceObject); + if (rootToUnlink != null) + { + PrefabUtility.UnpackPrefabInstance(rootToUnlink, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + McpLog.Info($"[ManagePrefabs] Unpacked prefab instance '{rootToUnlink.name}' before creating new prefab."); + } + } + catch (Exception e) + { + return new ErrorResponse($"Failed to unlink prefab instance: {e.Message}"); + } + } + + // 7. Create the prefab try { - GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( - sourceObject, - finalPath, - InteractionMode.AutomatedAction - ); + GameObject result = CreatePrefabAsset(sourceObject, finalPath, replaceExisting); - if (connectedInstance == null) + if (result == null) { - return new ErrorResponse($"Failed to save prefab asset at '{finalPath}'."); + return new ErrorResponse($"Failed to create prefab asset at '{finalPath}'."); } - Selection.activeGameObject = connectedInstance; + // 8. Select the newly created instance + Selection.activeGameObject = result; return new SuccessResponse( $"Prefab created at '{finalPath}' and instance linked.", new { prefabPath = finalPath, - instanceId = connectedInstance.GetInstanceID() + instanceId = result.GetInstanceID(), + instanceName = result.name, + wasUnlinked = unlinkIfInstance && objectValidation.shouldUnlink, + wasReplaced = replaceExisting && fileExistedAtPath, + componentCount = result.GetComponents().Length, + childCount = result.transform.childCount } ); } catch (Exception e) { - return new ErrorResponse($"Error saving prefab asset at '{finalPath}': {e.Message}"); + McpLog.Error($"[ManagePrefabs] Error creating prefab at '{finalPath}': {e}"); + return new ErrorResponse($"Error saving prefab asset: {e.Message}"); } } + /// + /// Validates parameters for creating a prefab from GameObject. + /// + private static (bool isValid, string errorMessage, string targetName, string finalPath, bool includeInactive, bool replaceExisting, bool unlinkIfInstance) + ValidateCreatePrefabParams(JObject @params) + { + string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); + if (string.IsNullOrEmpty(targetName)) + { + return (false, "'target' parameter is required for create_from_gameobject.", null, null, false, false, false); + } + + string requestedPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return (false, "'prefabPath' parameter is required for create_from_gameobject.", targetName, null, false, false, false); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); + if (sanitizedPath == null) + { + return (false, $"Invalid prefab path (path traversal detected): '{requestedPath}'", targetName, null, false, false, false); + } + if (string.IsNullOrEmpty(sanitizedPath)) + { + return (false, $"Invalid prefab path '{requestedPath}'. Path cannot be empty.", targetName, null, false, false, false); + } + if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + sanitizedPath += ".prefab"; + } + + // Validate path is within Assets folder + if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return (false, $"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'", targetName, null, false, false, false); + } + + bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; + bool replaceExisting = @params["allowOverwrite"]?.ToObject() ?? false; + bool unlinkIfInstance = @params["unlinkIfInstance"]?.ToObject() ?? false; + + return (true, null, targetName, sanitizedPath, includeInactive, replaceExisting, unlinkIfInstance); + } + + /// + /// Validates source object can be converted to prefab. + /// + private static (bool isValid, string errorMessage, bool shouldUnlink, string existingPrefabPath) + ValidateSourceObjectForPrefab(GameObject sourceObject, bool unlinkIfInstance) + { + // Check if this is a Prefab Asset (the .prefab file itself in the editor) + if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) + { + return (false, + $"GameObject '{sourceObject.name}' is part of a prefab asset. " + + "Open the prefab stage to save changes instead.", + false, null); + } + + // Check if this is already a Prefab Instance + PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); + if (status != PrefabInstanceStatus.NotAPrefab) + { + string existingPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(sourceObject); + + if (!unlinkIfInstance) + { + return (false, + $"GameObject '{sourceObject.name}' is already linked to prefab '{existingPath}'. " + + "Set 'unlinkIfInstance' to true to unlink it first, or modify the existing prefab instead.", + false, existingPath); + } + + // Needs to be unlinked + return (true, null, true, existingPath); + } + + return (true, null, false, null); + } + + /// + /// Creates a prefab asset from a GameObject. + /// + private static GameObject CreatePrefabAsset(GameObject sourceObject, string path, bool replaceExisting) + { + GameObject result = PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, + path, + InteractionMode.AutomatedAction + ); + + string action = replaceExisting ? "Replaced existing" : "Created new"; + McpLog.Info($"[ManagePrefabs] {action} prefab at '{path}'."); + + if (result != null) + { + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + return result; + } + + #endregion + + /// + /// Ensures the directory for an asset path exists, creating it if necessary. + /// private static void EnsureAssetDirectoryExists(string assetPath) { string directory = Path.GetDirectoryName(assetPath); @@ -219,37 +467,54 @@ private static void EnsureAssetDirectoryExists(string assetPath) return; } - string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); + // Use Application.dataPath for more reliable path resolution + // Application.dataPath points to the Assets folder (e.g., ".../ProjectName/Assets") + string assetsPath = Application.dataPath; + string projectRoot = Path.GetDirectoryName(assetsPath); + string fullDirectory = Path.Combine(projectRoot, directory); + if (!Directory.Exists(fullDirectory)) { Directory.CreateDirectory(fullDirectory); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + McpLog.Info($"[ManagePrefabs] Created directory: {directory}"); } } + /// + /// Finds a GameObject by name in the active scene or current prefab stage. + /// private static GameObject FindSceneObjectByName(string name, bool includeInactive) { + // First check if we're in Prefab Stage PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage?.prefabContentsRoot != null) { foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren(includeInactive)) { - if (transform.name == name) + if (transform.name == name && (includeInactive || transform.gameObject.activeSelf)) { return transform.gameObject; } } } + // Search in the active scene Scene activeScene = SceneManager.GetActiveScene(); foreach (GameObject root in activeScene.GetRootGameObjects()) { + // Check the root object itself + if (root.name == name && (includeInactive || root.activeSelf)) + { + return root; + } + + // Check children foreach (Transform transform in root.GetComponentsInChildren(includeInactive)) { - GameObject candidate = transform.gameObject; - if (candidate.name == name) + if (transform.name == name && (includeInactive || transform.gameObject.activeSelf)) { - return candidate; + return transform.gameObject; } } } @@ -257,6 +522,177 @@ private static GameObject FindSceneObjectByName(string name, bool includeInactiv return null; } + #region Read Operations + + /// + /// Gets basic metadata information about a prefab asset. + /// + private static object GetInfo(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return new ErrorResponse("'prefabPath' parameter is required for get_info."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + if (string.IsNullOrEmpty(sanitizedPath)) + { + return new ErrorResponse($"Invalid prefab path: '{prefabPath}'."); + } + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (prefabAsset == null) + { + return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); + } + + string guid = PrefabUtilityHelper.GetPrefabGUID(sanitizedPath); + PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset); + string prefabTypeString = assetType.ToString(); + var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(prefabAsset); + int childCount = PrefabUtilityHelper.CountChildrenRecursive(prefabAsset.transform); + var (isVariant, parentPrefab, _) = PrefabUtilityHelper.GetVariantInfo(prefabAsset); + + return new SuccessResponse( + $"Successfully retrieved prefab info.", + new + { + assetPath = sanitizedPath, + guid = guid, + prefabType = prefabTypeString, + rootObjectName = prefabAsset.name, + rootComponentTypes = componentTypes, + childCount = childCount, + isVariant = isVariant, + parentPrefab = parentPrefab + } + ); + } + + /// + /// Gets the hierarchical structure of a prefab asset. + /// Returns all objects in the prefab for full client-side filtering and search. + /// + private static object GetHierarchy(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return new ErrorResponse("'prefabPath' parameter is required for get_hierarchy."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + if (string.IsNullOrEmpty(sanitizedPath)) + { + return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed."); + } + + // Load prefab contents in background (without opening stage UI) + GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath); + if (prefabContents == null) + { + return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'."); + } + + try + { + // Build complete hierarchy items (no pagination) + var allItems = BuildHierarchyItems(prefabContents.transform, sanitizedPath); + + return new SuccessResponse( + $"Successfully retrieved prefab hierarchy. Found {allItems.Count} objects.", + new + { + prefabPath = sanitizedPath, + total = allItems.Count, + items = allItems + } + ); + } + finally + { + // Always unload prefab contents to free memory + PrefabUtility.UnloadPrefabContents(prefabContents); + } + } + + #endregion + + #region Hierarchy Builder + + /// + /// Builds a flat list of hierarchy items from a transform root. + /// + /// The root transform of the prefab. + /// Asset path of the main prefab. + /// List of hierarchy items with prefab information. + private static List BuildHierarchyItems(Transform root, string mainPrefabPath) + { + var items = new List(); + BuildHierarchyItemsRecursive(root, root, mainPrefabPath, "", items); + return items; + } + + /// + /// Recursively builds hierarchy items. + /// + /// Current transform being processed. + /// Root transform of the main prefab asset. + /// Asset path of the main prefab. + /// Parent path for building full hierarchy path. + /// List to accumulate hierarchy items. + private static void BuildHierarchyItemsRecursive(Transform transform, Transform mainPrefabRoot, string mainPrefabPath, string parentPath, List items) + { + if (transform == null) return; + + string name = transform.gameObject.name; + string path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}"; + int instanceId = transform.gameObject.GetInstanceID(); + bool activeSelf = transform.gameObject.activeSelf; + int childCount = transform.childCount; + var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(transform.gameObject); + + // Prefab information + bool isNestedPrefab = PrefabUtility.IsAnyPrefabInstanceRoot(transform.gameObject); + bool isPrefabRoot = transform == mainPrefabRoot; + int nestingDepth = isPrefabRoot ? 0 : PrefabUtilityHelper.GetPrefabNestingDepth(transform.gameObject, mainPrefabRoot); + string parentPrefabPath = isNestedPrefab && !isPrefabRoot + ? PrefabUtilityHelper.GetParentPrefabPath(transform.gameObject, mainPrefabRoot) + : null; + string nestedPrefabPath = isNestedPrefab ? PrefabUtilityHelper.GetNestedPrefabPath(transform.gameObject) : null; + + var item = new + { + name = name, + instanceId = instanceId, + path = path, + activeSelf = activeSelf, + childCount = childCount, + componentTypes = componentTypes, + prefab = new + { + isRoot = isPrefabRoot, + isNestedRoot = isNestedPrefab, + nestingDepth = nestingDepth, + assetPath = isNestedPrefab ? nestedPrefabPath : mainPrefabPath, + parentPath = parentPrefabPath + } + }; + + items.Add(item); + + // Recursively process children + foreach (Transform child in transform) + { + BuildHierarchyItemsRecursive(child, mainPrefabRoot, mainPrefabPath, path, items); + } + } + + #endregion + + /// + /// Serializes the prefab stage information for response. + /// private static object SerializeStage(PrefabStage stage) { if (stage == null) @@ -273,6 +709,5 @@ private static object SerializeStage(PrefabStage stage) isDirty = stage.scene.isDirty }; } - } } diff --git a/Server/src/cli/commands/prefab.py b/Server/src/cli/commands/prefab.py index 3a005fda6..5f5853738 100644 --- a/Server/src/cli/commands/prefab.py +++ b/Server/src/cli/commands/prefab.py @@ -11,18 +11,13 @@ @click.group() def prefab(): - """Prefab operations - open, save, create prefabs.""" + """Prefab operations - info, hierarchy, open, save, close, create prefabs.""" pass @prefab.command("open") @click.argument("path") -@click.option( - "--mode", "-m", - default="InIsolation", - help="Prefab stage mode (InIsolation)." -) -def open_stage(path: str, mode: str): +def open_stage(path: str): """Open a prefab in the prefab stage for editing. \b @@ -34,7 +29,6 @@ def open_stage(path: str, mode: str): params: dict[str, Any] = { "action": "open_stage", "prefabPath": path, - "mode": mode, } try: @@ -80,18 +74,29 @@ def close_stage(save: bool): @prefab.command("save") -def save_stage(): +@click.option( + "--force", "-f", + is_flag=True, + help="Force save even if no changes detected. Useful for automated workflows." +) +def save_stage(force: bool): """Save the currently open prefab stage. \b Examples: unity-mcp prefab save + unity-mcp prefab save --force """ config = get_config() + params: dict[str, Any] = { + "action": "save_open_stage", + } + if force: + params["force"] = True + try: - result = run_command("manage_prefabs", { - "action": "save_open_stage"}, config) + result = run_command("manage_prefabs", params, config) click.echo(format_output(result, config.format)) if result.get("success"): print_success("Saved prefab") @@ -100,6 +105,114 @@ def save_stage(): sys.exit(1) +@prefab.command("info") +@click.argument("path") +@click.option( + "--compact", "-c", + is_flag=True, + help="Show compact output (key values only)." +) +def info(path: str, compact: bool): + """Get information about a prefab asset. + + \b + Examples: + unity-mcp prefab info "Assets/Prefabs/Player.prefab" + unity-mcp prefab info "Assets/Prefabs/UI.prefab" --compact + """ + config = get_config() + + params: dict[str, Any] = { + "action": "get_info", + "prefabPath": path, + } + + try: + result = run_command("manage_prefabs", params, config) + # Get the actual response data from the wrapped result structure + response_data = result.get("result", result) + if compact and response_data.get("success") and response_data.get("data"): + data = response_data["data"] + click.echo(f"Prefab: {data.get('assetPath', path)}") + click.echo(f" Type: {data.get('prefabType', 'Unknown')}") + click.echo(f" Root: {data.get('rootObjectName', 'N/A')}") + click.echo(f" GUID: {data.get('guid', 'N/A')}") + click.echo(f" Components: {len(data.get('rootComponentTypes', []))}") + click.echo(f" Children: {data.get('childCount', 0)}") + if data.get('isVariant'): + click.echo(f" Variant of: {data.get('parentPrefab', 'N/A')}") + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@prefab.command("hierarchy") +@click.argument("path") +@click.option( + "--compact", "-c", + is_flag=True, + help="Show compact output (names and paths only)." +) +@click.option( + "--show-prefab-info", "-p", + is_flag=True, + help="Show prefab nesting information." +) +def hierarchy(path: str, compact: bool, show_prefab_info: bool): + """Get the hierarchical structure of a prefab. + + \b + Examples: + unity-mcp prefab hierarchy "Assets/Prefabs/Player.prefab" + unity-mcp prefab hierarchy "Assets/Prefabs/UI.prefab" --compact + unity-mcp prefab hierarchy "Assets/Prefabs/Complex.prefab" --show-prefab-info + """ + config = get_config() + + params: dict[str, Any] = { + "action": "get_hierarchy", + "prefabPath": path, + } + + try: + result = run_command("manage_prefabs", params, config) + # Get the actual response data from the wrapped result structure + response_data = result.get("result", result) + if compact and response_data.get("success") and response_data.get("data"): + data = response_data["data"] + items = data.get("items", []) + for item in items: + indent = " " * item.get("path", "").count("/") + prefab_info = "" + if show_prefab_info and item.get("prefab", {}).get("isNestedRoot"): + prefab_info = f" [nested: {item['prefab']['assetPath']}]" + click.echo(f"{indent}{item.get('name')}{prefab_info}") + click.echo(f"\nTotal: {data.get('total', 0)} objects") + elif show_prefab_info: + # Show prefab info in readable format + if response_data.get("success") and response_data.get("data"): + data = response_data["data"] + items = data.get("items", []) + for item in items: + prefab = item.get("prefab", {}) + prefab_info = "" + if prefab.get("isRoot"): + prefab_info = " [root]" + elif prefab.get("isNestedRoot"): + prefab_info = f" [nested: {prefab.get('nestingDepth', 0)}]" + click.echo(f"{item.get('path')}{prefab_info}") + click.echo(f"\nTotal: {data.get('total', 0)} objects") + else: + click.echo(format_output(result, config.format)) + else: + click.echo(format_output(result, config.format)) + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + @prefab.command("create") @click.argument("target") @click.argument("path") @@ -113,13 +226,19 @@ def save_stage(): is_flag=True, help="Include inactive objects when finding target." ) -def create(target: str, path: str, overwrite: bool, include_inactive: bool): +@click.option( + "--unlink-if-instance", + is_flag=True, + help="Unlink from existing prefab before creating new one." +) +def create(target: str, path: str, overwrite: bool, include_inactive: bool, unlink_if_instance: bool): """Create a prefab from a scene GameObject. \b Examples: unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab" unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite + unity-mcp prefab create "EnemyInstance" "Assets/Prefabs/BossEnemy.prefab" --unlink-if-instance """ config = get_config() @@ -133,6 +252,8 @@ def create(target: str, path: str, overwrite: bool, include_inactive: bool): params["allowOverwrite"] = True if include_inactive: params["searchInactive"] = True + if unlink_if_instance: + params["unlinkIfInstance"] = True try: result = run_command("manage_prefabs", params, config) diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index bc8fbc227..38d28322c 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -5,13 +5,29 @@ from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context +from services.tools.utils import coerce_bool from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry -from services.tools.utils import coerce_bool +from services.tools.preflight import preflight + + +# Required parameters for each action +REQUIRED_PARAMS = { + "get_info": ["prefab_path"], + "get_hierarchy": ["prefab_path"], + "open_stage": ["prefab_path"], + "create_from_gameobject": ["target", "prefab_path"], + "save_open_stage": [], + "close_stage": [], +} @mcp_for_unity_tool( - description="Performs prefab operations (open_stage, close_stage, save_open_stage, create_from_gameobject).", + description=( + "Manages Unity Prefab assets and stages. " + "Actions: get_info, get_hierarchy, open_stage, close_stage, save_open_stage, create_from_gameobject. " + "Use manage_asset action=search filterType=Prefab to list prefabs." + ), annotations=ToolAnnotations( title="Manage Prefabs", destructiveHint=True, @@ -19,50 +35,106 @@ ) async def manage_prefabs( ctx: Context, - action: Annotated[Literal["open_stage", "close_stage", "save_open_stage", "create_from_gameobject"], "Perform prefab operations."], - prefab_path: Annotated[str, - "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, - mode: Annotated[str, - "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, - save_before_close: Annotated[bool, - "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, - target: Annotated[str, - "Scene GameObject name required for create_from_gameobject"] | None = None, - allow_overwrite: Annotated[bool, - "Allow replacing an existing prefab at the same path"] | None = None, - search_inactive: Annotated[bool, - "Include inactive objects when resolving the target name"] | None = None, + action: Annotated[ + Literal[ + "open_stage", + "close_stage", + "save_open_stage", + "create_from_gameobject", + "get_info", + "get_hierarchy", + ], + "Prefab operation to perform.", + ], + prefab_path: Annotated[str, "Prefab asset path (e.g., Assets/Prefabs/MyPrefab.prefab)."] | None = None, + save_before_close: Annotated[bool, "Save before closing if unsaved changes exist."] | None = None, + target: Annotated[str, "Scene GameObject name for create_from_gameobject."] | None = None, + allow_overwrite: Annotated[bool, "Allow replacing existing prefab."] | None = None, + search_inactive: Annotated[bool, "Include inactive GameObjects in search."] | None = None, + unlink_if_instance: Annotated[bool, "Unlink from existing prefab before creating new one."] | None = None, + force: Annotated[bool, "Force save even if no changes detected. Useful for automated workflows."] | None = None, ) -> dict[str, Any]: - # Get active instance from session state - # Removed session_state import + # Validate required parameters + required = REQUIRED_PARAMS.get(action, []) + for param_name in required: + param_value = locals().get(param_name) + # Check for None and empty/whitespace strings + if param_value is None or (isinstance(param_value, str) and not param_value.strip()): + return { + "success": False, + "message": f"Action '{action}' requires parameter '{param_name}'." + } + unity_instance = get_unity_instance_from_context(ctx) + # Preflight check for operations to ensure Unity is ready + try: + gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True) + if gate is not None: + return gate.model_dump() + except Exception as exc: + return { + "success": False, + "message": f"Unity preflight check failed: {exc}" + } + try: + # Build parameters dictionary params: dict[str, Any] = {"action": action} + # Handle prefab path parameter if prefab_path: params["prefabPath"] = prefab_path - if mode: - params["mode"] = mode + + # Handle boolean parameters with proper coercion save_before_close_val = coerce_bool(save_before_close) if save_before_close_val is not None: params["saveBeforeClose"] = save_before_close_val + if target: params["target"] = target + allow_overwrite_val = coerce_bool(allow_overwrite) if allow_overwrite_val is not None: params["allowOverwrite"] = allow_overwrite_val + search_inactive_val = coerce_bool(search_inactive) if search_inactive_val is not None: params["searchInactive"] = search_inactive_val - response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_prefabs", params) - if isinstance(response, dict) and response.get("success"): - return { - "success": True, - "message": response.get("message", "Prefab operation successful."), - "data": response.get("data"), - } - return response if isinstance(response, dict) else {"success": False, "message": str(response)} + unlink_if_instance_val = coerce_bool(unlink_if_instance) + if unlink_if_instance_val is not None: + params["unlinkIfInstance"] = unlink_if_instance_val + + force_val = coerce_bool(force) + if force_val is not None: + params["force"] = force_val + + # Send command to Unity + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_prefabs", params + ) + + # Return Unity response directly; ensure success field exists + # Handle MCPResponse objects (returned on error) by converting to dict + if hasattr(response, 'model_dump'): + return response.model_dump() + if isinstance(response, dict): + if "success" not in response: + response["success"] = False + return response + return { + "success": False, + "message": f"Unexpected response type: {type(response).__name__}" + } + + except TimeoutError: + return { + "success": False, + "message": "Unity connection timeout. Please check if Unity is running and responsive." + } except Exception as exc: - return {"success": False, "message": f"Python error managing prefabs: {exc}"} + return { + "success": False, + "message": f"Error managing prefabs: {exc}" + } \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs new file mode 100644 index 000000000..a4d255b38 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs @@ -0,0 +1,807 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.TestTools; +using MCPForUnity.Editor.Tools.Prefabs; +using static MCPForUnityTests.Editor.TestUtilities; + +namespace MCPForUnityTests.Editor.Tools +{ + /// + /// Comprehensive test suite for Prefab CRUD operations and new features. + /// Tests cover: Create, Read, Update, Delete patterns, force save, unlink-if-instance, + /// overwrite handling, inactive object search, and save dialog prevention. + /// + public class ManagePrefabsCrudTests + { + private const string TempDirectory = "Assets/Temp/ManagePrefabsCrudTests"; + + [SetUp] + public void SetUp() + { + StageUtility.GoToMainStage(); + EnsureFolder(TempDirectory); + } + + [TearDown] + public void TearDown() + { + StageUtility.GoToMainStage(); + + if (AssetDatabase.IsValidFolder(TempDirectory)) + { + AssetDatabase.DeleteAsset(TempDirectory); + } + + CleanupEmptyParentFolders(TempDirectory); + } + + #region CREATE Tests + + [Test] + public void CreateFromGameObject_CreatesNewPrefab_WithValidParameters() + { + string prefabPath = Path.Combine(TempDirectory, "NewPrefab.prefab").Replace('\\', '/'); + GameObject sceneObject = new GameObject("TestObject"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = sceneObject.name, + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success"), "create_from_gameobject should succeed."); + var data = result["data"] as JObject; + Assert.AreEqual(prefabPath, data.Value("prefabPath")); + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNotNull(prefabAsset, "Prefab asset should exist at path."); + } + finally + { + SafeDeleteAsset(prefabPath); + if (sceneObject != null) UnityEngine.Object.DestroyImmediate(sceneObject, true); + } + } + + [Test] + public void CreateFromGameObject_UnlinksInstance_WhenUnlinkIfInstanceIsTrue() + { + // Create an initial prefab + string initialPrefabPath = Path.Combine(TempDirectory, "Original.prefab").Replace('\\', '/'); + GameObject sourceObject = new GameObject("SourceObject"); + GameObject instance = null; + + try + { + // Create initial prefab and connect source object to it + PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, initialPrefabPath, InteractionMode.AutomatedAction); + + // Verify source object is now linked + Assert.IsTrue(PrefabUtility.IsAnyPrefabInstanceRoot(sourceObject), + "Source object should be linked to prefab after SaveAsPrefabAssetAndConnect."); + + // Create new prefab with unlinkIfInstance + // The command will find sourceObject by name and unlink it + string newPrefabPath = Path.Combine(TempDirectory, "NewFromLinked.prefab").Replace('\\', '/'); + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = sourceObject.name, + ["prefabPath"] = newPrefabPath, + ["unlinkIfInstance"] = true + })); + + Assert.IsTrue(result.Value("success"), "create_from_gameobject with unlinkIfInstance should succeed."); + var data = result["data"] as JObject; + Assert.IsTrue(data.Value("wasUnlinked"), "wasUnlinked should be true."); + + // Note: After creating the new prefab, the sourceObject is now linked to the NEW prefab + // (via SaveAsPrefabAssetAndConnect in CreatePrefabAsset), which is the correct behavior. + // What matters is that it was unlinked from the original prefab first. + Assert.IsTrue(PrefabUtility.IsAnyPrefabInstanceRoot(sourceObject), + "Source object should now be linked to the new prefab."); + string currentPrefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(sourceObject); + Assert.AreNotEqual(initialPrefabPath, currentPrefabPath, + "Source object should NOT be linked to original prefab anymore."); + Assert.AreEqual(newPrefabPath, currentPrefabPath, + "Source object should now be linked to the new prefab."); + } + finally + { + SafeDeleteAsset(initialPrefabPath); + SafeDeleteAsset(Path.Combine(TempDirectory, "NewFromLinked.prefab").Replace('\\', '/')); + if (sourceObject != null) UnityEngine.Object.DestroyImmediate(sourceObject, true); + if (instance != null) UnityEngine.Object.DestroyImmediate(instance, true); + } + } + + [Test] + public void CreateFromGameObject_Fails_WhenTargetIsAlreadyLinked() + { + string prefabPath = Path.Combine(TempDirectory, "Existing.prefab").Replace('\\', '/'); + GameObject sourceObject = new GameObject("SourceObject"); + + try + { + // Create initial prefab and connect the source object to it + GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, prefabPath, InteractionMode.AutomatedAction); + + // Verify the source object is now linked to the prefab + Assert.IsTrue(PrefabUtility.IsAnyPrefabInstanceRoot(sourceObject), + "Source object should be linked to prefab after SaveAsPrefabAssetAndConnect."); + + // Try to create again without unlink - sourceObject.name should find the connected instance + string newPath = Path.Combine(TempDirectory, "Duplicate.prefab").Replace('\\', '/'); + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = sourceObject.name, + ["prefabPath"] = newPath + })); + + Assert.IsFalse(result.Value("success"), + "create_from_gameobject should fail when target is already linked."); + Assert.IsTrue(result.Value("error").Contains("already linked"), + "Error message should mention 'already linked'."); + } + finally + { + SafeDeleteAsset(prefabPath); + SafeDeleteAsset(Path.Combine(TempDirectory, "Duplicate.prefab").Replace('\\', '/')); + if (sourceObject != null) UnityEngine.Object.DestroyImmediate(sourceObject, true); + } + } + + [Test] + public void CreateFromGameObject_Overwrites_WhenAllowOverwriteIsTrue() + { + string prefabPath = Path.Combine(TempDirectory, "OverwriteTest.prefab").Replace('\\', '/'); + GameObject firstObject = new GameObject("OverwriteTest"); // Use path filename + GameObject secondObject = new GameObject("OverwriteTest"); // Use path filename + + try + { + // Create initial prefab + PrefabUtility.SaveAsPrefabAsset(firstObject, prefabPath, out bool _); + AssetDatabase.Refresh(); + + GameObject firstPrefab = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual("OverwriteTest", firstPrefab.name, "First prefab should have name 'OverwriteTest'."); + + // Overwrite with new object + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = secondObject.name, + ["prefabPath"] = prefabPath, + ["allowOverwrite"] = true + })); + + Assert.IsTrue(result.Value("success"), "create_from_gameobject with allowOverwrite should succeed."); + var data = result["data"] as JObject; + Assert.IsTrue(data.Value("wasReplaced"), "wasReplaced should be true."); + + AssetDatabase.Refresh(); + GameObject updatedPrefab = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual("OverwriteTest", updatedPrefab.name, "Prefab should be overwritten (keeps filename as name)."); + } + finally + { + SafeDeleteAsset(prefabPath); + if (firstObject != null) UnityEngine.Object.DestroyImmediate(firstObject, true); + if (secondObject != null) UnityEngine.Object.DestroyImmediate(secondObject, true); + } + } + + [Test] + public void CreateFromGameObject_GeneratesUniquePath_WhenFileExistsAndNoOverwrite() + { + string prefabPath = Path.Combine(TempDirectory, "UniqueTest.prefab").Replace('\\', '/'); + GameObject firstObject = new GameObject("FirstObject"); + GameObject secondObject = new GameObject("SecondObject"); + + try + { + // Create initial prefab + PrefabUtility.SaveAsPrefabAsset(firstObject, prefabPath, out bool _); + AssetDatabase.Refresh(); + + // Create again without overwrite - should generate unique path + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = secondObject.name, + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success"), "create_from_gameobject should succeed with unique path."); + var data = result["data"] as JObject; + string actualPath = data.Value("prefabPath"); + Assert.AreNotEqual(prefabPath, actualPath, "Path should be different (unique)."); + Assert.IsTrue(actualPath.Contains("UniqueTest 1"), "Unique path should contain suffix."); + + // Verify both prefabs exist + Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(prefabPath), + "Original prefab should still exist."); + Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(actualPath), + "New prefab should exist at unique path."); + } + finally + { + SafeDeleteAsset(prefabPath); + SafeDeleteAsset(Path.Combine(TempDirectory, "UniqueTest 1.prefab").Replace('\\', '/')); + if (firstObject != null) UnityEngine.Object.DestroyImmediate(firstObject, true); + if (secondObject != null) UnityEngine.Object.DestroyImmediate(secondObject, true); + } + } + + [Test] + public void CreateFromGameObject_FindsInactiveObject_WhenSearchInactiveIsTrue() + { + string prefabPath = Path.Combine(TempDirectory, "InactiveTest.prefab").Replace('\\', '/'); + GameObject inactiveObject = new GameObject("InactiveObject"); + inactiveObject.SetActive(false); + + try + { + // Try without searchInactive - should fail + var resultWithout = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = inactiveObject.name, + ["prefabPath"] = prefabPath + })); + + Assert.IsFalse(resultWithout.Value("success"), + "Should fail when object is inactive and searchInactive=false."); + + // Try with searchInactive - should succeed + var resultWith = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = inactiveObject.name, + ["prefabPath"] = prefabPath, + ["searchInactive"] = true + })); + + Assert.IsTrue(resultWith.Value("success"), + "Should succeed when searchInactive=true."); + } + finally + { + SafeDeleteAsset(prefabPath); + if (inactiveObject != null) UnityEngine.Object.DestroyImmediate(inactiveObject, true); + } + } + + [Test] + public void CreateFromGameObject_CreatesDirectory_WhenPathDoesNotExist() + { + string prefabPath = Path.Combine(TempDirectory, "Nested/Deep/Directory/NewPrefab.prefab").Replace('\\', '/'); + GameObject sceneObject = new GameObject("TestObject"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = sceneObject.name, + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success"), "Should create directories as needed."); + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNotNull(prefabAsset, "Prefab should exist at nested path."); + Assert.IsTrue(AssetDatabase.IsValidFolder(Path.Combine(TempDirectory, "Nested").Replace('\\', '/')), + "Nested directory should be created."); + } + finally + { + SafeDeleteAsset(prefabPath); + if (sceneObject != null) UnityEngine.Object.DestroyImmediate(sceneObject, true); + } + } + + #endregion + + #region READ Tests (GetInfo & GetHierarchy) + + [Test] + public void GetInfo_ReturnsCorrectMetadata_ForValidPrefab() + { + string prefabPath = CreateTestPrefab("InfoTestPrefab"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "get_info", + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success"), "get_info should succeed."); + var data = result["data"] as JObject; + + Assert.AreEqual(prefabPath, data.Value("assetPath")); + Assert.IsNotNull(data.Value("guid"), "GUID should be present."); + Assert.AreEqual("Regular", data.Value("prefabType"), "Should be Regular prefab type."); + Assert.AreEqual("InfoTestPrefab", data.Value("rootObjectName")); + Assert.AreEqual(0, data.Value("childCount"), "Should have no children."); + Assert.IsFalse(data.Value("isVariant"), "Should not be a variant."); + + var components = data["rootComponentTypes"] as JArray; + Assert.IsNotNull(components, "Component types should be present."); + Assert.IsTrue(components.Count > 0, "Should have at least one component."); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void GetInfo_ReturnsError_ForInvalidPath() + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "get_info", + ["prefabPath"] = "Assets/Nonexistent/Prefab.prefab" + })); + + Assert.IsFalse(result.Value("success"), "get_info should fail for invalid path."); + Assert.IsTrue(result.Value("error").Contains("No prefab asset found") || + result.Value("error").Contains("not found"), + "Error should mention prefab not found."); + } + + [Test] + public void GetHierarchy_ReturnsCompleteHierarchy_ForNestedPrefab() + { + string prefabPath = CreateNestedTestPrefab("HierarchyTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "get_hierarchy", + ["prefabPath"] = prefabPath + })); + + Assert.IsTrue(result.Value("success"), "get_hierarchy should succeed."); + var data = result["data"] as JObject; + + Assert.AreEqual(prefabPath, data.Value("prefabPath")); + int total = data.Value("total"); + Assert.IsTrue(total >= 3, $"Should have at least 3 objects (root + 2 children), got {total}."); + + var items = data["items"] as JArray; + Assert.IsNotNull(items, "Items should be present."); + Assert.AreEqual(total, items.Count, "Items count should match total."); + + // Find root object + var root = items.Cast().FirstOrDefault(j => j["prefab"]["isRoot"].Value()); + Assert.IsNotNull(root, "Should have a root object with isRoot=true."); + Assert.AreEqual("HierarchyTest", root.Value("name")); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void GetHierarchy_IncludesNestingInfo_ForNestedPrefabs() + { + // Create a parent prefab first + string parentPath = CreateTestPrefab("ParentPrefab"); + + try + { + // Create a prefab that contains the parent prefab as nested + string childPath = CreateTestPrefab("ChildPrefab"); + GameObject container = new GameObject("Container"); + GameObject nestedInstance = PrefabUtility.InstantiatePrefab( + AssetDatabase.LoadAssetAtPath(childPath)) as GameObject; + nestedInstance.transform.parent = container.transform; + + string nestedPrefabPath = Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/'); + PrefabUtility.SaveAsPrefabAsset(container, nestedPrefabPath, out bool _); + UnityEngine.Object.DestroyImmediate(container); + + AssetDatabase.Refresh(); + + // Expect the nested prefab warning due to test environment + LogAssert.Expect(UnityEngine.LogType.Error, new Regex("Nested Prefab problem")); + + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "get_hierarchy", + ["prefabPath"] = nestedPrefabPath + })); + + Assert.IsTrue(result.Value("success"), "get_hierarchy should succeed."); + var data = result["data"] as JObject; + var items = data["items"] as JArray; + + // Find the nested prefab + var nested = items.Cast().FirstOrDefault(j => j["prefab"]["isNestedRoot"].Value()); + Assert.IsNotNull(nested, "Should have a nested prefab root."); + Assert.AreEqual(1, nested["prefab"]["nestingDepth"].Value(), + "Nested prefab should have depth 1."); + } + finally + { + SafeDeleteAsset(parentPath); + SafeDeleteAsset(Path.Combine(TempDirectory, "ParentPrefab.prefab").Replace('\\', '/')); + SafeDeleteAsset(Path.Combine(TempDirectory, "ChildPrefab.prefab").Replace('\\', '/')); + SafeDeleteAsset(Path.Combine(TempDirectory, "NestedContainer.prefab").Replace('\\', '/')); + } + } + + #endregion + + #region UPDATE Tests (Open, Save, Close) + + [Test] + public void SaveOpenStage_WithForce_SavesEvenWhenNotDirty() + { + string prefabPath = CreateTestPrefab("ForceSaveTest"); + Vector3 originalScale = Vector3.one; + + try + { + ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + }); + + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + Assert.IsNotNull(stage, "Stage should be open."); + Assert.IsFalse(stage.scene.isDirty, "Stage should not be dirty initially."); + + // Save without force - should succeed but indicate no changes + var noForceResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "save_open_stage" + })); + + Assert.IsTrue(noForceResult.Value("success"), + "Save should succeed even when not dirty."); + + // Now save with force + var forceResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "save_open_stage", + ["force"] = true + })); + + Assert.IsTrue(forceResult.Value("success"), "Force save should succeed."); + var data = forceResult["data"] as JObject; + Assert.IsTrue(data.Value("isDirty") || data.Value("isOpen"), + "Stage should still be open after force save."); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void SaveOpenStage_DoesNotShowSaveDialog() + { + string prefabPath = CreateTestPrefab("NoDialogTest"); + + try + { + ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + }); + + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f); + // Mark as dirty to ensure changes are tracked + EditorUtility.SetDirty(stage.prefabContentsRoot); + + // This save should NOT show a dialog - it should complete synchronously + // If a dialog appeared, this would hang or require user interaction + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "save_open_stage", + ["force"] = true // Use force to ensure save happens + })); + + // If we got here without hanging, no dialog was shown + Assert.IsTrue(result.Value("success"), + "Save should complete without showing dialog."); + + // Verify the change was saved + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, + "Changes should be saved without dialog."); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void CloseStage_WithSaveBeforeClose_SavesDirtyChanges() + { + string prefabPath = CreateTestPrefab("CloseSaveTest"); + + try + { + ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + }); + + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + stage.prefabContentsRoot.transform.position = new Vector3(5f, 5f, 5f); + // Mark as dirty to ensure changes are tracked + EditorUtility.SetDirty(stage.prefabContentsRoot); + + // Close with save + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "close_stage", + ["saveBeforeClose"] = true + })); + + Assert.IsTrue(result.Value("success"), "Close with save should succeed."); + Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), + "Stage should be closed after close_stage."); + + // Verify changes were saved + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual(new Vector3(5f, 5f, 5f), reloaded.transform.position, + "Position change should be saved before close."); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void OpenEditClose_CompleteWorkflow_Succeeds() + { + string prefabPath = CreateTestPrefab("WorkflowTest"); + + try + { + // OPEN + var openResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "open_stage", + ["prefabPath"] = prefabPath + })); + Assert.IsTrue(openResult.Value("success"), "Open should succeed."); + + // EDIT + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + stage.prefabContentsRoot.transform.localRotation = Quaternion.Euler(45f, 45f, 45f); + // Mark as dirty to ensure changes are tracked + EditorUtility.SetDirty(stage.prefabContentsRoot); + + // SAVE + var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "save_open_stage", + ["force"] = true // Use force to ensure save happens + })); + Assert.IsTrue(saveResult.Value("success"), "Save should succeed."); + // Note: stage.scene.isDirty may still be true in Unity's internal state + // The important thing is that changes were saved (verified below) + + // CLOSE + var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "close_stage" + })); + Assert.IsTrue(closeResult.Value("success"), "Close should succeed."); + Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), + "No stage should be open after close."); + + // VERIFY + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.AreEqual(Quaternion.Euler(45f, 45f, 45f), reloaded.transform.localRotation, + "Rotation should be saved and persisted."); + } + finally + { + StageUtility.GoToMainStage(); + SafeDeleteAsset(prefabPath); + } + } + + #endregion + + #region Edge Cases & Error Handling + + [Test] + public void HandleCommand_ReturnsError_ForUnknownAction() + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "unknown_action" + })); + + Assert.IsFalse(result.Value("success"), "Unknown action should fail."); + Assert.IsTrue(result.Value("error").Contains("Unknown action"), + "Error should mention unknown action."); + } + + [Test] + public void HandleCommand_ReturnsError_ForNullParameters() + { + var result = ToJObject(ManagePrefabs.HandleCommand(null)); + + Assert.IsFalse(result.Value("success"), "Null parameters should fail."); + Assert.IsTrue(result.Value("error").Contains("null"), + "Error should mention null parameters."); + } + + [Test] + public void HandleCommand_ReturnsError_WhenActionIsMissing() + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject())); + + Assert.IsFalse(result.Value("success"), "Missing action should fail."); + Assert.IsTrue(result.Value("error").Contains("Action parameter is required"), + "Error should mention required action parameter."); + } + + [Test] + public void CreateFromGameObject_ReturnsError_ForEmptyTarget() + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["prefabPath"] = "Assets/Test.prefab" + })); + + Assert.IsFalse(result.Value("success"), "Missing target should fail."); + Assert.IsTrue(result.Value("error").Contains("'target' parameter is required"), + "Error should mention required target parameter."); + } + + [Test] + public void CreateFromGameObject_ReturnsError_ForEmptyPrefabPath() + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = "SomeObject" + })); + + Assert.IsFalse(result.Value("success"), "Missing prefabPath should fail."); + Assert.IsTrue(result.Value("error").Contains("'prefabPath' parameter is required"), + "Error should mention required prefabPath parameter."); + } + + [Test] + public void CreateFromGameObject_ReturnsError_ForPathTraversal() + { + GameObject testObject = new GameObject("TestObject"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = "TestObject", + ["prefabPath"] = "../../etc/passwd" + })); + + Assert.IsFalse(result.Value("success"), "Path traversal should be blocked."); + Assert.IsTrue(result.Value("error").Contains("path traversal") || + result.Value("error").Contains("Invalid"), + "Error should mention path traversal or invalid path."); + } + finally + { + if (testObject != null) UnityEngine.Object.DestroyImmediate(testObject, true); + } + } + + [Test] + public void CreateFromGameObject_AutoPrependsAssets_WhenPathIsRelative() + { + GameObject testObject = new GameObject("TestObject"); + + try + { + // SanitizeAssetPath auto-prepends "Assets/" to relative paths + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "create_from_gameobject", + ["target"] = "TestObject", + ["prefabPath"] = "SomeFolder/Prefab.prefab" + })); + + Assert.IsTrue(result.Value("success"), "Should auto-prepend Assets/ to relative path."); + + // Clean up the created prefab at the corrected path + SafeDeleteAsset("Assets/SomeFolder/Prefab.prefab"); + } + finally + { + if (testObject != null) UnityEngine.Object.DestroyImmediate(testObject, true); + } + } + + #endregion + + #region Test Helpers + + private static string CreateTestPrefab(string name) + { + EnsureFolder(TempDirectory); + GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube); + temp.name = name; + + string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); + PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success); + UnityEngine.Object.DestroyImmediate(temp); + AssetDatabase.Refresh(); + + if (!success) + { + throw new Exception($"Failed to create test prefab at {path}"); + } + return path; + } + + private static string CreateNestedTestPrefab(string name) + { + EnsureFolder(TempDirectory); + GameObject root = new GameObject(name); + + // Add children + GameObject child1 = new GameObject("Child1"); + child1.transform.parent = root.transform; + + GameObject child2 = new GameObject("Child2"); + child2.transform.parent = root.transform; + + // Add grandchild + GameObject grandchild = new GameObject("Grandchild"); + grandchild.transform.parent = child1.transform; + + string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); + PrefabUtility.SaveAsPrefabAsset(root, path, out bool success); + UnityEngine.Object.DestroyImmediate(root); + AssetDatabase.Refresh(); + + if (!success) + { + throw new Exception($"Failed to create nested test prefab at {path}"); + } + return path; + } + + #endregion + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs.meta similarity index 60% rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs.meta index 8ef3fdb2a..b26811c21 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs.meta @@ -1,11 +1,11 @@ fileFormatVersion: 2 -guid: 8e7a7e542325421ba6de4992ddb3f5db +guid: 7a8d9f0e1b2c3d4e5f6a7b8c9d0e1f2a MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs deleted file mode 100644 index a76d52d2e..000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System.IO; -using Newtonsoft.Json.Linq; -using NUnit.Framework; -using UnityEditor; -using UnityEditor.SceneManagement; -using UnityEngine; -using MCPForUnity.Editor.Tools.Prefabs; -using static MCPForUnityTests.Editor.TestUtilities; - -namespace MCPForUnityTests.Editor.Tools -{ - public class ManagePrefabsTests - { - private const string TempDirectory = "Assets/Temp/ManagePrefabsTests"; - - [SetUp] - public void SetUp() - { - StageUtility.GoToMainStage(); - EnsureTempDirectoryExists(); - } - - [TearDown] - public void TearDown() - { - StageUtility.GoToMainStage(); - - // Clean up temp directory after each test - if (AssetDatabase.IsValidFolder(TempDirectory)) - { - AssetDatabase.DeleteAsset(TempDirectory); - } - - // Clean up empty parent folders to avoid debris - CleanupEmptyParentFolders(TempDirectory); - } - - [Test] - public void OpenStage_OpensPrefabInIsolation() - { - string prefabPath = CreateTestPrefab("OpenStageCube"); - - try - { - var openParams = new JObject - { - ["action"] = "open_stage", - ["prefabPath"] = prefabPath - }; - - var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams)); - - Assert.IsTrue(openResult.Value("success"), "open_stage should succeed for a valid prefab."); - - UnityEditor.SceneManagement.PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); - Assert.IsNotNull(stage, "Prefab stage should be open after open_stage."); - Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path."); - - var stageInfo = ToJObject(MCPForUnity.Editor.Resources.Editor.PrefabStage.HandleCommand(new JObject())); - Assert.IsTrue(stageInfo.Value("success"), "get_prefab_stage should succeed when stage is open."); - - var data = stageInfo["data"] as JObject; - Assert.IsNotNull(data, "Stage info should include data payload."); - Assert.IsTrue(data.Value("isOpen")); - Assert.AreEqual(prefabPath, data.Value("assetPath")); - } - finally - { - StageUtility.GoToMainStage(); - AssetDatabase.DeleteAsset(prefabPath); - } - } - - [Test] - public void CloseStage_ReturnsSuccess_WhenNoStageOpen() - { - StageUtility.GoToMainStage(); - var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "close_stage" - })); - - Assert.IsTrue(closeResult.Value("success"), "close_stage should succeed even if no stage is open."); - } - - [Test] - public void CloseStage_ClosesOpenPrefabStage() - { - string prefabPath = CreateTestPrefab("CloseStageCube"); - - try - { - ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "open_stage", - ["prefabPath"] = prefabPath - }); - - var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "close_stage" - })); - - Assert.IsTrue(closeResult.Value("success"), "close_stage should succeed when stage is open."); - Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage."); - } - finally - { - StageUtility.GoToMainStage(); - AssetDatabase.DeleteAsset(prefabPath); - } - } - - [Test] - public void SaveOpenStage_SavesDirtyChanges() - { - string prefabPath = CreateTestPrefab("SaveStageCube"); - - try - { - ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "open_stage", - ["prefabPath"] = prefabPath - }); - - UnityEditor.SceneManagement.PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); - Assert.IsNotNull(stage, "Stage should be open before modifying."); - - stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f); - - var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "save_open_stage" - })); - - Assert.IsTrue(saveResult.Value("success"), "save_open_stage should succeed when stage is open."); - Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving."); - - GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); - Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage."); - } - finally - { - StageUtility.GoToMainStage(); - AssetDatabase.DeleteAsset(prefabPath); - } - } - - [Test] - public void SaveOpenStage_ReturnsError_WhenNoStageOpen() - { - StageUtility.GoToMainStage(); - - var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "save_open_stage" - })); - - Assert.IsFalse(saveResult.Value("success"), "save_open_stage should fail when no stage is open."); - } - - [Test] - public void CreateFromGameObject_CreatesPrefabAndLinksInstance() - { - EnsureTempDirectoryExists(); - StageUtility.GoToMainStage(); - - string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/'); - GameObject sceneObject = new GameObject("ScenePrefabSource"); - - try - { - var result = ToJObject(ManagePrefabs.HandleCommand(new JObject - { - ["action"] = "create_from_gameobject", - ["target"] = sceneObject.name, - ["prefabPath"] = prefabPath - })); - - Assert.IsTrue(result.Value("success"), "create_from_gameobject should succeed for a valid scene object."); - - var data = result["data"] as JObject; - Assert.IsNotNull(data, "Response data should include prefab information."); - - string savedPath = data.Value("prefabPath"); - Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path."); - - GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(savedPath); - Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path."); - - int instanceId = data.Value("instanceId"); - var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject; - Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId."); - Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab."); - - sceneObject = linkedInstance; - } - finally - { - if (AssetDatabase.LoadAssetAtPath(prefabPath) != null) - { - AssetDatabase.DeleteAsset(prefabPath); - } - - if (sceneObject != null) - { - if (PrefabUtility.IsPartOfPrefabInstance(sceneObject)) - { - PrefabUtility.UnpackPrefabInstance( - sceneObject, - PrefabUnpackMode.Completely, - InteractionMode.AutomatedAction - ); - } - UnityEngine.Object.DestroyImmediate(sceneObject, true); - } - } - } - - private static string CreateTestPrefab(string name) - { - EnsureTempDirectoryExists(); - - GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube); - temp.name = name; - - string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); - PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success); - UnityEngine.Object.DestroyImmediate(temp); - - Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab."); - return path; - } - - private static void EnsureTempDirectoryExists() - { - if (!AssetDatabase.IsValidFolder("Assets/Temp")) - { - AssetDatabase.CreateFolder("Assets", "Temp"); - } - - if (!AssetDatabase.IsValidFolder(TempDirectory)) - { - AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests"); - } - } - } -}