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

Make sure that single-file apps can find assemblies that contains sinks #353

Merged
merged 13 commits into from
May 5, 2023
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
5 changes: 4 additions & 1 deletion Build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ if($LASTEXITCODE -ne 0) { exit 2 }

Write-Output "build: Testing"

& dotnet test test\Serilog.Settings.Configuration.Tests --configuration Release --no-build --no-restore
# Dotnet test doesn't run separate TargetFrameworks in parallel: https://github.com/dotnet/sdk/issues/19147
# Workaround: use `dotnet test` on dlls directly in order to pass the `--parallel` option to vstest.
# The _reported_ runtime is wrong but the _actual_ used runtime is correct, see https://github.com/microsoft/vstest/issues/2037#issuecomment-720549173
& dotnet test test\Serilog.Settings.Configuration.Tests\bin\Release\*\Serilog.Settings.Configuration.Tests.dll --parallel

if($LASTEXITCODE -ne 0) { exit 3 }
5 changes: 5 additions & 0 deletions serilog-settings-configuration.sln
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample", "sample\Sample\Sam
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestDummies", "test\TestDummies\TestDummies.csproj", "{B7CF5068-DD19-4868-A268-5280BDE90361}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApp", "test\TestApp\TestApp.csproj", "{1B6E08F3-16C9-4912-BEEE-57DB78C92A12}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -54,6 +56,8 @@ Global
{B7CF5068-DD19-4868-A268-5280BDE90361}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7CF5068-DD19-4868-A268-5280BDE90361}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7CF5068-DD19-4868-A268-5280BDE90361}.Release|Any CPU.Build.0 = Release|Any CPU
{1B6E08F3-16C9-4912-BEEE-57DB78C92A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B6E08F3-16C9-4912-BEEE-57DB78C92A12}.Release|Any CPU.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -63,6 +67,7 @@ Global
{F793C6E8-C40A-4018-8884-C97E2BE38A54} = {D551DCB0-7771-4D01-BEBD-F7B57D1CF0E3}
{A00E5E32-54F9-401A-BBA1-2F6FCB6366CD} = {D24872B9-57F3-42A7-BC8D-F9DA222FCE1B}
{B7CF5068-DD19-4868-A268-5280BDE90361} = {D551DCB0-7771-4D01-BEBD-F7B57D1CF0E3}
{1B6E08F3-16C9-4912-BEEE-57DB78C92A12} = {D551DCB0-7771-4D01-BEBD-F7B57D1CF0E3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {485F8843-42D7-4267-B5FB-20FE9181DEE9}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,7 @@ protected static bool IsCaseInsensitiveMatch(string? text, string textToFind)

public static AssemblyFinder Auto()
{
try
{
// Need to check `Assembly.GetEntryAssembly()` first because
// `DependencyContext.Default` throws an exception when `Assembly.GetEntryAssembly()` returns null
if (Assembly.GetEntryAssembly() != null && DependencyContext.Default != null)
{
return new DependencyContextAssemblyFinder(DependencyContext.Default);
}
}
catch (NotSupportedException) when (typeof(object).Assembly.Location is "") // bundled mode detection
{
}

return new DllScanningAssemblyFinder();
return new CompositeAssemblyFinder(new DependencyContextAssemblyFinder(DependencyContext.Default), new DllScanningAssemblyFinder());
}

public static AssemblyFinder ForSource(ConfigurationAssemblySource configurationAssemblySource)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Reflection;

namespace Serilog.Settings.Configuration.Assemblies;

class CompositeAssemblyFinder : AssemblyFinder
{
readonly AssemblyFinder[] _assemblyFinders;

public CompositeAssemblyFinder(params AssemblyFinder[] assemblyFinders)
{
_assemblyFinders = assemblyFinders;
}

public override IReadOnlyList<AssemblyName> FindAssembliesContainingName(string nameToFind)
{
var assemblyNames = new List<AssemblyName>();
foreach (var assemblyFinder in _assemblyFinders)
{
assemblyNames.AddRange(assemblyFinder.FindAssembliesContainingName(nameToFind));
}
return assemblyNames;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@ namespace Serilog.Settings.Configuration.Assemblies;

sealed class DependencyContextAssemblyFinder : AssemblyFinder
{
readonly DependencyContext _dependencyContext;
readonly DependencyContext? _dependencyContext;

public DependencyContextAssemblyFinder(DependencyContext dependencyContext)
public DependencyContextAssemblyFinder(DependencyContext? dependencyContext)
{
_dependencyContext = dependencyContext ?? throw new ArgumentNullException(nameof(dependencyContext));
_dependencyContext = dependencyContext;
}

public override IReadOnlyList<AssemblyName> FindAssembliesContainingName(string nameToFind)
{
if (_dependencyContext == null)
return Array.Empty<AssemblyName>();

var query = from library in _dependencyContext.RuntimeLibraries
where IsReferencingSerilog(library)
from assemblyName in library.GetDefaultAssemblyNames(_dependencyContext)
where IsCaseInsensitiveMatch(assemblyName.Name, nameToFind)
select assemblyName;

return query.ToList().AsReadOnly();
return query.ToList();

static bool IsReferencingSerilog(Library library)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ where IsCaseInsensitiveMatch(assemblyFileName, nameToFind)
where assemblyName != null
select assemblyName;

return query.ToList().AsReadOnly();
return query.ToList();

static AssemblyName? TryGetAssemblyNameFrom(string path)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ static IReadOnlyCollection<Assembly> LoadConfigurationAssemblies(IConfiguration
{
if (string.IsNullOrWhiteSpace(simpleName))
throw new InvalidOperationException(
"A zero-length or whitespace assembly name was supplied to a Serilog.Using configuration statement.");
$"A zero-length or whitespace assembly name was supplied to a {usingSection.Path} configuration statement.");

var assembly = Assembly.Load(new AssemblyName(simpleName));
if (!assemblies.ContainsKey(assembly.FullName!))
Expand All @@ -384,7 +384,19 @@ static IReadOnlyCollection<Assembly> LoadConfigurationAssemblies(IConfiguration
assemblies.Add(assumed.FullName!, assumed);
}

return assemblies.Values.ToList().AsReadOnly();
if (assemblies.Count == 1)
{
var message = $"""
No {usingSection.Path} configuration section is defined and no Serilog assemblies were found.
This is most likely because the application is published as single-file.
Either add a {usingSection.Path} section or explicitly specify assemblies that contains sinks and other types through the reader options. For example:
var options = new ConfigurationReaderOptions(typeof(ConsoleLoggerConfigurationExtensions).Assembly, typeof(SerilogExpression).Assembly);
new LoggerConfiguration().ReadFrom.Configuration(configuration, options);
""";
throw new InvalidOperationException(message);
}

return assemblies.Values;
}

void CallConfigurationMethods(ILookup<string, Dictionary<string, IConfigurationArgumentValue>> methods, IReadOnlyCollection<MethodInfo> configurationMethods, object receiver)
Expand Down
158 changes: 158 additions & 0 deletions test/Serilog.Settings.Configuration.Tests/PublishSingleFileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.Diagnostics;
using System.Text;
using CliWrap;
using CliWrap.Exceptions;
using FluentAssertions;
using FluentAssertions.Execution;
using Serilog.Settings.Configuration.Tests.Support;
using Xunit.Abstractions;

namespace Serilog.Settings.Configuration.Tests;

[Trait("Category", "Integration")]
public sealed class PublishSingleFileTests : IDisposable, IClassFixture<TestApp>
{
readonly ITestOutputHelper _outputHelper;
readonly TestApp _testApp;
readonly AssertionScope _scope;

public PublishSingleFileTests(ITestOutputHelper outputHelper, TestApp testApp)
{
_outputHelper = outputHelper;
_testApp = testApp;
_scope = new AssertionScope();
}

public void Dispose()
{
_scope.Dispose();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_NoUsingAndNoAssembly(PublishMode publishMode)
{
var (isSingleFile, stdOut, stdErr) = await RunTestAppAsync(publishMode);
stdOut.Should().Be(isSingleFile ? "Expected exception" : "(Main thread) [Information] Expected success");
stdErr.Should().BeEmpty();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_UsingConsole(PublishMode publishMode)
{
var (isSingleFile, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--using-console");
stdOut.Should().Be(isSingleFile ? "() [Information] Expected success" : "(Main thread) [Information] Expected success");
if (isSingleFile)
stdErr.Should().Contain("Unable to find a method called WithThreadName");
else
stdErr.Should().BeEmpty();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_UsingThread(PublishMode publishMode)
{
var (isSingleFile, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--using-thread");
stdOut.Should().Be(isSingleFile ? "" : "(Main thread) [Information] Expected success");
if (isSingleFile)
stdErr.Should().Contain("Unable to find a method called Console");
else
stdErr.Should().BeEmpty();
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_AssemblyThread(PublishMode publishMode)
{
var (_, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--assembly-thread");
stdOut.Should().BeEmpty();
stdErr.Should().Contain("Unable to find a method called Console");
}

[Theory]
[ClassData(typeof(PublishModeTheoryData))]
public async Task RunTestApp_AssemblyConsole(PublishMode publishMode)
{
var (_, stdOut, stdErr) = await RunTestAppAsync(publishMode, "--assembly-console");
stdOut.Should().Be("() [Information] Expected success");
stdErr.Should().Contain("Unable to find a method called WithThreadName");
}

[Theory]
[ClassData(typeof(PublishModeAndStrategyTheoryData))]
public async Task RunTestApp_ConsoleAndThread(PublishMode publishMode, string strategy)
{
var (_, stdOut, stdErr) = await RunTestAppAsync(publishMode, $"--{strategy}-console", $"--{strategy}-thread");
stdOut.Should().Be("(Main thread) [Information] Expected success");
stdErr.Should().BeEmpty();
}

async Task<(bool IsSingleFile, string StdOut, string StdErr)> RunTestAppAsync(PublishMode publishMode, params string[] args)
{
// Determine whether the app is a _true_ single file, i.e. not a .NET Core 3.x version which
// [extracts bundled files to disk][1] and thus can find dlls.
// [1]: https://github.com/dotnet/designs/blob/main/accepted/2020/single-file/extract.md
var (isSingleFile, _) = await RunTestAppInternalAsync(publishMode, "is-single-file");
var (stdOut, stdErr) = await RunTestAppInternalAsync(publishMode, args);
return (bool.Parse(isSingleFile), stdOut, stdErr);
}

async Task<(string StdOut, string StdErr)> RunTestAppInternalAsync(PublishMode publishMode, params string[] args)
{
var stdOutBuilder = new StringBuilder();
var stdErrBuilder = new StringBuilder();

var command = Cli.Wrap(_testApp.GetExecutablePath(publishMode))
.WithArguments(args)
.WithValidation(CommandResultValidation.None)
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuilder))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuilder));

_outputHelper.WriteLine(command.ToString());

var stopwatch = Stopwatch.StartNew();
var result = await command.ExecuteAsync();
var executionTime = stopwatch.ElapsedMilliseconds;

var stdOut = stdOutBuilder.ToString().Trim();
var stdErr = stdErrBuilder.ToString().Trim();

_outputHelper.WriteLine($"Executed in {executionTime} ms");
_outputHelper.WriteLine(stdOut.Length > 0 ? $"stdout: {stdOut}" : "nothing on stdout");
_outputHelper.WriteLine(stdErr.Length > 0 ? $"stderr: {stdErr}" : "nothing on stderr");
_outputHelper.WriteLine("");

if (result.ExitCode != 0)
{
throw new CommandExecutionException(command, result.ExitCode, $"An unexpected exception has occurred while running {command}{Environment.NewLine}{stdErr}".Trim());
}

return (stdOut, stdErr);
}

class PublishModeTheoryData : TheoryData<PublishMode>
{
public PublishModeTheoryData()
{
foreach (var publishMode in PublishModeExtensions.GetPublishModes())
{
Add(publishMode);
}
}
}

class PublishModeAndStrategyTheoryData : TheoryData<PublishMode, string>
{
public PublishModeAndStrategyTheoryData()
{
foreach (var publishMode in PublishModeExtensions.GetPublishModes())
{
foreach (var strategy in new[] { "using", "assembly" })
{
Add(publishMode, strategy);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks Condition=" '$(OS)' == 'Windows_NT'">net48</TargetFrameworks>
<TargetFrameworks>$(TargetFrameworks);net7.0</TargetFrameworks>
<TargetFrameworks>$(TargetFrameworks);net7.0;net6.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
Expand All @@ -17,8 +17,12 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="CliWrap" Version="3.6.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NuGet.Frameworks" Version="6.5.0" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Serilog.Expressions" Version="3.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageReference Include="xunit" Version="2.4.2" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Serilog.Settings.Configuration.Tests;

static class DirectoryInfoExtensions
{
public static DirectoryInfo SubDirectory(this DirectoryInfo directory, params string[] paths)
=> new(Path.GetFullPath(Path.Combine(paths.Prepend(directory.FullName).ToArray())));

public static FileInfo File(this DirectoryInfo directory, params string[] paths)
=> new(Path.GetFullPath(Path.Combine(paths.Prepend(directory.FullName).ToArray())));
}
25 changes: 25 additions & 0 deletions test/Serilog.Settings.Configuration.Tests/Support/PublishMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Serilog.Settings.Configuration.Tests.Support;

/// <summary>
/// The possible application publish modes for the TestApp.
/// See also the <a href="https://learn.microsoft.com/en-us/dotnet/core/deploying/">.NET application publishing overview</a> documentation.
/// </summary>
public enum PublishMode
{
/// <summary>
/// Standard app publish, all dlls and related files are copied along the main executable.
/// </summary>
Standard,

/// <summary>
/// Publish a single file as a framework-dependent binary.
/// </summary>
/// <remarks>On .NET Framework, <a href="https://github.com/Fody/Costura">Costura</a> is used to publish as a single file.</remarks>
SingleFile,

/// <summary>
/// Publish a single file as a self contained binary, i.e. including the .NET libraries and target runtime.
/// </summary>
/// <remarks>This mode is ignored on .NET Framework as it doesn't make sense.</remarks>
SelfContained,
}
Loading