using Elsa.Common.Multitenancy; using Elsa.Expressions.Models; using Elsa.Features.Services; using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; using Elsa.Workflows.Options; using Elsa.Workflows.State; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; namespace Elsa.Testing.Shared; /// /// A test fixture for integration testing workflows and activities. /// Provides a fluent API to configure services, run workflows, and capture output. /// public class WorkflowTestFixture { private readonly TestApplicationBuilder _testApplicationBuilder; private IServiceProvider? _services; /// /// Initializes a new instance of the class. /// /// The test output helper public WorkflowTestFixture(ITestOutputHelper testOutputHelper) { _testApplicationBuilder = new(testOutputHelper); CapturingTextWriter = new(); _testApplicationBuilder.WithCapturingTextWriter(CapturingTextWriter); } /// /// Gets the capturing text writer that captures standard output from WriteLine activities. /// public CapturingTextWriter CapturingTextWriter { get; } /// /// Gets the service provider. Throws if Build() hasn't been called yet. /// public IServiceProvider Services => _services ?? throw new InvalidOperationException("Build() must be called before accessing services"); /// /// Configures Elsa features. /// /// Action to configure Elsa /// The fixture instance for method chaining [UsedImplicitly] public WorkflowTestFixture ConfigureElsa(Action configure) { _testApplicationBuilder.ConfigureElsa(configure); return this; } /// /// Configures the service collection. /// /// Action to configure the service collection /// The fixture instance for method chaining [UsedImplicitly] public WorkflowTestFixture ConfigureServices(Action configure) { _testApplicationBuilder.ConfigureServices(configure); return this; } /// /// Adds a workflow to the service provider. /// [UsedImplicitly] public WorkflowTestFixture AddWorkflow() where T : IWorkflow { _testApplicationBuilder.AddWorkflow(); return this; } /// /// Adds activities from the assembly containing the specified type. /// [UsedImplicitly] public WorkflowTestFixture AddActivitiesFrom() { _testApplicationBuilder.AddActivitiesFrom(); return this; } /// /// Adds workflows from the specified relative directory. /// /// The path segments of the directory [UsedImplicitly] public WorkflowTestFixture WithWorkflowsFromDirectory(params string[] directory) { _testApplicationBuilder.WithWorkflowsFromDirectory(directory); return this; } /// /// Builds the service provider and populates registries. /// Must be called before running workflows. /// public async Task BuildAsync() { if (_services != null) return this; _services = _testApplicationBuilder.Build(); var tenantService = Services.GetRequiredService(); await tenantService.ActivateTenantsAsync(); return this; } /// /// Runs a workflow and returns the workflow result. /// Automatically builds the fixture if not already built. /// /// The workflow to run /// Cancellation token /// The workflow result after execution public async Task RunWorkflowAsync(IWorkflow workflow, CancellationToken cancellationToken = default) { await BuildAsync(); var workflowRunner = Services.GetRequiredService(); return await workflowRunner.RunAsync(workflow, cancellationToken: cancellationToken); } /// /// Runs the specified workflow and returns the workflow result. /// Automatically builds the fixture if not already built. /// /// Cancellation token /// The workflow result after execution public async Task RunWorkflowAsync(CancellationToken cancellationToken = default) where TWorkflow : IWorkflow, new() { await BuildAsync(); var workflowRunner = Services.GetRequiredService(); return await workflowRunner.RunAsync(cancellationToken: cancellationToken); } /// /// Runs a workflow with the specified options and returns the workflow result. /// Automatically builds the fixture if not already built. /// /// The workflow to run /// Workflow execution options /// Cancellation token /// The workflow result after execution public async Task RunWorkflowAsync(IWorkflow workflow, RunWorkflowOptions options, CancellationToken cancellationToken = default) { await BuildAsync(); var workflowRunner = Services.GetRequiredService(); return await workflowRunner.RunAsync(workflow, options, cancellationToken); } /// /// Runs the specified workflow with the specified options and returns the workflow result. /// Automatically builds the fixture if not already built. /// /// Workflow execution options /// Cancellation token /// The workflow result after execution public async Task RunWorkflowAsync(RunWorkflowOptions options, CancellationToken cancellationToken = default) where TWorkflow : IWorkflow, new() { await BuildAsync(); var workflowRunner = Services.GetRequiredService(); return await workflowRunner.RunAsync(options, cancellationToken); } /// /// Runs an activity wrapped in a workflow and returns the workflow result. /// Automatically builds the fixture if not already built. /// /// The activity to run /// Cancellation token /// The workflow result after execution public async Task RunActivityAsync(IActivity activity, CancellationToken cancellationToken = default) { await BuildAsync(); var workflowRunner = Services.GetRequiredService(); return await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken); } /// /// Runs an activity wrapped in a workflow with the specified options and returns the workflow result. /// Automatically builds the fixture if not already built. /// /// The activity to run /// Workflow execution options /// Cancellation token /// The workflow result after execution public async Task RunActivityAsync(IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default) { await BuildAsync(); var workflowRunner = Services.GetRequiredService(); return await workflowRunner.RunAsync(activity, options, cancellationToken); } /// /// Runs a workflow by definition ID and returns the workflow state. /// Automatically builds the fixture if not already built. /// /// The workflow definition ID /// Optional input dictionary /// Optional workflow execution options /// The workflow state after execution public async Task RunWorkflowAsync(string definitionId, IDictionary? input = null, RunWorkflowOptions? options = null) { await BuildAsync(); return await Services.RunWorkflowUntilEndAsync(definitionId, input, runWorkflowOptions: options); } public async Task ImportWorkflowDefinitionAsync(string fileName) { await BuildAsync(); return await Services.ImportWorkflowDefinitionAsync(fileName); } /// /// Gets the outcomes produced by a specific activity from the workflow result. /// /// The workflow run result /// The activity to get outcomes for /// Collection of outcome names public IEnumerable GetOutcomes(RunWorkflowResult result, IActivity activity) { var activityContext = result.Journal.ActivityExecutionContexts .FirstOrDefault(c => c.Activity.Id == activity.Id); return activityContext?.GetOutcomes() ?? []; } /// /// Checks if a specific activity produced a specific outcome. /// /// The workflow run result /// The activity to check /// The outcome name to check for /// True if the activity produced the specified outcome public bool HasOutcome(RunWorkflowResult result, IActivity activity, string outcome) { return GetOutcomes(result, activity).Contains(outcome); } /// /// Gets the execution status of a specific activity from the workflow result. /// /// The workflow run result /// The activity to get status for /// The activity status, or null if the activity wasn't found in the journal public ActivityStatus? GetActivityStatus(RunWorkflowResult result, IActivity activity) { var activityContext = result.Journal.ActivityExecutionContexts .FirstOrDefault(c => c.Activity.Id == activity.Id); return activityContext?.Status; } /// /// Creates a WorkflowExecutionContext for testing. /// This creates a minimal workflow execution context without executing the workflow. /// /// Optional workflow variables to include in the workflow /// A WorkflowExecutionContext that can be used for testing public async Task CreateWorkflowExecutionContextAsync(Variable[]? variables = null) { if (_services == null) await BuildAsync(); // Create a minimal workflow with variables var workflow = new Workflow { Root = new Sequence() }; if (variables != null) foreach (var variable in variables) workflow.Variables.Add(variable); // Build the workflow graph var workflowGraphBuilder = Services.GetRequiredService(); var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow); // Create workflow execution context return await WorkflowExecutionContext.CreateAsync( Services, workflowGraph, $"test-instance-{Guid.NewGuid()}", CancellationToken.None ); } /// /// Creates an ActivityExecutionContext for testing. /// Creates a workflow execution context first, then creates an activity execution context for the specified activity. /// /// The activity to create a context for. If null, uses the workflow itself. /// Optional workflow variables to include /// An ActivityExecutionContext that can be used for testing public async Task CreateActivityExecutionContextAsync(IActivity? activity = null, Variable[]? variables = null) { var workflowExecutionContext = await CreateWorkflowExecutionContextAsync(variables); return await CreateActivityExecutionContextAsync(workflowExecutionContext, activity); } /// /// Creates an ActivityExecutionContext for testing using an existing WorkflowExecutionContext. /// /// The workflow execution context to use /// The activity to create a context for. If null, uses the workflow itself. /// An ActivityExecutionContext that can be used for testing public async Task CreateActivityExecutionContextAsync(WorkflowExecutionContext workflowExecutionContext, IActivity? activity = null) { // Use the workflow itself if no activity specified, as Workflow implements IVariableContainer. // This ensures variables are accessible. var targetActivity = activity ?? workflowExecutionContext.Workflow; return await workflowExecutionContext.CreateActivityExecutionContextAsync(targetActivity); } /// /// Creates an ExpressionExecutionContext for testing expression evaluation. /// This creates a minimal workflow and activity execution context, then initializes variables. /// Variables are properly registered and accessible via dynamic accessors (e.g., getMyVariable, setMyVariable). /// /// Optional workflow variables to include in the execution context /// An ExpressionExecutionContext that can be used for expression evaluation public async Task CreateExpressionExecutionContextAsync(Variable[]? variables = null) { var activityContext = await CreateActivityExecutionContextAsync(activity: null, variables: variables); return await CreateExpressionExecutionContextAsync(activityContext, variables); } /// /// Creates an ExpressionExecutionContext using an existing ActivityExecutionContext. /// Initializes variables if provided. /// /// The activity execution context to use /// Optional workflow variables to initialize /// An ExpressionExecutionContext that can be used for expression evaluation public Task CreateExpressionExecutionContextAsync(ActivityExecutionContext activityContext, Variable[]? variables = null) { // Initialize variables in the execution context if provided // Use Variable.Set() to properly register variables (same approach as ActivityTestFixture) if (variables != null) foreach (var variable in variables) variable.Set(activityContext.ExpressionExecutionContext, variable.Value); return Task.FromResult(activityContext.ExpressionExecutionContext); } }