Update nullable argument defaults and enhance ForEach tests with additional edge cases. (#6928)
* Update nullable argument defaults and enhance ForEach tests with additional edge cases. * Refactor input evaluation and JavaScript evaluator for clarity and consistency. * Update src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.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:
parent
bc4e4d60ee
commit
003e88c4da
|
|
@ -11,7 +11,7 @@ public class DelegateExpressionHandler : IExpressionHandler
|
|||
/// <inheritdoc />
|
||||
public async ValueTask<object?> EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions options)
|
||||
{
|
||||
var value = expression.Value is Func<ExpressionExecutionContext, ValueTask<object?>> @delegate ? await @delegate(context) : default;
|
||||
var value = expression.Value is Func<ExpressionExecutionContext, ValueTask<object?>> @delegate ? await @delegate(context) : null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ public class Literal : MemoryBlockReference
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Literal(object? value, string? id = default) : base(id!)
|
||||
public Literal(object? value, string? id = null) : base(id!)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ public class Literal<T> : Literal
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Literal(T value, string? id = default) : base(value!, id)
|
||||
public Literal(T value, string? id = null) : base(value!, id)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ public class MemoryRegister
|
|||
/// <summary>
|
||||
/// Constructor.
|
||||
/// </summary>
|
||||
public MemoryRegister(IDictionary<string, MemoryBlock>? blocks = default)
|
||||
public MemoryRegister(IDictionary<string, MemoryBlock>? blocks = null)
|
||||
{
|
||||
Blocks = blocks ?? new Dictionary<string, MemoryBlock>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification
|
|||
|
||||
var engineOptions = new Jint.Options
|
||||
{
|
||||
ExperimentalFeatures = ExperimentalFeature.TaskInterop,
|
||||
ExperimentalFeatures = ExperimentalFeature.TaskInterop
|
||||
};
|
||||
|
||||
ConfigureClrAccess(engineOptions);
|
||||
|
|
|
|||
|
|
@ -754,12 +754,6 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
if (blockReference is Literal literal)
|
||||
{
|
||||
value = literal.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,9 +118,12 @@ public static partial class ActivityExecutionContextExtensions
|
|||
|
||||
var memoryReference = wrappedInput?.MemoryBlockReference();
|
||||
|
||||
// When input is created from an activity provider, there may be no memory block reference.
|
||||
if (memoryReference?.Id != null!)
|
||||
if (memoryReference != null)
|
||||
{
|
||||
// When input is created from an activity provider, there may be no memory block reference ID.
|
||||
if (memoryReference.Id == null!)
|
||||
memoryReference.Id = $"{activity.NodeId}.{inputDescriptor.Name}"; // Construct a deterministic ID.
|
||||
|
||||
// Declare the input memory block in the current context.
|
||||
context.ExpressionExecutionContext.Set(memoryReference, value!);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,5 +33,5 @@ public abstract class Argument
|
|||
/// Gets or sets the memory block reference.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Func<MemoryBlockReference> MemoryBlockReference { get; set; } = default!;
|
||||
public Func<MemoryBlockReference> MemoryBlockReference { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -45,37 +45,37 @@ public class Input<T> : Input
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(T literal, string? id = default) : this(new Literal<T>(literal, id))
|
||||
public Input(T literal, string? id = null) : this(new Literal<T>(literal, id))
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Func<T> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
|
||||
public Input(Func<T> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Func<ExpressionExecutionContext, ValueTask<T?>> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
|
||||
public Input(Func<ExpressionExecutionContext, ValueTask<T?>> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Func<ValueTask<T?>> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
|
||||
public Input(Func<ValueTask<T?>> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Func<ExpressionExecutionContext, T> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
|
||||
public Input(Func<ExpressionExecutionContext, T> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Variable variable) : base(new Expression("Variable", variable), variable, typeof(T))
|
||||
public Input(Variable variable) : base(new("Variable", variable), variable, typeof(T))
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Output output) : base(new Expression("Output", output), output.MemoryBlockReference(), typeof(T))
|
||||
public Input(Output output) : base(new("Output", output), output.MemoryBlockReference(), typeof(T))
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ public class Input<T> : Input
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Input(Expression expression) : this(expression, new MemoryBlockReference())
|
||||
public Input(Expression expression) : this(expression, new())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,14 @@ namespace Elsa.Workflows.Models;
|
|||
/// <summary>
|
||||
/// Contains information about a workflow run, such as <see cref="WorkflowState"/>.
|
||||
/// </summary>
|
||||
public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, object? Result);
|
||||
public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, object? Result, Journal Journal);
|
||||
|
||||
/// <summary>
|
||||
/// Contains information about a workflow run, such as <see cref="WorkflowState"/>.
|
||||
/// </summary>
|
||||
public record RunWorkflowResult<TResult>(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, TResult Result);
|
||||
public record RunWorkflowResult<TResult>(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, TResult Result, Journal Journal);
|
||||
|
||||
public record Journal(ICollection<WorkflowExecutionLogEntry> WorkflowExecutionLogEntries, ICollection<ActivityExecutionContext> ActivityExecutionContexts)
|
||||
{
|
||||
public static Journal Empty => new([], []);
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ public class WorkflowRunner(
|
|||
public async Task<RunWorkflowResult<TResult>> RunAsync<TResult>(WorkflowBase<TResult> workflow, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await RunAsync((IWorkflow)workflow, options, cancellationToken);
|
||||
return new(result.WorkflowExecutionContext, result.WorkflowState, result.Workflow, (TResult)result.Result!);
|
||||
return new(result.WorkflowExecutionContext, result.WorkflowState, result.Workflow, (TResult)result.Result!, result.Journal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -217,8 +217,11 @@ public class WorkflowRunner(
|
|||
}
|
||||
|
||||
var result = workflow.ResultVariable?.Get(workflowExecutionContext.MemoryRegister);
|
||||
var workflowExecutionLogEntries = workflowExecutionContext.ExecutionLog.ToList();
|
||||
var activityExecutionContexts = workflowExecutionContext.ActivityExecutionContexts.ToList();
|
||||
var journal = new Journal(workflowExecutionLogEntries, activityExecutionContexts);
|
||||
await notificationSender.SendAsync(new WorkflowExecuted(workflow, workflowState, workflowExecutionContext), cancellationToken);
|
||||
await commitStateHandler.CommitAsync(workflowExecutionContext, workflowState, cancellationToken);
|
||||
return new(workflowExecutionContext, workflowState, workflowExecutionContext.Workflow, result);
|
||||
return new(workflowExecutionContext, workflowState, workflowExecutionContext.Workflow, result, journal);
|
||||
}
|
||||
}
|
||||
|
|
@ -201,7 +201,7 @@ internal class WorkflowInstance(
|
|||
if (_isRunning)
|
||||
{
|
||||
_queuedRunWorkflowOptions.Enqueue(runWorkflowOptions);
|
||||
return new(null!, null!, null!, null);
|
||||
return new(null!, null!, null!, null, Journal.Empty);
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
|
|
|
|||
|
|
@ -66,12 +66,12 @@ public class WorkflowHost : IWorkflowHost
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunWorkflowResult> RunWorkflowAsync(RunWorkflowOptions? @params = default, CancellationToken cancellationToken = default)
|
||||
public async Task<RunWorkflowResult> RunWorkflowAsync(RunWorkflowOptions? @params = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (WorkflowState.Status != WorkflowStatus.Running)
|
||||
{
|
||||
_logger.LogWarning("Attempt to resume workflow {WorkflowInstanceId} that is not in the Running state. The actual state is {ActualWorkflowStatus}", WorkflowState.Id, WorkflowState.Status);
|
||||
return new RunWorkflowResult(null!, WorkflowState, Workflow, null);
|
||||
return new(null!, WorkflowState, Workflow, null, Journal.Empty);
|
||||
}
|
||||
|
||||
var runOptions = new RunWorkflowOptions
|
||||
|
|
@ -85,7 +85,7 @@ public class WorkflowHost : IWorkflowHost
|
|||
TriggerActivityId = @params?.TriggerActivityId,
|
||||
ParentWorkflowInstanceId = @params?.ParentWorkflowInstanceId
|
||||
};
|
||||
_linkedTokenSource = new CancellationTokenSource();
|
||||
_linkedTokenSource = new();
|
||||
var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _linkedTokenSource.Token).Token;
|
||||
|
||||
await using var scope = _serviceScopeFactory.CreateAsyncScope();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using Elsa.Extensions;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Activities;
|
||||
using Elsa.Workflows.Models;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Elsa.Activities.IntegrationTests;
|
||||
|
|
@ -9,21 +11,259 @@ public class ForEachTests
|
|||
{
|
||||
private readonly CapturingTextWriter _capturingTextWriter = new();
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const string CurrentValueVar = "CurrentValue";
|
||||
|
||||
public ForEachTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
_serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
|
||||
}
|
||||
|
||||
|
||||
[Fact(DisplayName = "ForEach executes each activity for every item in the collection")]
|
||||
public async Task ForEach_ExecutesEachActivity_ForEveryItem()
|
||||
{
|
||||
var expectedLines = new[] {"a", "b", "c"};
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a", "b", "c"
|
||||
};
|
||||
var forEach = new ForEach<string>(expectedLines)
|
||||
{
|
||||
Body = new WriteLine(context => context.GetVariable<string>("CurrentValue"))
|
||||
Body = WriteCurrentValue()
|
||||
};
|
||||
await _serviceProvider.RunActivityAsync(forEach);
|
||||
Assert.Equal(expectedLines, _capturingTextWriter.Lines);
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach executes each activity for every item in the collection even if there's only one item")]
|
||||
public async Task ForEach_ExecutesEachActivity_ForSingleItem()
|
||||
{
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a"
|
||||
};
|
||||
var forEach = new ForEach<string>(expectedLines)
|
||||
{
|
||||
Body = WriteCurrentValue()
|
||||
};
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach completes when the collection is empty")]
|
||||
public async Task ForEach_Completes_WhenCollectionIsEmpty()
|
||||
{
|
||||
string[] expectedLines = [];
|
||||
var forEach = new ForEach<string>(expectedLines)
|
||||
{
|
||||
Body = WriteCurrentValue()
|
||||
};
|
||||
var result = await _serviceProvider.RunActivityAsync(forEach);
|
||||
var journal = result.Journal;
|
||||
var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach<string>);
|
||||
Assert.NotNull(forEachContext);
|
||||
Assert.Equal(ActivityStatus.Completed, forEachContext.Status);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach faults when the collection is null")]
|
||||
public async Task ForEach_Faults_WhenCollectionIsNull()
|
||||
{
|
||||
var forEach = new ForEach<string>((ICollection<string>)null!)
|
||||
{
|
||||
Body = WriteCurrentValue()
|
||||
};
|
||||
var result = await _serviceProvider.RunActivityAsync(forEach);
|
||||
var journal = result.Journal;
|
||||
var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach<string>);
|
||||
Assert.NotNull(forEachContext);
|
||||
Assert.Equal(ActivityStatus.Faulted, forEachContext.Status);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach breaks when the Break activity executed")]
|
||||
public async Task ForEach_BreaksOutOfLoop_WhenExecutingBreakActivity()
|
||||
{
|
||||
var dataSource = new[]
|
||||
{
|
||||
"a", "b", "c"
|
||||
};
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a"
|
||||
};
|
||||
|
||||
var forEach = new ForEach<string>(dataSource)
|
||||
{
|
||||
Body = new If
|
||||
{
|
||||
Condition = new(context => context.GetVariable<string>(CurrentValueVar) == "b"),
|
||||
Then = new Break(),
|
||||
Else = WriteCurrentValue()
|
||||
}
|
||||
};
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach executes each activity for different item types")]
|
||||
public async Task ForEach_ExecutesEachActivity_ForDifferentItemTypes()
|
||||
{
|
||||
var dataSource = new object?[]
|
||||
{
|
||||
"a", 2, null, new Foo()
|
||||
};
|
||||
var expectedLines = new object[]
|
||||
{
|
||||
"a", "2", "", "Baz"
|
||||
};
|
||||
var forEach = new ForEach<object?>(dataSource)
|
||||
{
|
||||
Body = new WriteLine(context => context.GetVariable<object>(CurrentValueVar)?.ToString() ?? "")
|
||||
};
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach completes when the end of the collection is reached even when an item is added to the collection at runtime")]
|
||||
public async Task ForEach_Completes_WhenItemAddedToCollectionAtRuntime()
|
||||
{
|
||||
var dataSource = new[]
|
||||
{
|
||||
"a", "b", "c"
|
||||
}.ToList();
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a", "b", "c", "e"
|
||||
};
|
||||
var dataSourceInput = new Input<ICollection<string>>(() => dataSource.ToList());
|
||||
var forEach = new ForEach<string>(dataSourceInput)
|
||||
{
|
||||
Body = new Sequence
|
||||
{
|
||||
Activities =
|
||||
[
|
||||
new If(context => context.GetVariable<string>(CurrentValueVar) == "b")
|
||||
{
|
||||
Then = new Inline(inlineContext =>
|
||||
{
|
||||
dataSource.Add("e");
|
||||
inlineContext.Set(dataSourceInput.MemoryBlockReference(), dataSource.ToList());
|
||||
})
|
||||
},
|
||||
WriteCurrentValue()
|
||||
]
|
||||
}
|
||||
};
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach completes when the end of the collection is reached even when an item is removed from the collection at runtime")]
|
||||
public async Task ForEach_Completes_WhenItemRemovedFromCollectionAtRuntime()
|
||||
{
|
||||
var dataSource = new[]
|
||||
{
|
||||
"a", "b", "c"
|
||||
}.ToList();
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a", "b"
|
||||
};
|
||||
var dataSourceInput = new Input<ICollection<string>>(() => dataSource.ToList());
|
||||
var forEach = new ForEach<string>(dataSourceInput)
|
||||
{
|
||||
Body = new Sequence
|
||||
{
|
||||
Activities =
|
||||
[
|
||||
new If(context => context.GetVariable<string>(CurrentValueVar) == "a")
|
||||
{
|
||||
Then = new Inline(inlineContext =>
|
||||
{
|
||||
dataSource.Remove("c");
|
||||
inlineContext.Set(dataSourceInput.MemoryBlockReference(), dataSource.ToList());
|
||||
})
|
||||
},
|
||||
WriteCurrentValue()
|
||||
]
|
||||
}
|
||||
};
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach completes when the end of the collection is reached even when an item is changed at runtime")]
|
||||
public async Task ForEach_Completes_WhenItemChangedAtRuntime()
|
||||
{
|
||||
var dataSource = new[]
|
||||
{
|
||||
"a", "b", "c"
|
||||
}.ToList();
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a", "b", "d"
|
||||
};
|
||||
var dataSourceInput = new Input<ICollection<string>>(() => dataSource.ToList());
|
||||
var forEach = new ForEach<string>(dataSourceInput)
|
||||
{
|
||||
Body = new Sequence
|
||||
{
|
||||
Activities =
|
||||
[
|
||||
new If(context => context.GetVariable<string>(CurrentValueVar) == "b")
|
||||
{
|
||||
Then = new Inline(inlineContext =>
|
||||
{
|
||||
dataSource.Remove("c");
|
||||
dataSource.Add("d");
|
||||
inlineContext.Set(dataSourceInput.MemoryBlockReference(), dataSource.ToList());
|
||||
})
|
||||
},
|
||||
WriteCurrentValue()
|
||||
]
|
||||
}
|
||||
};
|
||||
await RunAndAssertLines(forEach, expectedLines);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ForEach remains in the Running state when an activity faults")]
|
||||
public async Task ForEach_Suspends_WhenActivityFaults()
|
||||
{
|
||||
var dataSource = new[]
|
||||
{
|
||||
"a", "b", "c"
|
||||
}.ToList();
|
||||
var expectedLines = new[]
|
||||
{
|
||||
"a", "b"
|
||||
};
|
||||
var forEach = new ForEach<string>(dataSource)
|
||||
{
|
||||
Body = new Sequence
|
||||
{
|
||||
Activities =
|
||||
[
|
||||
new If(context => context.GetVariable<string>(CurrentValueVar) == "c")
|
||||
{
|
||||
Then = new Fault
|
||||
{
|
||||
Message = new("Faulted"),
|
||||
}
|
||||
},
|
||||
WriteCurrentValue()
|
||||
]
|
||||
}
|
||||
};
|
||||
var result = await _serviceProvider.RunActivityAsync(forEach);
|
||||
var journal = result.Journal;
|
||||
var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach<string>);
|
||||
Assert.Equal(expectedLines, _capturingTextWriter.Lines);
|
||||
Assert.NotNull(forEachContext);
|
||||
Assert.Equal(ActivityStatus.Running, forEachContext.Status);
|
||||
Assert.Equal(1, forEachContext.AggregateFaultCount);
|
||||
}
|
||||
|
||||
private static WriteLine WriteCurrentValue() => new(context => context.GetVariable<string>(CurrentValueVar));
|
||||
|
||||
private async Task RunAndAssertLines(IActivity activity, System.Collections.IEnumerable expected)
|
||||
{
|
||||
await _serviceProvider.RunActivityAsync(activity);
|
||||
Assert.Equal(expected, _capturingTextWriter.Lines);
|
||||
}
|
||||
}
|
||||
|
||||
record Foo(string Bar = "Baz")
|
||||
{
|
||||
public override string ToString() => Bar;
|
||||
}
|
||||
Loading…
Reference in a new issue