Configures Flowchart Execution via DI (#7141)

* Introduce `FlowchartExecutionMode` to streamline flowchart execution logic.

- Added `FlowchartExecutionMode` enum to represent execution modes (Default, TokenBased, CounterBased).
- Updated flowchart-related integration and unit tests to use the new execution mode.
- Removed the global `UseTokenFlow` flag in favor of execution-specific configuration via `RunWorkflowOptions`.
- Refactored flowchart-related APIs and test helpers for improved flexibility and modularity.

* Add support for configuring flowchart execution behavior via `FlowchartOptions` and DI.

- Introduced extensions for `FlowchartFeature` to simplify configuration.
- Added DI support for setting default execution modes.
- Refactored flowchart execution logic to prioritize configuration.

* Update default Flowchart execution settings to align with version 3.5.2 behavior

- Changed `DefaultExecutionMode` to `CounterBased`.
- Updated `UseTokenFlow` default to `false`.

* Update src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowchartExecutionMode.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/FlowchartFeatureExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/RunWorkflowOptionsExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor flowchart integration tests for improved formatting and consistency

* Refactor workflow tests and related services to improve reusability and align with updated Flowchart execution behavior

* Update src/modules/Elsa.Workflows.Core/Activities/Flowchart/Options/FlowchartOptions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor flowchart execution logic to use `FlowchartExecutionMode` enum, replacing boolean checks for improved clarity and extensibility.

* Refactor tests and workflow logic to replace boolean `useTokenFlow` with `FlowchartExecutionMode` enum for clarity and consistency.

* Refactor flowchart execution logic to centralize mode-based behavior handling and simplify implementation.

* Remove unnecessary whitespace in Flowchart.cs to improve code formatting

* Remove unnecessary whitespace in FlowJoinTests.cs to improve code formatting

* Update src/common/Elsa.Testing.Shared.Integration/WorkflowTestFixture.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-12-15 10:15:39 +01:00 committed by GitHub
parent 8b9070df09
commit 4bbdd6f3a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 859 additions and 486 deletions

View file

@ -12,6 +12,7 @@ using Elsa.Tenants.AspNetCore;
using Elsa.Tenants.Extensions;
using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Api;
using Elsa.Workflows.CommitStates.Strategies;
using Elsa.Workflows.IncidentStrategies;
@ -60,6 +61,7 @@ services
strategies.Add("Every 10 seconds", new PeriodicWorkflowStrategy(TimeSpan.FromSeconds(10)));
});
})
.UseFlowchart(flowchart => flowchart.UseCounterBasedExecution())
.UseWorkflowManagement(management =>
{
management.UseEntityFrameworkCore(ef => ef.UseSqlite());

View file

@ -20,70 +20,76 @@ namespace Elsa.Testing.Shared;
[PublicAPI]
public static class RunWorkflowExtensions
{
/// <summary>
/// Runs a workflow until its end, automatically resuming any bookmark it encounters.
/// </summary>
/// <param name="services">The services.</param>
/// <param name="workflowDefinitionId">The ID of the workflow definition.</param>
/// <param name="input">An optional dictionary of input values.</param>
/// <param name="correlationId">An optional correlation id of the workflow.</param>
/// <param name="versionOptions">An optional set of options to specify the version of the workflow definition to retrieve.</param>
/// <returns>The workflow state.</returns>
public static async Task<WorkflowState> RunWorkflowUntilEndAsync(this IServiceProvider services,
string workflowDefinitionId,
IDictionary<string, object>? input = null,
string? correlationId = null,
VersionOptions? versionOptions = null)
extension(IServiceProvider services)
{
var workflowDefinitionService = services.GetRequiredService<IWorkflowDefinitionService>();
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, versionOptions ?? VersionOptions.Published);
if (workflowGraph == null)
throw new InvalidOperationException($"Workflow definition with ID '{workflowDefinitionId}' not found.");
var workflowRuntime = services.GetRequiredService<IWorkflowRuntime>();
var workflowClient = await workflowRuntime.CreateClientAsync();
var response = await workflowClient.CreateAndRunInstanceAsync(new()
/// <summary>
/// Runs a workflow until its end, automatically resuming any bookmark it encounters.
/// </summary>
/// <param name="workflowDefinitionId">The ID of the workflow definition.</param>
/// <param name="input">An optional dictionary of input values.</param>
/// <param name="correlationId">An optional correlation id of the workflow.</param>
/// <param name="versionOptions">An optional set of options to specify the version of the workflow definition to retrieve.</param>
/// <param name="runWorkflowOptions">Optional workflow execution options.</param>
/// <returns>The workflow state.</returns>
public async Task<WorkflowState> RunWorkflowUntilEndAsync(string workflowDefinitionId,
IDictionary<string, object>? input = null,
string? correlationId = null,
VersionOptions? versionOptions = null,
RunWorkflowOptions? runWorkflowOptions = null)
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id),
Input = input,
CorrelationId = correlationId
});
var bookmarkStore = services.GetRequiredService<IBookmarkStore>();
var workflowDefinitionService = services.GetRequiredService<IWorkflowDefinitionService>();
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, versionOptions ?? VersionOptions.Published);
// Continue resuming the workflow for as long as there are bookmarks to resume and the workflow is not Finished.
while (response.Status != WorkflowStatus.Finished)
{
var bookmarks = (await bookmarkStore.FindManyAsync(new()
if (workflowGraph == null)
throw new InvalidOperationException($"Workflow definition with ID '{workflowDefinitionId}' not found.");
var workflowRuntime = services.GetRequiredService<IWorkflowRuntime>();
var workflowClient = await workflowRuntime.CreateClientAsync();
var response = await workflowClient.CreateAndRunInstanceAsync(new()
{
WorkflowInstanceId = response.WorkflowInstanceId
})).ToList();
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id),
Input = input,
CorrelationId = correlationId,
Properties = runWorkflowOptions?.Properties
});
if (!bookmarks.Any())
break;
var bookmarkStore = services.GetRequiredService<IBookmarkStore>();
foreach (var bookmark in bookmarks)
// Continue resuming the workflow for as long as there are bookmarks to resume and the workflow is not Finished.
while (response.Status != WorkflowStatus.Finished)
{
var runRequest = new RunWorkflowInstanceRequest
var bookmarks = (await bookmarkStore.FindManyAsync(new()
{
BookmarkId = bookmark.Id,
Input = input
};
response = await workflowClient.RunInstanceAsync(runRequest);
WorkflowInstanceId = response.WorkflowInstanceId
})).ToList();
if (!bookmarks.Any())
break;
foreach (var bookmark in bookmarks)
{
var runRequest = new RunWorkflowInstanceRequest
{
BookmarkId = bookmark.Id,
Input = input,
Properties = runWorkflowOptions?.Properties
};
response = await workflowClient.RunInstanceAsync(runRequest);
}
}
// Return the workflow state.
return await workflowClient.ExportStateAsync();
}
// Return the workflow state.
return await workflowClient.ExportStateAsync();
}
/// <summary>
/// Runs a workflow until its end, automatically resuming any bookmark it encounters.
/// </summary>
public static async Task<WorkflowState> RunWorkflowUntilEndAsync<TWorkflow>(this IServiceProvider services, IDictionary<string, object>? input = null) where TWorkflow : IWorkflow
{
var workflowDefinitionId = typeof(TWorkflow).Name;
return await services.RunWorkflowUntilEndAsync(workflowDefinitionId, input);
/// <summary>
/// Runs a workflow until its end, automatically resuming any bookmark it encounters.
/// </summary>
public async Task<WorkflowState> RunWorkflowUntilEndAsync<TWorkflow>(IDictionary<string, object>? input = null) where TWorkflow : IWorkflow
{
var workflowDefinitionId = typeof(TWorkflow).Name;
return await services.RunWorkflowUntilEndAsync(workflowDefinitionId, input);
}
}
}

View file

