Skip to content

Commit

Permalink
Support loading plugin dependencies from .deps.json on .NET Framework…
Browse files Browse the repository at this point in the history
… and Visual Studio MSBuild (#411)

* Support loading plugin dependencies from .deps.json on .NET Framework and Visual Studio MSBuild

* Update NUnitRetry

* Fix loading plugin assemblies from deps.json on linux (case sensitive)

* refactor resolvers and introduce base class

* Move external plugin tests to ExternalPluginsTest

* Add external test for SpecSync.AzureDevOps.TestSuiteBasedExecution.Reqnroll plugin

---------

Co-authored-by: Gáspár Nagy <[email protected]>
  • Loading branch information
obligaron and gasparnagy authored Feb 5, 2025
1 parent f59ced6 commit 182e933
Show file tree
Hide file tree
Showing 28 changed files with 379 additions and 106 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Reqnroll/PlatformCompatibility/PlatformHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public static void RegisterPluginAssemblyLoader(IObjectContainer container)
if (PlatformInformation.IsDotNetFramework)
container.RegisterTypeAs<DotNetFrameworkPluginAssemblyLoader, IPluginAssemblyLoader>();
else
container.RegisterTypeAs<PluginAssemblyLoader, IPluginAssemblyLoader>();
container.RegisterTypeAs<DotNetCorePluginAssemblyLoader, IPluginAssemblyLoader>();
}
}
93 changes: 93 additions & 0 deletions Reqnroll/Plugins/AssemblyResolverBase.cs
Original file line number Diff line number Diff line change
@@ -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> _assembly;

public Assembly GetAssembly() => _assembly.Value;

private ICompilationAssemblyResolver _assemblyResolver;
private DependencyContext _dependencyContext;

protected AssemblyResolverBase(string relativePath)
{
var path = Path.GetFullPath(relativePath);
_assembly = new Lazy<Assembly>(() => 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<string>();
_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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Reqnroll.Plugins;
/// <summary>
/// This class is used for .NET Core based frameworks (.NET 6+) only. For .NET Framework <see cref="DotNetFrameworkPluginAssemblyLoader"/> is used instead. See <see cref="PlatformCompatibility.PlatformHelper"/>.
/// </summary>
public class PluginAssemblyLoader : IPluginAssemblyLoader
public class DotNetCorePluginAssemblyLoader : IPluginAssemblyLoader
{
public Assembly LoadAssembly(string assemblyName) => PluginAssemblyResolver.Load(assemblyName);
public Assembly LoadAssembly(string assemblyPath) => DotNetCorePluginAssemblyResolver.Load(assemblyPath);
}
44 changes: 44 additions & 0 deletions Reqnroll/Plugins/DotNetCorePluginAssemblyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Reflection;
using System.Runtime.Loader;

namespace Reqnroll.Plugins;

/// <summary>
/// This class is used for .NET Core based frameworks (.NET 6+) only. See <see cref="PlatformCompatibility.PlatformHelper"/>.
/// </summary>
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();
}
}
5 changes: 4 additions & 1 deletion Reqnroll/Plugins/DotNetFrameworkPluginAssemblyLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

namespace Reqnroll.Plugins;

/// <summary>
/// This class is used for .NET Framework v4.* only. For .NET +6 <see cref="DotNetCorePluginAssemblyLoader"/> is used instead. See <see cref="PlatformCompatibility.PlatformHelper"/>.
/// </summary>
public class DotNetFrameworkPluginAssemblyLoader : IPluginAssemblyLoader
{
public Assembly LoadAssembly(string assemblyName) => Assembly.LoadFrom(assemblyName);
public Assembly LoadAssembly(string assemblyPath) => DotNetFrameworkPluginAssemblyResolver.Load(assemblyPath);
}
34 changes: 34 additions & 0 deletions Reqnroll/Plugins/DotNetFrameworkPluginAssemblyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;
using System.Reflection;

namespace Reqnroll.Plugins;

/// <summary>
/// This class is used for .NET Framework 4.* only. See <see cref="PlatformCompatibility.PlatformHelper"/>.
/// </summary>
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();
}
}
2 changes: 1 addition & 1 deletion Reqnroll/Plugins/IPluginAssemblyLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
namespace Reqnroll.Plugins;
public interface IPluginAssemblyLoader
{
Assembly LoadAssembly(string assemblyName);
Assembly LoadAssembly(string assemblyPath);
}
80 changes: 0 additions & 80 deletions Reqnroll/Plugins/PluginAssemblyResolver.cs

This file was deleted.

6 changes: 3 additions & 3 deletions Reqnroll/Plugins/RuntimePluginLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITraceListener>();

var plugin = loader.LoadPlugin("Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin.dll", listener.Object, It.IsAny<bool>());
Expand Down
2 changes: 1 addition & 1 deletion Tests/Reqnroll.PluginTests/Windsor/WindsorPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITraceListener>();

var plugin = loader.LoadPlugin("Reqnroll.Windsor.ReqnrollPlugin.dll", listener.Object, It.IsAny<bool>());
Expand Down
10 changes: 6 additions & 4 deletions Tests/Reqnroll.SystemTests/Drivers/TestFileManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,7 +32,7 @@ public string GetTestFileContent(string testFileName)
public IEnumerable<string> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Reqnroll.SystemTests.ExternalPlugins;

public abstract class ExternalPluginsTestBase : SystemTestBase
{
}
Loading

0 comments on commit 182e933

Please sign in to comment.