* feat(bpmn): scope variables, trigger opt-out and composability for BpmnProcess Completes the container W2 left minimal, with the four things it deferred. Scope variables. BpmnScopeVariables implements IBpmnVariableReader over the scope's memory register, walking outward so an inner scope sees the enclosing one's data, and BpmnScopeHost now declares ScopeVariables and hands the reader to every snapshot. The read is three-valued: false for a name nothing in scope declares, Null for a declared variable holding nothing, and StoredExternally for a value JSON cannot carry. That last case deviates from the issue, deliberately. The issue names the unmaterialized-driver case, which is not detectable from the container's side: PersistentVariablesMiddleware loads with no excludeTags, and VariablePersistenceManager marks a block IsInitialized before testing the exclusion, so a variable whose driver was never read is indistinguishable from one whose driver returned null. Closing that needs a change to Elsa.Workflows.Core, which is out of bounds here, so the reader answers only what the block actually says and the XML doc records why. The route it does have is real and in the same spirit: a value the host holds and cannot put on the wire faults loudly rather than reading as an empty collection. Trigger opt-out. BpmnProcess.IsRootScope names the BPMN meaning of Elsa's CanStartWorkflow rather than adding a second flag that could disagree with the gate TriggerIndexer actually reads. It is off unless something says otherwise, and the applier refuses to start a BpmnProcess that claims root position as another scope's work: the damage a mis-flagged subprocess does happens at publish time, so repairing the object graph at runtime would leave the trigger registered while every test went green. ITrigger itself remains #7929. Composability and outcomes. A BpmnProcess in a Flowchart runs and the flowchart carries on (D11), and a nested transaction completing Cancelled reaches its parent's completion callback with that outcome intact, which is the only reason the parent routes the cancel boundary rather than the ordinary sequence flow. Every guard was mutation-tested red before green: both non-Present answers of the reader, the reader left unwired, the opt-out's default flipped (7 tests red, including the pre-existing nested-scope ones), the refusal removed, and the outcome dropped at each end of the trip to the parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(bpmn): apply review findings on scope variables, command batching, and outcome doc Read a scope variable through Elsa's configured serializer (via IPayloadSerializer, serialized against the value's own runtime type so a polymorphic value is not wrapped in Elsa's type-tagged envelope) instead of bare JsonSerializerDefaults, so a value only Elsa's converters can carry no longer collapses to StoredExternally. Refuse a root-scope StartWork before any command in the batch is applied, not mid-list, so a refusal cannot leave scope memory partially mutated under ContinueWithIncidentsStrategy. Document that BpmnProcess completes with only its interpreter outcome, so a default/null-port Flowchart connection never fires from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(bpmn): filter the pre-scan explicitly Use commands.OfType<BpmnHostCommand.StartWork>() in ApplyAsync's root-scope pre-scan instead of a foreach + type-check, matching the static analysis suggestion. The apply loop below is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
192 lines
8.4 KiB
C#
192 lines
8.4 KiB
C#
using Elsa.Common;
|
|
using Elsa.Common.Multitenancy;
|
|
using Elsa.Common.Serialization;
|
|
using Elsa.Expressions.Contracts;
|
|
using Elsa.Expressions.Services;
|
|
using Elsa.Extensions;
|
|
using Elsa.Mediator.Contracts;
|
|
using Elsa.Workflows;
|
|
using Elsa.Workflows.Activities;
|
|
using Elsa.Workflows.CommitStates;
|
|
using Elsa.Workflows.Management.Providers;
|
|
using Elsa.Workflows.Management.Services;
|
|
using Elsa.Workflows.Memory;
|
|
using Elsa.Workflows.PortResolvers;
|
|
using Elsa.Workflows.Serialization.Serializers;
|
|
using JetBrains.Annotations;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.Testing.Shared;
|
|
|
|
/// <summary>
|
|
/// A test fixture for unit testing activities in isolation.
|
|
/// Provides a fluent API to configure services, variables, and execution context.
|
|
/// </summary>
|
|
public class ActivityTestFixture
|
|
{
|
|
private Action<ActivityExecutionContext>? _configureContextAction;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ActivityTestFixture"/> class.
|
|
/// </summary>
|
|
/// <param name="activity">The activity to test</param>
|
|
public ActivityTestFixture(IActivity activity)
|
|
{
|
|
Activity = activity;
|
|
Services = new ServiceCollection();
|
|
AddCoreWorkflowServices(Services);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents the activity being tested within the context of the activity test fixture.
|
|
/// Provides access to the activity for configuration, execution, and validation purposes.
|
|
/// </summary>
|
|
public IActivity Activity { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the service collection for registering additional services.
|
|
/// Use this to add services required by the activity under test.
|
|
/// </summary>
|
|
[UsedImplicitly]
|
|
public IServiceCollection Services { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Configures the service collection using a fluent action.
|
|
/// </summary>
|
|
/// <param name="configure">Action to configure the service collection</param>
|
|
/// <returns>The fixture instance for method chaining</returns>
|
|
public ActivityTestFixture ConfigureServices(Action<IServiceCollection> configure)
|
|
{
|
|
configure(Services);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures the activity execution context before execution.
|
|
/// Multiple calls to this method will chain the configuration actions together.
|
|
/// </summary>
|
|
/// <param name="configure">Action to configure the activity execution context</param>
|
|
/// <returns>The fixture instance for method chaining</returns>
|
|
[UsedImplicitly]
|
|
public ActivityTestFixture ConfigureContext(Action<ActivityExecutionContext> configure)
|
|
{
|
|
_configureContextAction += configure;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes the activity and returns the execution context.
|
|
/// </summary>
|
|
/// <returns>The ActivityExecutionContext after execution</returns>
|
|
public async Task<ActivityExecutionContext> ExecuteAsync()
|
|
{
|
|
var context = await BuildAsync();
|
|
return await ExecuteAsync(context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes the activity using a pre-built <see cref="ActivityExecutionContext"/>.
|
|
/// Useful when you need to customize the context before execution, such as setting initial workflow state or overriding correlation IDs.
|
|
/// </summary>
|
|
/// <param name="context">The pre-built context to execute</param>
|
|
/// <returns>The <see cref="ActivityExecutionContext"/> after execution</returns>
|
|
public async Task<ActivityExecutionContext> ExecuteAsync(ActivityExecutionContext context)
|
|
{
|
|
// Set up variables and inputs, then execute the activity
|
|
await SetupExistingVariablesAsync(Activity, context);
|
|
await context.EvaluateInputPropertiesAsync();
|
|
context.TransitionTo(ActivityStatus.Running);
|
|
await Activity.ExecuteAsync(context);
|
|
|
|
return context;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the ActivityExecutionContext without executing the activity.
|
|
/// </summary>
|
|
public async Task<ActivityExecutionContext> BuildAsync()
|
|
{
|
|
var serviceProvider = Services.BuildServiceProvider();
|
|
var activityRegistry = serviceProvider.GetRequiredService<IActivityRegistry>();
|
|
var workflowGraphBuilder = serviceProvider.GetRequiredService<IWorkflowGraphBuilder>();
|
|
|
|
await activityRegistry.RegisterAsync(Activity.GetType());
|
|
|
|
var workflow = Workflow.FromActivity(Activity);
|
|
var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow);
|
|
|
|
// Create workflow execution context using the static factory method
|
|
var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(
|
|
serviceProvider,
|
|
workflowGraph,
|
|
$"test-instance-{Guid.NewGuid()}",
|
|
CancellationToken.None
|
|
);
|
|
|
|
// Create ActivityExecutionContext for the actual activity we want to test
|
|
var context = await workflowExecutionContext.CreateActivityExecutionContextAsync(Activity);
|
|
|
|
// Apply any context configuration action
|
|
_configureContextAction?.Invoke(context);
|
|
|
|
return context;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets up existing variables found on the activity in the execution context.
|
|
/// This is necessary because in unit tests, variables need to be initialized.
|
|
/// </summary>
|
|
private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context)
|
|
{
|
|
var activityType = activity.GetType();
|
|
var variableProperties = activityType.GetProperties()
|
|
.Where(p => typeof(Variable).IsAssignableFrom(p.PropertyType))
|
|
.ToList();
|
|
|
|
foreach (var variable in variableProperties.Select(property => (Variable?)property.GetValue(activity)))
|
|
{
|
|
if(variable == null)
|
|
continue;
|
|
|
|
context.WorkflowExecutionContext.MemoryRegister.Declare(variable);
|
|
variable.Set(context.ExpressionExecutionContext, variable.Value);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static void AddCoreWorkflowServices(IServiceCollection services)
|
|
{
|
|
services.AddLogging();
|
|
services.AddSingleton<ISystemClock>(_ => Substitute.For<ISystemClock>());
|
|
services.AddSingleton<INotificationSender>(_ => Substitute.For<INotificationSender>());
|
|
services.AddSingleton<IActivityVisitor, ActivityVisitor>();
|
|
services.AddScoped<IExpressionEvaluator, ExpressionEvaluator>();
|
|
services.AddSingleton<IWellKnownTypeRegistry, WellKnownTypeRegistry>();
|
|
services.AddSingleton<IActivityDescriber, ActivityDescriber>();
|
|
services.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>();
|
|
services.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>();
|
|
services.AddSingleton<IActivityRegistry, ActivityRegistry>();
|
|
services.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>();
|
|
services.AddScoped<IIdentityGraphService, IdentityGraphService>();
|
|
services.AddScoped<IWorkflowGraphBuilder, WorkflowGraphBuilder>();
|
|
services.AddScoped<IActivityResolver, PropertyBasedActivityResolver>();
|
|
services.AddScoped<IActivityResolver, SwitchActivityResolver>();
|
|
services.AddScoped<DefaultActivityInputEvaluator>();
|
|
services.AddSingleton<IExpressionDescriptorProvider, DefaultExpressionDescriptorProvider>();
|
|
services.AddSingleton<IExpressionDescriptorRegistry, ExpressionDescriptorRegistry>();
|
|
services.AddSingleton<IIdentityGenerator>(_ => Substitute.For<IIdentityGenerator>());
|
|
services.AddSingleton<IHasher>(_ => Substitute.For<IHasher>());
|
|
services.AddSingleton<IStimulusHasher, StimulusHasher>();
|
|
services.AddSingleton<ICommitStateHandler>(_ => Substitute.For<ICommitStateHandler>());
|
|
services.AddSingleton<IActivitySchedulerFactory, ActivitySchedulerFactory>();
|
|
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, FakeWorkflowExecutionContextSchedulerStrategy>();
|
|
services.AddSingleton<IActivityExecutionContextSchedulerStrategy, FakeActivityExecutionContextSchedulerStrategy>();
|
|
services.AddSingleton<ITenantAccessor, DefaultTenantAccessor>();
|
|
services.AddScoped<IWorkflowStateExtractor, WorkflowStateExtractor>();
|
|
services.AddOptions();
|
|
services.AddSingleton<ISerializationTypeRegistry, SerializationTypeRegistry>();
|
|
services.AddSingleton<IPayloadSerializer, JsonPayloadSerializer>();
|
|
}
|
|
} |