diff --git a/.docfx/Dockerfile.docfx b/.docfx/Dockerfile.docfx index 2229c73..7d97dde 100644 --- a/.docfx/Dockerfile.docfx +++ b/.docfx/Dockerfile.docfx @@ -1,4 +1,4 @@ -ARG NGINX_VERSION=1.31.2-alpine +ARG NGINX_VERSION=1.31-alpine FROM --platform=$BUILDPLATFORM nginx:${NGINX_VERSION} AS base RUN rm -rf /usr/share/nginx/html/* diff --git a/.docfx/api/types/Codebelt.Bootstrapper.BootstrapperLogMessages.md b/.docfx/api/types/Codebelt.Bootstrapper.BootstrapperLogMessages.md index a4695eb..2e15dbc 100644 --- a/.docfx/api/types/Codebelt.Bootstrapper.BootstrapperLogMessages.md +++ b/.docfx/api/types/Codebelt.Bootstrapper.BootstrapperLogMessages.md @@ -5,6 +5,9 @@ example: --- The static `BootstrapperLogMessages` class is the host's source of truth for the four lifecycle events that `ConsoleHostedService` and `MinimalConsoleHostedService` log through `Decorator.EncloseToExpose(logger, false)`: `RunAsyncStarted` when the run loop begins, `RunAsyncPrematureEnd` when the host stops before the run finishes, `RunAsyncCompleted` when the run finishes cleanly, and `FatalErrorActivating` when the run loop's `catch` block sees an exception. The companion `UnableToActivateInstance` extension surfaces a warning when the bootstrapper could not resolve a startup or program type. To use them from your own `ConsoleStartup` or `MinimalConsoleProgram`, build an `ILogger` through your normal `ServiceCollection` configuration and wrap it with `Decorator.EncloseToExpose(logger, false)`; the resulting `IDecorator` exposes all five extensions through receiver-style calls. The example below wires a console logger, demonstrates the run-loop happy path, exercises the failure path, and shows the startup-resolution warning so every extension in this class is invoked in a single coherent workflow. +> [!NOTE] +> The `BootstrapperLogMessages` class is not intended to be used directly. That is why it's hidden behind the `Decorator.EncloseToExpose(logger, false)` call. The extensions are intended to be used through the `IDecorator` interface and only internal to the `ConsoleHostedService` and `MinimalConsoleHostedService` classes. + ```csharp using System; using Codebelt.Bootstrapper; diff --git a/.docfx/api/types/Codebelt.Bootstrapper.CommandLineContext.md b/.docfx/api/types/Codebelt.Bootstrapper.CommandLineContext.md new file mode 100644 index 0000000..0fa46b1 --- /dev/null +++ b/.docfx/api/types/Codebelt.Bootstrapper.CommandLineContext.md @@ -0,0 +1,28 @@ +--- +uid: Codebelt.Bootstrapper.CommandLineContext +example: +- *content +--- +The following example shows how to wrap the `args` array once at the start of `Main`, then let the rest of the bootstrapper flow inspect `CommandLineContext.Args` for switches without passing the raw array around. The outcome changes when the `--debug` switch is present, so the captured arguments directly control the startup mode. + +```csharp +using System; +using System.Linq; +using Codebelt.Bootstrapper; + +namespace CommandLineContextDemo; + +public static class Program +{ + public static void Main(string[] args) + { + var context = new CommandLineContext(args); + + var mode = context.Args.Contains("--debug", StringComparer.OrdinalIgnoreCase) + ? "Debug bootstrap enabled." + : "Standard bootstrap enabled."; + + Console.WriteLine(mode); + } +} +``` diff --git a/.nuget/Codebelt.Bootstrapper.Console/PackageReleaseNotes.txt b/.nuget/Codebelt.Bootstrapper.Console/PackageReleaseNotes.txt index 7f18143..0333635 100644 --- a/.nuget/Codebelt.Bootstrapper.Console/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Bootstrapper.Console/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.2.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +# Improvements +- REFACTORED ConsoleHostedService and MinimalConsoleHostedService to extract nested callback logic into focused private methods for improved code clarity, testability, and maintainability + Version: 5.1.2 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Codebelt.Bootstrapper.Web/PackageReleaseNotes.txt b/.nuget/Codebelt.Bootstrapper.Web/PackageReleaseNotes.txt index cb589f6..cffdb57 100644 --- a/.nuget/Codebelt.Bootstrapper.Web/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Bootstrapper.Web/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.2.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.1.2 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Codebelt.Bootstrapper.Worker/PackageReleaseNotes.txt b/.nuget/Codebelt.Bootstrapper.Worker/PackageReleaseNotes.txt index b9d0178..bb8554a 100644 --- a/.nuget/Codebelt.Bootstrapper.Worker/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Bootstrapper.Worker/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.2.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.1.2 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt b/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt index d9b48de..cf1cec4 100644 --- a/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.2.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +# New Features +- ADDED CommandLineContext class in the Codebelt.Bootstrapper namespace to encapsulate command-line arguments for convenient access + Version: 5.1.2 Availability: .NET 10 and .NET 9 diff --git a/CHANGELOG.md b/CHANGELOG.md index b418722..1140f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), For more details, please refer to `PackageReleaseNotes.txt` on a per assembly basis in the `.nuget` folder. +## [5.2.0] - 2026-07-27 + +This is a minor release introducing command-line context infrastructure, comprehensive test coverage for console hosted services, dependency updates, and build configuration hardening. + +### Added + +- CommandLineContext class in the Codebelt.Bootstrapper namespace to encapsulate command-line arguments for convenient access, +- Comprehensive unit test coverage for CommandLineContext, ConsoleHostedService, and MinimalConsoleHostedService to ensure robust argument handling and service lifecycle management. + +### Changed + +- All NuGet dependencies upgraded to latest stable releases: Codebelt.Extensions.Swashbuckle.AspNetCore to 10.2.4, Codebelt.Extensions.Xunit.App to 11.1.2, Cuemon.Core and Cuemon.Extensions.Hosting to 10.5.5, Microsoft.NET.Test.Sdk to 18.8.1, and all Microsoft.AspNetCore and Microsoft.Extensions packages for net9 (9.0.18) and net10 (10.0.10) to latest patch releases, +- Build analyzer configuration enhanced with warning suppressions (7035, CA2260, S6618), code style enforcement enabled in build process, and MinVer tag prefix configured to 'v', +- ConsoleHostedService and MinimalConsoleHostedService refactored to extract nested callback logic into focused private methods (StartRunAsync, RunAsync, ResolveLogger, LogRunAsyncStarted, LogUnableToActivate, LogFatalError) for improved code clarity, testability, and maintainability, +- DocFX nginx base image adjusted to 1.31-alpine to use stable release channel for documentation build infrastructure. + ## [5.1.2] - 2026-07-01 This is a patch release focused on dependency upgrades for security and stability, comprehensive API documentation enhancements, and CI pipeline robustness improvements. @@ -263,7 +279,7 @@ Highlighted features included in this release: - WorkerProgram class in the Codebelt.Bootstrapper.Worker namespace that is the base entry point of an application responsible for registering its WorkerStartup partner - WorkerStartup interface in the Codebelt.Bootstrapper.Worker namespace that provides the base class of a conventional based Startup class for a console application -[Unreleased]: https://github.com/codebeltnet/bootstrapper/compare/v5.1.2...HEAD +[5.2.0]: https://github.com/codebeltnet/bootstrapper/compare/v5.1.2...v5.2.0 [5.1.2]: https://github.com/codebeltnet/bootstrapper/compare/v5.1.1...v5.1.2 [5.1.1]: https://github.com/codebeltnet/bootstrapper/compare/v5.1.0...v5.1.1 [5.1.0]: https://github.com/codebeltnet/bootstrapper/compare/v5.0.7...v5.1.0 diff --git a/Codebelt.Bootstrapper.slnx b/Codebelt.Bootstrapper.slnx index e192db3..7e449de 100644 --- a/Codebelt.Bootstrapper.slnx +++ b/Codebelt.Bootstrapper.slnx @@ -20,6 +20,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 7f5eecd..2e56632 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -36,6 +36,9 @@ true latest Recommended + 7035,CA2260,S6618 + v + true @@ -57,7 +60,7 @@ false true 0 - none + none NU1701,NETSDK1206 false true @@ -71,7 +74,7 @@ false true 0 - none + none NU1701,NETSDK1206 false diff --git a/Directory.Packages.props b/Directory.Packages.props index c2beb68..4403cdf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,11 @@ true - - - - - + + + + + @@ -16,17 +16,17 @@ - - - - - + + + + + - - - - - + + + + + \ No newline at end of file diff --git a/src/Codebelt.Bootstrapper.Console/ConsoleHostedService.cs b/src/Codebelt.Bootstrapper.Console/ConsoleHostedService.cs index a52acf7..277c3d3 100644 --- a/src/Codebelt.Bootstrapper.Console/ConsoleHostedService.cs +++ b/src/Codebelt.Bootstrapper.Console/ConsoleHostedService.cs @@ -49,37 +49,55 @@ public ConsoleHostedService(IStartupFactory factory, IHostApplicationL public Task StartAsync(CancellationToken cancellationToken) { _logger = _provider.GetRequiredService>(); - var startup = _factory.Instance; + TStartup startup = _factory.Instance; if (startup != null) { startup.ConfigureConsole(_provider); - _events.OnApplicationStartedCallback += () => - { - _runAsyncTask = Task.Run(async () => - { - try - { - if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).RunAsyncStarted(); } - await startup.RunAsync(_provider, cancellationToken).ConfigureAwait(false); - _ranToCompletion = true; - } - catch (Exception e) - { - if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).FatalErrorActivating(typeof(TStartup).FullName, e); } - } - }, cancellationToken); - - StartWaitForCompletionOfRunAsync().ConfigureAwait(false); - }; + _events.OnApplicationStartedCallback += () => StartRunAsync(startup, cancellationToken); } else { - if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).UnableToActivateInstance(typeof(TStartup).FullName); } + LogUnableToActivate(typeof(TStartup).FullName); } return Task.CompletedTask; } + private void StartRunAsync(TStartup startup, CancellationToken cancellationToken) + { + _runAsyncTask = Task.Run(() => RunAsync(startup, cancellationToken), cancellationToken); + _ = StartWaitForCompletionOfRunAsync(); + } + + private async Task RunAsync(TStartup startup, CancellationToken cancellationToken) + { + try + { + LogRunAsyncStarted(); + await startup.RunAsync(_provider, cancellationToken).ConfigureAwait(false); + _ranToCompletion = true; + } + catch (Exception e) + { + LogFatalError(typeof(TStartup).FullName, e); + } + } + + private void LogRunAsyncStarted() + { + if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).RunAsyncStarted(); } + } + + private void LogUnableToActivate(string typeFullName) + { + if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).UnableToActivateInstance(typeFullName); } + } + + private void LogFatalError(string typeFullName, Exception exception) + { + if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).FatalErrorActivating(typeFullName, exception); } + } + private async Task StartWaitForCompletionOfRunAsync() { await _runAsyncTask.ConfigureAwait(false); diff --git a/src/Codebelt.Bootstrapper.Console/MinimalConsoleHostedService.cs b/src/Codebelt.Bootstrapper.Console/MinimalConsoleHostedService.cs index 497cafd..aa42d6a 100644 --- a/src/Codebelt.Bootstrapper.Console/MinimalConsoleHostedService.cs +++ b/src/Codebelt.Bootstrapper.Console/MinimalConsoleHostedService.cs @@ -47,39 +47,60 @@ public MinimalConsoleHostedService(IProgramFactory factory, IHostApplicationLife /// A that represents the asynchronous operation. public Task StartAsync(CancellationToken cancellationToken) { - _events.OnApplicationStartedCallback += () => - { - var program = _factory.Instance; - var programType = program?.GetType() ?? typeof(MinimalConsoleProgram); - var loggerType = typeof(ILogger<>).MakeGenericType(programType); + _events.OnApplicationStartedCallback += () => StartRunAsync(cancellationToken); + + return Task.CompletedTask; + } + + private void StartRunAsync(CancellationToken cancellationToken) + { + MinimalConsoleProgram program = _factory.Instance; + Type programType = program?.GetType() ?? typeof(MinimalConsoleProgram); - _logger = _provider.GetRequiredService(loggerType) as ILogger; + _logger = ResolveLogger(programType); + _runAsyncTask = Task.Run(() => RunAsync(program, programType, cancellationToken), cancellationToken); + _ = StartWaitForCompletionOfRunAsync(); + } + + private ILogger ResolveLogger(Type programType) + { + Type loggerType = typeof(ILogger<>).MakeGenericType(programType); + return _provider.GetRequiredService(loggerType) as ILogger; + } - _runAsyncTask = Task.Run(async () => + private async Task RunAsync(MinimalConsoleProgram program, Type programType, CancellationToken cancellationToken) + { + try + { + if (program == null) { - try - { - if (program != null) - { - if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).RunAsyncStarted(); } - await program.RunAsync(_provider, cancellationToken).ConfigureAwait(false); - _ranToCompletion = true; - } - else - { - if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).UnableToActivateInstance(programType.FullName); } - } - } - catch (Exception e) - { - if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).FatalErrorActivating(programType.FullName, e); } - } - }, cancellationToken); + LogUnableToActivate(programType.FullName); + return; + } - StartWaitForCompletionOfRunAsync().ConfigureAwait(false); - }; + LogRunAsyncStarted(); + await program.RunAsync(_provider, cancellationToken).ConfigureAwait(false); + _ranToCompletion = true; + } + catch (Exception e) + { + LogFatalError(programType.FullName, e); + } + } - return Task.CompletedTask; + private void LogRunAsyncStarted() + { + if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).RunAsyncStarted(); } + } + + private void LogUnableToActivate(string typeFullName) + { + if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).UnableToActivateInstance(typeFullName); } + } + + private void LogFatalError(string typeFullName, Exception exception) + { + if (!_suppressStatusMessages) { Decorator.EncloseToExpose(_logger, false).FatalErrorActivating(typeFullName, exception); } } private async Task StartWaitForCompletionOfRunAsync() diff --git a/src/Codebelt.Bootstrapper/CommandLineContext.cs b/src/Codebelt.Bootstrapper/CommandLineContext.cs new file mode 100644 index 0000000..52057d0 --- /dev/null +++ b/src/Codebelt.Bootstrapper/CommandLineContext.cs @@ -0,0 +1,29 @@ +namespace Codebelt.Bootstrapper; + +/// +/// Represents the command-line arguments available to a bootstrapper. +/// +public class CommandLineContext +{ + /// + /// Initializes a new instance of the class + /// with the specified command-line arguments. + /// + /// + /// The command-line arguments to expose through . + /// If , is initialized to an empty array. + /// + public CommandLineContext(string[] args) + { + Args = args ?? []; + } + + /// + /// Gets the command-line arguments associated with this context. + /// + /// + /// The array supplied to the constructor, or an empty array when the supplied value was + /// . The supplied array is retained without creating a copy. + /// + public string[] Args { get; } +} diff --git a/test/Codebelt.Bootstrapper.Console.Tests/Assets/HostedServiceTestDoubles.cs b/test/Codebelt.Bootstrapper.Console.Tests/Assets/HostedServiceTestDoubles.cs new file mode 100644 index 0000000..4fadaa2 --- /dev/null +++ b/test/Codebelt.Bootstrapper.Console.Tests/Assets/HostedServiceTestDoubles.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Codebelt.Bootstrapper.Console; + +internal sealed class FakeHostApplicationLifetime : IHostApplicationLifetime +{ + private readonly CancellationTokenSource _started = new(); + private readonly CancellationTokenSource _stopping = new(); + private readonly CancellationTokenSource _stopped = new(); + private readonly TaskCompletionSource _stopRequested = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public FakeHostApplicationLifetime() + { + _started.Cancel(); + } + + public CancellationToken ApplicationStarted => _started.Token; + + public CancellationToken ApplicationStopping => _stopping.Token; + + public CancellationToken ApplicationStopped => _stopped.Token; + + public bool StopApplicationWasCalled { get; private set; } + + public Task WaitForStopAsync() => _stopRequested.Task; + + public void StopApplication() + { + StopApplicationWasCalled = true; + _stopping.Cancel(); + _stopped.Cancel(); + _stopRequested.TrySetResult(true); + } +} + +internal sealed class FakeHostLifetimeEvents : IHostLifetimeEvents +{ + private Action _onApplicationStartedCallback = static () => { }; + private Action _onApplicationStoppingCallback = static () => { }; + private Action _onApplicationStoppedCallback = static () => { }; + + public bool OnApplicationStartedCallbackAssigned { get; private set; } + + public Action OnApplicationStartedCallback + { + get => _onApplicationStartedCallback; + set + { + OnApplicationStartedCallbackAssigned = true; + _onApplicationStartedCallback = value; + } + } + + public Action OnApplicationStoppingCallback + { + get => _onApplicationStoppingCallback; + set => _onApplicationStoppingCallback = value; + } + + public Action OnApplicationStoppedCallback + { + get => _onApplicationStoppedCallback; + set => _onApplicationStoppedCallback = value; + } + + public void RaiseApplicationStarted() + { + _onApplicationStartedCallback(); + } +} + +internal sealed class StaticStartupFactory(TStartup? instance) : IStartupFactory where TStartup : StartupRoot +{ + public TStartup Instance => instance!; +} + +internal sealed class StaticProgramFactory(MinimalConsoleProgram? instance) : IProgramFactory +{ + public MinimalConsoleProgram Instance => instance!; +} + +internal sealed class FakeHostEnvironment : IHostEnvironment +{ + public string EnvironmentName { get; set; } = Environments.Development; + + public string ApplicationName { get; set; } = nameof(FakeHostEnvironment); + + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); +} + +internal static class HostedServiceTestFactory +{ + public static ServiceProvider CreateServiceProvider(Action? configureServices = null, Action? setup = null) + { + var services = new ServiceCollection(); + + if (setup is null) + { + services.Configure(_ => { }); + } + else + { + services.Configure(setup); + } + + configureServices?.Invoke(services); + return services.BuildServiceProvider(); + } +} + +internal sealed record TestLogEntry(LogLevel LogLevel, string Message); + +internal sealed class TestLogger : ILogger +{ + private readonly List _entries = []; + + public IReadOnlyList Entries => _entries; + + public IDisposable BeginScope(TState state) where TState : notnull + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + _entries.Add(new TestLogEntry(logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} diff --git a/test/Codebelt.Bootstrapper.Console.Tests/Codebelt.Bootstrapper.Console.Tests.csproj b/test/Codebelt.Bootstrapper.Console.Tests/Codebelt.Bootstrapper.Console.Tests.csproj new file mode 100644 index 0000000..a999d4b --- /dev/null +++ b/test/Codebelt.Bootstrapper.Console.Tests/Codebelt.Bootstrapper.Console.Tests.csproj @@ -0,0 +1,11 @@ + + + + Codebelt.Bootstrapper.Console + + + + + + + diff --git a/test/Codebelt.Bootstrapper.Console.Tests/ConsoleHostedServiceTest.cs b/test/Codebelt.Bootstrapper.Console.Tests/ConsoleHostedServiceTest.cs new file mode 100644 index 0000000..7081488 --- /dev/null +++ b/test/Codebelt.Bootstrapper.Console.Tests/ConsoleHostedServiceTest.cs @@ -0,0 +1,115 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Codebelt.Bootstrapper.Console; + +public class ConsoleHostedServiceTest : Test +{ + public ConsoleHostedServiceTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task StartAsync_ShouldConfigureStartupImmediately_AndRunAfterApplicationStarted() + { + var startup = new SpyConsoleStartup(); + var logger = new TestLogger(); + await using var provider = HostedServiceTestFactory.CreateServiceProvider(services => + { + services.AddSingleton>(logger); + }); + var applicationLifetime = new FakeHostApplicationLifetime(); + var events = new FakeHostLifetimeEvents(); + var sut = new ConsoleHostedService(new StaticStartupFactory(startup), applicationLifetime, provider, events); + + await sut.StartAsync(CancellationToken.None); + + Assert.True(startup.ConfigureConsoleWasCalled); + Assert.False(startup.RunAsyncWasCalled); + Assert.True(events.OnApplicationStartedCallbackAssigned); + + events.RaiseApplicationStarted(); + await applicationLifetime.WaitForStopAsync(); + await sut.StopAsync(CancellationToken.None); + + Assert.True(startup.RunAsyncWasCalled); + Assert.True(applicationLifetime.StopApplicationWasCalled); + Assert.Same(provider, startup.ConfigureConsoleServiceProvider); + Assert.Same(provider, startup.RunAsyncServiceProvider); + + Assert.Collection(logger.Entries, + entry => + { + Assert.Equal(LogLevel.Information, entry.LogLevel); + Assert.Equal("RunAsync started.", entry.Message); + }, + entry => + { + Assert.Equal(LogLevel.Information, entry.LogLevel); + Assert.Equal("RunAsync completed successfully.", entry.Message); + }); + } + + [Fact] + public async Task StartAsync_ShouldLogUnableToActivate_WhenStartupFactoryReturnsNull() + { + var logger = new TestLogger(); + await using var provider = HostedServiceTestFactory.CreateServiceProvider(services => + { + services.AddSingleton>(logger); + }); + var applicationLifetime = new FakeHostApplicationLifetime(); + var events = new FakeHostLifetimeEvents(); + var sut = new ConsoleHostedService(new StaticStartupFactory(null), applicationLifetime, provider, events); + + await sut.StartAsync(CancellationToken.None); + + Assert.False(events.OnApplicationStartedCallbackAssigned); + Assert.False(applicationLifetime.StopApplicationWasCalled); + + Assert.Collection(logger.Entries, + entry => + { + Assert.Equal(LogLevel.Warning, entry.LogLevel); + Assert.Equal($"Unable to activate an instance of {typeof(SpyConsoleStartup).FullName}.", entry.Message); + }); + } + + private sealed class SpyConsoleStartup : ConsoleStartup + { + public SpyConsoleStartup() : base(new ConfigurationBuilder().Build(), new FakeHostEnvironment()) + { + } + + public bool ConfigureConsoleWasCalled { get; private set; } + + public IServiceProvider? ConfigureConsoleServiceProvider { get; private set; } + + public bool RunAsyncWasCalled { get; private set; } + + public IServiceProvider? RunAsyncServiceProvider { get; private set; } + + public override void ConfigureServices(IServiceCollection services) + { + } + + public override void ConfigureConsole(IServiceProvider serviceProvider) + { + ConfigureConsoleWasCalled = true; + ConfigureConsoleServiceProvider = serviceProvider; + } + + public override Task RunAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + RunAsyncWasCalled = true; + RunAsyncServiceProvider = serviceProvider; + return Task.CompletedTask; + } + } +} diff --git a/test/Codebelt.Bootstrapper.Console.Tests/MinimalConsoleHostedServiceTest.cs b/test/Codebelt.Bootstrapper.Console.Tests/MinimalConsoleHostedServiceTest.cs new file mode 100644 index 0000000..53c43d7 --- /dev/null +++ b/test/Codebelt.Bootstrapper.Console.Tests/MinimalConsoleHostedServiceTest.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Codebelt.Bootstrapper.Console; + +public class MinimalConsoleHostedServiceTest : Test +{ + public MinimalConsoleHostedServiceTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task StartAsync_ShouldRunProgramAfterApplicationStarted_AndStopApplication() + { + var program = new SpyMinimalConsoleProgram(); + var logger = new TestLogger(); + await using var provider = HostedServiceTestFactory.CreateServiceProvider(services => + { + services.AddSingleton>(logger); + }); + var applicationLifetime = new FakeHostApplicationLifetime(); + var events = new FakeHostLifetimeEvents(); + var sut = new MinimalConsoleHostedService(new StaticProgramFactory(program), applicationLifetime, provider, events); + + await sut.StartAsync(CancellationToken.None); + + Assert.False(program.RunAsyncWasCalled); + Assert.True(events.OnApplicationStartedCallbackAssigned); + + events.RaiseApplicationStarted(); + await applicationLifetime.WaitForStopAsync(); + await sut.StopAsync(CancellationToken.None); + + Assert.True(program.RunAsyncWasCalled); + Assert.True(applicationLifetime.StopApplicationWasCalled); + Assert.Same(provider, program.RunAsyncServiceProvider); + + Assert.Collection(logger.Entries, + entry => + { + Assert.Equal(LogLevel.Information, entry.LogLevel); + Assert.Equal("RunAsync started.", entry.Message); + }, + entry => + { + Assert.Equal(LogLevel.Information, entry.LogLevel); + Assert.Equal("RunAsync completed successfully.", entry.Message); + }); + } + + [Fact] + public async Task StartAsync_ShouldLogUnableToActivate_WhenProgramFactoryReturnsNull() + { + var logger = new TestLogger(); + await using var provider = HostedServiceTestFactory.CreateServiceProvider(services => + { + services.AddSingleton>(logger); + }); + var applicationLifetime = new FakeHostApplicationLifetime(); + var events = new FakeHostLifetimeEvents(); + var sut = new MinimalConsoleHostedService(new StaticProgramFactory(null), applicationLifetime, provider, events); + + await sut.StartAsync(CancellationToken.None); + events.RaiseApplicationStarted(); + await applicationLifetime.WaitForStopAsync(); + + Assert.True(events.OnApplicationStartedCallbackAssigned); + Assert.True(applicationLifetime.StopApplicationWasCalled); + + Assert.Collection(logger.Entries, + entry => + { + Assert.Equal(LogLevel.Warning, entry.LogLevel); + Assert.Equal($"Unable to activate an instance of {typeof(MinimalConsoleProgram).FullName}.", entry.Message); + }); + } + + private sealed class SpyMinimalConsoleProgram : MinimalConsoleProgram + { + public bool RunAsyncWasCalled { get; private set; } + + public IServiceProvider? RunAsyncServiceProvider { get; private set; } + + public override Task RunAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + RunAsyncWasCalled = true; + RunAsyncServiceProvider = serviceProvider; + return Task.CompletedTask; + } + } +} diff --git a/test/Codebelt.Bootstrapper.Tests/CommandLineContextTest.cs b/test/Codebelt.Bootstrapper.Tests/CommandLineContextTest.cs new file mode 100644 index 0000000..3be7d03 --- /dev/null +++ b/test/Codebelt.Bootstrapper.Tests/CommandLineContextTest.cs @@ -0,0 +1,86 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Bootstrapper +{ + public class CommandLineContextTest : Test + { + public CommandLineContextTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldInitializeWithEmptyArray_WhenArgsIsNull() + { + // Act + var context = new CommandLineContext(null); + + // Assert + Assert.NotNull(context.Args); + Assert.Empty(context.Args); + } + + [Fact] + public void Constructor_ShouldInitializeWithProvidedArray_WhenArgsIsEmpty() + { + // Arrange + var args = new string[] { }; + + // Act + var context = new CommandLineContext(args); + + // Assert + Assert.NotNull(context.Args); + Assert.Empty(context.Args); + Assert.Same(args, context.Args); + } + + [Fact] + public void Constructor_ShouldInitializeWithProvidedArray_WhenArgsContainsValues() + { + // Arrange + var args = new[] { "arg1", "arg2", "arg3" }; + + // Act + var context = new CommandLineContext(args); + + // Assert + Assert.NotNull(context.Args); + Assert.Equal(3, context.Args.Length); + Assert.Equal("arg1", context.Args[0]); + Assert.Equal("arg2", context.Args[1]); + Assert.Equal("arg3", context.Args[2]); + Assert.Same(args, context.Args); + } + + [Fact] + public void ArgsProperty_ShouldReturnActualReference_WhenMultipleAccessesOccur() + { + // Arrange + var args = new[] { "value1", "value2" }; + var context = new CommandLineContext(args); + + // Act + var firstAccess = context.Args; + var secondAccess = context.Args; + + // Assert + Assert.Same(firstAccess, secondAccess); + Assert.Same(args, firstAccess); + } + + [Fact] + public void Constructor_ShouldPreserveSingleArgument_WhenArgsProvidesOne() + { + // Arrange + var args = new[] { "--debug" }; + + // Act + var context = new CommandLineContext(args); + + // Assert + Assert.Single(context.Args); + Assert.Equal("--debug", context.Args[0]); + } + } +}