@ -20,51 +20,52 @@ namespace Elsa.Testing.Shared;
[PublicAPI]
public static class ServiceProviderExtensions
{
/// <summary>
/// Updates the registries.
/// </summary>
/// <param name="services">The services.</param>
public static Task PopulateRegistriesAsync(this IServiceProvider services)
extension(IServiceProvider services)
{
var registriesPopulator = services.GetRequiredService<IRegistriesPopulator>();
return registriesPopulator.PopulateAsync();
}
/// <summary>
/// Imports a workflow definition from a file.
/// </summary>
/// <param name="services">The services.</param>
/// <param name="fileName">The file name.</param>
/// <returns>The workflow definition.</returns>
public static async Task<WorkflowDefinition> ImportWorkflowDefinitionAsync(this IServiceProvider services, string fileName)
{
var json = await File.ReadAllTextAsync(fileName);
var serializer = services.GetRequiredService<IActivitySerializer>();
var model = serializer.Deserialize<WorkflowDefinitionModel>(json);
var workflowDefinitionRequest = new SaveWorkflowDefinitionRequest
/// <summary>
/// Updates the registries.
/// </summary>
public Task PopulateRegistriesAsync()
{
Model = model,
Publish = true
};
var registriesPopulator = services.GetRequiredService<IRegistriesPopulator>();
return registriesPopulator.PopulateAsync();
}
var workflowDefinitionImporter = services.GetRequiredService<IWorkflowDefinitionImporter>();
var result = await workflowDefinitionImporter.ImportAsync(workflowDefinitionRequest);
return result.WorkflowDefinition;
}
/// <summary>
/// Imports a workflow definition from a file.
/// </summary>
/// <param name="fileName">The file name.</param>
/// <returns>The workflow definition.</returns>
public async Task<WorkflowDefinition> ImportWorkflowDefinitionAsync(string fileName)
{
var json = await File.ReadAllTextAsync(fileName);
var serializer = services.GetRequiredService<IActivitySerializer>();
var model = serializer.Deserialize<WorkflowDefinitionModel>(json);
/// <summary>
/// Retrieves a workflow definition by its ID.
/// </summary>
/// <param name="services">The service provider.</param>
/// <param name="workflowDefinitionId">The definition ID of the workflow definition.</param>
/// <param name="versionOptions">Options to specify the version of the workflow definition to retrieve.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The retrieved workflow definition.</returns>
public static async Task<WorkflowDefinition> GetWorkflowDefinitionAsync(this IServiceProvider services, string workflowDefinitionId, VersionOptions versionOptions, CancellationToken cancellationToken = default)
{
var workflowDefinitionService = services.GetRequiredService<IWorkflowDefinitionService>();
var workflowDefinition = await workflowDefinitionService.FindWorkflowDefinitionAsync(workflowDefinitionId, versionOptions, cancellationToken);
return workflowDefinition!;
var workflowDefinitionRequest = new SaveWorkflowDefinitionRequest
{
Model = model,
Publish = true
};
var workflowDefinitionImporter = services.GetRequiredService<IWorkflowDefinitionImporter>();
var result = await workflowDefinitionImporter.ImportAsync(workflowDefinitionRequest);
return result.WorkflowDefinition;
}
/// <summary>
/// Retrieves a workflow definition by its ID.
/// </summary>
/// <param name="workflowDefinitionId">The definition ID of the workflow definition.</param>
/// <param name="versionOptions">Options to specify the version of the workflow definition to retrieve.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The retrieved workflow definition.</returns>
public async Task<WorkflowDefinition> GetWorkflowDefinitionAsync(string workflowDefinitionId, VersionOptions versionOptions, CancellationToken cancellationToken = default)
{
var workflowDefinitionService = services.GetRequiredService<IWorkflowDefinitionService>();
var workflowDefinition = await workflowDefinitionService.FindWorkflowDefinitionAsync(workflowDefinitionId, versionOptions, cancellationToken);
return workflowDefinition!;
}
}
}

View file

@ -2,8 +2,10 @@ 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;
@ -102,6 +104,9 @@ public class WorkflowTestFixture
/// </summary>
public async Task<WorkflowTestFixture> BuildAsync()
{
if (_services != null)
return this;
_services = _testApplicationBuilder.Build();
await Services.PopulateRegistriesAsync();
return this;
@ -116,12 +121,52 @@ public class WorkflowTestFixture
/// <returns>The workflow result after execution</returns>
public async Task<RunWorkflowResult> RunWorkflowAsync(IWorkflow workflow, CancellationToken cancellationToken = default)
{
if (_services == null)
await BuildAsync();
await BuildAsync();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
return await workflowRunner.RunAsync(workflow, cancellationToken: cancellationToken);
}
/// <summary>
/// Runs the specified workflow and returns the workflow result.
/// Automatically builds the fixture if not already built.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The workflow result after execution</returns>
public async Task<RunWorkflowResult> RunWorkflowAsync<TWorkflow>(CancellationToken cancellationToken = default) where TWorkflow : IWorkflow, new()
{
await BuildAsync();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
return await workflowRunner.RunAsync<TWorkflow>(cancellationToken: cancellationToken);
}
/// <summary>
/// Runs a workflow with the specified options and returns the workflow result.
/// Automatically builds the fixture if not already built.
/// </summary>
/// <param name="workflow">The workflow to run</param>
/// <param name="options">Workflow execution options</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The workflow result after execution</returns>
public async Task<RunWorkflowResult> RunWorkflowAsync(IWorkflow workflow, RunWorkflowOptions options, CancellationToken cancellationToken = default)
{
await BuildAsync();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
return await workflowRunner.RunAsync(workflow, options, cancellationToken);
}
/// <summary>
/// Runs the specified workflow with the specified options and returns the workflow result.
/// Automatically builds the fixture if not already built.
/// </summary>
/// <param name="options">Workflow execution options</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The workflow result after execution</returns>
public async Task<RunWorkflowResult> RunWorkflowAsync<TWorkflow>(RunWorkflowOptions options, CancellationToken cancellationToken = default) where TWorkflow : IWorkflow, new()
{
await BuildAsync();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
return await workflowRunner.RunAsync<TWorkflow>(options, cancellationToken);
}
/// <summary>
/// Runs an activity wrapped in a workflow and returns the workflow result.
@ -132,26 +177,45 @@ public class WorkflowTestFixture
/// <returns>The workflow result after execution</returns>
public async Task<RunWorkflowResult> RunActivityAsync(IActivity activity, CancellationToken cancellationToken = default)
{
if (_services == null)
await BuildAsync();
await BuildAsync();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
return await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken);
}
/// <summary>
/// Runs an activity wrapped in a workflow with the specified options and returns the workflow result.
/// Automatically builds the fixture if not already built.
/// </summary>
/// <param name="activity">The activity to run</param>
/// <param name="options">Workflow execution options</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The workflow result after execution</returns>
public async Task<RunWorkflowResult> RunActivityAsync(IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default)
{
await BuildAsync();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
return await workflowRunner.RunAsync(activity, options, cancellationToken);
}
/// <summary>
/// Runs a workflow by definition ID and returns the workflow state.
/// Automatically builds the fixture if not already built.
/// </summary>
/// <param name="definitionId">The workflow definition ID</param>
/// <param name="input">Optional input dictionary</param>
/// <param name="options">Optional workflow execution options</param>
/// <returns>The workflow state after execution</returns>
public async Task<WorkflowState> RunWorkflowAsync(string definitionId, IDictionary<string, object>? input = null)
public async Task<WorkflowState> RunWorkflowAsync(string definitionId, IDictionary<string, object>? input = null, RunWorkflowOptions? options = null)
{
if (_services == null)
await BuildAsync();
await BuildAsync();
return await Services.RunWorkflowUntilEndAsync(definitionId, input, runWorkflowOptions: options);
}
return await Services.RunWorkflowUntilEndAsync(definitionId, input);
public async Task<WorkflowDefinition> ImportWorkflowDefinitionAsync(string fileName)
{
await BuildAsync();
return await Services.ImportWorkflowDefinitionAsync(fileName);
}
/// <summary>

View file

