Skip to content
Merged
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 src/Blake.BuildTools/Blake.BuildTools.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<!-- Set InternalVisibleTo to allow access to internal members for testing -->
<ItemGroup>
<InternalsVisibleTo Include="Blake.CLI" />
<InternalsVisibleTo Include="Blake.BuildTools.Tests" />
</ItemGroup>

</Project>
41 changes: 41 additions & 0 deletions src/Blake.BuildTools/Utils/PluginLoadContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Reflection;
using System.Runtime.Loader;

namespace Blake.BuildTools.Utils;

/// <summary>
/// A custom AssemblyLoadContext that provides isolated plugin loading with dependency resolution.
/// Each plugin gets its own load context to avoid dependency conflicts.
/// </summary>
internal class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;

public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}

protected override Assembly? Load(AssemblyName assemblyName)
{
string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath != null)
{
return LoadFromAssemblyPath(assemblyPath);
}

// Return null to fall back to default load context for shared dependencies
return null;
}

protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (libraryPath != null)
{
return LoadUnmanagedDllFromPath(libraryPath);
}

return IntPtr.Zero;
}
}
9 changes: 7 additions & 2 deletions src/Blake.BuildTools/Utils/PluginLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,11 @@ private static void LoadPluginDLLs(List<string> files, List<PluginContext> plugi

try
{
var assembly = Assembly.LoadFrom(file);
// Create a new load context for this plugin to resolve its dependencies
var loadContext = new PluginLoadContext(file);
var assemblyName = new AssemblyName(Path.GetFileNameWithoutExtension(file));
var assembly = loadContext.LoadFromAssemblyName(assemblyName);

var pluginTypes = assembly.GetTypes()
.Where(t => typeof(IBlakePlugin).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);

Expand All @@ -199,7 +203,8 @@ private static void LoadPluginDLLs(List<string> files, List<PluginContext> plugi
}
catch (Exception ex)
{
logger?.LogError("Error loading plugin from {file}", file, ex);
logger?.LogError("Error loading plugin from {file}: {message}", file, ex.Message);
logger?.LogDebug(ex, "Full error details for plugin {file}", file);
}
}
}
Expand Down
70 changes: 70 additions & 0 deletions tests/Blake.BuildTools.Tests/Utils/PluginLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Blake.BuildTools.Utils;
using Microsoft.Extensions.Logging;
using Xunit;

namespace Blake.BuildTools.Tests.Utils;

public class PluginLoaderTests
{
[Fact]
public void LoadPluginDLLs_WithPluginWithDependencies_LoadsSuccessfully()
{
// Arrange
var logger = new TestLogger();
var pluginPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"..", "..", "..", "..", "..", "tests", "Blake.IntegrationTests",
"TestPluginWithDependencies", "bin", "Debug", "net9.0",
"BlakePlugin.TestPluginWithDependencies.dll"
));

// Skip test if plugin doesn't exist (build not run)
if (!File.Exists(pluginPath))
{
Assert.True(true, "Plugin not built - skipping test");
return;
}

var files = new List<string> { pluginPath };
var plugins = new List<PluginContext>();

// Act & Assert - should not throw exception
var exception = Record.Exception(() =>
{
// Use reflection to call the private method
var method = typeof(PluginLoader).GetMethod("LoadPluginDLLs",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method?.Invoke(null, new object[] { files, plugins, logger });
});

// Assert
Assert.Null(exception);
Assert.Single(plugins);
Assert.Equal("BlakePlugin.TestPluginWithDependencies", plugins[0].PluginName);

// Ensure no errors were logged
Assert.Empty(logger.ErrorMessages);
}

