diff --git a/all.sln b/all.sln index 673affad7..0b91a1c4f 100644 --- a/all.sln +++ b/all.sln @@ -406,6 +406,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Store.Next.Example07.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Store.Next.Example07.AppHost", "examples\Actor.Next\07-PerTypeOptions\Store.Next.Example07.AppHost\Store.Next.Example07.AppHost.csproj", "{B620B816-53AB-4DB1-B038-CCA35234AB0C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Versioning.Generators.Test", "test\Dapr.Workflow.Versioning.Generators.Test\Dapr.Workflow.Versioning.Generators.Test.csproj", "{FCDA0406-5FD9-4ACB-A3B1-C2685B034809}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -2408,6 +2410,18 @@ Global {B620B816-53AB-4DB1-B038-CCA35234AB0C}.Release|x64.Build.0 = Release|Any CPU {B620B816-53AB-4DB1-B038-CCA35234AB0C}.Release|x86.ActiveCfg = Release|Any CPU {B620B816-53AB-4DB1-B038-CCA35234AB0C}.Release|x86.Build.0 = Release|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Debug|x64.ActiveCfg = Debug|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Debug|x64.Build.0 = Debug|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Debug|x86.ActiveCfg = Debug|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Debug|x86.Build.0 = Debug|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Release|Any CPU.Build.0 = Release|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Release|x64.ActiveCfg = Release|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Release|x64.Build.0 = Release|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Release|x86.ActiveCfg = Release|Any CPU + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2606,6 +2620,7 @@ Global {9D3FA169-444F-4B8A-921E-17962829E457} = {451E5A30-5AC1-E437-EC03-1F862D847C4D} {9C989F53-0991-4E47-A4D3-6E1EF76537D6} = {451E5A30-5AC1-E437-EC03-1F862D847C4D} {B620B816-53AB-4DB1-B038-CCA35234AB0C} = {451E5A30-5AC1-E437-EC03-1F862D847C4D} + {FCDA0406-5FD9-4ACB-A3B1-C2685B034809} = {DD020B34-460F-455F-8D17-CF4A949F100B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs index 3646186aa..3608270ae 100644 --- a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs +++ b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs @@ -126,6 +126,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) Diagnostic: (string?)$"Rejected '{symbolName}': does not inherit from Workflow<,> (base type: {baseTypeInfo})"); } + // Abstract or open-generic types cannot be instantiated; skip them. + if (symbol.IsAbstract || symbol.TypeParameters.Length > 0) + { + return (Workflow: (DiscoveredWorkflow?)null, + Diagnostic: (string?)$"Rejected '{symbolName}': abstract or open-generic workflow types are not registered."); + } + // Look for [WorkflowVersion] by symbol identity AttributeData? attrData = null; if (ks.WorkflowVersionAttribute is not null) @@ -410,6 +417,10 @@ private static IEnumerable DiscoverReferencedWorkflows( if (!InheritsFromWorkflow(type, knownSymbols.WorkflowBase)) continue; + // Skip abstract types and open generics — they can't be instantiated. + if (type.IsAbstract || type.TypeParameters.Length > 0) + continue; + AttributeData? attrData = null; if (knownSymbols.WorkflowVersionAttribute is not null) { diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning/AbstractClosedWorkflowRegression.cs b/test/Dapr.IntegrationTest.Workflow.Versioning/AbstractClosedWorkflowRegression.cs new file mode 100644 index 000000000..80c332fdb --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow.Versioning/AbstractClosedWorkflowRegression.cs @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------ +// 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. +// ------------------------------------------------------------------------ + +// Regression coverage for https://github.com/dapr/dotnet-sdk/issues/1898 +// +// An abstract *closed* (non-generic) workflow isolates the IsAbstract filter independent of +// the open-generic filter. The source generator must skip it, so neither its type name nor +// its declared canonical name may appear in the generated versioning registry. +// +// Gated to C# 16 / .NET 14 (which ships with the Dapr SDK 1.19 release) — this scenario is +// not exercised on prior target frameworks. The Dapr SDK 1.19 release will add .NET 14 +// support; until that merge, this test is dormant (the type and test are compiled out). + +#if NET14_0_OR_GREATER +using Dapr.Testcontainers.Xunit.Attributes; +using Dapr.Workflow; +using Dapr.Workflow.Versioning; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow.Versioning; + +internal static class AbstractClosedWorkflowConstants +{ + public const string CanonicalName = "AbstractClosedWorkflow"; +} + +[WorkflowVersion(CanonicalName = AbstractClosedWorkflowConstants.CanonicalName, Version = "1")] +internal abstract class AbstractClosedWorkflow : Workflow +{ +} + +public sealed class AbstractClosedWorkflowRegressionTests +{ + /// + /// The abstract closed (non-generic) workflow must be excluded from the generated + /// versioning registry: its declared canonical name must not be a registry key, and its + /// type name must not appear in any registered workflow list. + /// + [MinimumDaprRuntimeFact("1.19")] + public void AbstractClosedWorkflow_IsExcludedFromVersioningRegistry() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDaprWorkflowVersioning(); + services.AddDaprWorkflowBuilder(configureRuntime: _ => { }); + + using var provider = services.BuildServiceProvider(); + var registry = GeneratedWorkflowVersionRegistry.GetWorkflowVersionRegistry(provider); + + Assert.False(registry.ContainsKey(AbstractClosedWorkflowConstants.CanonicalName), + "The abstract closed workflow's canonical name must not appear as a registry key."); + Assert.DoesNotContain( + registry.SelectMany(kv => kv.Value), + v => v.Contains(nameof(AbstractClosedWorkflow), StringComparison.Ordinal)); + } +} +#endif diff --git a/test/Dapr.Workflow.Versioning.Generators.Test/Dapr.Workflow.Versioning.Generators.Test.csproj b/test/Dapr.Workflow.Versioning.Generators.Test/Dapr.Workflow.Versioning.Generators.Test.csproj new file mode 100644 index 000000000..3c2ef905d --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Generators.Test/Dapr.Workflow.Versioning.Generators.Test.csproj @@ -0,0 +1,38 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + true + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/test/Dapr.Workflow.Versioning.Generators.Test/GeneratorTestHarness.cs b/test/Dapr.Workflow.Versioning.Generators.Test/GeneratorTestHarness.cs new file mode 100644 index 000000000..d2247a87b --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Generators.Test/GeneratorTestHarness.cs @@ -0,0 +1,100 @@ +// ------------------------------------------------------------------------ +// 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Dapr.Workflow.Versioning.Generators.Test; + +/// +/// In-process harness that runs over a snippet of +/// user source and returns the generated registry source plus generator diagnostics. +/// +internal static class GeneratorTestHarness +{ + public const string RegistryGeneratedFileName = "Dapr_Workflow_Versioning.g.cs"; + + private static MetadataReference AbstractionsReference() + { + var path = Path.Combine(AppContext.BaseDirectory, "Dapr.Workflow.Abstractions.dll"); + return MetadataReference.CreateFromFile(path); + } + + private static MetadataReference VersioningAbstractionsReference() + { + var path = Path.Combine(AppContext.BaseDirectory, "Dapr.Workflow.Versioning.Abstractions.dll"); + return MetadataReference.CreateFromFile(path); + } + + /// + /// Compiles and runs the source generator, returning the + /// generated registry source (or an empty string when the generator emits nothing) and + /// any diagnostics the generator reports. + /// + public static Task<(string GeneratedSource, Diagnostic[] Diagnostics)> RunAsync(string userSource) + { + // Build a complete reference set from the assemblies already loaded into the test host + // (System.Runtime, System.Text.Json, Microsoft.Extensions.*, etc.) plus the abstractions + // assemblies under test. This avoids the fragility of hand-picking individual framework refs. + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(MetadataReference? (a) => MetadataReference.CreateFromFile(a.Location)) + .Where(r => r is not null) + .Cast() + .ToList(); + references.Add(AbstractionsReference()); + references.Add(VersioningAbstractionsReference()); + + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: new[] { CSharpSyntaxTree.ParseText(userSource) }, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + var generator = new WorkflowSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGenerators(compilation); + var runResult = driver.GetRunResult(); + + var generated = string.Empty; + foreach (var tree in runResult.GeneratedTrees.Where(tree => tree.FilePath.EndsWith(RegistryGeneratedFileName, StringComparison.Ordinal))) + { + generated = tree.ToString(); + } + + return Task.FromResult((generated, runResult.Diagnostics.ToArray())); + } + + /// Asserts that no error-severity diagnostics were reported by the generator. + public static void AssertNoErrorDiagnostics(Diagnostic[] diagnostics) + { + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + Assert.True(errors.Count == 0, + $"Expected no error diagnostics, but got: {string.Join("; ", errors.Select(d => d.ToString()))}"); + } + + /// + /// Re-parses the generated source and asserts it is syntactically valid C#. This catches + /// generator defects such as an orphan else branch (the root cause of issue #1898). + /// + public static void AssertNoSyntaxErrors(string source) + { + if (string.IsNullOrEmpty(source)) + return; + + var tree = CSharpSyntaxTree.ParseText(source); + var errors = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + Assert.True(errors.Count == 0, + $"Expected no syntax errors in generated source, but got: {string.Join("; ", errors.Select(d => d.ToString()))}"); + } +} diff --git a/test/Dapr.Workflow.Versioning.Generators.Test/WorkflowActivitySourceGeneratorTests.cs b/test/Dapr.Workflow.Versioning.Generators.Test/WorkflowActivitySourceGeneratorTests.cs new file mode 100644 index 000000000..0e9588cb6 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Generators.Test/WorkflowActivitySourceGeneratorTests.cs @@ -0,0 +1,143 @@ +// ------------------------------------------------------------------------ +// 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. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Generators.Test; + +/// +/// Activity discovery tests for . +/// +/// +/// The activity discovery pipeline mirrors the workflow pipeline and already filtered +/// abstract/open-generic types at discovery (the pattern the workflow fix followed). These +/// tests guard that behavior against regressions, covering abstract, open-generic, and +/// concrete activities, plus an activities-only project (no workflows) to verify the +/// registry is still emitted and well-formed when the workflow side is empty. +/// +public sealed class WorkflowActivitySourceGeneratorTests +{ + /// + /// An abstract activity must not be registered — it cannot be instantiated. + /// + [Fact] + public async Task AbstractActivity_IsExcluded() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public abstract class AbstractActivity : WorkflowActivity { } +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for an abstract-only activity, but got:{Environment.NewLine}{generated}"); + } + + /// + /// An open-generic activity must not be registered — its unbound type parameters cannot + /// be referenced in the generated registration call. + /// + [Fact] + public async Task OpenGenericActivity_IsExcluded() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public sealed class GenericActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, T input) + => Task.FromResult(string.Empty); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for an open-generic activity, but got:{Environment.NewLine}{generated}"); + } + + /// + /// A concrete activity must be registered by the generated registry. + /// + [Fact] + public async Task ConcreteActivity_IsRegistered() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public sealed class PlainActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, string input) + => Task.FromResult(input); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.False(string.IsNullOrEmpty(generated), "Expected generated registry source for the concrete activity."); + Assert.Contains("PlainActivity", generated, StringComparison.Ordinal); + GeneratorTestHarness.AssertNoSyntaxErrors(generated); + } + + /// + /// An activities-only project (no workflows) must still emit a well-formed registry + /// containing the activity registration. This guards the workflows.Count == 0 + /// early-return and the empty-concreteWorkflows path in RegisterAlias + /// (no orphan else branch) when the workflow side is empty. + /// + [Fact] + public async Task ActivitiesOnly_NoWorkflows_EmitsActivityRegistry() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public sealed class FirstActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, string input) + => Task.FromResult(input); +} + +public sealed class SecondActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, int input) + => Task.FromResult(input > 0); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.False(string.IsNullOrEmpty(generated), "Expected generated registry source for the activities-only project."); + Assert.Contains("FirstActivity", generated, StringComparison.Ordinal); + Assert.Contains("SecondActivity", generated, StringComparison.Ordinal); + // No workflows are present, so no workflow registration call should be emitted. + Assert.DoesNotContain("RegisterWorkflow", generated, StringComparison.Ordinal); + Assert.Contains("RegisterActivity", generated, StringComparison.Ordinal); + GeneratorTestHarness.AssertNoSyntaxErrors(generated); + } +} diff --git a/test/Dapr.Workflow.Versioning.Generators.Test/WorkflowSourceGeneratorTests.cs b/test/Dapr.Workflow.Versioning.Generators.Test/WorkflowSourceGeneratorTests.cs new file mode 100644 index 000000000..55735dcb2 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Generators.Test/WorkflowSourceGeneratorTests.cs @@ -0,0 +1,272 @@ +// ------------------------------------------------------------------------ +// 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. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Generators.Test; + +/// +/// Regression coverage for https://github.com/dapr/dotnet-sdk/issues/1898 +/// +/// +/// +/// The source generator must not attempt to register abstract or open-generic workflow +/// classes. When a project contains only an abstract open-generic workflow (no concrete +/// derivatives), the generator previously emitted a registry with an orphan else +/// branch (and, before #1885, referenced the unbound type parameters), producing +/// CS0246/syntax errors at build time. +/// +/// +/// These tests exercise each class variation — abstract open-generic, concrete open-generic, +/// and concrete closed — both with and without [WorkflowVersion] metadata, and verify +/// abstract/open-generic types are skipped at the discovery stage so no invalid source is +/// emitted. The abstract closed (non-generic) variation is covered separately in the +/// integration test project (gated to Dapr 1.19 / C# 16). +/// +/// +public sealed class WorkflowSourceGeneratorTests +{ + // ── Plain (non-versioned) workflows ─────────────────────────────────────── + + /// + /// Reproduces issue #1898: a project containing only an abstract open-generic workflow + /// (no concrete derivatives) must not emit any registry source. Previously the generator + /// emitted an orphan else branch (invalid C#), failing the build. + /// + [Fact] + public async Task AbstractOpenGenericWorkflowOnly_EmitsNoRegistrySource() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public abstract class CustomWorkflow : Workflow { } +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for an abstract-only workflow, but got:{Environment.NewLine}{generated}"); + } + + /// + /// A concrete open-generic workflow (non-abstract, but unbound type parameters) must also + /// be skipped — it cannot be instantiated and would emit references to unbound type + /// parameters (CS0246). This isolates the TypeParameters.Length > 0 filter + /// independent of IsAbstract. + /// + [Fact] + public async Task ConcreteOpenGenericWorkflowOnly_EmitsNoRegistrySource() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public sealed class GenericWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, TInput input) + => Task.FromResult(string.Empty); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for a concrete open-generic workflow, but got:{Environment.NewLine}{generated}"); + } + + /// + /// A plain concrete workflow must still be registered by the generated registry. + /// + [Fact] + public async Task ConcreteWorkflow_EmitsRegistration() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; + +namespace MyApp; + +public sealed class PlainWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, string input) + => Task.FromResult(input); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.False(string.IsNullOrEmpty(generated), "Expected generated registry source for the concrete workflow."); + Assert.Contains("PlainWorkflow", generated, StringComparison.Ordinal); + GeneratorTestHarness.AssertNoSyntaxErrors(generated); + } + + /// + /// An abstract open-generic workflow with a concrete closed derivative must register only + /// the concrete type. The generated source must not reference the abstract base's unbound + /// type parameters and must be syntactically valid. + /// + [Fact] + public async Task AbstractGenericBaseWithConcreteDerivative_RegistersOnlyConcrete() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; +using Dapr.Workflow.Versioning; + +namespace MyApp; + +public abstract class CustomWorkflowBase : Workflow { } + +[WorkflowVersion(CanonicalName = "MyWorkflow", Version = "1")] +public sealed class MyWorkflow : CustomWorkflowBase +{ + public override Task RunAsync(WorkflowContext context, string input) + => Task.FromResult(input); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.False(string.IsNullOrEmpty(generated), "Expected generated registry source for the concrete derivative."); + Assert.Contains("MyWorkflow", generated, StringComparison.Ordinal); + // The abstract open-generic base must not appear in the generated registration code + // (it would reference unbound type parameters and produce CS0246). + Assert.DoesNotContain("CustomWorkflowBase", generated, StringComparison.Ordinal); + GeneratorTestHarness.AssertNoSyntaxErrors(generated); + } + + // ── Versioned workflows ([WorkflowVersion]) ────────────────────────────── + // Validates the versioning registry path (CreateEntries / RegisterAlias), + // not just basic registration — the exact code path that broke in #1898. + + /// + /// An abstract open-generic workflow carrying [WorkflowVersion] must be skipped + /// entirely — neither its type name nor its declared canonical name may appear in the + /// generated versioning registry. With no other concrete types, no source is emitted. + /// + [Fact] + public async Task AbstractOpenGenericWorkflow_WithVersionAttribute_IsExcluded() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; +using Dapr.Workflow.Versioning; + +namespace MyApp; + +[WorkflowVersion(CanonicalName = "AbsOpenGeneric", Version = "1")] +public abstract class AbsOpenGenericWorkflow : Workflow { } +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for a versioned abstract open-generic workflow, but got:{Environment.NewLine}{generated}"); + } + + /// + /// A concrete open-generic workflow carrying [WorkflowVersion] must be skipped — + /// its unbound type parameters cannot be referenced in the generated registration calls. + /// Isolates the open-generic filter on a non-abstract type with versioning metadata. + /// + [Fact] + public async Task ConcreteOpenGenericWorkflow_WithVersionAttribute_IsExcluded() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; +using Dapr.Workflow.Versioning; + +namespace MyApp; + +[WorkflowVersion(CanonicalName = "ConcOpenGeneric", Version = "1")] +public sealed class ConcOpenGenericWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, T input) + => Task.FromResult(string.Empty); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for a versioned concrete open-generic workflow, but got:{Environment.NewLine}{generated}"); + } + + /// + /// A concrete closed workflow carrying [WorkflowVersion] must be included in the + /// generated versioning registry: its type name and declared canonical name must appear, + /// and the emitted source must be syntactically valid (the if/else if/else chain + /// in RegisterAlias must be well-formed — the exact code that produced an orphan + /// else in #1898 when only abstract/generic workflows were present). + /// + [Fact] + public async Task ConcreteClosedWorkflow_WithVersionAttribute_IsRegisteredWithCanonicalName() + { + const string source = """ +using System.Threading.Tasks; +using Dapr.Workflow; +using Dapr.Workflow.Versioning; + +namespace MyApp; + +[WorkflowVersion(CanonicalName = "VersionedConcrete", Version = "1")] +public sealed class VersionedConcreteWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, string input) + => Task.FromResult(input); +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.False(string.IsNullOrEmpty(generated), "Expected generated registry source for the versioned concrete workflow."); + Assert.Contains("VersionedConcreteWorkflow", generated, StringComparison.Ordinal); + // The declared canonical name must be emitted into the versioning registry entries. + Assert.Contains("VersionedConcrete", generated, StringComparison.Ordinal); + GeneratorTestHarness.AssertNoSyntaxErrors(generated); + } + + /// + /// A class that does not inherit from Workflow<,> must never be registered, + /// guarding against false positives from the discovery filter. + /// + [Fact] + public async Task NonWorkflowClass_IsNotRegistered() + { + const string source = """ +namespace MyApp; + +public sealed class NotAWorkflow +{ + public string Value { get; set; } = string.Empty; +} +"""; + + var (generated, diagnostics) = await GeneratorTestHarness.RunAsync(source); + + GeneratorTestHarness.AssertNoErrorDiagnostics(diagnostics); + Assert.True(string.IsNullOrEmpty(generated), + $"Expected no generated registry source for a non-workflow class, but got:{Environment.NewLine}{generated}"); + } +}