Tests: All plugin integration tests to resolve plugin's types

Reported by @napoly

In an integration test for a plugin, attempt to resolve a type provided by that plugin using `BTCPayServerTester`.

For example:
```
tester.GetService<MoneroRPCProvider>();
```

The type should be resolved successfully.

The type fails to resolve.

During the test run, the dotnet runtime attempts to load `MoneroRPCProvider` in the default load context (`AssemblyLoadContext.Default`). It locates the plugin assembly in the test directory and loads it there.

In contrast, when BTCPay Server loads a plugin, it creates a dedicated plugin load context, and the plugin’s `MoneroRPCProvider` is loaded inside that context. This results in two distinct `MoneroRPCProvider` types: one in the default context and one in the plugin context.

This PR forces the plugin context, during integration tests, to load the types it resolves into the default assembly context rather than its own. This prevents duplicate type definitions.

As a side effect, behavior may differ slightly between running BTCPay Server normally and running tests, but this should be acceptable in most cases.

Relevant discussion: #6851
This commit is contained in:
Nicolas Dorier
2025-11-20 23:25:34 +09:00
parent 50b5c9a2b9
commit e002f59f4c
6 changed files with 40 additions and 3 deletions

View File

@@ -88,6 +88,12 @@ namespace BTCPayServer.Tests
public bool MockRates { get; set; } = true;
public string SocksEndpoint { get; set; }
/// <summary>
/// This helps testing plugins.
/// See https://github.com/btcpayserver/btcpayserver/pull/7008
/// </summary>
public bool LoadPluginsInDefaultAssemblyContext { get; set; } = true;
public HashSet<string> Chains { get; set; } = new HashSet<string>() { "BTC" };
public bool UseLightning { get; set; }
public bool CheatMode { get; set; } = true;
@@ -167,6 +173,8 @@ namespace BTCPayServer.Tests
#if DEBUG
confBuilder.AddJsonFile("appsettings.dev.json", true, false);
#endif
if (LoadPluginsInDefaultAssemblyContext)
confBuilder.AddInMemoryCollection([new("TEST_RUNNER_ENABLED", "true")]);
var conf = confBuilder.Build();
_Host = new WebHostBuilder()
.UseDefaultServiceProvider(options =>

View File

@@ -30,6 +30,7 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
private bool _isCollectible;
private bool _loadInMemory;
private bool _loadAssembliesInDefaultLoadContext;
private bool _shadowCopyNativeLibraries;
/// <summary>
@@ -65,6 +66,7 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
_lazyLoadReferences,
_isCollectible,
_loadInMemory,
_loadAssembliesInDefaultLoadContext,
_shadowCopyNativeLibraries);
}
@@ -319,6 +321,18 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
return this;
}
/// <summary>
/// This will load assemblies into the default load context.
/// This is used for integration tests. Tests run in the default load context, so we do
/// not want type mismatch errors due to loading types in different load contexts.
/// </summary>
/// <returns></returns>
public AssemblyLoadContextBuilder LoadAssembliesInDefaultLoadContext()
{
_loadAssembliesInDefaultLoadContext = true;
return this;
}
/// <summary>
/// Shadow copy native libraries (unmanaged DLLs) to avoid locking of these files.
/// This is not as efficient, so is not enabled by default, but is required for scenarios

View File

@@ -31,6 +31,7 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
private readonly bool _preferDefaultLoadContext;
private readonly string[] _resourceRoots;
private readonly bool _loadInMemory;
private readonly bool _loadAssembliesInDefaultLoadContext;
private readonly bool _lazyLoadReferences;
private readonly List<AssemblyLoadContext> _assemblyLoadContexts = new();
private readonly AssemblyDependencyResolver _dependencyResolver;
@@ -49,6 +50,7 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
bool lazyLoadReferences,
bool isCollectible,
bool loadInMemory,
bool loadAssembliesInDefaultLoadContext,
bool shadowCopyNativeLibraries)
: base(Path.GetFileNameWithoutExtension(mainAssemblyPath), isCollectible)
{
@@ -190,11 +192,12 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
return null;
}
private AssemblyLoadContext LoadContext => _loadAssembliesInDefaultLoadContext ? Default : this;
public Assembly LoadAssemblyFromFilePath(string path)
{
if (!_loadInMemory)
{
return LoadFromAssemblyPath(path);
return LoadContext.LoadFromAssemblyPath(path);
}
using var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
@@ -202,9 +205,9 @@ namespace BTCPayServer.Plugins.Dotnet.Loader
if (File.Exists(pdbPath))
{
using var pdbFile = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return LoadFromStream(file, pdbFile);
return LoadContext.LoadFromStream(file, pdbFile);
}
return LoadFromStream(file);
return LoadContext.LoadFromStream(file);
}

View File

@@ -120,5 +120,12 @@ namespace BTCPayServer.Plugins.Dotnet
/// Default value is 200 milliseconds.
/// </summary>
public TimeSpan ReloadDelay { get; set; } = TimeSpan.FromMilliseconds(200);
/// <summary>
/// This will load assemblies into the default load context.
/// This is used for integration tests. Tests run in the default load context, so we do
/// not want type mismatch errors due to loading types in different load contexts.
/// </summary>
public bool LoadAssembliesInDefaultLoadContext { get; set; }
}
}

View File

@@ -344,6 +344,10 @@ namespace BTCPayServer.Plugins.Dotnet
builder.SetMainAssemblyPath(config.MainAssemblyPath);
builder.SetDefaultContext(config.DefaultContext);
if (config.LoadAssembliesInDefaultLoadContext)
{
builder.LoadAssembliesInDefaultLoadContext();
}
foreach (var ext in config.PrivateAssemblies)
{

View File

@@ -187,6 +187,7 @@ namespace BTCPayServer.Plugins
// this ensures that the version of MVC is shared between this app and the plugin
c.PreferSharedTypes = true;
c.IsUnloadable = false;
c.LoadAssembliesInDefaultLoadContext = config.GetOrDefault<bool>("TEST_RUNNER_ENABLED", false);
});
var pluginAssembly = loader.LoadDefaultAssembly();