|
| 1 | +/* |
| 2 | ++------------------------------------------------------------------+ |
| 3 | +| Author: Ivan Murzak (https://github.com/IvanMurzak) | |
| 4 | +| Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) | |
| 5 | +| Copyright (c) 2025 Ivan Murzak | |
| 6 | +| Licensed under the Apache License, Version 2.0. | |
| 7 | +| See the LICENSE file in the project root for more information. | |
| 8 | ++------------------------------------------------------------------+ |
| 9 | +*/ |
| 10 | + |
| 11 | +#nullable enable |
| 12 | +using System.Collections.Generic; |
| 13 | +using System.IO; |
| 14 | +using NUnit.Framework; |
| 15 | +using com.IvanMurzak.Unity.MCP.Editor.DependencyResolver; |
| 16 | + |
| 17 | +namespace com.IvanMurzak.Unity.MCP.Editor.Tests.DependencyResolverTests |
| 18 | +{ |
| 19 | + /// <summary> |
| 20 | + /// Coverage for <see cref="NuGetPackageInstaller.RemoveStaleVersionDllsByStem"/>: |
| 21 | + /// the filesystem-driven companion to the manifest-driven |
| 22 | + /// <c>RemoveStaleSiblingVersions</c>. Catches stale |
| 23 | + /// <c>{stem}.<olderVersion>.dll</c> files (and unversioned |
| 24 | + /// <c>{stem}.dll</c> legacy artifacts) that the manifest knows nothing |
| 25 | + /// about — manifest deletion, partial-restore failure, AV quarantine, or |
| 26 | + /// manual disk edits all leave the manifest out of sync with disk, and |
| 27 | + /// without this sweep the resolver would write the new version alongside |
| 28 | + /// the stale one and brick the project with duplicate-assembly errors. |
| 29 | + /// </summary> |
| 30 | + [TestFixture] |
| 31 | + public class NuGetPackageInstallerStaleVersionFilesystemSweepTests |
| 32 | + { |
| 33 | + string _installPath = string.Empty; |
| 34 | + |
| 35 | + [SetUp] |
| 36 | + public void SetUp() |
| 37 | + { |
| 38 | + _installPath = Path.Combine( |
| 39 | + Path.GetTempPath(), |
| 40 | + "UnityMcp-StaleSweep-" + Path.GetRandomFileName()); |
| 41 | + Directory.CreateDirectory(_installPath); |
| 42 | + } |
| 43 | + |
| 44 | + [TearDown] |
| 45 | + public void TearDown() |
| 46 | + { |
| 47 | + if (Directory.Exists(_installPath)) |
| 48 | + { |
| 49 | + try { Directory.Delete(_installPath, recursive: true); } |
| 50 | + catch { /* best-effort cleanup */ } |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + [Test] |
| 55 | + public void RemoveStaleVersionDllsByStem_DeletesOlderVersionAndItsMeta() |
| 56 | + { |
| 57 | + // Project upgraded com.IvanMurzak.McpPlugin from 6.2.0 → 6.2.1, but |
| 58 | + // the manifest entry was wiped out. The filesystem sweep MUST find |
| 59 | + // the orphan McpPlugin.6.2.0.dll and remove it before extraction |
| 60 | + // writes McpPlugin.6.2.1.dll alongside. |
| 61 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.0.dll"), "stale"); |
| 62 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.0.dll.meta"), "meta"); |
| 63 | + |
| 64 | + var planned = new List<PlannedDll> |
| 65 | + { |
| 66 | + Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"), |
| 67 | + }; |
| 68 | + |
| 69 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 70 | + _installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin"); |
| 71 | + |
| 72 | + Assert.IsTrue(removed); |
| 73 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.0.dll"))); |
| 74 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.0.dll.meta")), |
| 75 | + ".meta sidecar must be deleted alongside its DLL."); |
| 76 | + } |
| 77 | + |
| 78 | + [Test] |
| 79 | + public void RemoveStaleVersionDllsByStem_DeletesUnversionedLegacyDll() |
| 80 | + { |
| 81 | + // Even older legacy: a bare {stem}.dll without a version tail — |
| 82 | + // produced by some pre-flat-layout manual install. Same removal |
| 83 | + // contract: it cannot coexist with the new versioned form. |
| 84 | + File.WriteAllText(Path.Combine(_installPath, "ReflectorNet.dll"), "very-legacy"); |
| 85 | + File.WriteAllText(Path.Combine(_installPath, "ReflectorNet.dll.meta"), "meta"); |
| 86 | + |
| 87 | + var planned = new List<PlannedDll> |
| 88 | + { |
| 89 | + Plan("lib/netstandard2.0/ReflectorNet.dll", "ReflectorNet.5.1.1.dll"), |
| 90 | + }; |
| 91 | + |
| 92 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 93 | + _installPath, planned, keepVersion: "5.1.1", packageId: "com.IvanMurzak.ReflectorNet"); |
| 94 | + |
| 95 | + Assert.IsTrue(removed); |
| 96 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "ReflectorNet.dll"))); |
| 97 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "ReflectorNet.dll.meta"))); |
| 98 | + } |
| 99 | + |
| 100 | + [Test] |
| 101 | + public void RemoveStaleVersionDllsByStem_PreservesCanonicalCurrentVersionFile() |
| 102 | + { |
| 103 | + // The current-version filename must survive the sweep — extraction |
| 104 | + // overwrites it (or alreadyOnDisk short-circuits cleanly). |
| 105 | + var canonical = "System.Text.Json.8.0.5.dll"; |
| 106 | + File.WriteAllText(Path.Combine(_installPath, canonical), "current"); |
| 107 | + File.WriteAllText(Path.Combine(_installPath, canonical + ".meta"), "meta"); |
| 108 | + |
| 109 | + var planned = new List<PlannedDll> |
| 110 | + { |
| 111 | + Plan("lib/netstandard2.0/System.Text.Json.dll", canonical), |
| 112 | + }; |
| 113 | + |
| 114 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 115 | + _installPath, planned, keepVersion: "8.0.5", packageId: "System.Text.Json"); |
| 116 | + |
| 117 | + Assert.IsFalse(removed, "Nothing to clean up when only the canonical file is on disk."); |
| 118 | + Assert.IsTrue(File.Exists(Path.Combine(_installPath, canonical))); |
| 119 | + Assert.IsTrue(File.Exists(Path.Combine(_installPath, canonical + ".meta"))); |
| 120 | + } |
| 121 | + |
| 122 | + [Test] |
| 123 | + public void RemoveStaleVersionDllsByStem_HandlesMultipleStaleVersionsForSameStem() |
| 124 | + { |
| 125 | + // Multiple stale versions on disk (e.g. user kept upgrading without |
| 126 | + // restart, leaving a chain of unwanted files). All non-canonical |
| 127 | + // versions get swept. |
| 128 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.1.0.dll"), "stale"); |
| 129 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.6.2.0.dll"), "stale"); |
| 130 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.dll"), "stale-unversioned"); |
| 131 | + |
| 132 | + var planned = new List<PlannedDll> |
| 133 | + { |
| 134 | + Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"), |
| 135 | + }; |
| 136 | + |
| 137 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 138 | + _installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin"); |
| 139 | + |
| 140 | + Assert.IsTrue(removed); |
| 141 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.1.0.dll"))); |
| 142 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.6.2.0.dll"))); |
| 143 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "McpPlugin.dll"))); |
| 144 | + } |
| 145 | + |
| 146 | + [Test] |
| 147 | + public void RemoveStaleVersionDllsByStem_HandlesMultipleStems_ForMultiDllPackage() |
| 148 | + { |
| 149 | + // A package shipping multiple DLLs (e.g. Microsoft.Bcl.Memory ships |
| 150 | + // System.Memory + System.Buffers + System.Runtime.CompilerServices.Unsafe). |
| 151 | + // Every shipped stem gets its own scan; stale versions of each are |
| 152 | + // removed independently. |
| 153 | + File.WriteAllText(Path.Combine(_installPath, "System.Memory.9.0.0.dll"), "stale"); |
| 154 | + File.WriteAllText(Path.Combine(_installPath, "System.Buffers.9.0.0.dll"), "stale"); |
| 155 | + File.WriteAllText(Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.5.0.0.dll"), "stale"); |
| 156 | + |
| 157 | + var planned = new List<PlannedDll> |
| 158 | + { |
| 159 | + Plan("lib/netstandard2.0/System.Memory.dll", "System.Memory.10.0.3.dll"), |
| 160 | + Plan("lib/netstandard2.0/System.Buffers.dll", "System.Buffers.10.0.3.dll"), |
| 161 | + Plan("lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll", "System.Runtime.CompilerServices.Unsafe.10.0.3.dll"), |
| 162 | + }; |
| 163 | + |
| 164 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 165 | + _installPath, planned, keepVersion: "10.0.3", packageId: "Microsoft.Bcl.Memory"); |
| 166 | + |
| 167 | + Assert.IsTrue(removed); |
| 168 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Memory.9.0.0.dll"))); |
| 169 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Buffers.9.0.0.dll"))); |
| 170 | + Assert.IsFalse(File.Exists(Path.Combine(_installPath, "System.Runtime.CompilerServices.Unsafe.5.0.0.dll"))); |
| 171 | + } |
| 172 | + |
| 173 | + [Test] |
| 174 | + public void RemoveStaleVersionDllsByStem_DoesNotMatchSiblingPackageDllWithLongerStem() |
| 175 | + { |
| 176 | + // Cross-stem boundary: a package shipping McpPlugin.dll must NOT |
| 177 | + // sweep McpPlugin.Common.<v>.dll on disk — that file belongs to |
| 178 | + // a sibling package. The regex requires either ".dll" or a |
| 179 | + // numeric version tail directly after the stem; "Common" is |
| 180 | + // neither, so the regex's stem boundary holds. |
| 181 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.Common.6.2.0.dll"), "sibling-package"); |
| 182 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.Common.6.2.0.dll.meta"), "meta"); |
| 183 | + |
| 184 | + var planned = new List<PlannedDll> |
| 185 | + { |
| 186 | + Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"), |
| 187 | + }; |
| 188 | + |
| 189 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 190 | + _installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin"); |
| 191 | + |
| 192 | + Assert.IsFalse(removed); |
| 193 | + Assert.IsTrue(File.Exists(Path.Combine(_installPath, "McpPlugin.Common.6.2.0.dll")), |
| 194 | + "DLL belonging to a different package's stem must not be deleted by the McpPlugin sweep."); |
| 195 | + } |
| 196 | + |
| 197 | + [Test] |
| 198 | + public void RemoveStaleVersionDllsByStem_DoesNotMatchNonNumericTailAfterStem() |
| 199 | + { |
| 200 | + // Defensive: a third-party file the user dropped in like |
| 201 | + // McpPlugin.Foo.dll has a non-numeric tail and must survive the |
| 202 | + // sweep. (The regex demands a numeric-only version tail.) |
| 203 | + File.WriteAllText(Path.Combine(_installPath, "McpPlugin.Foo.dll"), "third-party"); |
| 204 | + |
| 205 | + var planned = new List<PlannedDll> |
| 206 | + { |
| 207 | + Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"), |
| 208 | + }; |
| 209 | + |
| 210 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 211 | + _installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin"); |
| 212 | + |
| 213 | + Assert.IsFalse(removed); |
| 214 | + Assert.IsTrue(File.Exists(Path.Combine(_installPath, "McpPlugin.Foo.dll"))); |
| 215 | + } |
| 216 | + |
| 217 | + [Test] |
| 218 | + public void RemoveStaleVersionDllsByStem_NoOpWhenInstallPathIsEmpty() |
| 219 | + { |
| 220 | + // Brand-new install: nothing on disk yet, sweep is a no-op. |
| 221 | + var planned = new List<PlannedDll> |
| 222 | + { |
| 223 | + Plan("lib/netstandard2.0/McpPlugin.dll", "McpPlugin.6.2.1.dll"), |
| 224 | + }; |
| 225 | + |
| 226 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 227 | + _installPath, planned, keepVersion: "6.2.1", packageId: "com.IvanMurzak.McpPlugin"); |
| 228 | + |
| 229 | + Assert.IsFalse(removed); |
| 230 | + } |
| 231 | + |
| 232 | + [Test] |
| 233 | + public void RemoveStaleVersionDllsByStem_NoOpWhenPlannedSetIsEmpty() |
| 234 | + { |
| 235 | + // Development-only / framework-incompatible package paths produce |
| 236 | + // an empty planned set; the sweep must be a no-op so we don't |
| 237 | + // accidentally start deleting unrelated DLLs. |
| 238 | + File.WriteAllText(Path.Combine(_installPath, "Some.Other.1.0.0.dll"), "unrelated"); |
| 239 | + |
| 240 | + var removed = NuGetPackageInstaller.RemoveStaleVersionDllsByStem( |
| 241 | + _installPath, new List<PlannedDll>(), keepVersion: "1.0.0", packageId: "AnyPackage"); |
| 242 | + |
| 243 | + Assert.IsFalse(removed); |
| 244 | + Assert.IsTrue(File.Exists(Path.Combine(_installPath, "Some.Other.1.0.0.dll"))); |
| 245 | + } |
| 246 | + |
| 247 | + static PlannedDll Plan(string entryFullName, string canonicalFileName) => |
| 248 | + new PlannedDll(entryFullName, canonicalFileName, Path.Combine("/ignored", canonicalFileName)); |
| 249 | + } |
| 250 | +} |
0 commit comments