Skip to content
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
6 changes: 6 additions & 0 deletions .github/workflows/fsharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down Expand Up @@ -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
26 changes: 26 additions & 0 deletions src/Testing/Wolverine.Behavioural.FSharpApp/Domain.fs
Original file line number Diff line number Diff line change
@@ -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<int>(TaskCreationOptions.RunContinuationsAsynchronously)

/// Reset before each behavioural run.
let reset () =
completion <- TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously)

/// Called by the handler when it runs.
let record (value: int) = completion.TrySetResult(value) |> ignore

/// Awaited by the test.
let received () : Task<int> = 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
25 changes: 25 additions & 0 deletions src/Testing/Wolverine.Behavioural.FSharpApp/Generated.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// <auto-generated/>

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
The "application assembly" for the F# behavioural run-step (issue GH-2969). It contains BOTH the
F# handler AND its pre-generated F# adapter (Generated.fs, emitted by the
Wolverine.Behavioural.FSharpTests generation step). The behavioural test boots a Wolverine host
in TypeLoadMode.Static with ApplicationAssembly = this assembly, so Wolverine loads the
pre-generated F# MessageHandler by name and runs it — proving the generated F# executes at
runtime, not merely that it compiles.
-->

<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<IsPackable>false</IsPackable>

<LangVersion></LangVersion>
<ImplicitUsings>false</ImplicitUsings>
<Nullable>disable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>

<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
</PropertyGroup>

<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Generated.fs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Wolverine\Wolverine.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FSharp.Core" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared configuration + F# rendering for the behavioural run-step. <see cref="Configure" /> 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.
/// </summary>
public static class BehaviouralCodegen
{
/// <summary>
/// The handler-discovery configuration shared by generation and runtime. Deliberately minimal +
/// deterministic so the generated handler type name is stable.
/// </summary>
public static void Configure(WolverineOptions opts)
{
opts.Discovery.DisableConventionalDiscovery();
opts.Discovery.IncludeType<BehaviouralPingHandler>();
}

/// <summary>
/// Renders the BehaviouralPing chain's handler adapter as F# via the no-host codegen path.
/// </summary>
public static string GenerateCode()
{
DynamicCodeBuilder.WithinCodegenCommand = true;
try
{
using var host = Host.CreateDefaultBuilder()
.UseWolverine(Configure)
.Build();

_ = host.Services.GetServices<ICodeFileCollection>().ToArray();

var handlerGraph = host.Services.GetRequiredService<HandlerGraph>();
var chain = handlerGraph.ChainFor(typeof(BehaviouralPing))
?? throw new InvalidOperationException("No handler chain was built for BehaviouralPing.");

var serviceVariableSource = host.Services.GetService<IServiceVariableSource>();
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");
}
}
105 changes: 105 additions & 0 deletions src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The F# behavioural run-step (issue GH-2969): boots a real Wolverine host in
/// <see cref="TypeLoadMode.Static" /> 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.
/// </summary>
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);
}

/// <summary>
/// Generation gate: regenerate the app's <c>Generated.fs</c> from the shared config and
/// <c>dotnet build</c> the app, so the committed F# adapter can't silently drift from the
/// codegen output (mirrors the per-store compile-gates).
/// </summary>
[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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
The F# behavioural run-step (issue GH-2969). Unlike the compile-gates (which only prove the
generated F# compiles), this project proves it RUNS: it boots a real Wolverine host in
TypeLoadMode.Static against the Wolverine.Behavioural.FSharpApp assembly (which carries the
pre-generated F# adapter), sends a message, and asserts the F# handler executed. A sibling
generation gate regenerates + dotnet-builds the app's Generated.fs so it can't silently drift.
-->

<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="All" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Wolverine.Behavioural.FSharpApp\Wolverine.Behavioural.FSharpApp.fsproj" />
</ItemGroup>

</Project>
2 changes: 2 additions & 0 deletions wolverine_fsharp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
<Project Path="src/Samples/WolverineCosmosFSharpSample/WolverineCosmosFSharpSample.fsproj" />
<Project Path="src/Testing/Wolverine.Cosmos.FSharpFixture/Wolverine.Cosmos.FSharpFixture.fsproj" />
<Project Path="src/Testing/Wolverine.Cosmos.FSharpTests/Wolverine.Cosmos.FSharpTests.csproj" />
<Project Path="src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj" />
<Project Path="src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj" />
</Folder>
<Folder Name="/Wolverine/">
<Project Path="src/Wolverine/Wolverine.csproj" />
Expand Down