From f863849003b9508d5d740f64b847673a0e1fb8b2 Mon Sep 17 00:00:00 2001 From: Nils Gruson Date: Thu, 9 Jan 2025 16:38:55 +0100 Subject: [PATCH] Add analyzers and code fixes for Dapr workflows Introduce `WorkflowRegistrationAnalyzer` and `WorkflowActivityAnalyzer` to validate workflow and activity registrations in the DI container. Implement corresponding code fix providers to suggest automatic fixes. Add tests to ensure functionality and robustness of the Dapr workflow framework. Signed-off-by: Nils Gruson --- .../AnalyzerReleases.Shipped.md | 3 +- .../WorkflowActivityAnalyzer.cs | 104 ---------- ...flowActivityRegistrationCodeFixProvider.cs | 124 ++++++++++++ .../WorkflowRegistrationAnalyzer.cs | 178 ++++++++++++++++++ .../WorkflowRegistrationCodeFixProvider.cs | 124 ++++++++++++ .../Dapr.Workflow.Analyzers.Test/Utilities.cs | 54 ++++++ .../{Verify.cs => VerifyAnalyzer.cs} | 38 ++-- .../VerifyCodeFix.cs | 56 ++++++ .../WorkflowActivityAnalyzerTests.cs | 84 --------- ...ctivityRegistrationCodeFixProviderTests.cs | 75 ++++++++ .../WorkflowRegistrationAnalyzerTests.cs | 161 ++++++++++++++++ ...orkflowRegistrationCodeFixProviderTests.cs | 79 ++++++++ 12 files changed, 868 insertions(+), 212 deletions(-) delete mode 100644 src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs create mode 100644 src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs create mode 100644 src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs create mode 100644 src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs create mode 100644 test/Dapr.Workflow.Analyzers.Test/Utilities.cs rename test/Dapr.Workflow.Analyzers.Test/{Verify.cs => VerifyAnalyzer.cs} (58%) create mode 100644 test/Dapr.Workflow.Analyzers.Test/VerifyCodeFix.cs delete mode 100644 test/Dapr.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs create mode 100644 test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs create mode 100644 test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs create mode 100644 test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs diff --git a/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md index a69e0089..8a4a7a2b 100644 --- a/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md @@ -4,4 +4,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|-------------------- -DAPR1001| Usage | Warning | The class '{0}' is not registered \ No newline at end of file +DAPR1001| Usage | Warning | The workflow class '{0}' is not registered +DAPR1002| Usage | Warning | The workflow activity class '{0}' is not registered \ No newline at end of file diff --git a/src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs deleted file mode 100644 index 1ee6b556..00000000 --- a/src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Dapr.Workflow.Analyzers; - -/// -/// Analyzes whether or not workflow activities are registered. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public class WorkflowActivityAnalyzer : DiagnosticAnalyzer -{ - private static readonly DiagnosticDescriptor DiagnosticDescriptor = new( - "DAPR1001", - "Class not registered in DI", - "The class '{0}' is not registered in the DI container", - "Usage", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - - /// - /// Gets the supported diagnostics for this analyzer. - /// - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptor); - - /// - /// Initializes the analyzer. - /// - /// The analysis context. - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeSyntaxNode, SyntaxKind.InvocationExpression); - } - - private void AnalyzeSyntaxNode(SyntaxNodeAnalysisContext context) - { - var invocationExpr = (InvocationExpressionSyntax)context.Node; - - if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) - return; - - if (memberAccessExpr.Name.Identifier.Text != "CallActivityAsync") - return; - - var argumentList = invocationExpr.ArgumentList.Arguments; - if (argumentList.Count == 0) - return; - - var firstArgument = argumentList[0].Expression; - if (firstArgument is InvocationExpressionSyntax nameofInvocation) - { - var activityName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); - if (activityName != null) - { - bool isRegistered = CheckIfActivityIsRegistered(activityName, context.SemanticModel); - if (!isRegistered) - { - var diagnostic = Diagnostic.Create(DiagnosticDescriptor, firstArgument.GetLocation(), activityName); - context.ReportDiagnostic(diagnostic); - } - } - } - } - - private static bool CheckIfActivityIsRegistered(string activityName, SemanticModel semanticModel) - { - var methodInvocations = new List(); - foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) - { - var root = syntaxTree.GetRoot(); - methodInvocations.AddRange(root.DescendantNodes().OfType()); - } - - foreach (var invocation in methodInvocations) - { - if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) - { - continue; - } - - var methodName = memberAccess.Name.Identifier.Text; - if (methodName == "RegisterActivity") - { - if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) - { - if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) - { - if (string.Equals(typeArgument.Identifier.Text, activityName)) - { - return true; - } - } - } - } - } - - return false; - } -} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs b/src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs new file mode 100644 index 00000000..91158bb6 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs @@ -0,0 +1,124 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Provides code fixes for DAPR1002 diagnostic. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WorkflowActivityRegistrationCodeFixProvider))] +[Shared] +public class WorkflowActivityRegistrationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR1002"); + + /// + /// Registers the code fix for the diagnostic. + /// + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Register workflow activity"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => RegisterWorkflowActivityAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task RegisterWorkflowActivityAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var oldInvocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + + if (oldInvocation is null) + return document; + + if (root == null || oldInvocation == null) + return document; + + // Extract the workflow activity type name + var workflowActivityType = oldInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString(); + + if (string.IsNullOrEmpty(workflowActivityType)) + return document; + + // Get the compilation + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + if (compilation == null) + return document; + + InvocationExpressionSyntax? addDaprWorkflowInvocation = null; + SyntaxNode? targetRoot = null; + Document? targetDocument = null; + + // Iterate through all syntax trees in the compilation + foreach (var syntaxTree in compilation.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + addDaprWorkflowInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); + + if (addDaprWorkflowInvocation != null) + { + targetRoot = syntaxRoot; + targetDocument = document.Project.GetDocument(syntaxTree); + break; + } + } + + if (addDaprWorkflowInvocation == null || targetRoot == null || targetDocument == null) + return document; + + // Find the options lambda block + var optionsLambda = addDaprWorkflowInvocation.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + // Extract the parameter name from the lambda expression + var parameterName = optionsLambda.Parameter.Identifier.Text; + + // Create the new workflow registration statement + var registerWorkflowStatement = SyntaxFactory.ParseStatement($"{parameterName}.RegisterActivity<{workflowActivityType}>();"); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // Add the new registration statement to the options block + var newOptionsBlock = optionsBlock.AddStatements(registerWorkflowStatement); + + // Replace the old options block with the new one + var newRoot = targetRoot.ReplaceNode(optionsBlock, newOptionsBlock); + + // Format the new root. + newRoot = Formatter.Format(newRoot, document.Project.Solution.Workspace); + + return targetDocument.WithSyntaxRoot(newRoot); + } + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider instance. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } +} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs new file mode 100644 index 00000000..f335d717 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs @@ -0,0 +1,178 @@ +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Analyzes whether or not workflow activities are registered. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class WorkflowRegistrationAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor WorkflowDiagnosticDescriptor = new( + "DAPR1001", + "Workflow not registered", + "The workflow class '{0}' is not registered", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor WorkflowActivityDiagnosticDescriptor = new( + "DAPR1002", + "Workflow activity not registered", + "The workflow activity class '{0}' is not registered", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + + /// + /// Gets the supported diagnostics for this analyzer. + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(WorkflowDiagnosticDescriptor, WorkflowActivityDiagnosticDescriptor); + + /// + /// Initializes the analyzer. + /// + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeWorkflowRegistration, SyntaxKind.InvocationExpression); + context.RegisterSyntaxNodeAction(AnalyzeWorkflowActivityRegistration, SyntaxKind.InvocationExpression); + } + + private void AnalyzeWorkflowRegistration(SyntaxNodeAnalysisContext context) + { + var invocationExpr = (InvocationExpressionSyntax)context.Node; + + if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) + return; + + if (memberAccessExpr.Name.Identifier.Text != "ScheduleNewWorkflowAsync") + return; + + var argumentList = invocationExpr.ArgumentList.Arguments; + if (argumentList.Count == 0) + return; + + var firstArgument = argumentList[0].Expression; + if (firstArgument is InvocationExpressionSyntax nameofInvocation) + { + var workflowName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); + if (workflowName != null) + { + bool isRegistered = CheckIfWorkflowIsRegistered(workflowName, context.SemanticModel); + if (!isRegistered) + { + var diagnostic = Diagnostic.Create(WorkflowDiagnosticDescriptor, firstArgument.GetLocation(), workflowName); + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private void AnalyzeWorkflowActivityRegistration(SyntaxNodeAnalysisContext context) + { + var invocationExpr = (InvocationExpressionSyntax)context.Node; + + if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) + return; + + if (memberAccessExpr.Name.Identifier.Text != "CallActivityAsync") + return; + + var argumentList = invocationExpr.ArgumentList.Arguments; + if (argumentList.Count == 0) + return; + + var firstArgument = argumentList[0].Expression; + if (firstArgument is InvocationExpressionSyntax nameofInvocation) + { + var activityName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); + if (activityName != null) + { + bool isRegistered = CheckIfActivityIsRegistered(activityName, context.SemanticModel); + if (!isRegistered) + { + var diagnostic = Diagnostic.Create(WorkflowActivityDiagnosticDescriptor, firstArgument.GetLocation(), activityName); + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static bool CheckIfWorkflowIsRegistered(string workflowName, SemanticModel semanticModel) + { + var methodInvocations = new List(); + foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) + { + var root = syntaxTree.GetRoot(); + methodInvocations.AddRange(root.DescendantNodes().OfType()); + } + + foreach (var invocation in methodInvocations) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == "RegisterWorkflow") + { + if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) + { + if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) + { + if (typeArgument.Identifier.Text == workflowName) + { + return true; + } + } + } + } + } + + return false; + } + + private static bool CheckIfActivityIsRegistered(string activityName, SemanticModel semanticModel) + { + var methodInvocations = new List(); + foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) + { + var root = syntaxTree.GetRoot(); + methodInvocations.AddRange(root.DescendantNodes().OfType()); + } + + foreach (var invocation in methodInvocations) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == "RegisterActivity") + { + if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) + { + if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) + { + if (typeArgument.Identifier.Text == activityName) + { + return true; + } + } + } + } + } + + return false; + } +} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs new file mode 100644 index 00000000..06e34e36 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs @@ -0,0 +1,124 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Provides code fixes for DAPR1001 diagnostic. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WorkflowRegistrationCodeFixProvider))] +[Shared] +public class WorkflowRegistrationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR1001"); + + /// + /// Registers the code fix for the diagnostic. + /// + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Register workflow"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => RegisterWorkflowAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task RegisterWorkflowAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var oldInvocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + + if (oldInvocation is null) + return document; + + if (root == null || oldInvocation == null) + return document; + + // Extract the workflow type name + var workflowType = oldInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString(); + + if (string.IsNullOrEmpty(workflowType)) + return document; + + // Get the compilation + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + if (compilation == null) + return document; + + InvocationExpressionSyntax? addDaprWorkflowInvocation = null; + SyntaxNode? targetRoot = null; + Document? targetDocument = null; + + // Iterate through all syntax trees in the compilation + foreach (var syntaxTree in compilation.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + addDaprWorkflowInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); + + if (addDaprWorkflowInvocation != null) + { + targetRoot = syntaxRoot; + targetDocument = document.Project.GetDocument(syntaxTree); + break; + } + } + + if (addDaprWorkflowInvocation == null || targetRoot == null || targetDocument == null) + return document; + + // Find the options lambda block + var optionsLambda = addDaprWorkflowInvocation.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // Extract the parameter name from the lambda expression + var parameterName = optionsLambda.Parameter.Identifier.Text; + + // Create the new workflow registration statement + var registerWorkflowStatement = SyntaxFactory.ParseStatement($"{parameterName}.RegisterWorkflow<{workflowType}>();"); + + // Add the new registration statement to the options block + var newOptionsBlock = optionsBlock.AddStatements(registerWorkflowStatement); + + // Replace the old options block with the new one + var newRoot = targetRoot.ReplaceNode(optionsBlock, newOptionsBlock); + + // Format the new root. + newRoot = Formatter.Format(newRoot, document.Project.Solution.Workspace); + + return targetDocument.WithSyntaxRoot(newRoot); + } + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider instance. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs new file mode 100644 index 00000000..51ebfef4 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Reflection; + +namespace Dapr.Workflow.Analyzers.Test; + +internal static class Utilities +{ + public static async Task<(ImmutableArray diagnostics, Document document, Workspace workspace)> GetDiagnosticsAdvanced(string code) + { + var workspace = new AdhocWorkspace(); + + // Create a new project with necessary references + var project = workspace.AddProject("TestProject", LanguageNames.CSharp) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(Workflow<,>))); + + // Add the document to the project + var document = project.AddDocument("TestDocument.cs", code); + + // Get the syntax tree and create a compilation + var syntaxTree = await document.GetSyntaxTreeAsync() ?? throw new InvalidOperationException("Syntax tree is null"); + var compilation = CSharpCompilation.Create("TestCompilation") + .AddSyntaxTrees(syntaxTree) + .AddReferences(project.MetadataReferences); + + var compilationWithAnalyzer = compilation.WithAnalyzers( + ImmutableArray.Create( + new WorkflowRegistrationAnalyzer())); + + // Get diagnostics from the compilation + var diagnostics = await compilationWithAnalyzer.GetAllDiagnosticsAsync(); + return (diagnostics, document, workspace); + } + + public static MetadataReference[] GetAllReferencesNeededForType(Type type) + { + var files = GetAllAssemblyFilesNeededForType(type); + + return files.Select(x => MetadataReference.CreateFromFile(x)).Cast().ToArray(); + } + + private static ImmutableArray GetAllAssemblyFilesNeededForType(Type type) + { + return type.Assembly.GetReferencedAssemblies() + .Select(x => Assembly.Load(x.FullName)) + .Append(type.Assembly) + .Select(x => x.Location) + .ToImmutableArray(); + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/Verify.cs b/test/Dapr.Workflow.Analyzers.Test/VerifyAnalyzer.cs similarity index 58% rename from test/Dapr.Workflow.Analyzers.Test/Verify.cs rename to test/Dapr.Workflow.Analyzers.Test/VerifyAnalyzer.cs index 6a892ef2..2c0204b0 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Verify.cs +++ b/test/Dapr.Workflow.Analyzers.Test/VerifyAnalyzer.cs @@ -1,12 +1,10 @@ using Microsoft.CodeAnalysis; -using System.Collections.Immutable; -using System.Reflection; using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; namespace Dapr.Workflow.Analyzers.Test; -internal static class Verify +internal static class VerifyAnalyzer { public static DiagnosticResult Diagnostic(string diagnosticId, DiagnosticSeverity diagnosticSeverity) { @@ -20,15 +18,25 @@ public static async Task VerifyAnalyzerAsync(string source, params DiagnosticRes public static async Task VerifyAnalyzerAsync(string source, string? program, params DiagnosticResult[] expected) { - var test = new Test { TestCode = source, ReferenceAssemblies = ReferenceAssemblies.Net.Net60 }; + var test = new Test { TestCode = source }; + +#if NET6_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net60; +#elif NET7_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net70; +#elif NET8_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net90; +#endif if (program != null) { test.TestState.Sources.Add(("Program.cs", program)); } - var metadataReferences = GetAllReferencesNeededForType(typeof(WorkflowActivityAnalyzer)).ToList(); - metadataReferences.AddRange(GetAllReferencesNeededForType(typeof(Workflow<,>))); + var metadataReferences = Utilities.GetAllReferencesNeededForType(typeof(WorkflowRegistrationAnalyzer)).ToList(); + metadataReferences.AddRange(Utilities.GetAllReferencesNeededForType(typeof(Workflow<,>))); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); foreach (var reference in metadataReferences) @@ -40,7 +48,7 @@ public static async Task VerifyAnalyzerAsync(string source, string? program, par await test.RunAsync(CancellationToken.None); } - private class Test : CSharpAnalyzerTest + private class Test : CSharpAnalyzerTest { public Test() { @@ -52,20 +60,4 @@ public Test() }); } } - - private static MetadataReference[] GetAllReferencesNeededForType(Type type) - { - var files = GetAllAssemblyFilesNeededForType(type); - - return files.Select(x => MetadataReference.CreateFromFile(x)).Cast().ToArray(); - } - - private static ImmutableArray GetAllAssemblyFilesNeededForType(Type type) - { - return type.Assembly.GetReferencedAssemblies() - .Select(x => Assembly.Load(x.FullName)) - .Append(type.Assembly) - .Select(x => x.Location) - .ToImmutableArray(); - } } diff --git a/test/Dapr.Workflow.Analyzers.Test/VerifyCodeFix.cs b/test/Dapr.Workflow.Analyzers.Test/VerifyCodeFix.cs new file mode 100644 index 00000000..ba7c89b5 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/VerifyCodeFix.cs @@ -0,0 +1,56 @@ +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Dapr.Workflow.Analyzers.Test; + +internal class VerifyCodeFix +{ + public static async Task RunTest(string code, string expectedChangedCode) where T : CodeFixProvider, new() + { + var (diagnostics, document, workspace) = await Utilities.GetDiagnosticsAdvanced(code); + + Assert.Single(diagnostics); + + var diagnostic = diagnostics[0]; + + var codeFixProvider = new T(); + + CodeAction? registeredCodeAction = null; + + var context = new CodeFixContext(document, diagnostic, (codeAction, _) => + { + if (registeredCodeAction != null) + throw new Exception("Code action was registered more than once"); + + registeredCodeAction = codeAction; + + }, CancellationToken.None); + + await codeFixProvider.RegisterCodeFixesAsync(context); + + if (registeredCodeAction == null) + throw new Exception("Code action was not registered"); + + var operations = await registeredCodeAction.GetOperationsAsync(CancellationToken.None); + + foreach (var operation in operations) + { + operation.Apply(workspace, CancellationToken.None); + } + + var updatedDocument = workspace.CurrentSolution.GetDocument(document.Id) ?? throw new Exception("Updated document is null"); + var newCode = (await updatedDocument.GetTextAsync()).ToString(); + + // Normalize whitespace + string NormalizeWhitespace(string input) + { + var separator = new[] { ' ', '\r', '\n' }; + return string.Join(" ", input.Split(separator, StringSplitOptions.RemoveEmptyEntries)); + } + + var normalizedExpectedCode = NormalizeWhitespace(expectedChangedCode); + var normalizedNewCode = NormalizeWhitespace(newCode); + + Assert.Equal(normalizedExpectedCode, normalizedNewCode); + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs deleted file mode 100644 index 07a38e51..00000000 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace Dapr.Workflow.Analyzers.Test; - -public class WorkflowActivityAnalyzerTests -{ - [Fact] - public async Task VerifyNotifyActivityNotRegistered() - { - var testCode = @" -using Dapr.Workflow; -using System.Threading.Tasks; - -class OrderProcessingWorkflow : Workflow -{ - public override async Task RunAsync(WorkflowContext context, OrderPayload order) - { - await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); - return new OrderResult(""Order processed""); - } -} - -class OrderPayload { } -class OrderResult(string message) { } -class Notification { public Notification(string message) { } } -class NotifyActivity { } -"; - - var expected = Verify.Diagnostic("DAPR1001", DiagnosticSeverity.Warning) - .WithSpan(9, 41, 9, 63).WithMessage("The class 'NotifyActivity' is not registered in the DI container"); - - await Verify.VerifyAnalyzerAsync(testCode, expected); - } - - [Fact] - public async Task VerifyNotifyActivityRegistered() - { - var testCode = @" - using Dapr.Workflow; - using Microsoft.Extensions.DependencyInjection; - using System.Threading.Tasks; - - class OrderProcessingWorkflow : Workflow - { - public override async Task RunAsync(WorkflowContext context, OrderPayload order) - { - await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); - return new OrderResult(""Order processed""); - } - } - - record OrderPayload { } - record OrderResult(string message) { } - record Notification(string Message); - - class NotifyActivity : WorkflowActivity - { - - public override Task RunAsync(WorkflowActivityContext context, Notification notification) - { - return Task.FromResult(null); - } - } - "; - - var startupCode = @" - using Dapr.Workflow; - using Microsoft.Extensions.DependencyInjection; - - internal static class Extensions - { - public static void AddApplicationServices(this IServiceCollection services) - { - services.AddDaprWorkflow(options => - { - options.RegisterActivity(); - }); - } - } - "; - - await Verify.VerifyAnalyzerAsync(testCode, startupCode); - } -} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs new file mode 100644 index 00000000..57cda2f2 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs @@ -0,0 +1,75 @@ +namespace Dapr.Workflow.Analyzers.Test; + +public class WorkflowActivityRegistrationCodeFixProviderTests +{ + [Fact] + public async Task VerifyWorkflowActivityRegistrationCodeFix() + { + var code = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + }); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification { public Notification(string message) { } } + class NotifyActivity { } + "; + + var expectedChangedCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + options.RegisterActivity(); + }); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification { public Notification(string message) { } } + class NotifyActivity { } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs new file mode 100644 index 00000000..9ce8d1d8 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs @@ -0,0 +1,161 @@ +using Microsoft.CodeAnalysis; + +namespace Dapr.Workflow.Analyzers.Test; + +public class WorkflowRegistrationAnalyzerTests +{ + public class Workflow + { + [Fact] + public async Task VerifyWorkflowNotRegistered() + { + var testCode = @" + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + return new OrderResult(""Order processed""); + } + } + + class UseWorkflow() + { + public async Task RunWorkflow(DaprWorkflowClient client, OrderPayload order) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, order); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR1001", DiagnosticSeverity.Warning) + .WithSpan(17, 63, 17, 94).WithMessage("The workflow class 'OrderProcessingWorkflow' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyWorkflowRegistered() + { + var testCode = @" + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + var startupCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + + internal static class Extensions + { + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, startupCode); + } + } + + public class WorkflowActivity + { + [Fact] + public async Task VerifyActivityNotRegistered() + { + var testCode = @" + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification { public Notification(string message) { } } + class NotifyActivity { } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR1002", DiagnosticSeverity.Warning) + .WithSpan(9, 57, 9, 79).WithMessage("The workflow activity class 'NotifyActivity' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyActivityRegistered() + { + var testCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification(string Message); + + class NotifyActivity : WorkflowActivity + { + + public override Task RunAsync(WorkflowActivityContext context, Notification notification) + { + return Task.FromResult(null); + } + } + "; + + var startupCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + + internal static class Extensions + { + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddDaprWorkflow(options => + { + options.RegisterActivity(); + }); + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, startupCode); + } + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs new file mode 100644 index 00000000..0ec1941f --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs @@ -0,0 +1,79 @@ +namespace Dapr.Workflow.Analyzers.Test; + +public class WorkflowRegistrationCodeFixProviderTests +{ + [Fact] + public async Task VerifyWorkflowRegistrationCodeFix() + { + var code = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult(""Order processed"")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + var expectedChangedCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult(""Order processed"")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +}