// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. #if NETCOREAPP using System.Runtime.Loader; #endif #if NET9_0_OR_GREATER using System.Reflection.Emit; #endif using Microsoft.Testing.Platform.Builder; using Microsoft.Testing.Platform.DynamicExtensions; using Microsoft.Testing.Platform.Helpers; namespace Microsoft.Testing.Platform.UnitTests; /// /// Exercises the real loader rather than the fake used by , so that /// the caching and load-context rules are covered rather than assumed. /// [TestClass] public sealed class DynamicExtensionAssemblyLoaderTests { #if NET9_0_OR_GREATER private const string CarriedContractName = "Contoso.CarriedContract.Abstractions"; #endif private static string ThisAssemblyPath => typeof(DynamicExtensionAssemblyLoaderTests).Assembly.Location; [TestMethod] public void LoadAssembly_SamePathTwice_ReturnsTheSameAssemblyInstance() { DynamicExtensionAssemblyLoader loader = new(new SystemFileSystem()); Assembly first = loader.LoadAssembly(ThisAssemblyPath); Assembly second = loader.LoadAssembly(ThisAssemblyPath); // Two manifests naming the same assembly must share one load context, otherwise their types would not // be interchangeable and the assembly would be loaded twice. Assert.AreSame(first, second); } [TestMethod] public void LoadAssembly_ResolvesThePlatformContractToTheHostsCopy() { DynamicExtensionAssemblyLoader loader = new(new SystemFileSystem()); Assembly loaded = loader.LoadAssembly(ThisAssemblyPath); Type? hookType = loaded.GetType(typeof(IdentityProbe).FullName!, throwOnError: false); Assert.IsNotNull(hookType); MethodInfo? builderTypeAccessor = hookType.GetMethod(nameof(IdentityProbe.GetBuilderInterfaceType), BindingFlags.Public | BindingFlags.Static); Assert.IsNotNull(builderTypeAccessor); // The probe runs inside the (possibly isolated) context, and the ITestApplicationBuilder it reports // must be the very same runtime type the host uses — that identity is what lets a dynamically loaded // extension register into the host at all. object? typeFromLoadedCopy = builderTypeAccessor.Invoke(null, null); Assert.AreSame(typeof(ITestApplicationBuilder), typeFromLoadedCopy); } #if NETCOREAPP [TestMethod] public void LoadAssembly_OnDotNet_LoadsIntoAnIsolatedContext() { DynamicExtensionAssemblyLoader loader = new(new SystemFileSystem()); Assembly loaded = loader.LoadAssembly(ThisAssemblyPath); Assert.IsTrue(loader.IsIsolated); Assert.AreNotSame(typeof(DynamicExtensionAssemblyLoaderTests).Assembly, loaded, "The extension must not be resolved from the host's own context."); } #else [TestMethod] public void LoadAssembly_OnDotNetFramework_ReportsThatIsolationIsUnavailable() { DynamicExtensionAssemblyLoader loader = new(new SystemFileSystem()); // Not a limitation we can fix on this target, but one the loader must report so the diagnostic log can // say so rather than implying an isolation guarantee that does not exist. Assert.IsFalse(loader.IsIsolated); } #endif #if NETCOREAPP [TestMethod] public void SharedContract_IsResolvedByNameEvenWhenAHigherVersionIsRequested() { // The extension may have been compiled against a newer Microsoft.Testing.Platform than the host. // Resolving through the extension's full AssemblyName would apply version binding, fail to bind, and // silently give the extension a private copy of the platform whose ITestApplicationBuilder is a // different type -- which then surfaces as a bogus "the hook does not exist". Assembly platform = typeof(ITestApplicationBuilder).Assembly; AssemblyName requestedWithHigherVersion = new(platform.GetName().Name!) { Version = new Version(platform.GetName().Version!.Major + 1, 0, 0, 0), }; Assembly? resolved = InvokeResolveSharedContractAssembly(requestedWithHigherVersion); Assert.AreSame(platform, resolved); } [TestMethod] public void SharedContract_ThatIsNotLoadedYetIsStillResolvedFromTheApplication() { // Dynamic hooks run before static ones, so a contract can be in the application's dependency graph // without having been loaded yet. Settling for the extension's private copy in that window would split // the contract as soon as a static extension loaded the real one. // // The assembly must genuinely not be loaded, otherwise the already-loaded scan short-circuits and this // never reaches Default.LoadFromAssemblyName. Naming a type (for example typeof(Moq.Mock)) would itself // load it, so pick from framework assemblies that resolve but that a test process has no reason to have // touched, and verify the precondition rather than assuming it. // // Which stage answered is not observable from here -- both return the same instance -- so a parallel // test loading a candidate in the window between the check and the call would make this pass without // covering the branch. Closing that would take a dedicated test process for one branch, which is not // worth it: mutating Default.LoadFromAssemblyName away fails this test under the full parallel suite, // and if every candidate ever becomes loaded the assertion below says exactly that. string[] candidates = ["System.Net.Ping", "System.Formats.Tar", "System.Net.WebSockets", "System.IO.Pipes"]; string? notLoaded = candidates.FirstOrDefault(name => !AssemblyLoadContext.Default.Assemblies.Any(a => string.Equals(a.GetName().Name, name, StringComparison.OrdinalIgnoreCase))); Assert.IsNotNull(notLoaded, "Every candidate was already loaded, so this test could not exercise the load-by-name path."); Assembly? resolved = InvokeResolveSharedContractAssembly(new AssemblyName(notLoaded)); Assert.IsNotNull(resolved); Assert.AreEqual(notLoaded, resolved.GetName().Name); } [TestMethod] public void SharedContract_ThatNobodyCarriesResolvesToNothing() { Assembly? resolved = InvokeResolveSharedContractAssembly(new AssemblyName("Contoso.NotPresent.Abstractions")); Assert.IsNull(resolved); } #if NET9_0_OR_GREATER [TestMethod] public void SharedContract_CarriedOnlyByExtensions_IsPromotedSoEveryExtensionGetsTheSameCopy() { // The ordinary manifest-only deployment: the application does not carry the contract at all and every // extension brings its own copy. Loading each copy into its own context would give a capability // published by one dynamic extension a different identity from the one another extension consumes -- // exactly the split the shared list exists to prevent. The first copy found is promoted into the // default context and must then be handed to every extension that follows. string root = Path.Combine(Path.GetTempPath(), $"mtp-carried-contract-{Guid.NewGuid():N}"); string firstExtensionDirectory = Path.Combine(root, "first"); string secondExtensionDirectory = Path.Combine(root, "second"); Directory.CreateDirectory(firstExtensionDirectory); Directory.CreateDirectory(secondExtensionDirectory); try { // Deliberately different versions: if the second extension were handed its own copy back, the // assertion below would still see the right simple name, so only the version can tell the promoted // copy apart from a private one. WriteCarriedContractAssembly(Path.Combine(firstExtensionDirectory, CarriedContractName + ".dll"), new Version(1, 0, 0, 0)); WriteCarriedContractAssembly(Path.Combine(secondExtensionDirectory, CarriedContractName + ".dll"), new Version(2, 0, 0, 0)); Assembly? alreadyLoaded = AssemblyLoadContext.Default.Assemblies .FirstOrDefault(a => string.Equals(a.GetName().Name, CarriedContractName, StringComparison.OrdinalIgnoreCase)); Assert.IsNull(alreadyLoaded, "The contract must be absent from the application, otherwise this never reaches the promotion path."); Assembly? fromFirstExtension = InvokeResolveSharedContractAssembly( new AssemblyName(CarriedContractName) { Version = new Version(1, 0, 0, 0) }, Path.Combine(firstExtensionDirectory, "Contoso.First.Extension.dll")); Assembly? fromSecondExtension = InvokeResolveSharedContractAssembly( new AssemblyName(CarriedContractName) { Version = new Version(2, 0, 0, 0) }, Path.Combine(secondExtensionDirectory, "Contoso.Second.Extension.dll")); Assert.IsNotNull(fromFirstExtension); Assert.AreSame(fromFirstExtension, fromSecondExtension, "Both extensions must observe one contract identity."); Assert.AreEqual(new Version(1, 0, 0, 0), fromFirstExtension.GetName().Version, "The copy promoted first must stay canonical, whatever version a later extension ships."); Assert.Contains(fromFirstExtension, AssemblyLoadContext.Default.Assemblies, "The promoted copy must live in the default context so statically loaded code agrees with it too."); } finally { try { Directory.Delete(root, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Promotion is by definition into the non-collectible default context, so the file stays // mapped for the life of the process and cannot be deleted on Windows. Leaving it is the // price of covering this path at all. } } } private static void WriteCarriedContractAssembly(string path, Version version) { // A real assembly on disk rather than a copy of an existing one: the resolver matches on the simple // name recorded in the metadata, so a renamed file would take a different branch than the one under // test and quietly turn this into a tautology. PersistedAssemblyBuilder builder = new(new AssemblyName(CarriedContractName) { Version = version }, typeof(object).Assembly); builder.DefineDynamicModule(CarriedContractName); builder.Save(path); } #endif private static Assembly? InvokeResolveSharedContractAssembly(AssemblyName assemblyName, string? extensionAssemblyPath = null) { Type contextType = typeof(DynamicExtensionAssemblyLoader) .GetNestedType("DynamicExtensionLoadContext", BindingFlags.NonPublic)!; Assert.IsNotNull(contextType); object context = Activator.CreateInstance( contextType, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, binder: null, args: [extensionAssemblyPath ?? ThisAssemblyPath, new SystemFileSystem()], culture: null)!; Assert.IsNotNull(context); MethodInfo method = contextType.GetMethod("ResolveSharedContractAssembly", BindingFlags.NonPublic | BindingFlags.Instance)!; Assert.IsNotNull(method); try { return (Assembly?)method.Invoke(context, [assemblyName]); } catch (TargetInvocationException ex) when (ex.InnerException is not null) { throw ex.InnerException; } } #endif [TestMethod] public void LoadAssembly_WithMissingFile_Throws() { DynamicExtensionAssemblyLoader loader = new(new SystemFileSystem()); string missing = Path.Combine(Path.GetDirectoryName(ThisAssemblyPath)!, "ThisAssemblyDoesNotExist.dll"); Assert.ThrowsExactly(() => loader.LoadAssembly(missing)); } /// /// Loaded reflectively from a second copy of this assembly to compare type identity across the boundary. /// public static class IdentityProbe { public static Type GetBuilderInterfaceType() => typeof(ITestApplicationBuilder); } }