diff --git a/.github/workflows/fsharp.yml b/.github/workflows/fsharp.yml index 29d8e58dd..7a6c8d6b4 100644 --- a/.github/workflows/fsharp.yml +++ b/.github/workflows/fsharp.yml @@ -26,6 +26,7 @@ on: - 'src/Testing/Wolverine.Marten.FSharp*/**' - 'src/Testing/Wolverine.MartenAggregate.FSharp*/**' - 'src/Testing/Wolverine.Cosmos.FSharp*/**' + - 'src/Testing/Wolverine.Behavioural.FSharp*/**' - 'src/Samples/WolverineFSharpSample/**' - 'src/Samples/WolverineMartenFSharpSample/**' - 'src/Samples/WolverineMartenAggregateFSharpSample/**' @@ -82,3 +83,8 @@ jobs: - name: Compile-gate (FluentValidation + CosmosDB surface) run: dotnet test src/Testing/Wolverine.Cosmos.FSharpTests/Wolverine.Cosmos.FSharpTests.csproj -c "$config" --nologo + + # Run-verified: boots a host in TypeLoadMode.Static against the pre-generated F# adapter and + # asserts the handler actually executes (not just compiles). + - name: Behavioural run-step (TypeLoadMode.Static) + run: dotnet test src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj -c "$config" --nologo diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs b/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs new file mode 100644 index 000000000..9b73b7bac --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs @@ -0,0 +1,26 @@ +namespace WolverineBehaviouralFSharpApp + +open System.Threading.Tasks + +/// An in-process sink so the behavioural test can assert the generated F# handler adapter actually +/// executed at runtime (under TypeLoadMode.Static), not merely that it compiled. +module BehaviouralSink = + let mutable private completion = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + + /// Reset before each behavioural run. + let reset () = + completion <- TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + + /// Called by the handler when it runs. + let record (value: int) = completion.TrySetResult(value) |> ignore + + /// Awaited by the test. + let received () : Task = completion.Task + +/// The message handled by BehaviouralPingHandler. +type BehaviouralPing = { Value: int } + +/// A minimal F# handler. The generated F# adapter (Generated.fs) calls this; the behavioural test +/// boots a host in TypeLoadMode.Static, sends a BehaviouralPing, and asserts the sink recorded it. +type BehaviouralPingHandler = + static member Handle(ping: BehaviouralPing) = BehaviouralSink.record ping.Value diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Generated.fs b/src/Testing/Wolverine.Behavioural.FSharpApp/Generated.fs new file mode 100644 index 000000000..fe4e56a40 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Generated.fs @@ -0,0 +1,25 @@ +// + +namespace Internal.Generated.WolverineHandlers + +open System +open System.Threading +open System.Threading.Tasks +open Wolverine.Runtime +open Wolverine.Runtime.Handlers + +type BehaviouralPingHandler1244766258() = + inherit Wolverine.Runtime.Handlers.MessageHandler() + override this.HandleAsync(context: Wolverine.Runtime.MessageContext, cancellation: System.Threading.CancellationToken) : System.Threading.Tasks.Task = + // The actual message body + let behaviouralPing = context.Envelope.Message :?> WolverineBehaviouralFSharpApp.BehaviouralPing + + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("message.handler", "WolverineBehaviouralFSharpApp.BehaviouralPingHandler") |> ignore + System.Diagnostics.Activity.Current.SetTag("handler.type", "WolverineBehaviouralFSharpApp.BehaviouralPingHandler") |> ignore + + // The actual message execution + WolverineBehaviouralFSharpApp.BehaviouralPingHandler.Handle(behaviouralPing) + + System.Threading.Tasks.Task.CompletedTask + diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj new file mode 100644 index 000000000..62f5ccc0d --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj @@ -0,0 +1,38 @@ + + + + + + net9.0 + false + + + false + disable + false + false + + true + + + + + + + + + + + + + + + + diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralCodegen.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralCodegen.cs new file mode 100644 index 000000000..df2a30385 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralCodegen.cs @@ -0,0 +1,72 @@ +using System.Runtime.CompilerServices; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Wolverine; +using Wolverine.Runtime.Handlers; +using WolverineBehaviouralFSharpApp; + +namespace Wolverine.Behavioural.FSharpTests; + +/// +/// Shared configuration + F# rendering for the behavioural run-step. is used +/// by BOTH the generation step (to emit the F# adapter) and the runtime host (to compute the same +/// chain, hence the same generated type name) so the pre-generated type is found under static load. +/// +public static class BehaviouralCodegen +{ + /// + /// The handler-discovery configuration shared by generation and runtime. Deliberately minimal + + /// deterministic so the generated handler type name is stable. + /// + public static void Configure(WolverineOptions opts) + { + opts.Discovery.DisableConventionalDiscovery(); + opts.Discovery.IncludeType(); + } + + /// + /// Renders the BehaviouralPing chain's handler adapter as F# via the no-host codegen path. + /// + public static string GenerateCode() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = Host.CreateDefaultBuilder() + .UseWolverine(Configure) + .Build(); + + _ = host.Services.GetServices().ToArray(); + + var handlerGraph = host.Services.GetRequiredService(); + var chain = handlerGraph.ChainFor(typeof(BehaviouralPing)) + ?? throw new InvalidOperationException("No handler chain was built for BehaviouralPing."); + + var serviceVariableSource = host.Services.GetService(); + var generatedAssembly = handlerGraph.StartAssembly(handlerGraph.Rules); + ((ICodeFile)chain).AssembleTypes(generatedAssembly); + + return generatedAssembly.GenerateFSharpCode(serviceVariableSource); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + public static string GeneratedFilePath([CallerFilePath] string thisFile = "") + { + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var srcTestingDir = Path.GetDirectoryName(testProjectDir)!; + return Path.Combine(srcTestingDir, "Wolverine.Behavioural.FSharpApp", "Generated.fs"); + } + + public static string AppProjectPath([CallerFilePath] string thisFile = "") + { + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var srcTestingDir = Path.GetDirectoryName(testProjectDir)!; + return Path.Combine(srcTestingDir, "Wolverine.Behavioural.FSharpApp", "Wolverine.Behavioural.FSharpApp.fsproj"); + } +} diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs new file mode 100644 index 000000000..eac077903 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using JasperFx.CodeGeneration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using WolverineBehaviouralFSharpApp; +using Xunit; +using Xunit.Abstractions; + +namespace Wolverine.Behavioural.FSharpTests; + +/// +/// The F# behavioural run-step (issue GH-2969): boots a real Wolverine host in +/// against the Wolverine.Behavioural.FSharpApp assembly (which +/// carries the committed, pre-generated F# handler adapter), sends a message, and asserts the F# +/// handler actually executed — run-verification, not just compilation. +/// +public class BehaviouralRunStep +{ + private readonly ITestOutputHelper _output; + + public BehaviouralRunStep(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task generated_fsharp_handler_runs_under_static_load() + { + BehaviouralSink.reset(); + + var appAssembly = typeof(BehaviouralPingHandler).Assembly; + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + BehaviouralCodegen.Configure(opts); + + // Load the pre-generated F# handler adapter out of the app assembly instead of + // compiling at runtime. Setting ApplicationAssembly (which cascades to + // CodeGeneration.ApplicationAssembly) BEFORE bootstrap pins the assembly Wolverine + // scans for pre-built types to the F# app — not this test assembly. If the committed + // Generated.fs has drifted from this config, the type name won't match and the host + // throws ExpectedTypeMissingException at startup — a loud, useful signal. + opts.ApplicationAssembly = appAssembly; + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static; + }) + .StartAsync(); + + var bus = host.MessageBus(); + await bus.InvokeAsync(new BehaviouralPing(42)); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var received = await BehaviouralSink.received().WaitAsync(timeout.Token); + + received.ShouldBe(42); + } + + /// + /// Generation gate: regenerate the app's Generated.fs from the shared config and + /// dotnet build the app, so the committed F# adapter can't silently drift from the + /// codegen output (mirrors the per-store compile-gates). + /// + [Fact] + public void generated_fsharp_regenerates_and_compiles() + { + var code = BehaviouralCodegen.GenerateCode(); + var generatedFile = BehaviouralCodegen.GeneratedFilePath(); + File.WriteAllText(generatedFile, code); + _output.WriteLine(code); + + var appProject = BehaviouralCodegen.AppProjectPath(); + var (exitCode, output) = RunDotnet($"build \"{appProject}\" -c Debug --nologo"); + + if (exitCode != 0 && (output.Contains("FS0193") || output.Contains("internal error") + || output.Contains("being used by another process") + || output.Contains("MSB3883"))) + { + (exitCode, output) = RunDotnet($"build \"{appProject}\" -c Debug --nologo"); + } + + _output.WriteLine(output); + exitCode.ShouldBe(0); + } + + private static (int ExitCode, string Output) RunDotnet(string arguments) + { + var info = new ProcessStartInfo("dotnet", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + info.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; + info.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + + using var process = Process.Start(info)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + + return (process.ExitCode, stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult()); + } +} diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj b/src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj new file mode 100644 index 000000000..5c498e951 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj @@ -0,0 +1,31 @@ + + + + + + net9.0 + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/wolverine_fsharp.slnx b/wolverine_fsharp.slnx index b7d94cef8..90ed69e87 100644 --- a/wolverine_fsharp.slnx +++ b/wolverine_fsharp.slnx @@ -28,6 +28,8 @@ + +