diff --git a/jasperfx.slnx b/jasperfx.slnx index bc69704..4869e16 100644 --- a/jasperfx.slnx +++ b/jasperfx.slnx @@ -41,6 +41,7 @@ + diff --git a/src/CoreTests/CoreTests.csproj b/src/CoreTests/CoreTests.csproj index 2d184b5..58b5793 100644 --- a/src/CoreTests/CoreTests.csproj +++ b/src/CoreTests/CoreTests.csproj @@ -28,6 +28,7 @@ + diff --git a/src/CoreTests/JasperFxOptionsTests.cs b/src/CoreTests/JasperFxOptionsTests.cs index 91d9c22..d2b2a9a 100644 --- a/src/CoreTests/JasperFxOptionsTests.cs +++ b/src/CoreTests/JasperFxOptionsTests.cs @@ -1,3 +1,4 @@ +using ExtensionStandIn; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CommandLine.Descriptions; @@ -395,4 +396,97 @@ public void never_adopts_a_test_runner_as_the_calling_assembly() assembly.ShouldNotBeNull(); JasperFxOptions.IsTestRunnerAssembly(assembly.GetName().Name!).ShouldBeFalse(); } + + // GH-601: UseWolverine() and AddMarten() both call services.AddJasperFx() from inside their own + // assembly, so the first frame outside JasperFx belongs to the extension rather than the application. + + [Theory] + [InlineData("JasperFx")] + [InlineData("JasperFx.Events")] + [InlineData("JasperFx.RuntimeCompiler")] + [InlineData("Wolverine")] + [InlineData("Wolverine.SqlServer")] + [InlineData("WolverineFx.RabbitMQ")] + [InlineData("Marten")] + [InlineData("Marten.AspNetCore")] + [InlineData("Weasel.Postgresql")] + [InlineData("Polecat")] + [InlineData("CritterWatch.Server")] + [InlineData("Oakton")] + public void recognizes_critter_stack_framework_assemblies(string assemblyName) + { + JasperFxOptions.IsCritterStackAssembly(assemblyName).ShouldBeTrue(); + } + + [Theory] + // An application is not framework code just because it is named for a product. Matching is by exact + // name or dotted prefix, never a bare StartsWith. + [InlineData("MartenPlayground")] + [InlineData("WolverineDemo")] + [InlineData("JasperFxSamples")] + [InlineData("MyApp")] + [InlineData("CoreTests")] + // And the Critter Stack repos' own test assemblies ARE the application under test -- their handlers + // and documents are the types discovery has to find. + [InlineData("Wolverine.RabbitMQ.Tests")] + [InlineData("Marten.Testing")] + [InlineData("JasperFx.Events.Tests")] + public void does_not_mistake_applications_or_suites_for_framework_assemblies(string assemblyName) + { + JasperFxOptions.IsCritterStackAssembly(assemblyName).ShouldBeFalse(); + } + + [Fact] + public async Task registration_is_attributed_to_the_app_not_the_extension_that_registered_for_it() + { + var original = JasperFxOptions.RememberedApplicationAssembly; + try + { + JasperFxOptions.RememberedApplicationAssembly = null; + + // AddSomeCritterStackTool lives in an assembly named "Wolverine.StackWalkStandIn" and calls + // AddJasperFx() on our behalf, which is exactly what UseWolverine()/AddMarten() do. + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(s => s.AddSomeCritterStackTool()) + .UseEnvironment("Development") + .StartAsync(TestContext.Current.CancellationToken); + + var options = host.Services.GetRequiredService(); + + options.RegistrationCallingAssembly.ShouldBe(GetType().Assembly); + + // ...and because registration is now attributed correctly, the GH-3521 divergence warning + // stays quiet on a host where nothing is actually wrong. + options.ApplicationAssemblyReuseWarning.ShouldBeNull(); + } + finally + { + JasperFxOptions.RememberedApplicationAssembly = original; + } + } + + [Fact] + public void an_extension_registration_does_not_pin_the_extension_as_the_application_assembly() + { + var original = JasperFxOptions.RememberedApplicationAssembly; + try + { + JasperFxOptions.RememberedApplicationAssembly = null; + + // No meaningful IHostEnvironment.ApplicationName here, so establishApplicationAssembly falls + // through to the process-wide pin that AddJasperFx seeded from the same stack walk. Before the + // fix that pin -- and therefore type discovery -- was the extension assembly. + var services = new ServiceCollection(); + services.AddSomeCritterStackTool(); + + var options = services.BuildServiceProvider().GetRequiredService(); + + options.ApplicationAssembly.ShouldBe(GetType().Assembly); + JasperFxOptions.RememberedApplicationAssembly.ShouldBe(GetType().Assembly); + } + finally + { + JasperFxOptions.RememberedApplicationAssembly = original; + } + } } \ No newline at end of file diff --git a/src/ExtensionStandIn/CritterStackExtension.cs b/src/ExtensionStandIn/CritterStackExtension.cs new file mode 100644 index 0000000..3244526 --- /dev/null +++ b/src/ExtensionStandIn/CritterStackExtension.cs @@ -0,0 +1,18 @@ +using JasperFx; +using Microsoft.Extensions.DependencyInjection; + +namespace ExtensionStandIn; + +/// +/// Stands in for a Critter Stack extension in the one respect that matters to +/// JasperFxOptions.DetermineCallingAssembly: it calls AddJasperFx() from inside its own +/// assembly on the application's behalf, exactly the way UseWolverine() and AddMarten() do. +/// This assembly is named "Wolverine.StackWalkStandIn" so the walk sees it as framework code. See GH-601. +/// +public static class CritterStackExtension +{ + public static IServiceCollection AddSomeCritterStackTool(this IServiceCollection services) + { + return services.AddJasperFx(); + } +} diff --git a/src/ExtensionStandIn/ExtensionStandIn.csproj b/src/ExtensionStandIn/ExtensionStandIn.csproj new file mode 100644 index 0000000..7697f9f --- /dev/null +++ b/src/ExtensionStandIn/ExtensionStandIn.csproj @@ -0,0 +1,13 @@ + + + + Wolverine.StackWalkStandIn + false + + + + + + diff --git a/src/JasperFx/JasperFxOptions.cs b/src/JasperFx/JasperFxOptions.cs index 9d547dd..72617c7 100644 --- a/src/JasperFx/JasperFxOptions.cs +++ b/src/JasperFx/JasperFxOptions.cs @@ -241,7 +241,7 @@ private void checkForDivergentApplicationAssembly(Assembly adopted) } if (assemblyName.StartsWith("System") || assemblyName.StartsWith("Microsoft") || - IsTestRunnerAssembly(assemblyName)) + IsTestRunnerAssembly(assemblyName) || IsCritterStackAssembly(assemblyName)) { continue; } @@ -273,6 +273,57 @@ internal static bool IsTestRunnerAssembly(string assemblyName) || assemblyName.StartsWith("NCrunch.", StringComparison.OrdinalIgnoreCase); } + // GH-601: a Critter Stack extension registers JasperFx on the application's behalf -- + // UseWolverine() and AddMarten() both call services.AddJasperFx() from inside their own assembly -- + // so the first frame outside JasperFx belongs to the EXTENSION, not the app. RegistrationCallingAssembly + // then stopped meaning what its name says, and checkForDivergentApplicationAssembly compared the + // extension against the (correctly resolved) application assembly and warned on healthy hosts. That + // inverts the warning's whole value: it trains readers to ignore it, and its absence stops being + // evidence -- on JasperFx/wolverine#3776 "zero occurrences of the warning" was explicitly recorded as + // grounds for ruling out an application-assembly problem, which was exactly the bug. + // + // Matched as an exact name or a dotted prefix rather than a bare StartsWith, so an application named + // "MartenPlayground" or "WolverineDemo" is left alone. + private static readonly string[] _critterStackAssemblies = + [ + "JasperFx", + "Wolverine", + "WolverineFx", + "Marten", + "Weasel", + "Polecat", + "CritterWatch", + "Oakton" + ]; + + internal static bool IsCritterStackAssembly(string assemblyName) + { + // The test assemblies inside the Critter Stack repos themselves -- Wolverine.RabbitMQ.Tests, + // Marten.Testing and friends -- ARE the application as far as discovery is concerned, because the + // handlers and documents under test live in them. Skipping those would reintroduce GH-600's defect + // from the other direction. + if (assemblyName.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) + || assemblyName.EndsWith("Testing", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + foreach (var name in _critterStackAssemblies) + { + if (assemblyName.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (assemblyName.StartsWith(name + ".", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + /// /// Attempts to resolve the project root directory by climbing up from the current path /// looking for .csproj or .sln files. This is useful for Console apps where