/* +------------------------------------------------------------------+ | 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 using System.IO; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using NUnit.Framework; using UnityEngine.TestTools; using com.IvanMurzak.Unity.MCP.Editor.DependencyResolver; namespace com.IvanMurzak.Unity.MCP.Editor.Tests.DependencyResolverTests { /// /// Coverage for issue #733's mandatory legacy → flat-layout migration. Every /// existing user upgrading to the new resolver still has DLLs sitting under /// Assets/Plugins/NuGet/{Id}.{Version}/ from a pre-fix install. The /// migration MUST delete those folders before any flat-layout DLL is /// written, in the same restore cycle, otherwise the project ends up with /// duplicate copies of every assembly and Unity's compiler errors with /// CS0436 / CS0433. /// [TestFixture] public class NuGetLegacyMigrationTests { string _installPath = string.Empty; [SetUp] public void SetUp() { _installPath = Path.Combine( Path.GetTempPath(), "UnityMcp-Migration-" + Path.GetRandomFileName()); Directory.CreateDirectory(_installPath); } [TearDown] public void TearDown() { if (Directory.Exists(_installPath)) { try { Directory.Delete(_installPath, recursive: true); } catch { /* best-effort cleanup */ } } } [Test] public void Run_NoLegacyState_ReturnsNoLegacyState_OnEmptyInstallPath() { var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.NoLegacyState, result.Outcome); Assert.AreEqual(0, result.RemovedItems.Count); } [Test] public void Run_NoLegacyState_ReturnsNoLegacyState_OnUnversionedFlatInstall() { // Steady-state install in the unversioned flat layout: no legacy // {Id}.{Version}/ directories, no versioned-filename {stem}.{v}.dll // siblings — the migration must short-circuit and touch nothing. File.WriteAllText(Path.Combine(_installPath, "System.Memory.dll"), "dummy"); File.WriteAllText(Path.Combine(_installPath, "System.Memory.dll.meta"), "meta"); File.WriteAllText(Path.Combine(_installPath, ".nuget-installed.json"), "{}"); var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.NoLegacyState, result.Outcome); Assert.IsTrue(File.Exists(Path.Combine(_installPath, "System.Memory.dll"))); } [Test] public void Run_HappyPath_RemovesLegacyDirectoriesAndMetas() { // Seed several legacy directories: single-DLL package and a multi-DLL package. CreateLegacyPackage("System.Text.Json", "8.0.5", "System.Text.Json.dll"); CreateLegacyPackage("Microsoft.Bcl.Memory", "10.0.3", "System.Memory.dll", "System.Buffers.dll", "System.Runtime.CompilerServices.Unsafe.dll"); var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.Migrated, result.Outcome); Assert.AreEqual(2, result.RemovedItems.Count); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5"))); Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5.meta"))); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "Microsoft.Bcl.Memory.10.0.3"))); Assert.IsFalse(File.Exists(Path.Combine(_installPath, "Microsoft.Bcl.Memory.10.0.3.meta"))); } [Test] public void Run_Idempotent_SecondRunIsNoOp() { CreateLegacyPackage("System.Text.Json", "8.0.5", "System.Text.Json.dll"); var first = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.Migrated, first.Outcome); var second = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.NoLegacyState, second.Outcome); Assert.AreEqual(0, second.RemovedItems.Count); } [Test] public void Run_MixedLegacyState_MigratesLegacyDirsAndVersionedFiles_LeavesUnrelatedContent() { // Mixed install: a legacy {Id}.{Version}/ folder, a flat // versioned-filename DLL, and unrelated user content. The migration // sweeps both legacy artifacts in one pass and leaves the user's // directory + unversioned canonical untouched. CreateLegacyPackage("System.Text.Json", "8.0.5", "System.Text.Json.dll"); File.WriteAllText(Path.Combine(_installPath, "Microsoft.Bcl.Memory.10.0.3.dll"), "flat-versioned-leftover"); File.WriteAllText(Path.Combine(_installPath, "Microsoft.Bcl.Memory.dll"), "canonical"); Directory.CreateDirectory(Path.Combine(_installPath, "ReadMe")); File.WriteAllText(Path.Combine(_installPath, "ReadMe", "notes.txt"), "user notes"); var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.Migrated, result.Outcome); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5"))); Assert.IsFalse(File.Exists(Path.Combine(_installPath, "Microsoft.Bcl.Memory.10.0.3.dll")), "Versioned-filename flat DLLs are now legacy and must be swept."); Assert.IsTrue(File.Exists(Path.Combine(_installPath, "Microsoft.Bcl.Memory.dll")), "Unversioned canonical filenames must not be touched by migration."); Assert.IsTrue(Directory.Exists(Path.Combine(_installPath, "ReadMe")), "Non-package directories must not be touched by migration."); } [Test] public void Run_VersionedFilenameDllsAtRoot_AreSwept() { // Migration from the pre-unversioned-filename resolver: any flat // {stem}.{numericVersion}.dll at the install root is by // definition stale once the canonical filename is just {stem}.dll. // The migration sweeps them in the same pass that removes // legacy {Id}.{Version}/ directories. File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.1.dll"), "stale"); File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.1.dll.meta"), "meta"); File.WriteAllText(Path.Combine(_installPath, "System.Text.Json.8.0.5.dll"), "stale"); File.WriteAllText(Path.Combine(_installPath, "System.Text.Json.8.0.5.dll.meta"), "meta"); // Unversioned canonicals must NOT be touched. File.WriteAllText(Path.Combine(_installPath, "ReadMe.dll"), "user-content"); File.WriteAllText(Path.Combine(_installPath, "Foo.dll"), "canonical"); var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.Migrated, result.Outcome); Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.1.dll"))); Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.1.dll.meta")), ".meta sidecar must be deleted alongside the DLL."); Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5.dll"))); Assert.IsTrue(File.Exists(Path.Combine(_installPath, "ReadMe.dll")), "Unversioned filenames are the new canonical and must be preserved."); Assert.IsTrue(File.Exists(Path.Combine(_installPath, "Foo.dll"))); } [Test] public void Run_LegacyDirWithSemVerPrerelease_RemovedAsLegacy() { // NuGet folder names can contain SemVer prerelease / build-metadata // suffixes (e.g. `Microsoft.AspNetCore.SignalR.Client.8.0.15-preview`). // System.Version.TryParse rejects those tails, so an earlier version // of the migration silently left those folders on disk and the user // ended up with both the legacy nested DLL and the new flat-layout // DLL coexisting — duplicate-assembly compile errors. Pin the // SemVer-shape fallback in `ExtractPackageIdFromDirName` so the // migration removes them. CreateLegacyPackage("Foo.Bar", "1.0.0-preview", "Foo.Bar.dll"); CreateLegacyPackage("Baz", "2.3.4+build.42", "Baz.dll"); CreateLegacyPackage("Qux", "1.0.0-rc.1", "Qux.dll"); var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.Migrated, result.Outcome); Assert.AreEqual(3, result.RemovedItems.Count); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "Foo.Bar.1.0.0-preview"))); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "Baz.2.3.4+build.42"))); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "Qux.1.0.0-rc.1"))); } #if UNITY_EDITOR_WIN // Windows-only: simulates a `FileShare.None` lock that Unity holds on a // DLL it has loaded into the editor AppDomain. Linux/macOS use advisory // locking — `FileShare.None` does not prevent `File.Delete` there — so // gating on `UNITY_EDITOR_WIN` keeps the assertion honest. The // post-best-effort migration no longer aborts the entire restore on a // single locked file: the call returns `AbortedFileLock` to surface // that some directory is still on disk, but the caller may continue // safely (the locked file's PluginImporter is disabled so the next // domain reload unloads it and the next migration pass cleans it up). [Test] public void Run_FileLock_LeavesBlockedFolderIntactAndReportsAbortedFileLock() { CreateLegacyPackage("System.Text.Json", "8.0.5", "System.Text.Json.dll"); var lockedDll = Path.Combine(_installPath, "System.Text.Json.8.0.5", "System.Text.Json.dll"); // Best-effort migration logs a warning (not an error) for blocked folders. LogAssert.Expect(UnityEngine.LogType.Warning, new Regex(@"\[NuGet\] Could not fully remove legacy install directory")); using (var lockHandle = new FileStream(lockedDll, FileMode.Open, FileAccess.Read, FileShare.None)) { var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.AbortedFileLock, result.Outcome); Assert.IsNotNull(result.FirstFailedItem); Assert.IsNotNull(result.FirstFailureMessage); // Blocked folder still on disk — the next reload picks it back up. Assert.IsTrue(Directory.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5"))); } } [Test] public void Run_FileLock_OneFolderBlocked_OtherFoldersAreStillRemoved() { // Mixed state: a blocked folder must NOT prevent unblocked folders // from being cleaned up — that's the whole point of best-effort. CreateLegacyPackage("System.Text.Json", "8.0.5", "System.Text.Json.dll"); CreateLegacyPackage("R3", "1.3.0", "R3.dll"); var lockedDll = Path.Combine(_installPath, "System.Text.Json.8.0.5", "System.Text.Json.dll"); LogAssert.Expect(UnityEngine.LogType.Warning, new Regex(@"\[NuGet\] Could not fully remove legacy install directory")); using (var lockHandle = new FileStream(lockedDll, FileMode.Open, FileAccess.Read, FileShare.None)) { var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.AbortedFileLock, result.Outcome); Assert.AreEqual(1, result.RemovedItems.Count, "The unblocked folder must still be removed even though a sibling folder was locked."); Assert.IsTrue(Directory.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5")), "Blocked folder remains on disk for retry on the next pass."); Assert.IsFalse(Directory.Exists(Path.Combine(_installPath, "R3.1.3.0")), "Unblocked folder must have been removed."); } } #endif #if !UNITY_EDITOR_WIN // Unix counterpart of Run_FileLock_LeavesBlockedFolderIntactAndReportsAbortedFileLock. // The migration catches both `IOException` (Windows file-lock) and // `UnauthorizedAccessException` (Unix permission denial / antivirus / // read-only flags) inside its per-file delete loop. Here we deny write // on the install-path parent via `chmod 0o500`, which makes // `File.Delete` and the trailing `Directory.Delete` both throw // `UnauthorizedAccessException`. Self-skips when running as root. [Test] public void Run_PermissionDenied_LeavesBlockedFolderIntactAndReportsAbortedFileLock() { if (geteuid() == 0) Assert.Ignore("chmod-based permission denial is bypassed by the kernel for uid=0; run this test as a non-root user."); CreateLegacyPackage("System.Text.Json", "8.0.5", "System.Text.Json.dll"); LogAssert.Expect(UnityEngine.LogType.Warning, new Regex(@"\[NuGet\] Could not fully remove legacy install directory")); const uint READ_EXEC_ONLY = 0b101_000_000; // 0o500 — r-x------ const uint READ_WRITE_EXEC = 0b111_000_000; // 0o700 — rwx------ (TearDown-friendly) if (chmod(_installPath, READ_EXEC_ONLY) != 0) Assert.Inconclusive($"chmod {_installPath} -> 0o500 failed (errno={Marshal.GetLastWin32Error()})"); try { var result = NuGetLegacyMigration.Run(_installPath); Assert.AreEqual(NuGetLegacyMigration.Outcome.AbortedFileLock, result.Outcome); Assert.IsNotNull(result.FirstFailedItem); Assert.IsNotNull(result.FirstFailureMessage); Assert.IsTrue(Directory.Exists(Path.Combine(_installPath, "System.Text.Json.8.0.5"))); } finally { // Always restore so [TearDown]'s recursive delete of `_installPath` succeeds. chmod(_installPath, READ_WRITE_EXEC); } } [DllImport("libc", SetLastError = true)] static extern int chmod(string pathname, uint mode); [DllImport("libc", SetLastError = true)] static extern uint geteuid(); #endif /// /// Creates a fake legacy {Id}.{Version}/ directory with the /// given DLL filenames inside, plus the sibling .meta. /// void CreateLegacyPackage(string id, string version, params string[] dllNames) { var dirName = $"{id}.{version}"; var dirPath = Path.Combine(_installPath, dirName); Directory.CreateDirectory(dirPath); foreach (var dll in dllNames) File.WriteAllText(Path.Combine(dirPath, dll), "dummy"); File.WriteAllText(dirPath + ".meta", "fileFormatVersion: 2\nguid: 00000000000000000000000000000000\n"); } } }