diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa272675..8c6e99692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * MsTest: Only use TestContext for output and not Console.WriteLine (#368) * Fix: Replace deprecated dependency `Specflow.Internal.Json` with `System.Text.Json`. The dependency was used for laoding `reqnroll.json`, for Visual Studio integration and for telemetry. (#373) +* Fix: Support loading plugin dependencies from .deps.json on .NET Framework and Visual Studio MSBuild (#408) *Contributors of this release (in alphabetical order):* @clrudolphi, @obligaron, @olegKoshmeliuk diff --git a/Reqnroll/PlatformCompatibility/PlatformHelper.cs b/Reqnroll/PlatformCompatibility/PlatformHelper.cs index ca573b5ce..cfaa6a5e6 100644 --- a/Reqnroll/PlatformCompatibility/PlatformHelper.cs +++ b/Reqnroll/PlatformCompatibility/PlatformHelper.cs @@ -9,6 +9,6 @@ public static void RegisterPluginAssemblyLoader(IObjectContainer container) if (PlatformInformation.IsDotNetFramework) container.RegisterTypeAs(); else - container.RegisterTypeAs(); + container.RegisterTypeAs(); } } diff --git a/Reqnroll/Plugins/AssemblyResolverBase.cs b/Reqnroll/Plugins/AssemblyResolverBase.cs new file mode 100644 index 000000000..eb2be1361 --- /dev/null +++ b/Reqnroll/Plugins/AssemblyResolverBase.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyModel; +using Microsoft.Extensions.DependencyModel.Resolution; + +namespace Reqnroll.Plugins; + +public abstract class AssemblyResolverBase +{ + private readonly Lazy _assembly; + + public Assembly GetAssembly() => _assembly.Value; + + private ICompilationAssemblyResolver _assemblyResolver; + private DependencyContext _dependencyContext; + + protected AssemblyResolverBase(string relativePath) + { + var path = Path.GetFullPath(relativePath); + _assembly = new Lazy(() => Initialize(path)); + } + + protected abstract Assembly Initialize(string path); + + protected void SetupDependencyContext(string path, Assembly assembly, bool throwOnError) + { + try + { + _dependencyContext = DependencyContext.Load(assembly); + + if (_dependencyContext is null) return; + + _assemblyResolver = new CompositeCompilationAssemblyResolver( + [ + new AppBaseCompilationAssemblyResolver(Path.GetDirectoryName(path)!), + new ReferenceAssemblyPathResolver(), + new PackageCompilationAssemblyResolver() + ]); + } + catch (Exception) + { + if (throwOnError) + throw; + + // We ignore if there was a problem with initializing context from .deps.json + } + } + + protected abstract Assembly LoadAssemblyFromPath(string assemblyPath); + + protected Assembly TryResolveAssembly(AssemblyName name) + { + var library = _dependencyContext?.RuntimeLibraries.FirstOrDefault( + runtimeLibrary => string.Equals(runtimeLibrary.Name, name.Name, StringComparison.OrdinalIgnoreCase)); + + if (library == null) + return null; + + var wrapper = new CompilationLibrary( + library.Type, + library.Name, + library.Version, + library.Hash, + library.RuntimeAssemblyGroups.SelectMany(g => g.AssetPaths), + library.Dependencies, + library.Serviceable, + library.Path, + library.HashPath); + + var assemblies = new List(); + _assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies); + + if (_assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies) && assemblies.Count > 0) + { + foreach (var assemblyPath in assemblies) + { + try + { + return LoadAssemblyFromPath(assemblyPath); + } + catch + { + // Don't throw if we can't load the specified assembly (perhaps something is missing or misconfigured) + } + } + } + + return null; + } +} diff --git a/Reqnroll/Plugins/PluginAssemblyLoader.cs b/Reqnroll/Plugins/DotNetCorePluginAssemblyLoader.cs similarity index 62% rename from Reqnroll/Plugins/PluginAssemblyLoader.cs rename to Reqnroll/Plugins/DotNetCorePluginAssemblyLoader.cs index c4a73829d..d86433962 100644 --- a/Reqnroll/Plugins/PluginAssemblyLoader.cs +++ b/Reqnroll/Plugins/DotNetCorePluginAssemblyLoader.cs @@ -5,7 +5,7 @@ namespace Reqnroll.Plugins; /// /// This class is used for .NET Core based frameworks (.NET 6+) only. For .NET Framework is used instead. See . /// -public class PluginAssemblyLoader : IPluginAssemblyLoader +public class DotNetCorePluginAssemblyLoader : IPluginAssemblyLoader { - public Assembly LoadAssembly(string assemblyName) => PluginAssemblyResolver.Load(assemblyName); + public Assembly LoadAssembly(string assemblyPath) => DotNetCorePluginAssemblyResolver.Load(assemblyPath); } diff --git a/Reqnroll/Plugins/DotNetCorePluginAssemblyResolver.cs b/Reqnroll/Plugins/DotNetCorePluginAssemblyResolver.cs new file mode 100644 index 000000000..abd841f45 --- /dev/null +++ b/Reqnroll/Plugins/DotNetCorePluginAssemblyResolver.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Reqnroll.Plugins; + +/// +/// This class is used for .NET Core based frameworks (.NET 6+) only. See . +/// +public sealed class DotNetCorePluginAssemblyResolver(string path) : AssemblyResolverBase(path) +{ + private AssemblyLoadContext _loadContext; + + protected override Assembly Initialize(string path) + { + _loadContext = AssemblyLoadContext.GetLoadContext(typeof(DotNetCorePluginAssemblyResolver).Assembly); + var assembly = LoadAssemblyFromPath(path); + + SetupDependencyContext(path, assembly, true); + + _loadContext.Resolving += OnResolving; + _loadContext.Unloading += OnUnloading; + + return assembly; + } + + protected override Assembly LoadAssemblyFromPath(string assemblyPath) + => _loadContext.LoadFromAssemblyPath(assemblyPath); + + private void OnUnloading(AssemblyLoadContext context) + { + _loadContext.Resolving -= OnResolving; + _loadContext.Unloading -= OnUnloading; + } + + private Assembly OnResolving(AssemblyLoadContext context, AssemblyName name) + { + return TryResolveAssembly(name); + } + + public static Assembly Load(string path) + { + return new DotNetCorePluginAssemblyResolver(path).GetAssembly(); + } +} \ No newline at end of file diff --git a/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyLoader.cs b/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyLoader.cs index 7ee805f63..bfac5cc78 100644 --- a/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyLoader.cs +++ b/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyLoader.cs @@ -2,7 +2,10 @@ namespace Reqnroll.Plugins; +/// +/// This class is used for .NET Framework v4.* only. For .NET +6 is used instead. See . +/// public class DotNetFrameworkPluginAssemblyLoader : IPluginAssemblyLoader { - public Assembly LoadAssembly(string assemblyName) => Assembly.LoadFrom(assemblyName); + public Assembly LoadAssembly(string assemblyPath) => DotNetFrameworkPluginAssemblyResolver.Load(assemblyPath); } diff --git a/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyResolver.cs b/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyResolver.cs new file mode 100644 index 000000000..b0ef9e0bf --- /dev/null +++ b/Reqnroll/Plugins/DotNetFrameworkPluginAssemblyResolver.cs @@ -0,0 +1,34 @@ +using System; +using System.Reflection; + +namespace Reqnroll.Plugins; + +/// +/// This class is used for .NET Framework 4.* only. See . +/// +public sealed class DotNetFrameworkPluginAssemblyResolver(string path) : AssemblyResolverBase(path) +{ + protected override Assembly Initialize(string path) + { + var assembly = LoadAssemblyFromPath(path); + + SetupDependencyContext(path, assembly, false); + AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; + + return assembly; + } + + protected override Assembly LoadAssemblyFromPath(string assemblyPath) + => Assembly.LoadFrom(assemblyPath); + + private Assembly OnAssemblyResolve(object sender, ResolveEventArgs args) + { + var assemblyName = new AssemblyName(args.Name); + return TryResolveAssembly(assemblyName); + } + + public static Assembly Load(string path) + { + return new DotNetFrameworkPluginAssemblyResolver(path).GetAssembly(); + } +} \ No newline at end of file diff --git a/Reqnroll/Plugins/IPluginAssemblyLoader.cs b/Reqnroll/Plugins/IPluginAssemblyLoader.cs index 7d46da021..ba438cd67 100644 --- a/Reqnroll/Plugins/IPluginAssemblyLoader.cs +++ b/Reqnroll/Plugins/IPluginAssemblyLoader.cs @@ -3,5 +3,5 @@ namespace Reqnroll.Plugins; public interface IPluginAssemblyLoader { - Assembly LoadAssembly(string assemblyName); + Assembly LoadAssembly(string assemblyPath); } \ No newline at end of file diff --git a/Reqnroll/Plugins/PluginAssemblyResolver.cs b/Reqnroll/Plugins/PluginAssemblyResolver.cs deleted file mode 100644 index fd23e999f..000000000 --- a/Reqnroll/Plugins/PluginAssemblyResolver.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.Loader; -using Microsoft.Extensions.DependencyModel; -using Microsoft.Extensions.DependencyModel.Resolution; - -namespace Reqnroll.Plugins -{ - /// - /// This class is used for .NET Core based frameworks (.NET 6+) only. See . - /// - public sealed class PluginAssemblyResolver - { - private readonly ICompilationAssemblyResolver _assemblyResolver; - private readonly DependencyContext _dependencyContext; - private readonly AssemblyLoadContext _loadContext; - - public Assembly Assembly { get; } - - public PluginAssemblyResolver(string path) - { - _loadContext = AssemblyLoadContext.GetLoadContext(typeof(PluginAssemblyResolver).Assembly); - Assembly = _loadContext.LoadFromAssemblyPath(path); - _dependencyContext = DependencyContext.Load(Assembly); - - _assemblyResolver = new CompositeCompilationAssemblyResolver( - [ - new AppBaseCompilationAssemblyResolver(Path.GetDirectoryName(path)!), - new ReferenceAssemblyPathResolver(), - new PackageCompilationAssemblyResolver() - ]); - - _loadContext.Resolving += OnResolving; - _loadContext.Unloading += OnUnloading; - } - - private void OnUnloading(AssemblyLoadContext context) - { - _loadContext.Resolving -= OnResolving; - _loadContext.Unloading -= OnUnloading; - } - - private Assembly OnResolving(AssemblyLoadContext context, AssemblyName name) - { - var library = _dependencyContext?.RuntimeLibraries.FirstOrDefault( - runtimeLibrary => string.Equals(runtimeLibrary.Name, name.Name, StringComparison.OrdinalIgnoreCase)); - - if (library != null) - { - var wrapper = new CompilationLibrary( - library.Type, - library.Name, - library.Version, - library.Hash, - library.RuntimeAssemblyGroups.SelectMany(g => g.AssetPaths), - library.Dependencies, - library.Serviceable); - - var assemblies = new List(); - _assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies); - - if (assemblies.Count > 0) - { - return _loadContext.LoadFromAssemblyPath(assemblies[0]); - } - } - - return null; - } - - public static Assembly Load(string path) - { - var absolutePath = Path.GetFullPath(path); - return new PluginAssemblyResolver(absolutePath).Assembly; - } - } -} diff --git a/Reqnroll/Plugins/RuntimePluginLoader.cs b/Reqnroll/Plugins/RuntimePluginLoader.cs index 1d61da1f0..b512c2a2b 100644 --- a/Reqnroll/Plugins/RuntimePluginLoader.cs +++ b/Reqnroll/Plugins/RuntimePluginLoader.cs @@ -7,16 +7,16 @@ namespace Reqnroll.Plugins { public class RuntimePluginLoader(IPluginAssemblyLoader _pluginAssemblyLoader) : IRuntimePluginLoader { - public IRuntimePlugin LoadPlugin(string pluginAssemblyName, ITraceListener traceListener, bool traceMissingPluginAttribute) + public IRuntimePlugin LoadPlugin(string pluginAssemblyPath, ITraceListener traceListener, bool traceMissingPluginAttribute) { Assembly assembly; try { - assembly = _pluginAssemblyLoader.LoadAssembly(pluginAssemblyName); + assembly = _pluginAssemblyLoader.LoadAssembly(pluginAssemblyPath); } catch (Exception ex) { - throw new ReqnrollException($"Unable to load plugin: {pluginAssemblyName}. Please check https://go.reqnroll.net/doc-plugins for details. (Framework: {PlatformInformation.DotNetFrameworkDescription})", ex); + throw new ReqnrollException($"Unable to load plugin: {pluginAssemblyPath}. Please check https://go.reqnroll.net/doc-plugins for details. (Framework: {PlatformInformation.DotNetFrameworkDescription})", ex); } var pluginAttribute = (RuntimePluginAttribute)Attribute.GetCustomAttribute(assembly, typeof(RuntimePluginAttribute)); diff --git a/Tests/Reqnroll.PluginTests/Generator/GeneratorPluginLoaderTests.cs b/Tests/Reqnroll.PluginTests/Generator/GeneratorPluginLoaderTests.cs index 2c65090a2..8a1f9d453 100644 --- a/Tests/Reqnroll.PluginTests/Generator/GeneratorPluginLoaderTests.cs +++ b/Tests/Reqnroll.PluginTests/Generator/GeneratorPluginLoaderTests.cs @@ -12,7 +12,7 @@ public class GeneratorPluginLoaderTests public void LoadPlugin_LoadXUnitSuccessfully() { //ARRANGE - var generatorPluginLoader = new GeneratorPluginLoader(new PluginAssemblyLoader()); + var generatorPluginLoader = new GeneratorPluginLoader(new DotNetCorePluginAssemblyLoader()); //ACT var pluginDescriptor = new PluginDescriptor("Reqnroll.xUnit.Generator.ReqnrollPlugin", "Reqnroll.xUnit.Generator.ReqnrollPlugin.dll", PluginType.Generator, ""); diff --git a/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/MicrosoftExtensionsDependencyInjectionTests.cs b/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/MicrosoftExtensionsDependencyInjectionTests.cs index 299225104..5e884999c 100644 --- a/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/MicrosoftExtensionsDependencyInjectionTests.cs +++ b/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/MicrosoftExtensionsDependencyInjectionTests.cs @@ -11,7 +11,7 @@ public class MicrosoftExtensionsDependencyInjectionTests [Fact] public void LoadPlugin_MicrosoftExtensionsDependencyInjection_ShouldNotBeNull() { - var loader = new RuntimePluginLoader(new PluginAssemblyLoader()); + var loader = new RuntimePluginLoader(new DotNetCorePluginAssemblyLoader()); var listener = new Mock(); var plugin = loader.LoadPlugin("Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin.dll", listener.Object, It.IsAny()); diff --git a/Tests/Reqnroll.PluginTests/Windsor/WindsorPluginTests.cs b/Tests/Reqnroll.PluginTests/Windsor/WindsorPluginTests.cs index 29e02ad57..5b57fe17d 100644 --- a/Tests/Reqnroll.PluginTests/Windsor/WindsorPluginTests.cs +++ b/Tests/Reqnroll.PluginTests/Windsor/WindsorPluginTests.cs @@ -21,7 +21,7 @@ public class WindsorPluginTests [Fact] public void Can_load_Windsor_plugin() { - var loader = new RuntimePluginLoader(new PluginAssemblyLoader()); + var loader = new RuntimePluginLoader(new DotNetCorePluginAssemblyLoader()); var listener = new Mock(); var plugin = loader.LoadPlugin("Reqnroll.Windsor.ReqnrollPlugin.dll", listener.Object, It.IsAny()); diff --git a/Tests/Reqnroll.SystemTests/Drivers/TestFileManager.cs b/Tests/Reqnroll.SystemTests/Drivers/TestFileManager.cs index 95cb6149b..1a0d17398 100644 --- a/Tests/Reqnroll.SystemTests/Drivers/TestFileManager.cs +++ b/Tests/Reqnroll.SystemTests/Drivers/TestFileManager.cs @@ -12,12 +12,14 @@ public class TestFileManager { private const string RootNamespace = "Reqnroll.SystemTests"; private const string TestFileFolder = "Resources"; - private readonly string _prefix = $"{RootNamespace}.{TestFileFolder}"; - public string GetTestFileContent(string testFileName) + private string GetPrefix(string? resourceGroup = null) => + resourceGroup == null ? $"{RootNamespace}.{TestFileFolder}" : $"{RootNamespace}.{resourceGroup}.{TestFileFolder}"; + + public string GetTestFileContent(string testFileName, string? resourceGroup = null) { var testFileResourceName = testFileName.Replace('/', '.'); - var resourceName = $"{_prefix}.{testFileResourceName}"; + var resourceName = $"{GetPrefix(resourceGroup)}.{testFileResourceName}"; var projectTemplateStream = Assembly .GetExecutingAssembly() .GetManifestResourceStream(resourceName); @@ -30,7 +32,7 @@ public string GetTestFileContent(string testFileName) public IEnumerable GetTestFeatureFiles() { var assembly = Assembly.GetExecutingAssembly(); - string prefixToRemove = $"{_prefix}."; + string prefixToRemove = $"{GetPrefix()}."; return assembly.GetManifestResourceNames() .Where(rn => rn.EndsWith(".feature") && rn.StartsWith(prefixToRemove)) .Select(rn => rn.Substring(prefixToRemove.Length)); diff --git a/Tests/Reqnroll.SystemTests/ExternalPlugins/ExternalPluginsTestBase.cs b/Tests/Reqnroll.SystemTests/ExternalPlugins/ExternalPluginsTestBase.cs new file mode 100644 index 000000000..7bb4a56b6 --- /dev/null +++ b/Tests/Reqnroll.SystemTests/ExternalPlugins/ExternalPluginsTestBase.cs @@ -0,0 +1,5 @@ +namespace Reqnroll.SystemTests.ExternalPlugins; + +public abstract class ExternalPluginsTestBase : SystemTestBase +{ +} \ No newline at end of file diff --git a/Tests/Reqnroll.SystemTests/ExternalPlugins/NUnitRetryPluginTest.cs b/Tests/Reqnroll.SystemTests/ExternalPlugins/NUnitRetryPluginTest.cs new file mode 100644 index 000000000..62ef408bc --- /dev/null +++ b/Tests/Reqnroll.SystemTests/ExternalPlugins/NUnitRetryPluginTest.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll.TestProjectGenerator; +using Reqnroll.TestProjectGenerator.Driver; +using System.Linq; + +namespace Reqnroll.SystemTests.ExternalPlugins; + +[TestClass] +public class NUnitRetryPluginTest : ExternalPluginsTestBase +{ + protected override void TestInitialize() + { + base.TestInitialize(); + _testRunConfiguration.UnitTestProvider = UnitTestProvider.NUnit4; + _projectsDriver.AddNuGetPackage("NUnitRetry.ReqnrollPlugin", "1.0.100"); + } + + [TestMethod] + public void NUnitRetry_should_work_with_Reqnroll() + { + AddFeatureFileFromResource("NUnitRetryPlugin/NUnitRetryPluginTestFeature.feature", resourceGroup: "ExternalPlugins"); + AddBindingClassFromResource("NUnitRetryPlugin/NUnitRetryPluginTestStepDefinitions.cs", resourceGroup: "ExternalPlugins"); + + ExecuteTests(); + + ShouldAllScenariosPass(); + + var simulatedErrors = _bindingDriver.GetActualLogLines("simulated-error").ToList(); + simulatedErrors.Should().HaveCount(_preparedTests * 2); // two simulated error per test + } + + [TestMethod] + [TestCategory("MsBuild")] + public void NUnitRetry_should_work_with_Reqnroll_on_DotNetFramework_generation() + { + // compiling with MsBuild forces the generation to run with .NET Framework + _compilationDriver.SetBuildTool(BuildTool.MSBuild); + NUnitRetry_should_work_with_Reqnroll(); + } +} diff --git a/Tests/Reqnroll.SystemTests/ExternalPlugins/Resources/NUnitRetryPlugin/NUnitRetryPluginTestFeature.feature b/Tests/Reqnroll.SystemTests/ExternalPlugins/Resources/NUnitRetryPlugin/NUnitRetryPluginTestFeature.feature new file mode 100644 index 000000000..323504455 --- /dev/null +++ b/Tests/Reqnroll.SystemTests/ExternalPlugins/Resources/NUnitRetryPlugin/NUnitRetryPluginTestFeature.feature @@ -0,0 +1,13 @@ +Feature: NRetryPluginFeature + +Used by Reqnroll.SystemTests.Plugins.NUnitRetryPluginTest + +Scenario: Scenario with Retry + When fail for first 2 times A + +Scenario Outline: Scenario outline with Retry + When fail for first 2 times