Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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/Plugins/DotNetFrameworkPluginAssemblyLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ namespace Reqnroll.Plugins;

public class DotNetFrameworkPluginAssemblyLoader : IPluginAssemblyLoader
{
public Assembly LoadAssembly(string assemblyName) => Assembly.LoadFrom(assemblyName);
public Assembly LoadAssembly(string path) => DotNetFrameworkPluginAssemblyResolver.Load(path);
}
101 changes: 101 additions & 0 deletions Reqnroll/Plugins/DotNetFrameworkPluginAssemblyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using Microsoft.Extensions.DependencyModel;
using Microsoft.Extensions.DependencyModel.Resolution;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;

namespace Reqnroll.Plugins
{
public sealed class DotNetFrameworkPluginAssemblyResolver
{
public Assembly Assembly { get; }

DependencyContext resolverRependencyContext;
CompositeCompilationAssemblyResolver assemblyResolver;

public DotNetFrameworkPluginAssemblyResolver(string path)
{
var absolutePath = Path.GetFullPath(path);
Assembly = Assembly.LoadFrom(absolutePath);

try
{
SetupDependencyContext(path);
}
catch (Exception)
{
// Don't throw if we can't load the dependencies from .deps.json
}
}
void SetupDependencyContext(string path)
{
resolverRependencyContext = DependencyContext.Load(Assembly);

if (resolverRependencyContext is null)
return;

assemblyResolver = new CompositeCompilationAssemblyResolver(
[
new AppBaseCompilationAssemblyResolver(Path.GetDirectoryName(path)!),
new ReferenceAssemblyPathResolver(),
new PackageCompilationAssemblyResolver()
]);

AppDomain.CurrentDomain.AssemblyResolve += TryAssemblyResolve;
}

Assembly TryAssemblyResolve(object sender, ResolveEventArgs args)
{
try
{
var assemblyName = new AssemblyName(args.Name);
var library = resolverRependencyContext.RuntimeLibraries.FirstOrDefault(runtimeLibrary => string.Equals(runtimeLibrary.Name, assemblyName.Name, StringComparison.OrdinalIgnoreCase));
if (library is 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>();
if (assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies))
{
foreach (var asm in assemblies)
{
try
{
var assembly = Assembly.LoadFrom(asm);
return assembly;
}
catch
{
// Don't throw if we can't load the specified assembly (perhaps something is missing or misconfigured)
continue;
}
}
}
return null;
}
catch
{
// Don't throw if we can't load the dependencies from .deps.json
return null;
}
}

public static Assembly Load(string path)
{
var absolutePath = Path.GetFullPath(path);
return new DotNetFrameworkPluginAssemblyResolver(absolutePath).Assembly;
}
}
}
67 changes: 50 additions & 17 deletions Reqnroll/Plugins/PluginAssemblyResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,28 @@ 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()
]);
try
{
_dependencyContext = DependencyContext.Load(Assembly);

if (_dependencyContext is null)
return;

_loadContext.Resolving += OnResolving;
_loadContext.Unloading += OnUnloading;
_assemblyResolver = new CompositeCompilationAssemblyResolver(
[
new AppBaseCompilationAssemblyResolver(Path.GetDirectoryName(path)!),
new ReferenceAssemblyPathResolver(),
new PackageCompilationAssemblyResolver()
]);

_loadContext.Resolving += OnResolving;
_loadContext.Unloading += OnUnloading;
}
catch (Exception)
{
// Don't throw if we can't load the dependencies from .deps.json
}
}

private void OnUnloading(AssemblyLoadContext context)
Expand All @@ -45,30 +56,52 @@ private void OnUnloading(AssemblyLoadContext context)

private Assembly OnResolving(AssemblyLoadContext context, AssemblyName name)
{
var library = _dependencyContext?.RuntimeLibraries.FirstOrDefault(
try
{
var library = _dependencyContext?.RuntimeLibraries.FirstOrDefault(
runtimeLibrary => string.Equals(runtimeLibrary.Name, name.Name, StringComparison.OrdinalIgnoreCase));

if (library != null)
{
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.Serviceable,
library.Path,
library.HashPath);

var assemblies = new List<string>();
_assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies);

if (assemblies.Count > 0)
if (_assemblyResolver.TryResolveAssemblyPaths(wrapper, assemblies))
{
return _loadContext.LoadFromAssemblyPath(assemblies[0]);
foreach (var asm in assemblies)
{
try
{
var assembly = _loadContext.LoadFromAssemblyPath(asm);
return assembly;
}
catch
{
// Don't throw if we can't load the specified assembly (perhaps something is missing or misconfigured)
continue;
}
}
}
}

return null;
return null;
}
catch
{
// Don't throw if we can't load the dependencies from .deps.json
return null;
}
}

public static Assembly Load(string path)
Expand Down
41 changes: 41 additions & 0 deletions Tests/Reqnroll.SystemTests/Plugins/NUnitRetryPluginTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Reqnroll.TestProjectGenerator;
using Reqnroll.TestProjectGenerator.Driver;
using System.Linq;

namespace Reqnroll.SystemTests.Plugins;

[TestClass]
public class NUnitRetryPluginTest : SystemTestBase
{
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");
AddBindingClassFromResource("NUnitRetryPlugin/NUnitRetryPluginTestStepDefinitions.cs");

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();
}
}
Original file line number Diff line number Diff line change
@@ -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 <label>
Examples:
| label |
| B |
| C |
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Used by Reqnroll.SystemTests.Plugins.NUnitRetryPluginTest
using System.Collections.Generic;
using NUnit.Framework;

namespace NUnitRetryPluginTest.StepDefinitions
{
[Binding]
public class NUnitRetryPluginTestStepDefinitions
{
private static readonly Dictionary<string, int> RetriesByLabel = new Dictionary<string, int>();

[When("fail for first {int} times {word}")]
public void WhenFailForFirstTwoTimes(int retryCount, string label)
{
if (!RetriesByLabel.TryGetValue(label, out var retries))
{
retries = 0;
}
var failTest = retries < retryCount;
RetriesByLabel[label] = ++retries;
if (failTest)
{
Log.LogCustom("simulated-error", label);
}
Assert.That(failTest, Is.False);
}
}
}
16 changes: 16 additions & 0 deletions docs/extend/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ This is passed to the MSBuild task as a parameter and later used to load the plu
* `CustomizeDependencies` - overrides registrations in the Generator container
* `ConfigurationDefaults` - adjust configuration values

### Third-Party Dependencies

If your plugin uses third party assemblies, you need to make sure that the dependencies can be found.

Reqnroll will attempt to find your plugin's dependencies by

1. Loading assemblies from the same directory as your assembly.
2. Loading your plugin's .deps.json and loading the specified runtime assembly (e.g. from NuGet cache).

Note: If you are using Nuget to publish your plugin, make sure your NuGet package contains the correct dependencies, otherwise the NuGet cache may be empty.

3. Check if the assembly is provided by Reqnroll itself (e.g. System.CodeDom).

Note: The assemblies included by Reqnroll can change between versions, e.g. we switched to System.Text.Json at some point.
So if you want to be on the safe side, do not rely on this approach.

## Combined Package with both plugins

You can have a single NuGet package that contains both the runtime and generator plugins.
Expand Down
Loading