@ -1,8 +1,11 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Activities.Flowchart.Options;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Signals;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Elsa.Workflows.Activities.Flowchart.Activities;
@ -14,9 +17,17 @@ namespace Elsa.Workflows.Activities.Flowchart.Activities;
public partial class Flowchart : Container
{
/// <summary>
/// Set this to <c>false</c> from your program file in case you wish to use the old counter based model.
/// The property key used to store the flowchart execution mode in <see cref="WorkflowExecutionContext.Properties"/>.
/// </summary>
public static bool UseTokenFlow = true;
public const string ExecutionModePropertyKey = "Flowchart:ExecutionMode";
/// <summary>
/// Set this to <c>false</c> from your program file in case you wish to use the old counter based model.
/// This static field is used as a final fallback when no execution mode is specified via options or workflow execution context properties.
/// Note: Prefer using <see cref="FlowchartOptions"/> configured via DI for application-wide settings.
/// </summary>
// ReSharper disable once GrammarMistakeInComment
public static bool UseTokenFlow = false; // Default to false to maintain the same behavior with 3.5.2 out of the box.
/// <inheritdoc />
public Flowchart([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
@ -29,7 +40,7 @@ public partial class Flowchart : Container
/// <summary>
/// The activity to execute when the flowchart starts.
/// </summary>
[Port][Browsable(false)] public IActivity? Start { get; set; }
[Port] [Browsable(false)] public IActivity? Start { get; set; }
/// <summary>
/// A list of connections between activities.
@ -78,16 +89,18 @@ public partial class Flowchart : Container
private ValueTask OnChildCompletedAsync(ActivityCompletedContext context)
{
return UseTokenFlow
? OnChildCompletedTokenBasedLogicAsync(context)
: OnChildCompletedCounterBasedLogicAsync(context);
return ExecuteBasedOnMode(
context.TargetContext,
() => OnChildCompletedTokenBasedLogicAsync(context),
() => OnChildCompletedCounterBasedLogicAsync(context));
}
private ValueTask OnActivityCanceledAsync(CancelSignal signal, SignalContext context)
{
return UseTokenFlow
? OnTokenFlowActivityCanceledAsync(signal, context)
: OnCounterFlowActivityCanceledAsync(signal, context);
return ExecuteBasedOnMode(
context.ReceiverActivityExecutionContext,
() => OnTokenFlowActivityCanceledAsync(signal, context),
() => OnCounterFlowActivityCanceledAsync(signal, context));
}
private async Task CompleteIfNoPendingWorkAsync(ActivityExecutionContext context)
@ -104,4 +117,50 @@ public partial class Flowchart : Container
}
}
}
private static ValueTask ExecuteBasedOnMode(ActivityExecutionContext context, Func<ValueTask> tokenBasedAction, Func<ValueTask> counterBasedAction)
{
var mode = GetEffectiveExecutionMode(context);
return mode switch
{
FlowchartExecutionMode.TokenBased => tokenBasedAction(),
FlowchartExecutionMode.CounterBased or FlowchartExecutionMode.Default or _ => counterBasedAction()
};
}
/// <summary>
/// Gets the effective execution mode for this flowchart execution.
/// Priority: WorkflowExecutionContext.Properties > FlowchartOptions (DI) > Static UseTokenFlow flag
/// </summary>
private static FlowchartExecutionMode GetEffectiveExecutionMode(ActivityExecutionContext context)
{
var workflowExecutionContext = context.WorkflowExecutionContext;
if (!workflowExecutionContext.Properties.TryGetValue(ExecutionModePropertyKey, out var modeValue))
return GetDefaultModeFromOptions(context);
var mode = ParseExecutionMode(modeValue);
return mode != FlowchartExecutionMode.Default ? mode : GetDefaultModeFromOptions(context);
}
private static FlowchartExecutionMode ParseExecutionMode(object modeValue)
{
return modeValue switch
{
FlowchartExecutionMode executionMode => executionMode,
string str when Enum.TryParse<FlowchartExecutionMode>(str, true, out var parsed) => parsed,
int intValue when Enum.IsDefined(typeof(FlowchartExecutionMode), intValue) => (FlowchartExecutionMode)intValue,
_ => FlowchartExecutionMode.Default
};
}
private static FlowchartExecutionMode GetDefaultModeFromOptions(ActivityExecutionContext context)
{
var options = context.WorkflowExecutionContext.ServiceProvider.GetService<IOptions<FlowchartOptions>>();
var mode = options?.Value.DefaultExecutionMode ?? FlowchartExecutionMode.Default;
if (mode == FlowchartExecutionMode.Default)
return UseTokenFlow ? FlowchartExecutionMode.TokenBased : FlowchartExecutionMode.CounterBased;
return mode;
}
}

View file

@ -0,0 +1,47 @@
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Activities.Flowchart.Options;
using Elsa.Workflows.Features;
namespace Elsa.Workflows.Activities.Flowchart.Extensions;
/// <summary>
/// Extension methods for <see cref="FlowchartFeature"/>.
/// </summary>
public static class FlowchartFeatureExtensions
{
extension(FlowchartFeature feature)
{
/// <summary>
/// Configures the flowchart options.
/// </summary>
public FlowchartFeature ConfigureFlowchart(Action<FlowchartOptions> configure)
{
feature.FlowchartOptionsConfigurator = configure;
return feature;
}
/// <summary>
/// Sets the default execution mode for flowcharts to token-based.
/// </summary>
public FlowchartFeature UseTokenBasedExecution()
{
return feature.ConfigureFlowchart(options => options.DefaultExecutionMode = FlowchartExecutionMode.TokenBased);
}
/// <summary>
/// Sets the default execution mode for flowcharts to counter-based (legacy mode).
/// </summary>
public FlowchartFeature UseCounterBasedExecution()
{
return feature.ConfigureFlowchart(options => options.DefaultExecutionMode = FlowchartExecutionMode.CounterBased);
}
/// <summary>
/// Sets the default execution mode for flowcharts to the specified mode.
/// </summary>
public FlowchartFeature UseExecution(FlowchartExecutionMode mode)
{
return feature.ConfigureFlowchart(options => options.DefaultExecutionMode = mode);
}
}
}

View file

