From 3c9f4b49b1259f7476cb46090e04400fcc98b34e Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 21:08:48 +0200 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=93=9D=20add=20note=20about=20inten?= =?UTF-8?q?ded=20usage=20of=20BootstrapperLogMessages=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/types/Codebelt.Bootstrapper.BootstrapperLogMessages.md | 3 +++ 1 file changed, 3 insertions(+) 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; From b2148535deb0efe68820b227668dcdb2d88eae47 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 21:36:06 +0200 Subject: [PATCH 02/12] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20upgrade=20package=20?= =?UTF-8?q?dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated all central NuGet package versions to latest stable releases across all dependency groups: Codebelt.Extensions, Cuemon, Microsoft.NET.Test.Sdk, and targeted framework-specific packages for net9 and net10. --- Directory.Packages.props | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 From f63dd4cf4b910d455ae075b2a13a4dc7382e3b05 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 21:36:18 +0200 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=94=A7=20update=20build=20analyzer?= =?UTF-8?q?=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added analyzer warning suppressions (7035, CA2260, S6618), enabled code style enforcement in build, and configured MinVer tag prefix. Fixed incorrect AnalysisLevel property to AnalysisMode in test and benchmark project configurations. --- Directory.Build.props | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From 0e91829fc08dc904287593c2fa453b759a8f8d5e Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 21:36:28 +0200 Subject: [PATCH 04/12] =?UTF-8?q?=E2=AC=87=EF=B8=8F=20downgrade=20docker?= =?UTF-8?q?=20base=20image=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusted nginx base image from 1.31.2-alpine to 1.31-alpine to use the stable release channel for documentation build infrastructure. --- .docfx/Dockerfile.docfx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/* From d77dd64ef54c13a3f30b9374ab8403cbda34537d Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 21:36:38 +0200 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=A8=20add=20command-line=20context?= =?UTF-8?q?=20to=20bootstrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced CommandLineContext class to encapsulate and expose command-line arguments within the bootstrapper infrastructure, providing a consistent interface for accessing args across all project types. --- .../CommandLineContext.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/Codebelt.Bootstrapper/CommandLineContext.cs 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; } +} From c0f595c41c6cd2a1ebc3bfeb29ca1cfc053238a0 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 21:36:50 +0200 Subject: [PATCH 06/12] =?UTF-8?q?=E2=9C=85=20add=20tests=20for=20command-l?= =?UTF-8?q?ine=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive unit test coverage for CommandLineContext, verifying null-arg handling, empty arrays, populated arrays, and reference preservation across multiple property accesses. --- .../CommandLineContextTest.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test/Codebelt.Bootstrapper.Tests/CommandLineContextTest.cs 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]); + } + } +} From c85108c4feb17f09584077c585c51c031b4eddf0 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Sun, 26 Jul 2026 21:54:41 +0200 Subject: [PATCH 07/12] =?UTF-8?q?=E2=9C=85=20add=20unit=20tests=20for=20co?= =?UTF-8?q?nsole=20hosted=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests for ConsoleHostedService and MinimalConsoleHostedService provide validation of service initialization and lifecycle management. These tests ensure that startup delegates are properly invoked and that logging is correctly handled in both traditional and minimal console application patterns. --- Codebelt.Bootstrapper.slnx | 1 + .../Assets/HostedServiceTestDoubles.cs | 152 ++++++++++++++++++ ...Codebelt.Bootstrapper.Console.Tests.csproj | 11 ++ .../ConsoleHostedServiceTest.cs | 115 +++++++++++++ .../MinimalConsoleHostedServiceTest.cs | 96 +++++++++++ 5 files changed, 375 insertions(+) create mode 100644 test/Codebelt.Bootstrapper.Console.Tests/Assets/HostedServiceTestDoubles.cs create mode 100644 test/Codebelt.Bootstrapper.Console.Tests/Codebelt.Bootstrapper.Console.Tests.csproj create mode 100644 test/Codebelt.Bootstrapper.Console.Tests/ConsoleHostedServiceTest.cs create mode 100644 test/Codebelt.Bootstrapper.Console.Tests/MinimalConsoleHostedServiceTest.cs 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/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; + } + } +} From 07d5e169f73ddd5cc7c3a112882cae78bd36c4a2 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Sun, 26 Jul 2026 21:54:52 +0200 Subject: [PATCH 08/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20console?= =?UTF-8?q?=20hosted=20services=20for=20readability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract nested callback logic into focused private methods: StartRunAsync, RunAsync, ResolveLogger, LogRunAsyncStarted, LogUnableToActivate, and LogFatalError. This improves code clarity, testability, and maintainability by reducing complexity in StartAsync and making error-handling paths explicit. --- .../ConsoleHostedService.cs | 58 +++++++++----- .../MinimalConsoleHostedService.cs | 77 ++++++++++++------- 2 files changed, 87 insertions(+), 48 deletions(-) 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() From 0df9ee3cb78f94ca0a279749772b0c224d3e8f1e Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 22:04:25 +0200 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=93=A6=20update=20package=20release?= =?UTF-8?q?=20notes=20for=20v5.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add v5.2.0 release notes to all four package release-note files documenting the new CommandLineContext infrastructure, comprehensive test coverage, dependency updates, build analyzer enhancements, and refactored hosted service implementations. --- .../PackageReleaseNotes.txt | 9 +++++++++ .nuget/Codebelt.Bootstrapper.Web/PackageReleaseNotes.txt | 6 ++++++ .../Codebelt.Bootstrapper.Worker/PackageReleaseNotes.txt | 6 ++++++ .nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt | 9 +++++++++ 4 files changed, 30 insertions(+) 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..c14c6a7 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 and provide consistent access to command-line arguments within the bootstrapper infrastructure + Version: 5.1.2 Availability: .NET 10 and .NET 9 From d4a737e12e5c47d5560b73a83d48397bdf98dbf4 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sun, 26 Jul 2026 22:04:43 +0200 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=92=AC=20update=20changelog=20for?= =?UTF-8?q?=20v5.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document v5.2.0 release with new CommandLineContext infrastructure, comprehensive unit test coverage for console hosted services, all dependency upgrades to latest stable releases, build analyzer enhancements with warning suppressions, refactored service implementations for improved code clarity, and DocFX infrastructure updates. --- CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b418722..fdf408e 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-26 + +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 and provide consistent access to command-line arguments within the bootstrapper infrastructure, +- 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 From 998b0003bc0e453da1f7ddfda815a00e24554240 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Mon, 27 Jul 2026 00:22:47 +0200 Subject: [PATCH 11/12] =?UTF-8?q?=E2=9C=A8=20add=20documentation=20for=20C?= =?UTF-8?q?ommandLineContext=20with=20example=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...odebelt.Bootstrapper.CommandLineContext.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .docfx/api/types/Codebelt.Bootstrapper.CommandLineContext.md 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); + } +} +``` From f3298cf36390b7c6a6cb00c65506c83ff4c259bc Mon Sep 17 00:00:00 2001 From: gimlichael Date: Mon, 27 Jul 2026 00:25:18 +0200 Subject: [PATCH 12/12] =?UTF-8?q?=E2=9C=A8=20update=20commandlinecontext?= =?UTF-8?q?=20description=20for=20clarity=20in=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt | 2 +- CHANGELOG.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt b/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt index c14c6a7..cf1cec4 100644 --- a/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Bootstrapper/PackageReleaseNotes.txt @@ -5,7 +5,7 @@ Availability: .NET 10 and .NET 9 - 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 and provide consistent access to command-line arguments within the bootstrapper infrastructure +- 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 fdf408e..1140f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,13 @@ 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-26 +## [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 and provide consistent access to command-line arguments within the bootstrapper infrastructure, +- 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