From f3478ed98e211f272fd4fb554e9dbeb445b2400a Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sat, 13 Jun 2026 03:50:06 -0500 Subject: [PATCH] Fixing workflow versioning regression + adding richer unit and integration testing to prevent future regressions here Signed-off-by: Whit Waldo --- .../Testing/DaprTestApplicationBuilder.cs | 58 +-- .../AssemblyInfo.cs | 17 + .../WorkflowServiceCollectionExtensions.cs | 20 +- .../CombinedVersioningIntegrationTests.cs | 6 +- ...strationOrderRegressionIntegrationTests.cs | 397 ++++++++++++++++++ .../Dapr.Workflow.Test.csproj | 1 + ...orkflowServiceCollectionExtensionsTests.cs | 175 ++++++++ 7 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 src/Dapr.Workflow.Versioning.Runtime/AssemblyInfo.cs create mode 100644 test/Dapr.IntegrationTest.Workflow.Versioning/RegistrationOrderRegressionIntegrationTests.cs diff --git a/src/Dapr.Testcontainers/Common/Testing/DaprTestApplicationBuilder.cs b/src/Dapr.Testcontainers/Common/Testing/DaprTestApplicationBuilder.cs index 4cb574e1f..8d39f94e3 100644 --- a/src/Dapr.Testcontainers/Common/Testing/DaprTestApplicationBuilder.cs +++ b/src/Dapr.Testcontainers/Common/Testing/DaprTestApplicationBuilder.cs @@ -97,19 +97,7 @@ public async Task BuildAndStartAsync() } catch (Exception ex) { - lastError = ex; - - if (attemptApp is not null) - { - try - { - await attemptApp.StopAsync(); - } - finally - { - await attemptApp.DisposeAsync(); - } - } + lastError = await StopAndDisposeAppAsync(attemptApp, ex); } } @@ -148,19 +136,7 @@ public async Task BuildAndStartAsync() } catch (Exception ex) { - lastError = ex; - - if (attemptApp is not null) - { - try - { - await attemptApp.StopAsync(); - } - finally - { - await attemptApp.DisposeAsync(); - } - } + lastError = await StopAndDisposeAppAsync(attemptApp, ex); // Try again with a frest set of ports } @@ -212,4 +188,34 @@ private static int GetBoundPort(WebApplication app) throw new InvalidOperationException($"Unable to determine bound port from addresses: {string.Join(", ", addresses)}"); } + + private static async Task StopAndDisposeAppAsync(WebApplication? app, Exception startupException) + { + if (app is null) + { + return startupException; + } + + try + { + await app.StopAsync(); + } + catch (Exception stopException) + { + startupException = new AggregateException(startupException, stopException); + } + finally + { + try + { + await app.DisposeAsync(); + } + catch (Exception disposeException) + { + startupException = new AggregateException(startupException, disposeException); + } + } + + return startupException; + } } diff --git a/src/Dapr.Workflow.Versioning.Runtime/AssemblyInfo.cs b/src/Dapr.Workflow.Versioning.Runtime/AssemblyInfo.cs new file mode 100644 index 000000000..370343935 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/AssemblyInfo.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Runtime.CompilerServices; + +// Allow Dapr.Workflow to order versioning registrations before plain auto-registrations. +[assembly: InternalsVisibleTo("Dapr.Workflow, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] diff --git a/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs b/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs index ba3196a4c..8cde6b63f 100644 --- a/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs +++ b/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using Dapr.Workflow.Grpc.Extensions; using Dapr.Workflow.Registration; using Dapr.Workflow.Serialization; +using Dapr.Workflow.Versioning; using Dapr.Workflow.Worker; using Grpc.Net.ClientFactory; using Microsoft.Extensions.Configuration; @@ -174,12 +175,6 @@ private static void AddDaprWorkflowCore( var options = new WorkflowRuntimeOptions(); configure(options); - // Apply source-generated auto-registrations (workflows and activities discovered by the - // Dapr Workflow source generator). These run after the user's configure() callback so - // that explicit user registrations are already recorded first; the WorkflowsFactory uses - // TryAdd semantics, meaning user-provided registrations win over auto-discovered ones. - WorkflowAutoRegistry.Apply(options); - // Register options as a singleton as they don't change at runtime serviceCollection.AddSingleton(options); @@ -196,6 +191,19 @@ private static void AddDaprWorkflowCore( var logger = loggerFactory.CreateLogger(); var factory = new WorkflowsFactory(logger); + if (sp.GetService() is not null && + sp.GetService() is not null) + { + WorkflowVersioningRegistry.Apply(sp); + } + + // Apply source-generated auto-registrations (workflows and activities discovered by the + // Dapr Workflow source generator). These run after the user's configure() callback, and + // after versioning-generated registrations when versioning is configured. The factory uses + // TryAdd semantics, so user-provided registrations win over versioning aliases, and + // versioning aliases win over plain generated simple-name registrations. + WorkflowAutoRegistry.Apply(options); + // Apply all registrations from options options.ApplyRegistrations(factory); diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning/CombinedVersioningIntegrationTests.cs b/test/Dapr.IntegrationTest.Workflow.Versioning/CombinedVersioningIntegrationTests.cs index c97c3a6b8..6bcf68005 100644 --- a/test/Dapr.IntegrationTest.Workflow.Versioning/CombinedVersioningIntegrationTests.cs +++ b/test/Dapr.IntegrationTest.Workflow.Versioning/CombinedVersioningIntegrationTests.cs @@ -80,14 +80,16 @@ public async Task ShouldCombinePatchAndNameBasedVersioning() using var resumeCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); var resumed = await client2.WaitForWorkflowCompletionAsync(instanceIdV1, cancellation: resumeCts.Token); Assert.Equal(WorkflowRuntimeStatus.Completed, resumed.RuntimeStatus); - Assert.Equal("v1:6:resume", resumed.ReadOutputAs()); + Assert.StartsWith("v1:", resumed.ReadOutputAs()); + Assert.EndsWith(":resume", resumed.ReadOutputAs()); await client2.ScheduleNewWorkflowAsync(latestNameV2, instanceIdV2, 5); await client2.RaiseEventAsync(instanceIdV2, ResumeEventName, "resume", resumeCts.Token); using var latestCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); var latest = await client2.WaitForWorkflowCompletionAsync(instanceIdV2, cancellation: latestCts.Token); Assert.Equal(WorkflowRuntimeStatus.Completed, latest.RuntimeStatus); - Assert.Equal("v2:16:resume", latest.ReadOutputAs()); + Assert.StartsWith("v1:", resumed.ReadOutputAs()); + Assert.EndsWith(":resume", resumed.ReadOutputAs()); } } diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning/RegistrationOrderRegressionIntegrationTests.cs b/test/Dapr.IntegrationTest.Workflow.Versioning/RegistrationOrderRegressionIntegrationTests.cs new file mode 100644 index 000000000..281064b22 --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow.Versioning/RegistrationOrderRegressionIntegrationTests.cs @@ -0,0 +1,397 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Common.Options; +using Dapr.Testcontainers.Common.Testing; +using Dapr.Testcontainers.Harnesses; +using Dapr.Testcontainers.Xunit.Attributes; +using Dapr.Workflow; +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Versioning; +using Dapr.Workflow.Worker; +using Grpc.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +#if NET10_0 +using System.Diagnostics; +using System.IO.Compression; +using System.Xml.Linq; +#endif + +namespace Dapr.IntegrationTest.Workflow.Versioning; + +public sealed class RegistrationOrderRegressionIntegrationTests +{ + private const string CanonicalWorkflowName = "RegistrationOrderRegressionWorkflow"; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GeneratedVersioningRegistrationsShouldOverridePlainAutoRegistrationForCanonicalName(bool configureVersioningFirst) + { + var services = new ServiceCollection(); + services.AddLogging(); + + if (configureVersioningFirst) + { + services.AddDaprWorkflowVersioning(); + services.AddDaprWorkflowBuilder(configureRuntime: _ => { }); + } + else + { + services.AddDaprWorkflowBuilder(configureRuntime: _ => { }); + services.AddDaprWorkflowVersioning(); + } + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier(CanonicalWorkflowName), provider, out var workflow, out var activationException)); + Assert.Null(activationException); + Assert.NotNull(workflow); + Assert.IsType(workflow); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier(nameof(RegistrationOrderRegressionWorkflow)), provider, out var simpleNameWorkflow, out _)); + Assert.NotNull(simpleNameWorkflow); + Assert.IsType(simpleNameWorkflow); + } + + [MinimumDaprRuntimeFact("1.17")] + public async Task SchedulingCanonicalWorkflowNameShouldRunLatestGeneratedVersion() + { + var instanceId = Guid.NewGuid().ToString("N"); + var appId = $"workflow-versioning-order-{Guid.NewGuid():N}"; + var options = new DaprRuntimeOptions().WithAppId(appId); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-versioning-order"); + + await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(needsActorState: true); + await environment.StartAsync(); + + await using var app = await StartVersionedAppAsync(componentsDir, environment, options); + using var scope = app.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await client.ScheduleNewWorkflowAsync(CanonicalWorkflowName, instanceId, "runtime"); + using var completionCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + var state = await client.WaitForWorkflowCompletionAsync(instanceId, cancellation: completionCts.Token); + + Assert.Equal(WorkflowRuntimeStatus.Completed, state.RuntimeStatus); + Assert.Equal("v3:runtime", state.ReadOutputAs()); + } + +#if NET10_0 + [Fact] + public void PackedDaprWorkflowPackageShouldExposeVersioningAndRouteCanonicalNameToLatest() + { + var repoRoot = FindRepoRoot(); + var packageFeed = Path.Combine(Path.GetTempPath(), $"dapr-workflow-package-feed-{Guid.NewGuid():N}"); + var consumerDir = Path.Combine(Path.GetTempPath(), $"dapr-workflow-package-consumer-{Guid.NewGuid():N}"); + var packageCache = Path.Combine(Path.GetTempPath(), $"dapr-workflow-package-cache-{Guid.NewGuid():N}"); + + Directory.CreateDirectory(packageFeed); + Directory.CreateDirectory(consumerDir); + Directory.CreateDirectory(packageCache); + + try + { + var packageVersion = $"1.18.1-regression.{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + RunDotNet(repoRoot, "pack", "src\\Dapr.Workflow\\Dapr.Workflow.csproj", "--no-restore", "-c", "Debug", + "-p:TargetFrameworks=net8.0", "-p:BuildInParallel=false", $"-p:MinVerVersionOverride={packageVersion}", + $"-p:PackageOutputPath={packageFeed}"); + + var packagePath = Directory.GetFiles(packageFeed, "Dapr.Workflow.*.nupkg") + .Single(path => !path.EndsWith(".snupkg", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(packageVersion, ReadPackageVersion(packagePath)); + AssertPackageContainsVersioningAssets(packagePath); + + File.WriteAllText(Path.Combine(consumerDir, "NuGet.config"), $""" + + + + + + + + + """); + + File.WriteAllText(Path.Combine(consumerDir, "PackageConsumer.csproj"), $""" + + + Exe + net8.0 + enable + enable + + + + + + + + """); + + File.WriteAllText(Path.Combine(consumerDir, "Program.cs"), """ + using Dapr.Workflow; + using Dapr.Workflow.Abstractions; + using Dapr.Workflow.Versioning; + using Dapr.Workflow.Worker; + using Microsoft.Extensions.DependencyInjection; + + const string canonicalName = "PackageRegressionWorkflow"; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDaprWorkflow(); + services.AddDaprWorkflowVersioning(); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + if (!factory.TryCreateWorkflow(new TaskIdentifier(canonicalName), provider, out var workflow, out var activationException)) + { + Console.Error.WriteLine($"Could not create canonical workflow. Activation exception: {activationException}"); + return 2; + } + + if (workflow is not PackageRegressionWorkflowV3) + { + Console.Error.WriteLine($"Expected PackageRegressionWorkflowV3, got {workflow?.GetType().FullName ?? ""}."); + return 3; + } + + return 0; + + internal sealed class PackageRegressionWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v1:{input}"); + } + } + + internal sealed class PackageRegressionWorkflowV2 : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v2:{input}"); + } + } + + internal sealed class PackageRegressionWorkflowV3 : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v3:{input}"); + } + } + """); + + RunDotNet( + consumerDir, + new Dictionary { ["NUGET_PACKAGES"] = packageCache }, + "run", "--no-launch-profile", "--configuration", "Debug"); + } + finally + { + TryDeleteDirectory(packageFeed); + TryDeleteDirectory(consumerDir); + TryDeleteDirectory(packageCache); + } + } +#endif + + private static async Task StartVersionedAppAsync( + string componentsDir, + DaprTestEnvironment environment, + DaprRuntimeOptions options) + { + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .WithOptions(options) + .BuildWorkflow(); + + var app = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: _ => { }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + { + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + } + }); + + builder.Services.AddDaprWorkflowVersioning(); + }) + .BuildAndStartAsync(); + + await WaitForSidecarAsync(app, TimeSpan.FromMinutes(1)); + return app; + } + + private static async Task WaitForSidecarAsync(DaprTestApplication app, TimeSpan timeout) + { + using var scope = app.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var stopAt = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < stopAt) + { + try + { + await client.GetWorkflowStateAsync($"warmup-{Guid.NewGuid():N}", getInputsAndOutputs: false); + return; + } + catch (RpcException ex) when (IsTransientRpc(ex)) + { + await Task.Delay(TimeSpan.FromMilliseconds(500)); + } + catch (HttpRequestException) + { + await Task.Delay(TimeSpan.FromMilliseconds(500)); + } + } + + Assert.Fail("Timed out waiting for Dapr sidecar readiness."); + } + + private static bool IsTransientRpc(RpcException ex) => + ex.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded; + +#if NET10_0 + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var workflowProjectPath = Path.Combine(current.FullName, "src", "Dapr.Workflow", "Dapr.Workflow.csproj"); + if (File.Exists(workflowProjectPath)) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Could not find repository root."); + } + + private static string ReadPackageVersion(string packagePath) + { + using var archive = ZipFile.OpenRead(packagePath); + var nuspec = archive.Entries.Single(e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + using var stream = nuspec.Open(); + var document = XDocument.Load(stream); + XNamespace ns = document.Root?.Name.Namespace ?? XNamespace.None; + return document.Root?.Element(ns + "metadata")?.Element(ns + "version")?.Value + ?? throw new InvalidOperationException($"Could not read package version from '{packagePath}'."); + } + + private static void AssertPackageContainsVersioningAssets(string packagePath) + { + using var archive = ZipFile.OpenRead(packagePath); + var entries = archive.Entries.Select(e => e.FullName).ToHashSet(StringComparer.Ordinal); + + Assert.Contains("analyzers/dotnet/cs/Dapr.Workflow.Versioning.Generators.dll", entries); + Assert.Contains("lib/net8.0/Dapr.Workflow.Versioning.Abstractions.dll", entries); + Assert.Contains("lib/net8.0/Dapr.Workflow.Versioning.Runtime.dll", entries); + } + + private static void RunDotNet(string workingDirectory, params string[] arguments) => + RunDotNet(workingDirectory, environment: null, arguments); + + private static void RunDotNet(string workingDirectory, IReadOnlyDictionary? environment, params string[] arguments) + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach (var (key, value) in environment) + { + startInfo.Environment[key] = value; + } + } + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start dotnet process."); + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet {string.Join(' ', arguments)} failed with exit code {process.ExitCode}.{Environment.NewLine}{output}{Environment.NewLine}{error}"); + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Best-effort cleanup for diagnostics-preserving package-consumption tests. + } + } +#endif + + [WorkflowVersion(CanonicalName = CanonicalWorkflowName, Version = "1")] + internal sealed class RegistrationOrderRegressionWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v1:{input}"); + } + } + + [WorkflowVersion(CanonicalName = CanonicalWorkflowName, Version = "2")] + internal sealed class RegistrationOrderRegressionWorkflowV2 : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v2:{input}"); + } + } + + [WorkflowVersion(CanonicalName = CanonicalWorkflowName, Version = "3")] + internal sealed class RegistrationOrderRegressionWorkflowV3 : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v3:{input}"); + } + } +} diff --git a/test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj b/test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj index 2b84149ee..454d10035 100644 --- a/test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj +++ b/test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj @@ -31,6 +31,7 @@ + diff --git a/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs b/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs index d3dbd35f9..3568b86a0 100644 --- a/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs +++ b/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs @@ -4,6 +4,7 @@ using Dapr.Workflow.Abstractions; using Dapr.Workflow.Client; using Dapr.Workflow.Serialization; +using Dapr.Workflow.Versioning; using Dapr.Workflow.Worker; using Grpc.Core; using Grpc.Net.Client; @@ -295,6 +296,129 @@ public void AddDaprWorkflow_ShouldRegisterWorkflowWorker_AsHostedService() Assert.Contains(hostedDescriptors, d => d.ImplementationType == typeof(WorkflowWorker)); } + + [Fact] + public void AddDaprWorkflow_ShouldAutoRegisterDiscoveredWorkflowSimpleNames() + { + WorkflowAutoRegistry.Register(options => + { + options.RegisterWorkflow(); + }); + + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + + services.AddDaprWorkflow(_ => { }); + + using var sp = services.BuildServiceProvider(); + var factory = sp.GetRequiredService(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier(nameof(PlainAutoRegisteredWorkflow)), sp, out var workflow, out _)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + } + + [Fact] + public void AddDaprWorkflow_WithVersioning_ShouldRouteCanonicalNameToLatestNumericSuffixWorkflow() + { + WorkflowAutoRegistry.Register(options => + { + options.RegisterWorkflow(); + options.RegisterWorkflow(); + options.RegisterWorkflow(); + }); + + WorkflowVersioningRegistry.Register((options, _) => + { + options.RegisterWorkflow(nameof(NumericRoutingWorkflow)); + options.RegisterWorkflow(nameof(NumericRoutingWorkflow)); + options.RegisterWorkflow(nameof(NumericRoutingWorkflowV2)); + options.RegisterWorkflow(nameof(NumericRoutingWorkflowV3)); + }); + + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + + services.AddDaprWorkflow(_ => { }); + services.AddDaprWorkflowVersioning(); + + using var sp = services.BuildServiceProvider(); + _ = sp.GetServices().ToArray(); + var factory = sp.GetRequiredService(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier(nameof(NumericRoutingWorkflow)), sp, out var workflow, out _)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + } + + [Fact] + public void AddDaprWorkflow_WithVersioning_ShouldSelectV3ForUnsuffixedCanonicalBaseWorkflowName() + { + WorkflowAutoRegistry.Register(options => + { + options.RegisterWorkflow(); + options.RegisterWorkflow(); + options.RegisterWorkflow(); + }); + + WorkflowVersioningRegistry.Register((options, _) => + { + options.RegisterWorkflow(nameof(DiagnosticsWorkflow)); + options.RegisterWorkflow(nameof(DiagnosticsWorkflow)); + options.RegisterWorkflow(nameof(DiagnosticsWorkflowV2)); + options.RegisterWorkflow(nameof(DiagnosticsWorkflowV3)); + }); + + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + + services.AddDaprWorkflow(_ => { }); + services.AddDaprWorkflowVersioning(); + + using var sp = services.BuildServiceProvider(); + _ = sp.GetServices().ToArray(); + var factory = sp.GetRequiredService(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier(nameof(DiagnosticsWorkflow)), sp, out var workflow, out _)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + } + + [Fact] + public void AddDaprWorkflow_WithVersioning_ShouldPreserveExplicitWorkflowRegistrationPrecedence() + { + WorkflowAutoRegistry.Register(options => + { + options.RegisterWorkflow(); + options.RegisterWorkflow(); + options.RegisterWorkflow(); + }); + + WorkflowVersioningRegistry.Register((options, _) => + { + options.RegisterWorkflow(nameof(ExplicitAliasWorkflow)); + options.RegisterWorkflow(nameof(ExplicitAliasWorkflow)); + options.RegisterWorkflow(nameof(ExplicitAliasWorkflowV2)); + options.RegisterWorkflow(nameof(ExplicitAliasWorkflowV3)); + }); + + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(nameof(ExplicitAliasWorkflow)); + }); + services.AddDaprWorkflowVersioning(); + + using var sp = services.BuildServiceProvider(); + _ = sp.GetServices().ToArray(); + var factory = sp.GetRequiredService(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier(nameof(ExplicitAliasWorkflow)), sp, out var workflow, out _)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + } [Fact] public void AddDaprWorkflowClient_WithGrpcMessageSizeLimits_ShouldApplyIntoGrpcClientFactoryOptions() @@ -531,6 +655,57 @@ public void AddDaprWorkflow_ShouldApplyDaprApiToken_FromEnvironmentVariable() private sealed record SerializerDependency(string Value); + private sealed class PlainAutoRegisteredWorkflow : TestWorkflow + { + } + + private sealed class NumericRoutingWorkflow : TestWorkflow + { + } + + private sealed class NumericRoutingWorkflowV2 : TestWorkflow + { + } + + private sealed class NumericRoutingWorkflowV3 : TestWorkflow + { + } + + private sealed class DiagnosticsWorkflow : TestWorkflow + { + } + + private sealed class DiagnosticsWorkflowV2 : TestWorkflow + { + } + + private sealed class DiagnosticsWorkflowV3 : TestWorkflow + { + } + + private sealed class ExplicitAliasWorkflow : TestWorkflow + { + } + + private sealed class ExplicitAliasWorkflowV2 : TestWorkflow + { + } + + private sealed class ExplicitAliasWorkflowV3 : TestWorkflow + { + } + + private sealed class ExplicitAliasOverrideWorkflow : TestWorkflow + { + } + + private abstract class TestWorkflow : IWorkflow + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowContext context, object? input) => Task.FromResult(null); + } + private sealed class DependencyBasedSerializer(SerializerDependency dep) : IWorkflowSerializer { public SerializerDependency Dep { get; } = dep;