@ -0,0 +1,39 @@
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Options;
namespace Elsa.Workflows.Activities.Flowchart.Extensions;
/// <summary>
/// Extension methods for <see cref="RunWorkflowOptions"/> to configure flowchart execution mode.
/// </summary>
public static class RunWorkflowOptionsExtensions
{
extension(RunWorkflowOptions options)
{
/// <summary>
/// Sets the flowchart execution mode to token-based.
/// </summary>
public RunWorkflowOptions WithTokenBasedFlowchart()
{
return options.WithFlowchartExecutionMode(FlowchartExecutionMode.TokenBased);
}
/// <summary>
/// Sets the flowchart execution mode to counter-based (legacy mode).
/// </summary>
public RunWorkflowOptions WithCounterBasedFlowchart()
{
return options.WithFlowchartExecutionMode(FlowchartExecutionMode.CounterBased);
}
/// <summary>
/// Sets the flowchart execution mode.
/// </summary>
public RunWorkflowOptions WithFlowchartExecutionMode(FlowchartExecutionMode mode)
{
options.Properties ??= new Dictionary<string, object>();
options.Properties[Activities.Flowchart.ExecutionModePropertyKey] = mode;
return options;
}
}
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Workflows.Activities.Flowchart.Models;
/// <summary>
/// Specifies the execution mode for flowchart activities.
/// </summary>
public enum FlowchartExecutionMode
{
/// <summary>
/// Use the default mode as specified by <see cref="Elsa.Workflows.Activities.Flowchart.Flowchart.UseTokenFlow"/>.
/// </summary>
Default = 0,
/// <summary>
/// Use token-based flow logic.
/// </summary>
TokenBased = 1,
/// <summary>
/// Use counter-based flow logic (legacy mode).
/// </summary>
CounterBased = 2
}

View file

@ -0,0 +1,15 @@
using Elsa.Workflows.Activities.Flowchart.Models;
namespace Elsa.Workflows.Activities.Flowchart.Options;
/// <summary>
/// Options for configuring flowchart execution behavior.
/// </summary>
public class FlowchartOptions
{
/// <summary>
/// Gets or sets the default execution mode for flowcharts when not explicitly specified.
/// Defaults to <see cref="FlowchartExecutionMode.CounterBased"/>.
/// </summary>
public FlowchartExecutionMode DefaultExecutionMode { get; set; } = FlowchartExecutionMode.CounterBased; // Default to counter-based in order to maintain the same behavior with 3.5.2 out of the box.
}

View file

@ -6,9 +6,18 @@ namespace Elsa.Extensions;
public static class ModuleExtensions
{
public static IModule UseWorkflows(this IModule configuration, Action<WorkflowsFeature>? configure = default)
extension(IModule configuration)
{
configuration.Configure(configure);
return configuration;
public IModule UseWorkflows(Action<WorkflowsFeature>? configure = null)
{
configuration.Configure(configure);
return configuration;
}
public IModule UseFlowchart(Action<FlowchartFeature>? configure = null)
{
configuration.Configure(configure);
return configuration;
}
}
}

View file

@ -2,7 +2,9 @@ using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Activities.Flowchart.Options;
using Elsa.Workflows.Activities.Flowchart.Serialization;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Features;
@ -16,11 +18,21 @@ public class FlowchartFeature : FeatureBase
{
}
/// <summary>
/// A delegate to configure <see cref="FlowchartOptions"/>.
/// </summary>
public Action<FlowchartOptions>? FlowchartOptionsConfigurator { get; set; }
/// <inheritdoc />
public override void Apply()
{
Services.AddSerializationOptionsConfigurator<FlowchartSerializationOptionConfigurator>();
// Register FlowchartOptions
Services.AddOptions<FlowchartOptions>();
if (FlowchartOptionsConfigurator != null)
Services.Configure(FlowchartOptionsConfigurator);
}
public override void Configure()

View file

@ -1,6 +1,9 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Options;
using Xunit.Abstractions;
namespace Elsa.Activities.IntegrationTests.Branching;
@ -9,23 +12,15 @@ namespace Elsa.Activities.IntegrationTests.Branching;
/// Integration tests for FlowDecision activity in flowchart scenarios.
/// </summary>
[Collection("FlowchartTests")]
public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
public class FlowDecisionTests(ITestOutputHelper testOutputHelper)
{
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
private readonly bool _originalFlowMode = Flowchart.UseTokenFlow;
public void Dispose()
{
Flowchart.UseTokenFlow = _originalFlowMode;
}
[Theory(DisplayName = "FlowDecision follows correct path based on condition")]
[MemberData(nameof(BasicPathTestCases))]
public async Task Should_Follow_Correct_Path_Based_On_Condition(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs)
public async Task Should_Follow_Correct_Path_Based_On_Condition(FlowchartExecutionMode executionMode, bool condition, string[] expectedOutputs, string[] unexpectedOutputs)
{
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var start = new WriteLine("Start");
var decision = new FlowDecision(ctx => condition);
var truePath = new WriteLine("TruePath");
@ -43,8 +38,10 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
}
};
var options = new RunWorkflowOptions().WithFlowchartExecutionMode(executionMode);
// Act
await _fixture.RunActivityAsync(flowchart);
await _fixture.RunActivityAsync(flowchart, options);
// Assert
AssertOutputs(expectedOutputs, unexpectedOutputs);
@ -53,19 +50,17 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
public static IEnumerable<object[]> BasicPathTestCases()
{
// useTokenFlow, condition, expectedOutputs, unexpectedOutputs
yield return [true, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }];
yield return [true, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }];
yield return [false, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }];
yield return [false, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }];
yield return [FlowchartExecutionMode.TokenBased, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }];
yield return [FlowchartExecutionMode.TokenBased, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }];
yield return [FlowchartExecutionMode.CounterBased, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }];
yield return [FlowchartExecutionMode.CounterBased, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }];
}
[Theory(DisplayName = "FlowDecision handles nested decisions")]
[MemberData(nameof(NestedDecisionTestCases))]
public async Task Should_Handle_Nested_Decisions(bool useTokenFlow, bool outerCondition, bool innerCondition, string[] expectedOutputs, string[] unexpectedOutputs)
public async Task Should_Handle_Nested_Decisions(FlowchartExecutionMode executionMode, bool outerCondition, bool innerCondition, string[] expectedOutputs, string[] unexpectedOutputs)
{
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var start = new WriteLine("Start");
var outerDecision = new FlowDecision(ctx => outerCondition);
var innerDecision = new FlowDecision(ctx => innerCondition);
@ -87,8 +82,10 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
}
};
var options = new RunWorkflowOptions().WithFlowchartExecutionMode(executionMode);
// Act
await _fixture.RunActivityAsync(flowchart);
await _fixture.RunActivityAsync(flowchart, options);
// Assert
AssertOutputs(expectedOutputs, unexpectedOutputs);
@ -97,23 +94,21 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
public static IEnumerable<object[]> NestedDecisionTestCases()
{
// useTokenFlow, outerCondition, innerCondition, expectedOutputs, unexpectedOutputs
yield return [true, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }];
yield return [true, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }];
yield return [true, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [true, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [false, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }];
yield return [false, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }];
yield return [false, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [false, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [FlowchartExecutionMode.TokenBased, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }];
yield return [FlowchartExecutionMode.TokenBased, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }];
yield return [FlowchartExecutionMode.TokenBased, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [FlowchartExecutionMode.TokenBased, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [FlowchartExecutionMode.CounterBased, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }];
yield return [FlowchartExecutionMode.CounterBased, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }];
yield return [FlowchartExecutionMode.CounterBased, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
yield return [FlowchartExecutionMode.CounterBased, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }];
}
[Theory(DisplayName = "FlowDecision works with only one path connected")]
[MemberData(nameof(OnePathConnectedTestCases))]
public async Task Should_Work_With_Only_One_Path_Connected(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs)
public async Task Should_Work_With_Only_One_Path_Connected(FlowchartExecutionMode executionMode, bool condition, string[] expectedOutputs, string[] unexpectedOutputs)
{
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var start = new WriteLine("Start");
var decision = new FlowDecision(ctx => condition);
var truePath = new WriteLine("TruePath");
@ -132,8 +127,10 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
}
};
var options = new RunWorkflowOptions().WithFlowchartExecutionMode(executionMode);
// Act
await _fixture.RunActivityAsync(flowchart);
await _fixture.RunActivityAsync(flowchart, options);
// Assert
AssertOutputs(expectedOutputs, unexpectedOutputs);
@ -142,19 +139,17 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
public static IEnumerable<object[]> OnePathConnectedTestCases()
{
// useTokenFlow, condition, expectedOutputs, unexpectedOutputs
yield return [true, true, new[] { "Start", "TruePath", "End" }, Array.Empty<string>()];
yield return [true, false, new[] { "Start" }, new[] { "TruePath", "End" }];
yield return [false, true, new[] { "Start", "TruePath", "End" }, Array.Empty<string>()];
yield return [false, false, new[] { "Start" }, new[] { "TruePath", "End" }];
yield return [FlowchartExecutionMode.TokenBased, true, new[] { "Start", "TruePath", "End" }, Array.Empty<string>()];
yield return [FlowchartExecutionMode.TokenBased, false, new[] { "Start" }, new[] { "TruePath", "End" }];
yield return [FlowchartExecutionMode.CounterBased, true, new[] { "Start", "TruePath", "End" }, Array.Empty<string>()];
yield return [FlowchartExecutionMode.CounterBased, false, new[] { "Start" }, new[] { "TruePath", "End" }];
}
[Theory(DisplayName = "FlowDecision converges paths correctly")]
[MemberData(nameof(ConvergePathsTestCases))]
public async Task Should_Converge_Paths_Correctly(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs)
public async Task Should_Converge_Paths_Correctly(FlowchartExecutionMode executionMode, bool condition, string[] expectedOutputs, string[] unexpectedOutputs)
{
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var start = new WriteLine("Start");
var decision = new FlowDecision(ctx => condition);
var truePath = new WriteLine("TruePath");
@ -175,8 +170,10 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
}
};
var options = new RunWorkflowOptions().WithFlowchartExecutionMode(executionMode);
// Act
await _fixture.RunActivityAsync(flowchart);
await _fixture.RunActivityAsync(flowchart, options);
// Assert
AssertOutputs(expectedOutputs, unexpectedOutputs);
@ -185,22 +182,15 @@ public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable
public static IEnumerable<object[]> ConvergePathsTestCases()
{
// useTokenFlow, condition, expectedOutputs, unexpectedOutputs
yield return [true, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }];
yield return [true, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }];
yield return [false, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }];
yield return [false, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }];
yield return [FlowchartExecutionMode.TokenBased, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }];
yield return [FlowchartExecutionMode.TokenBased, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }];
yield return [FlowchartExecutionMode.CounterBased, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }];
yield return [FlowchartExecutionMode.CounterBased, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }];
}
private void AssertOutputs(string[] expectedOutputs, string[] unexpectedOutputs)
{
foreach (var expected in expectedOutputs)
{
Assert.Contains(expected, _fixture.CapturingTextWriter.Lines);
}
foreach (var unexpected in unexpectedOutputs)
{
Assert.DoesNotContain(unexpected, _fixture.CapturingTextWriter.Lines);
}
foreach (var expected in expectedOutputs) Assert.Contains(expected, _fixture.CapturingTextWriter.Lines);
foreach (var unexpected in unexpectedOutputs) Assert.DoesNotContain(unexpected, _fixture.CapturingTextWriter.Lines);
}
}

View file