private class TestLogger : ILogger
{
public List<string> ErrorMessages { get; } = new List<string>();
public List<string> InfoMessages { get; } = new List<string>();

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var message = formatter(state, exception);
if (logLevel == LogLevel.Error)
{
ErrorMessages.Add(message);
}
else if (logLevel == LogLevel.Information)
{
InfoMessages.Add(message);
}
}
}
}
64 changes: 64 additions & 0 deletions tests/Blake.IntegrationTests/Commands/BlakePluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,70 @@ public async Task BlakeBake_WithoutProjectFile_SkipsPluginLoading()
Assert.DoesNotContain("plugin", result.ErrorText, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task BlakeBake_WithPluginWithDependencies_LoadsAndExecutesCorrectly()
{
// Arrange
var testDir = CreateTempDirectory("blake-plugins-with-deps");
var projectName = "PluginWithDepsTest";

FileSystemHelper.CreateMinimalBlazorWasmProject(testDir, projectName);

FileSystemHelper.CreateMarkdownFile(
Path.Combine(testDir, "Posts", "test.md"),
"Test Post",
"Content"
);

FileSystemHelper.CreateRazorTemplate(
Path.Combine(testDir, "Posts", "template.razor"),
@"@page ""/posts/{Slug}""
<h1>@Model.Title</h1>
<div>@((MarkupString)Html)</div>"
);

// Build the test plugin with dependencies first
var pluginWithDepsPath = Path.Combine(GetCurrentDirectory(), "tests", "Blake.IntegrationTests", "TestPluginWithDependencies");
var pluginBuildResult = await RunProcessAsync("dotnet", "build", pluginWithDepsPath);
Assert.Equal(0, pluginBuildResult.ExitCode);

// Add plugin project reference to the test project
var csprojPath = Path.Combine(testDir, $"{projectName}.csproj");
var csprojContent = File.ReadAllText(csprojPath);

var pluginProjectPath = Path.Combine(pluginWithDepsPath, "BlakePlugin.TestPluginWithDependencies.csproj");
var relativePath = Path.GetRelativePath(testDir, pluginProjectPath);

var updatedCsproj = csprojContent.Replace("</Project>",
$@" <ItemGroup>
<ProjectReference Include=""{relativePath}"" />
</ItemGroup>

</Project>");
File.WriteAllText(csprojPath, updatedCsproj);

// Act
var result = await RunBlakeCommandAsync($"bake \"{testDir}\" --verbosity Debug");

// Assert
Assert.Equal(0, result.ExitCode);

// Plugin should have created marker files with JSON serialization
FileSystemHelper.AssertFileExists(Path.Combine(testDir, ".plugin-with-deps-before-bake.txt"));
FileSystemHelper.AssertFileExists(Path.Combine(testDir, ".plugin-with-deps-after-bake.txt"));

// Verify the JSON content was written correctly (meaning dependencies loaded)
var beforeContent = File.ReadAllText(Path.Combine(testDir, ".plugin-with-deps-before-bake.txt"));
var afterContent = File.ReadAllText(Path.Combine(testDir, ".plugin-with-deps-after-bake.txt"));

Assert.Contains("Plugin with dependencies loaded successfully", beforeContent);
Assert.Contains("Plugin dependencies working in AfterBakeAsync", afterContent);

// Should show plugin logs
Assert.Contains("TestPluginWithDependencies: BeforeBakeAsync called", result.OutputText);
Assert.Contains("TestPluginWithDependencies: AfterBakeAsync called", result.OutputText);
}

private static string GetCurrentDirectory()
{
var currentDir = Directory.GetCurrentDirectory();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\Blake.BuildTools\Blake.BuildTools.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Blake.BuildTools;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace BlakePlugin.TestPluginWithDependencies;

public class TestPluginWithDependencies : IBlakePlugin
{
public Task BeforeBakeAsync(BlakeContext context, ILogger? logger = null)
{
logger?.LogInformation("TestPluginWithDependencies: BeforeBakeAsync called");

// Use Newtonsoft.Json to test dependency loading
var testObject = new { Message = "Plugin with dependencies loaded successfully", PageCount = context.MarkdownPages.Count };
var serialized = JsonConvert.SerializeObject(testObject);

logger?.LogInformation("TestPluginWithDependencies: Serialized data: {SerializedData}", serialized);

// Create a marker file to prove the plugin ran with dependencies
var testFilePath = Path.Combine(context.ProjectPath, ".plugin-with-deps-before-bake.txt");
File.WriteAllText(testFilePath, serialized);

return Task.CompletedTask;
}

public Task AfterBakeAsync(BlakeContext context, ILogger? logger = null)
{
logger?.LogInformation("TestPluginWithDependencies: AfterBakeAsync called with {PageCount} generated pages", context.GeneratedPages.Count);

// Use Newtonsoft.Json again to ensure dependency is still available
var testObject = new { Message = "Plugin dependencies working in AfterBakeAsync", GeneratedPageCount = context.GeneratedPages.Count };
var serialized = JsonConvert.SerializeObject(testObject);

var testFilePath = Path.Combine(context.ProjectPath, ".plugin-with-deps-after-bake.txt");
File.WriteAllText(testFilePath, serialized);

return Task.CompletedTask;
}
}