Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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", "net8.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;
}
}