@ -10,22 +10,15 @@ namespace Elsa.Activities.IntegrationTests.Branching;
/// <summary>
/// Integration tests for FlowJoin activity in complex flowchart scenarios.
/// </summary>
public class FlowJoinTests : IDisposable
public class FlowJoinTests
{
private readonly IServiceProvider _services;
private readonly CapturingTextWriter _output;
private readonly bool _originalFlowMode;
public FlowJoinTests(ITestOutputHelper testOutputHelper)
{
_output = new();
_services = CreateServiceProvider(testOutputHelper, _output);
_originalFlowMode = Flowchart.UseTokenFlow;
}
public void Dispose()
{
Flowchart.UseTokenFlow = _originalFlowMode;
}
[Theory]
@ -37,17 +30,17 @@ public class FlowJoinTests : IDisposable
{
// Test with a more complex flowchart that has multiple activities
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var executionMode = useTokenFlow ? FlowchartExecutionMode.TokenBased : FlowchartExecutionMode.CounterBased;
var startActivity = new WriteLine("Start");
var flowJoin = new FlowJoin { Mode = new(joinMode) };
var afterJoin = new WriteLine("AfterJoin");
var flowchart = new Flowchart
{
Start = startActivity,
Activities = { startActivity, flowJoin, afterJoin },
Connections =
Connections =
{
new() { Source = new(startActivity, "Done"), Target = new(flowJoin) },
new() { Source = new(flowJoin, "Done"), Target = new(afterJoin) }
@ -55,7 +48,7 @@ public class FlowJoinTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, executionMode);
// Assert
Assert.Contains("Start", _output.Lines);
@ -82,7 +75,7 @@ public class FlowJoinTests : IDisposable
{
// Test FlowJoin with a Fork-Join pattern
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var executionMode = useTokenFlow ? FlowchartExecutionMode.TokenBased : FlowchartExecutionMode.CounterBased;
var start = new WriteLine("Start");
var fork = new FlowFork { Branches = new(["Branch1", "Branch2"]) };
@ -107,7 +100,7 @@ public class FlowJoinTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, executionMode);
// Assert
Assert.Contains("Start", _output.Lines);
@ -149,7 +142,7 @@ public class FlowJoinTests : IDisposable
{
// Test multiple FlowJoin activities in a complex flowchart
// Arrange
Flowchart.UseTokenFlow = useTokenFlow;
var executionMode = useTokenFlow ? FlowchartExecutionMode.TokenBased : FlowchartExecutionMode.CounterBased;
var start = new WriteLine("Start");
var fork1 = new FlowFork { Branches = new(["A", "B"]) };
@ -185,7 +178,7 @@ public class FlowJoinTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, executionMode);
// Assert
Assert.Contains("Start", _output.Lines);

View file

@ -11,23 +11,15 @@ namespace Elsa.Activities.IntegrationTests.Flow;
/// Integration tests for counter-based flowchart execution strategy.
/// </summary>
[Collection("FlowchartTests")]
public class FlowchartCounterBasedTests : IDisposable
public class FlowchartCounterBasedTests
{
private readonly IServiceProvider _services;
private readonly CapturingTextWriter _output;
private readonly bool _originalFlowMode;
public FlowchartCounterBasedTests(ITestOutputHelper testOutputHelper)
{
_output = new();
_services = CreateServiceProvider(testOutputHelper, _output);
_originalFlowMode = Flowchart.UseTokenFlow;
Flowchart.UseTokenFlow = false;
}
public void Dispose()
{
Flowchart.UseTokenFlow = _originalFlowMode;
}
[Fact(DisplayName = "Executes simple linear flowchart")]
@ -41,7 +33,7 @@ public class FlowchartCounterBasedTests : IDisposable
);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Equal(3, _output.Lines.Count);
@ -60,7 +52,7 @@ public class FlowchartCounterBasedTests : IDisposable
var flowchart = CreateBranchingFlowchart(start, branch1, branch2);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Equal(3, _output.Lines.Count);
@ -81,7 +73,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Single(_output.Lines);
@ -98,7 +90,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
var result = await RunFlowchartAsync(_services, flowchart);
var result = await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.NotNull(result);
@ -122,7 +114,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Single(_output.Lines);
@ -154,7 +146,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -188,7 +180,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -229,7 +221,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -269,7 +261,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -292,7 +284,7 @@ public class FlowchartCounterBasedTests : IDisposable
);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Equal(4, _output.Lines.Count);
@ -342,7 +334,7 @@ public class FlowchartCounterBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Single(_output.Lines);

View file

@ -1,8 +1,10 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Xunit.Abstractions;
namespace Elsa.Activities.IntegrationTests.Flow;
@ -20,9 +22,13 @@ public static class FlowchartTestHelpers
return builder.Build();
}
public static async Task<RunWorkflowResult> RunFlowchartAsync(IServiceProvider services, Flowchart flowchart)
public static async Task<RunWorkflowResult> RunFlowchartAsync(IServiceProvider services, Flowchart flowchart, FlowchartExecutionMode? executionMode = null)
{
return await services.RunActivityAsync(flowchart);
var options = executionMode.HasValue
? new RunWorkflowOptions().WithFlowchartExecutionMode(executionMode.Value)
: null;
return await services.RunActivityAsync(flowchart, options);
}
public static Connection CreateConnection(IActivity source, IActivity target, string? outcome = "Done")

View file

@ -12,23 +12,15 @@ namespace Elsa.Activities.IntegrationTests.Flow;
/// Integration tests for token-based flowchart execution strategy.
/// </summary>
[Collection("FlowchartTests")]
public class FlowchartTokenBasedTests : IDisposable
public class FlowchartTokenBasedTests
{
private readonly IServiceProvider _services;
private readonly CapturingTextWriter _output;
private readonly bool _originalFlowMode;
public FlowchartTokenBasedTests(ITestOutputHelper testOutputHelper)
{
_output = new();
_services = CreateServiceProvider(testOutputHelper, _output);
_originalFlowMode = Flowchart.UseTokenFlow;
Flowchart.UseTokenFlow = true;
}
public void Dispose()
{
Flowchart.UseTokenFlow = _originalFlowMode;
}
[Fact(DisplayName = "Executes simple linear flowchart")]
@ -42,7 +34,7 @@ public class FlowchartTokenBasedTests : IDisposable
);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Equal(3, _output.Lines.Count);
@ -61,7 +53,7 @@ public class FlowchartTokenBasedTests : IDisposable
var flowchart = CreateBranchingFlowchart(start, branch1, branch2);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Equal(3, _output.Lines.Count);
@ -82,7 +74,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Single(_output.Lines);
@ -99,7 +91,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
var result = await RunFlowchartAsync(_services, flowchart);
var result = await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.NotNull(result);
@ -123,7 +115,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Single(_output.Lines);
@ -154,7 +146,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -186,7 +178,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -220,7 +212,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -254,7 +246,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -274,7 +266,7 @@ public class FlowchartTokenBasedTests : IDisposable
var flowchart = CreateSimpleLinearFlowchart(start, middle, end);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
// Tokens should be consumed after each activity completes
@ -318,7 +310,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -361,7 +353,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -385,7 +377,7 @@ public class FlowchartTokenBasedTests : IDisposable
);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Equal(4, _output.Lines.Count);
@ -435,7 +427,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Single(_output.Lines);
@ -477,7 +469,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -498,7 +490,7 @@ public class FlowchartTokenBasedTests : IDisposable
var flowchart = CreateSimpleLinearFlowchart(start, single, end);
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
Assert.Contains("Start", _output.Lines);
@ -532,7 +524,7 @@ public class FlowchartTokenBasedTests : IDisposable
};
// Act
await RunFlowchartAsync(_services, flowchart);
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.TokenBased);
// Assert
// Verify all activities executed in a valid order

View file

