using Elsa.Common; using Elsa.Expressions.Contracts; using Elsa.Expressions.Services; using Elsa.Extensions; using Elsa.Mediator.Contracts; using Elsa.Workflows; using Elsa.Workflows.CommitStates; using Elsa.Workflows.Management.Providers; using Elsa.Workflows.Management.Services; using Microsoft.Extensions.DependencyInjection; using NSubstitute; namespace Elsa.Activities.UnitTests.Helpers; /// /// Helper class for unit testing activities in isolation. /// Provides methods to execute activities with minimal setup, abstracting away the complexity /// of creating WorkflowExecutionContext and ActivityExecutionContext. /// public static class ActivityTestHelper { /// /// Executes an activity in isolation for unit testing purposes and returns the execution context. /// This method handles all the complexity of setting up the execution context, /// evaluating inputs, and executing the activity. /// /// The activity to execute /// The ActivityExecutionContext used for execution public static async Task ExecuteActivityAsync(IActivity activity) { var context = CreateMinimalActivityExecutionContext(activity, out var serviceProvider); // Set up variables and inputs, then execute the activity await SetupExistingVariablesAsync(activity, context); await SetupInputValuesInMemoryAsync(activity, context, serviceProvider); await context.EvaluateInputPropertiesAsync(); await activity.ExecuteAsync(context); return context; } /// /// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities. /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern /// to provide proper context for activities. /// private static ActivityExecutionContext CreateMinimalActivityExecutionContext(IActivity activity, out IServiceProvider serviceProvider) { // Create a minimal service provider with the required services for expression evaluation var services = new ServiceCollection(); // Add core services services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(); // Add real expression evaluation services instead of mocks services.AddScoped(); // Add the well-known type registry required by expression handlers services.AddSingleton(); // Add the default expression descriptor provider which includes Literal expressions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => Substitute.For()); // Mock the complex workflow-level dependencies services.AddSingleton(_ => Substitute.For()); // Set up the activity registry lookup service to return proper descriptors var activityRegistryLookup = Substitute.For(); activityRegistryLookup.FindAsync(Arg.Any(), Arg.Any()).Returns(callInfo => { var activityType = callInfo.ArgAt(0); return Task.FromResult(new ActivityDescriptor { TypeName = activityType, Kind = ActivityKind.Action, Category = "Test", Description = "Test activity for unit testing", Version = 1 }); }); services.AddSingleton(_ => activityRegistryLookup); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); serviceProvider = services.BuildServiceProvider(); // Create a minimal workflow activity.Id ??= $"test-activity-{Guid.NewGuid()}"; var workflow = new Workflow { Root = activity }; // Create a simple workflow graph manually instead of using the builder var rootNode = new ActivityNode(activity, "Root"); var nodes = new List { rootNode }; var workflowGraph = new WorkflowGraph(workflow, rootNode, nodes); // Create workflow execution context using the static factory method var workflowExecutionContext = WorkflowExecutionContext.CreateAsync( serviceProvider, workflowGraph, $"test-instance-{Guid.NewGuid()}", CancellationToken.None ).GetAwaiter().GetResult(); // Create ActivityExecutionContext for the actual activity we want to test var activityExecutionContext = workflowExecutionContext.CreateActivityExecutionContextAsync(activity) .GetAwaiter().GetResult(); return activityExecutionContext; } /// /// Sets up existing variables found on the activity in the execution context. /// This is necessary because in unit tests, variables need to be initialized. /// private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context) { var activityType = activity.GetType(); var variableProperties = activityType.GetProperties() .Where(p => p.PropertyType.IsGenericType && p.PropertyType.BaseType == typeof(Variable)) .ToList(); foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) { variable.Set(context.ExpressionExecutionContext, variable.Value); } return Task.CompletedTask; } /// /// Sets up input values in memory blocks so that context.Get() can find them during activity execution. /// This mimics what the workflow engine does when evaluating inputs. /// private static async Task SetupInputValuesInMemoryAsync(IActivity activity, ActivityExecutionContext context, IServiceProvider serviceProvider) { var activityType = activity.GetType(); var inputProperties = activityType.GetProperties() .Where(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(Input<>)) .ToList(); foreach (var input in inputProperties.Select(property => property.GetValue(activity) as Input)) { if (input?.Expression == null) { continue; } // Get the memory block reference for this input var memoryBlockReference = input.MemoryBlockReference(); // Evaluate the input using the expression evaluator var expressionEvaluator = serviceProvider.GetService(); var evaluatedValue = await expressionEvaluator!.EvaluateAsync(input, context.ExpressionExecutionContext); // Set the value in the memory block memoryBlockReference.Set(context, evaluatedValue); } } }