@ -2,40 +2,25 @@ using Elsa.Expressions.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.IntegrationTests.Scenarios.FlowchartNextActivity.Workflows;
using Elsa.Workflows.Memory;
using Microsoft.Extensions.DependencyInjection;
using Elsa.Workflows.Options;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.FlowchartNextActivity;
public class FlowchartNextActivityTests
public class FlowchartNextActivityTests(ITestOutputHelper testOutputHelper)
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
private readonly IWorkflowRunner _workflowRunner;
public FlowchartNextActivityTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.AddActivitiesFrom<FlowchartNextActivityTests>()
.Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
private readonly WorkflowTestFixture _fixture = new WorkflowTestFixture(testOutputHelper).AddActivitiesFrom<FlowchartNextActivityTests>();
[Fact(DisplayName = "Flowchart only schedules next activity connected to outcome of previous activity.")]
public async Task Test1()
public async Task FlowchartOnlySchedulesNextConnectedActivity()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<FlowchartWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(new[]
{
"Line 1"
}, lines);
await _fixture.RunWorkflowAsync<FlowchartWorkflow>();
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(["Line 1"], lines);
}
[Fact(DisplayName = "Flowchart with backward connections and a dangling activity")]
@ -117,9 +102,8 @@ public class FlowchartNextActivityTests
};
});
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
var result = await _fixture.RunWorkflowAsync(workflow);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(new[]
{
@ -127,12 +111,10 @@ public class FlowchartNextActivityTests
}, lines);
}
[Fact(DisplayName = "Flowchart with an invalid backward connection")]
[Fact(DisplayName = "Flowchart with an invalid backward connection (counter-based mode only)")]
public async Task InvalidBackwardConnectionTest()
{
if(Flowchart.UseTokenFlow)
return;
// This test is only valid for counter-based mode
var workflow = new TestWorkflow(workflowBuilder =>
{
var start = new Start
@ -184,9 +166,9 @@ public class FlowchartNextActivityTests
};
});
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
var options = new RunWorkflowOptions().WithCounterBasedFlowchart();
var result = await _fixture.RunWorkflowAsync(workflow, options);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus);
Assert.Single(result.WorkflowState.Incidents);
Assert.Equal("Invalid backward connection: Every path from the source ('WriteLineE') must go through the target ('WriteLineC') when tracing back to the start.", result.WorkflowState.Incidents.First().Message);
@ -222,13 +204,14 @@ public class FlowchartNextActivityTests
};
var loopbackDecision = new FlowSwitch
{
Cases = {
Cases =
{
new("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
},
Mode = new(SwitchMode.MatchFirst)
};
var end = new End();
workflowBuilder.Root = new Flowchart
{
Variables =
@ -257,18 +240,20 @@ public class FlowchartNextActivityTests
new(c, join),
new(d, join),
new(join, incrementLoop),
new(incrementLoop,loopbackDecision),
new(incrementLoop, loopbackDecision),
new(new(loopbackDecision, "LessThanThree"), new Endpoint(a)),
new(new(loopbackDecision, "Default"), new Endpoint(end)),
}
};
});
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
var result = await _fixture.RunWorkflowAsync(workflow);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(new[] { "A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C", "D"}, lines);
Assert.Equal(new[]
{
"A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C", "D"
}, lines);
}
[Theory(DisplayName = "Flowchart with a Join activity executed multiple times, bug 6479")]
@ -284,7 +269,8 @@ public class FlowchartNextActivityTests
var start = new Start();
var loopbackSwitch = new FlowSwitch
{
Cases = {
Cases =
{
new("DoLoopback", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
},
Mode = new(SwitchMode.MatchFirst)
@ -301,7 +287,7 @@ public class FlowchartNextActivityTests
};
var b = new WriteLine("B");
var end = new End();
workflowBuilder.Root = new Flowchart
{
Variables =
@ -332,9 +318,8 @@ public class FlowchartNextActivityTests
};
});
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
var result = await _fixture.RunWorkflowAsync(workflow);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(new[]
{
@ -342,13 +327,31 @@ public class FlowchartNextActivityTests
}, lines);
}
[Theory(DisplayName = "Flowchart Join behaves correctly")]
[InlineData(false, FlowJoinMode.WaitAll, new[] { "A", "B", "C", "D", "F" })] // "E" is not scheduled because join has an unfollowed inbound connection
[InlineData(false, FlowJoinMode.WaitAllActive, new[] { "A", "B", "C", "D", "E", "F" })] // "E" gets scheduled by join with an unfollowed inbound connection
[InlineData(false, FlowJoinMode.WaitAny, new[] { "A", "B", "C", "D", "E", "F" })] // "E" only scheduled once
[InlineData(true, FlowJoinMode.WaitAll, new[] { "A", "B", "C", "E", "F" })] // all Join inbound connections followed, "E" gets scheduled
[InlineData(true, FlowJoinMode.WaitAllActive, new[] { "A", "B", "C", "E", "F" })] // all Join inbound connections followed, "E" gets scheduled
[InlineData(true, FlowJoinMode.WaitAny, new[] { "A", "B", "C", "E", "F" })] // "E" only scheduled once
[Theory(DisplayName = "Flowchart Join behaves correctly (counter-based mode)")]
[InlineData(false, FlowJoinMode.WaitAll, new[]
{
"A", "B", "C", "D", "F"
})] // "E" is not scheduled because join has an unfollowed inbound connection
[InlineData(false, FlowJoinMode.WaitAllActive, new[]
{
"A", "B", "C", "D", "E", "F"
})] // "E" gets scheduled by join with an unfollowed inbound connection
[InlineData(false, FlowJoinMode.WaitAny, new[]
{
"A", "B", "C", "D", "E", "F"
})] // "E" only scheduled once
[InlineData(true, FlowJoinMode.WaitAll, new[]
{
"A", "B", "C", "E", "F"
})] // all Join inbound connections followed, "E" gets scheduled
[InlineData(true, FlowJoinMode.WaitAllActive, new[]
{
"A", "B", "C", "E", "F"
})] // all Join inbound connections followed, "E" gets scheduled
[InlineData(true, FlowJoinMode.WaitAny, new[]
{
"A", "B", "C", "E", "F"
})] // "E" only scheduled once
// Start
// / | \
// / | \
@ -362,54 +365,75 @@ public class FlowchartNextActivityTests
// (false) \ | /
// | Join
// D |
// \ E
// \ E
// \ /
// \ /
// \ /
// \ /
// F
public async Task JoinBehavesCorrectly(bool decisionResult, FlowJoinMode joinMode, string[] expectedLines)
{
Flowchart.UseTokenFlow = false;
// This test validates counter-based mode behavior
var workflow = new TestWorkflow(workflowBuilder =>
{
var start = new Start() { Id = "Start" };
var a = new WriteLine("A") { Id = "WriteLineA" };
var b = new WriteLine("B") { Id = "WriteLineB" };
var c = new WriteLine("C") { Id = "WriteLineC" };
var start = new Start()
{
Id = "Start"
};
var a = new WriteLine("A")
{
Id = "WriteLineA"
};
var b = new WriteLine("B")
{
Id = "WriteLineB"
};
var c = new WriteLine("C")
{
Id = "WriteLineC"
};
var decision = new FlowDecision()
{
Condition = new(new Literal<bool>(decisionResult))
};
var d = new WriteLine("D") { Id = "WriteLineD" };
var d = new WriteLine("D")
{
Id = "WriteLineD"
};
var join = new FlowJoin()
{
Mode = new(joinMode)
};
var e = new WriteLine("E") { Id = "WriteLineE" };
var f = new WriteLine("F") { Id = "WriteLineF" };
var e = new WriteLine("E")
{
Id = "WriteLineE"
};
var f = new WriteLine("F")
{
Id = "WriteLineF"
};
workflowBuilder.Root = new Flowchart
{
Activities =
{
start,
a,
b,
c,
decision,
d,
join,
e,
f,
},
{
start,
a,
b,
c,
decision,
d,
join,
e,
f,
},
Connections =
{
new(start, a),
new(start, b),
new(start, c),
new(a, decision),
new(new Endpoint(decision, "True"), new Endpoint(join)),
new(new Endpoint(decision, "False"), new Endpoint(d)),
new(new(decision, "True"), new Endpoint(join)),
new(new(decision, "False"), new Endpoint(d)),
new(b, join),
new(c, join),
new(d, f),
@ -419,11 +443,10 @@ public class FlowchartNextActivityTests
};
});
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
var options = new RunWorkflowOptions().WithCounterBasedFlowchart();
var result = await _fixture.RunWorkflowAsync(workflow, options);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(expectedLines, lines);
Flowchart.UseTokenFlow = true;
}
}

View file

@ -1,36 +1,29 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Elsa.Workflows.Options;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class BraidedWorkflowTests
public class BraidedWorkflowTests(ITestOutputHelper testOutputHelper)
{
private readonly IWorkflowRunner _workflowRunner;
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public BraidedWorkflowTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
[Fact(DisplayName = "Braided workflows are executed correctly")]
public async Task Test1()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<BraidedWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
await _fixture.RunWorkflowAsync<BraidedWorkflow>(options);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(new[] { "WriteLine1", "WriteLine2", "WriteLine3", "WriteLine4", "WriteLine5", "WriteLine6", "WriteLine7" }, lines);
}
[Fact(DisplayName = "Braided workflows complete the workflow")]
public async Task Test2()
{
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync<BraidedWorkflow>();
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
var result = await _fixture.RunWorkflowAsync<BraidedWorkflow>(options);
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
}
}

View file

@ -1,17 +1,13 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Options;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class ForkDecisionJoinTests
public class ForkDecisionJoinTests(ITestOutputHelper testOutputHelper)
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public ForkDecisionJoinTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
[Fact(DisplayName = "The implicit join configured with Stream merge mode should execute.")]
public async Task ImplicitJoinStreamShouldExecute()
@ -36,36 +32,32 @@ public class ForkDecisionJoinTests
{
await RunAndAssert("fork-decision-join-waitall.json", ["A", "C", "B", "D"]);
}
[Fact(DisplayName = "An implicit join from the True and False branches should execute because the join mode is Stream and by default, all active branches are joined.")]
public async Task ImplicitJoinFromBranchesShouldExecute()
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json");
var workflowDefinition = await _fixture.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json");
// Execute.
var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Execute with token-based mode.
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
var workflowState = await _fixture.RunWorkflowAsync(workflowDefinition.DefinitionId, options: options);
// Assert.
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
}
private async Task RunAndAssert(string workflowFileName, string[] expectedLines)
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/{workflowFileName}");
var workflowDefinition = await _fixture.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/{workflowFileName}");
// Execute with token-based mode.
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
await _fixture.RunWorkflowAsync(workflowDefinition.DefinitionId, options: options);
// Execute.
await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Assert.
var lines = _capturingTextWriter.Lines.ToList();
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(expectedLines, lines);
}
}

View file

@ -1,36 +1,29 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Elsa.Workflows.Options;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class ImplicitWorkflowTests
public class ImplicitWorkflowTests(ITestOutputHelper testOutputHelper)
{
private readonly IWorkflowRunner _workflowRunner;
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public ImplicitWorkflowTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
[Fact(DisplayName = "Implicit loop workflows are executed correctly")]
public async Task Test1()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<ImplicitLoopWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
await _fixture.RunWorkflowAsync<ImplicitLoopWorkflow>(options);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
Assert.Equal(new[] { "Start", "Retry", "End" }, lines);
}
[Fact(DisplayName = "Implicit loop workflows complete the workflow")]
public async Task Test2()
{
await _services.PopulateRegistriesAsync();
var result = await _workflowRunner.RunAsync<ImplicitLoopWorkflow>();
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
var result = await _fixture.RunWorkflowAsync<ImplicitLoopWorkflow>(options);
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
}
}

View file

@ -1,35 +1,29 @@
using Elsa.Common.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class JoinRunsOnceTests
public class JoinRunsOnceTests(ITestOutputHelper testOutputHelper)
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public JoinRunsOnceTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
[Fact(DisplayName = "The Join activity executes only once, not twice")]
public async Task Test1()
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/join.json");
var workflowDefinition = await _fixture.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/join.json");
// Execute with token-based mode.
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
var state = await _fixture.RunWorkflowAsync(workflowDefinition.DefinitionId, options: options);
// Execute.
var state = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Assert.
var journal = await _services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new()
var journal = await _fixture.Services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new()
{
WorkflowInstanceId = state.Id,
ActivityId = "802725996be1b582",

View file

@ -1,35 +1,29 @@
using Elsa.Common.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities.Flowchart.Extensions;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class ParallelJoinCompletesTests
public class ParallelJoinCompletesTests(ITestOutputHelper testOutputHelper)
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public ParallelJoinCompletesTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
[Fact(DisplayName = "The ParallelForEach activity completes when its Body contains a Join activity")]
public async Task Test1()
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync("Scenarios/JoinBehaviors/Workflows/parallel-join.json");
var workflowDefinition = await _fixture.ImportWorkflowDefinitionAsync("Scenarios/JoinBehaviors/Workflows/parallel-join.json");
// Execute with token-based mode.
var options = new RunWorkflowOptions().WithTokenBasedFlowchart();
var state = await _fixture.RunWorkflowAsync(workflowDefinition.DefinitionId, options: options);
// Execute.
var state = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Assert.
var journal = await _services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new()
var journal = await _fixture.Services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new()
{
WorkflowInstanceId = state.Id,
ActivityId = "70fc1183cd5800f2",

View file

@ -1 +1,149 @@
{"$schema":"https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json","id":"c2601be150ec7739","definitionId":"b8c92c080faf734f","name":"Decision Merge","createdAt":"2025-10-08T19:00:25.918638\u002B00:00","version":5,"toolVersion":"3.6.0.0","variables":[],"inputs":[],"outputs":[],"outcomes":[],"customProperties":{},"isReadonly":false,"isSystem":false,"isLatest":true,"isPublished":true,"options":{"autoUpdateConsumingWorkflows":false},"root":{"id":"990d07168c5223de","nodeId":"Workflow1:990d07168c5223de","name":"Flowchart1","type":"Elsa.Flowchart","version":1,"customProperties":{"notFoundConnections":[],"canStartWorkflow":false,"runAsynchronously":false},"metadata":{},"activities":[{"condition":{"typeName":"Boolean","expression":{"type":"Literal","value":false}},"id":"a38215f28410f472","nodeId":"Workflow1:990d07168c5223de:a38215f28410f472","name":"FlowDecision1","type":"Elsa.FlowDecision","version":1,"customProperties":{"canStartWorkflow":false,"runAsynchronously":false},"metadata":{"designer":{"position":{"x":-237.5,"y":-255.5},"size":{"width":149.84375,"height":67.9765625}}}},{"id":"50e042cfab596891","nodeId":"Workflow1:990d07168c5223de:50e042cfab596891","name":"Start1","type":"Elsa.Start","version":1,"customProperties":{"canStartWorkflow":false,"runAsynchronously":false},"metadata":{"designer":{"position":{"x":-485.5,"y":-255.5},"size":{"width":122.6484375,"height":67.9765625}}}},{"id":"d5b9c2affe09a93b","nodeId":"Workflow1:990d07168c5223de:d5b9c2affe09a93b","name":"End1","type":"Elsa.End","version":1,"customProperties":{"canStartWorkflow":false,"runAsynchronously":false,"mergeMode":"None"},"metadata":{"designer":{"position":{"x":75.5,"y":-255.5},"size":{"width":115.3125,"height":67.9765625}}}}],"variables":[],"connections":[{"source":{"activity":"50e042cfab596891","port":"Done"},"target":{"activity":"a38215f28410f472","port":"In"},"vertices":[]},{"source":{"activity":"a38215f28410f472","port":"True"},"target":{"activity":"d5b9c2affe09a93b","port":"In"},"vertices":[]},{"source":{"activity":"a38215f28410f472","port":"False"},"target":{"activity":"d5b9c2affe09a93b","port":"In"},"vertices":[]}]}}
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "c2601be150ec7739",
"definitionId": "b8c92c080faf734f",
"name": "Decision Merge",
"createdAt": "2025-10-08T19:00:25.918638\u002B00:00",
"version": 5,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "990d07168c5223de",
"nodeId": "Workflow1:990d07168c5223de",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"condition": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": false
}
},
"id": "a38215f28410f472",
"nodeId": "Workflow1:990d07168c5223de:a38215f28410f472",
"name": "FlowDecision1",
"type": "Elsa.FlowDecision",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -237.5,
"y": -255.5
},
"size": {
"width": 149.84375,
"height": 67.9765625
}
}
}
},
{
"id": "50e042cfab596891",
"nodeId": "Workflow1:990d07168c5223de:50e042cfab596891",
"name": "Start1",
"type": "Elsa.Start",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -485.5,
"y": -255.5
},
"size": {
"width": 122.6484375,
"height": 67.9765625
}
}
}
},
{
"id": "d5b9c2affe09a93b",
"nodeId": "Workflow1:990d07168c5223de:d5b9c2affe09a93b",
"name": "End1",
"type": "Elsa.End",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false,
"mergeMode": "None"
},
"metadata": {
"designer": {
"position": {
"x": 75.5,
"y": -255.5
},
"size": {
"width": 115.3125,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": [
{
"source": {
"activity": "50e042cfab596891",
"port": "Done"
},
"target": {
"activity": "a38215f28410f472",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "a38215f28410f472",
"port": "True"
},
"target": {
"activity": "d5b9c2affe09a93b",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "a38215f28410f472",
"port": "False"
},
"target": {
"activity": "d5b9c2affe09a93b",
"port": "In"
},
"vertices": []
}
]
}
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "strict-converge-test",
"definitionId": "strict-converge-001",
"definitionId": "3d3412c458178ff4",
"name": "Fork-Decision-Converge-Strict",
"description": "Tests the strictest Converge mode that requires ALL inbound connections to execute",
"createdAt": "2025-10-20T00:00:00.000000+00:00",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "b60374027329240a",
"definitionId": "3d3412c458178fff",
"definitionId": "3d3412c458178ff1",
"name": "Fork-issues",
"description": "show case",
"createdAt": "2025-09-30T16:56:12.420213\u002B00:00",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "eed88d95ad53e0f",
"definitionId": "3d3412c458178fff",
"definitionId": "3d3412c458178ff2",
"name": "Fork-issues",
"description": "show case",
"createdAt": "2025-09-30T17:03:24.623714\u002B00:00",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "93277d4cd53f2dfc",
"definitionId": "3d3412c458178fff",
"definitionId": "3d3412c458178ff3",
"name": "Fork-issues",
"description": "show case",
"createdAt": "2025-09-30T17:19:02.380015\u002B00:00",

View file

@ -11,33 +11,33 @@ namespace Elsa.Activities.UnitTests.Branching;
public class FlowJoinTests
{
[Theory]
[InlineData(true, FlowJoinMode.WaitAny)]
[InlineData(true, FlowJoinMode.WaitAll)]
[InlineData(false, FlowJoinMode.WaitAny)]
[InlineData(false, FlowJoinMode.WaitAll)]
public async Task Should_Complete_In_All_Flow_Mode_Combinations(bool useTokenFlow, FlowJoinMode joinMode)
[InlineData(FlowchartExecutionMode.TokenBased, FlowJoinMode.WaitAny)]
[InlineData(FlowchartExecutionMode.TokenBased, FlowJoinMode.WaitAll)]
[InlineData(FlowchartExecutionMode.CounterBased, FlowJoinMode.WaitAny)]
[InlineData(FlowchartExecutionMode.CounterBased, FlowJoinMode.WaitAll)]
public async Task Should_Complete_In_All_Flow_Mode_Combinations(FlowchartExecutionMode executionMode, FlowJoinMode joinMode)
{
// Arrange & Act
var context = await ExecuteWithFlowModeAsync(useTokenFlow, joinMode);
var context = await ExecuteWithFlowModeAsync(executionMode, joinMode);
// Assert
Assert.True(context.IsCompleted);
}
[Theory]
[InlineData(true, FlowJoinMode.WaitAny)]
[InlineData(true, FlowJoinMode.WaitAll)]
[InlineData(false, FlowJoinMode.WaitAny)]
[InlineData(false, FlowJoinMode.WaitAll)]
public async Task Should_Execute_In_Flowchart_Context_For_All_Combinations(bool useTokenFlow, FlowJoinMode joinMode)
[InlineData(FlowchartExecutionMode.TokenBased, FlowJoinMode.WaitAny)]
[InlineData(FlowchartExecutionMode.TokenBased, FlowJoinMode.WaitAll)]
[InlineData(FlowchartExecutionMode.CounterBased, FlowJoinMode.WaitAny)]
[InlineData(FlowchartExecutionMode.CounterBased, FlowJoinMode.WaitAll)]
public async Task Should_Execute_In_Flowchart_Context_For_All_Combinations(FlowchartExecutionMode executionMode, FlowJoinMode joinMode)
{
// Arrange & Act
var (context, flowJoin) = await ExecuteInFlowchartWithFlowModeAsync(useTokenFlow, joinMode);
var (context, flowJoin) = await ExecuteInFlowchartWithFlowModeAsync(executionMode, joinMode);
// Assert
Assert.NotNull(context);
var expectedMessage = useTokenFlow
var expectedMessage = executionMode == FlowchartExecutionMode.TokenBased
? $"Token flow mode with {joinMode} should schedule the join activity"
: $"Counter flow mode with {joinMode} should schedule the join activity as start";
@ -51,8 +51,8 @@ public class FlowJoinTests
var joinMode = FlowJoinMode.WaitAll; // Use WaitAll to highlight differences
// Act
var tokenContext = await ExecuteWithFlowModeAsync(true, joinMode);
var counterContext = await ExecuteWithFlowModeAsync(false, joinMode);
var tokenContext = await ExecuteWithFlowModeAsync(FlowchartExecutionMode.TokenBased, joinMode);
var counterContext = await ExecuteWithFlowModeAsync(FlowchartExecutionMode.CounterBased, joinMode);
// Assert
Assert.True(tokenContext.IsCompleted, "Token flow should always complete");
@ -74,62 +74,48 @@ public class FlowJoinTests
};
/// <summary>
/// Executes a FlowJoin activity with the specified flow mode, handling the UseTokenFlow setup and teardown.
/// Executes a FlowJoin activity with the specified flow mode.
/// </summary>
private static async Task<ActivityExecutionContext> ExecuteWithFlowModeAsync(bool useTokenFlow, FlowJoinMode joinMode)
private static async Task<ActivityExecutionContext> ExecuteWithFlowModeAsync(FlowchartExecutionMode executionMode, FlowJoinMode joinMode)
{
return await WithFlowModeAsync(useTokenFlow, async () =>
{
var flowJoin = CreateFlowJoin(joinMode);
return await ExecuteAsync(flowJoin);
});
var flowJoin = CreateFlowJoin(joinMode);
return await ExecuteAsync(flowJoin, executionMode);
}
/// <summary>
/// Executes a FlowJoin activity within a flowchart context with the specified flow mode.
/// </summary>
private static async Task<(ActivityExecutionContext context, FlowJoin flowJoin)> ExecuteInFlowchartWithFlowModeAsync(bool useTokenFlow, FlowJoinMode joinMode)
private static async Task<(ActivityExecutionContext context, FlowJoin flowJoin)> ExecuteInFlowchartWithFlowModeAsync(FlowchartExecutionMode executionMode, FlowJoinMode joinMode)
{
return await WithFlowModeAsync(useTokenFlow, async () =>
{
var flowJoin = CreateFlowJoin(joinMode);
var flowchart = CreateSimpleFlowchart(flowJoin);
var context = await ExecuteFlowchartAsync(flowchart);
return (context, flowJoin);
});
var flowJoin = CreateFlowJoin(joinMode);
var flowchart = CreateSimpleFlowchart(flowJoin);
var context = await ExecuteFlowchartAsync(flowchart, executionMode);
return (context, flowJoin);
}
/// <summary>
/// Executes an action with the specified flow mode, ensuring proper setup and teardown of UseTokenFlow.
/// Executes a flowchart using the ActivityTestFixture with the specified execution mode.
/// </summary>
private static async Task<T> WithFlowModeAsync<T>(bool useTokenFlow, Func<Task<T>> action)
private static Task<ActivityExecutionContext> ExecuteFlowchartAsync(Flowchart flowchart, FlowchartExecutionMode? executionMode = null)
{
var originalValue = Flowchart.UseTokenFlow;
Flowchart.UseTokenFlow = useTokenFlow;
return ExecuteAsync(flowchart, executionMode);
}
try
/// <summary>
/// Executes an activity using the ActivityTestFixture with the specified execution mode.
/// </summary>
private static async Task<ActivityExecutionContext> ExecuteAsync(IActivity activity, FlowchartExecutionMode? executionMode = null)
{
var fixture = new ActivityTestFixture(activity);
if (executionMode.HasValue)
{
return await action();
fixture.ConfigureContext(context =>
{
context.WorkflowExecutionContext.Properties[Flowchart.ExecutionModePropertyKey] = executionMode.Value;
});
}
finally
{
Flowchart.UseTokenFlow = originalValue;
}
}
/// <summary>
/// Executes an activity using the ActivityTestFixture.
/// </summary>
private static async Task<ActivityExecutionContext> ExecuteAsync(IActivity activity)
{
return await new ActivityTestFixture(activity).ExecuteAsync();
}
/// <summary>
/// Executes a flowchart using the ActivityTestFixture.
/// </summary>
private static async Task<ActivityExecutionContext> ExecuteFlowchartAsync(Flowchart flowchart)
{
return await new ActivityTestFixture(flowchart).ExecuteAsync();
return await fixture.ExecuteAsync();
}
}

View file

@ -1,6 +1,7 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
namespace Elsa.Activities.UnitTests.Flow;
@ -9,9 +10,18 @@ namespace Elsa.Activities.UnitTests.Flow;
/// </summary>
public static class FlowchartTestHelpers
{
public static async Task<ActivityExecutionContext> ExecuteFlowchartAsync(Flowchart flowchart)
public static async Task<ActivityExecutionContext> ExecuteFlowchartAsync(Flowchart flowchart, FlowchartExecutionMode? executionMode = null)
{
var fixture = new ActivityTestFixture(flowchart);
if (executionMode.HasValue)
{
fixture.ConfigureContext(context =>
{
context.WorkflowExecutionContext.Properties[Flowchart.ExecutionModePropertyKey] = executionMode.Value;
});
}
return await fixture.ExecuteAsync();
}
}

View file

@ -1,5 +1,6 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
using static Elsa.Activities.UnitTests.Flow.FlowchartTestHelpers;
namespace Elsa.Activities.UnitTests.Flow;
@ -43,34 +44,24 @@ public class FlowchartTests
Assert.False(context.HasScheduledActivity(new WriteLine("NonExistent")));
}
[Theory(DisplayName = "Respects UseTokenFlow flag")]
[InlineData(true)]
[InlineData(false)]
public async Task RespectsUseTokenFlowFlag(bool useTokenFlow)
[Theory(DisplayName = "Respects execution mode")]
[InlineData(FlowchartExecutionMode.TokenBased)]
[InlineData(FlowchartExecutionMode.CounterBased)]
public async Task RespectsExecutionMode(FlowchartExecutionMode executionMode)
{
// Arrange
var originalValue = Flowchart.UseTokenFlow;
Flowchart.UseTokenFlow = useTokenFlow;
try
var activity = new WriteLine("Test");
var flowchart = new Flowchart
{
var activity = new WriteLine("Test");
var flowchart = new Flowchart
{
Start = activity,
Activities = { activity }
};
Start = activity,
Activities = { activity }
};
// Act
var context = await ExecuteFlowchartAsync(flowchart);
// Act
var context = await ExecuteFlowchartAsync(flowchart, executionMode);
// Assert - just verify it executes without error
Assert.NotNull(context);
}
finally
{
Flowchart.UseTokenFlow = originalValue;
}
// Assert - just verify it executes without error
Assert.NotNull(context);
}
[Fact(DisplayName = "Accepts empty connections collection")]