diff --git a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs
index 4efb894ef..37e12a909 100644
--- a/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/RunActivityExtensions.cs
@@ -10,36 +10,41 @@ namespace Elsa.Testing.Shared;
/// Provides extension methods for .
///
[PublicAPI]
+[Obsolete("Use WorkflowTestFixture instead.")]
public static class RunActivityExtensions
{
- ///
- /// Runs the specified activity.
- ///
/// The service provider.
- /// The activity to run.
- /// An optional cancellation token.
- /// The result of running the activity.
- public static async Task RunActivityAsync(this IServiceProvider services, IActivity activity, CancellationToken cancellationToken = default)
+ extension(IServiceProvider services)
{
- await services.PopulateRegistriesAsync();
- var workflowRunner = services.GetRequiredService();
- var result = await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken);
- return result;
- }
+ ///
+ /// Runs the specified activity.
+ ///
+ /// The activity to run.
+ /// An optional cancellation token.
+ /// The result of running the activity.
+ [Obsolete("Use WorkflowTestFixture instead.")]
+ public async Task RunActivityAsync(IActivity activity, CancellationToken cancellationToken = default)
+ {
+ await services.PopulateRegistriesAsync();
+ var workflowRunner = services.GetRequiredService();
+ var result = await workflowRunner.RunAsync(activity, cancellationToken: cancellationToken);
+ return result;
+ }
- ///
- /// Runs the specified activity.
- ///
- /// The service provider.
- /// The activity to run.
- /// An set of options.
- /// An optional cancellation token.
- /// The result of running the activity.
- public static async Task RunActivityAsync(this IServiceProvider services, IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default)
- {
- await services.PopulateRegistriesAsync();
- var workflowRunner = services.GetRequiredService();
- var result = await workflowRunner.RunAsync(activity, options, cancellationToken);
- return result;
+ ///
+ /// Runs the specified activity.
+ ///
+ /// The activity to run.
+ /// A set of options.
+ /// An optional cancellation token.
+ /// The result of running the activity.
+ [Obsolete("Use WorkflowTestFixture instead.")]
+ public async Task RunActivityAsync(IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default)
+ {
+ await services.PopulateRegistriesAsync();
+ var workflowRunner = services.GetRequiredService();
+ var result = await workflowRunner.RunAsync(activity, options, cancellationToken);
+ return result;
+ }
}
}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
index 5fdb20243..e86856189 100644
--- a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
@@ -1,12 +1,9 @@
using Elsa.Common.Models;
using Elsa.Workflows;
using Elsa.Workflows.Management;
-using Elsa.Workflows.Management.Entities;
-using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
-using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.State;
using JetBrains.Annotations;
diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultAssertions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultAssertions.cs
new file mode 100644
index 000000000..5f8fcb174
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultAssertions.cs
@@ -0,0 +1,122 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using JetBrains.Annotations;
+using Xunit;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// Provides assertion methods for to facilitate Journal-based testing.
+/// These methods integrate with xUnit's assertion framework.
+///
+[PublicAPI]
+public static class RunWorkflowResultAssertions
+{
+ /// The workflow result.
+ extension(RunWorkflowResult result)
+ {
+ ///
+ /// Asserts that the specified activity was executed (present in the journal).
+ ///
+ /// The activity that should have been executed.
+ public void AssertActivityExecuted(IActivity activity)
+ {
+ var context = result.GetActivityContext(activity);
+ Assert.NotNull(context);
+ }
+
+ ///
+ /// Asserts that the specified activity was not executed (not present in the journal).
+ ///
+ /// The activity that should not have been executed.
+ public void AssertActivityNotExecuted(IActivity activity)
+ {
+ var context = result.GetActivityContext(activity);
+ Assert.Null(context);
+ }
+
+ ///
+ /// Asserts that the specified activity completed successfully.
+ ///
+ /// The activity that should have completed.
+ public void AssertActivityCompleted(IActivity activity)
+ {
+ var context = result.GetActivityContext(activity);
+ Assert.NotNull(context);
+ Assert.Equal(ActivityStatus.Completed, context.Status);
+ }
+
+ ///
+ /// Asserts that the specified activity has the expected status.
+ ///
+ /// The activity to check.
+ /// The expected status.
+ public void AssertActivityStatus(IActivity activity, ActivityStatus expectedStatus)
+ {
+ var context = result.GetActivityContext(activity);
+ Assert.NotNull(context);
+ Assert.Equal(expectedStatus, context.Status);
+ }
+
+ ///
+ /// Asserts that all specified activities were executed.
+ ///
+ /// The activities that should have been executed.
+ public void AssertActivitiesExecuted(params IActivity[] activities)
+ {
+ foreach (var activity in activities)
+ result.AssertActivityExecuted(activity);
+ }
+
+ ///
+ /// Asserts that all specified activities were not executed.
+ ///
+ /// The activities that should not have been executed.
+ public void AssertActivitiesNotExecuted(params IActivity[] activities)
+ {
+ foreach (var activity in activities)
+ result.AssertActivityNotExecuted(activity);
+ }
+
+ ///
+ /// Asserts that all specified activities completed successfully.
+ ///
+ /// The activities that should have completed.
+ public void AssertActivitiesCompleted(params IActivity[] activities)
+ {
+ foreach (var activity in activities)
+ result.AssertActivityCompleted(activity);
+ }
+
+ ///
+ /// Asserts that the specified activity was executed exactly the expected number of times.
+ ///
+ /// The activity to check.
+ /// The expected execution count.
+ public void AssertActivityExecutionCount(IActivity activity, int expectedCount)
+ {
+ var actualCount = result.GetExecutionCount(activity);
+ Assert.Equal(expectedCount, actualCount);
+ }
+
+ ///
+ /// Asserts that the workflow completed successfully.
+ ///
+ public void AssertWorkflowCompleted()
+ {
+ Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+ }
+
+ ///
+ /// Asserts that the workflow has the expected status and substatus.
+ ///
+ /// The expected workflow status.
+ /// The expected workflow substatus.
+ public void AssertWorkflowStatus(WorkflowStatus expectedStatus, WorkflowSubStatus expectedSubStatus)
+ {
+ Assert.Equal(expectedStatus, result.WorkflowState.Status);
+ Assert.Equal(expectedSubStatus, result.WorkflowState.SubStatus);
+ }
+ }
+}
diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultExtensions.cs
new file mode 100644
index 000000000..10d47fec4
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowResultExtensions.cs
@@ -0,0 +1,72 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using JetBrains.Annotations;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// Provides extension methods for to facilitate testing with Journal-based assertions.
+///
+[PublicAPI]
+public static class RunWorkflowResultExtensions
+{
+ /// The workflow result.
+ extension(RunWorkflowResult result)
+ {
+ ///
+ /// Gets the activity execution context for the specified activity from the workflow result's journal.
+ ///
+ /// The activity to find.
+ /// The activity execution context, or null if not found.
+ public ActivityExecutionContext? GetActivityContext(IActivity activity) => result.Journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == activity);
+
+ ///
+ /// Checks if the specified activity was executed (present in the journal).
+ ///
+ /// The activity to check.
+ /// True if the activity was executed, false otherwise.
+ public bool WasExecuted(IActivity activity) => result.GetActivityContext(activity) != null;
+
+ ///
+ /// Checks if the specified activity completed successfully.
+ ///
+ /// The activity to check.
+ /// True if the activity completed, false otherwise.
+ public bool WasCompleted(IActivity activity)
+ {
+ var context = result.GetActivityContext(activity);
+ return context?.Status == ActivityStatus.Completed;
+ }
+
+ ///
+ /// Gets the execution status of the specified activity.
+ ///
+ /// The activity to check.
+ /// The activity status, or null if the activity was not executed.
+ public ActivityStatus? GetActivityStatus(IActivity activity) => result.GetActivityContext(activity)?.Status;
+
+ ///
+ /// Gets all executed activities from the workflow result's journal.
+ ///
+ /// Collection of executed activities.
+ public IEnumerable GetExecutedActivities() => result.Journal.ActivityExecutionContexts.Select(x => x.Activity);
+
+ ///
+ /// Gets all completed activities from the workflow result's journal.
+ ///
+ /// Collection of completed activities.
+ public IEnumerable GetCompletedActivities() =>
+ result.Journal.ActivityExecutionContexts
+ .Where(x => x.Status == ActivityStatus.Completed)
+ .Select(x => x.Activity);
+
+ ///
+ /// Counts how many times the specified activity was executed.
+ /// Useful for activities that may execute multiple times (e.g., in loops).
+ ///
+ /// The activity to count.
+ /// The number of times the activity was executed.
+ public int GetExecutionCount(IActivity activity) =>
+ result.Journal.ActivityExecutionContexts.Count(x => x.Activity == activity);
+ }
+}
diff --git a/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs
index 18cbcfd79..1ef8bbaa5 100644
--- a/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs
@@ -3,12 +3,7 @@ using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Models;
-using Elsa.Workflows.Models;
-using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
-using Elsa.Workflows.Runtime.Filters;
-using Elsa.Workflows.Runtime.Messages;
-using Elsa.Workflows.State;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Switch.cs b/src/modules/Elsa.Workflows.Core/Activities/Switch.cs
index d40c0fb53..1b94436d6 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Switch.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Switch.cs
@@ -56,19 +56,37 @@ public class Switch : Activity
var matchingCases = (await FindMatchingCasesAsync(context.ExpressionExecutionContext)).ToList();
var hasAnyMatches = matchingCases.Any();
var mode = context.Get(Mode);
- var results = mode == SwitchMode.MatchFirst ? hasAnyMatches ? new[] { matchingCases.First() } : Array.Empty() : matchingCases.ToArray();
+ var results = mode == SwitchMode.MatchFirst
+ ? hasAnyMatches
+ ? new[] { matchingCases.First() }
+ : Array.Empty()
+ : matchingCases.ToArray();
+
+ var scheduledActivityIds = new HashSet();
if (hasAnyMatches)
{
foreach (var result in results)
{
- await context.ScheduleActivityAsync(result.Activity, OnChildActivityCompletedAsync);
- }
+ if (result.Activity == null)
+ continue;
- return;
+ await context.ScheduleActivityAsync(result.Activity, OnChildActivityCompletedAsync);
+ scheduledActivityIds.Add(result.Activity.Id);
+ }
+ }
+ else if (Default != null)
+ {
+ await context.ScheduleActivityAsync(Default, OnChildActivityCompletedAsync);
+ scheduledActivityIds.Add(Default.Id);
}
- await context.ScheduleActivityAsync(Default, OnChildActivityCompletedAsync);
+ context.SetProperty("ScheduledActivityIds", scheduledActivityIds);
+
+ if (!scheduledActivityIds.Any())
+ {
+ await context.CompleteActivityAsync();
+ }
}
private async Task> FindMatchingCasesAsync(ExpressionExecutionContext context)
@@ -89,9 +107,19 @@ public class Switch : Activity
return matchingCases;
}
+ ///
+ /// Tracks the completion of scheduled child activities and completes the Switch activity only after all scheduled children have finished executing.
+ ///
private async ValueTask OnChildActivityCompletedAsync(ActivityCompletedContext context)
{
- await context.TargetContext.CompleteActivityAsync();
+ var scheduledActivityIds = context.TargetContext.GetProperty>("ScheduledActivityIds");
+
+ if (scheduledActivityIds != null
+ && scheduledActivityIds.Remove(context.ChildContext.Activity.Id)
+ && scheduledActivityIds.Count == 0)
+ {
+ await context.TargetContext.CompleteActivityAsync();
+ }
}
}
diff --git a/test/integration/Elsa.Activities.IntegrationTests/Branching/SwitchTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Branching/SwitchTests.cs
new file mode 100644
index 000000000..891e53089
--- /dev/null
+++ b/test/integration/Elsa.Activities.IntegrationTests/Branching/SwitchTests.cs
@@ -0,0 +1,288 @@
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using Elsa.Workflows.Activities;
+using Xunit.Abstractions;
+
+namespace Elsa.Activities.IntegrationTests.Branching;
+
+///
+/// Integration tests for Switch activity.
+///
+public class SwitchTests(ITestOutputHelper testOutputHelper)
+{
+ private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
+
+ [Fact(DisplayName = "Switch completes immediately when no matches and no activities scheduled")]
+ public async Task Switch_CompletesImmediately_WhenNoMatchesAndNoActivitiesScheduled()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+ var case2Activity = new WriteLine("Case 2");
+
+ var switchActivity = new Switch
+ {
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity),
+ new("Case 2", () => false, case2Activity)
+ },
+ Default = null
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivityCompleted(switchActivity);
+ result.AssertActivitiesNotExecuted(case1Activity, case2Activity);
+ }
+
+ [Fact(DisplayName = "Switch completes immediately when no matches but has null default")]
+ public async Task Switch_CompletesImmediately_WhenNoMatchesAndDefaultIsNull()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+
+ var switchActivity = new Switch
+ {
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivityCompleted(switchActivity);
+ result.AssertActivityNotExecuted(case1Activity);
+ }
+
+ [Fact(DisplayName = "Switch schedules default activity when no cases match")]
+ public async Task Switch_SchedulesDefaultActivity_WhenNoCasesMatch()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+ var case2Activity = new WriteLine("Case 2");
+ var defaultActivity = new WriteLine("Default");
+
+ var switchActivity = new Switch
+ {
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity),
+ new("Case 2", () => false, case2Activity)
+ },
+ Default = defaultActivity
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, defaultActivity);
+ result.AssertActivitiesNotExecuted(case1Activity, case2Activity);
+ }
+
+ [Fact(DisplayName = "Switch in MatchFirst mode schedules only first matching case")]
+ public async Task Switch_InMatchFirstMode_SchedulesOnlyFirstMatchingCase()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+ var case2Activity = new WriteLine("Case 2");
+ var case3Activity = new WriteLine("Case 3");
+
+ var switchActivity = new Switch
+ {
+ Mode = new(SwitchMode.MatchFirst),
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity),
+ new("Case 2", () => true, case2Activity),
+ new("Case 3", () => true, case3Activity)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, case2Activity);
+ result.AssertActivitiesNotExecuted(case1Activity, case3Activity);
+ }
+
+ [Fact(DisplayName = "Switch in MatchAny mode schedules all matching cases")]
+ public async Task Switch_InMatchAnyMode_SchedulesAllMatchingCases()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+ var case2Activity = new WriteLine("Case 2");
+ var case3Activity = new WriteLine("Case 3");
+ var case4Activity = new WriteLine("Case 4");
+ var case5Activity = new WriteLine("Case 5");
+
+ var switchActivity = new Switch
+ {
+ Mode = new(SwitchMode.MatchAny),
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity),
+ new("Case 2", () => true, case2Activity),
+ new("Case 3", () => true, case3Activity),
+ new("Case 4", () => false, case4Activity),
+ new("Case 5", () => true, case5Activity)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, case2Activity, case3Activity, case5Activity);
+ result.AssertActivitiesNotExecuted(case1Activity, case4Activity);
+ }
+
+ [Fact(DisplayName = "Switch completes only after all scheduled activities complete")]
+ public async Task Switch_CompletesOnlyAfterAllScheduledActivitiesComplete()
+ {
+ // Arrange
+ var activity1 = new WriteLine("Activity 1");
+ var activity2 = new WriteLine("Activity 2");
+ var activity3 = new WriteLine("Activity 3");
+
+ var switchActivity = new Switch
+ {
+ Mode = new(SwitchMode.MatchAny),
+ Cases =
+ {
+ new("Case 1", () => true, activity1),
+ new("Case 2", () => true, activity2),
+ new("Case 3", () => true, activity3)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, activity1, activity2, activity3);
+ }
+
+ [Fact(DisplayName = "Switch completes after default activity completes")]
+ public async Task Switch_CompletesAfterDefaultActivityCompletes()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+ var defaultActivity = new WriteLine("Default Activity");
+
+ var switchActivity = new Switch
+ {
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity)
+ },
+ Default = defaultActivity
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, defaultActivity);
+ result.AssertActivityNotExecuted(case1Activity);
+ }
+
+ [Fact(DisplayName = "Switch with nested activities completes after all complete")]
+ public async Task Switch_WithNestedActivities_CompletesAfterAllComplete()
+ {
+ // Arrange
+ var nestedSequence1 = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("Nested 1A"),
+ new WriteLine("Nested 1B")
+ }
+ };
+
+ var nestedSequence2 = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("Nested 2A"),
+ new WriteLine("Nested 2B")
+ }
+ };
+
+ var switchActivity = new Switch
+ {
+ Mode = new(SwitchMode.MatchAny),
+ Cases =
+ {
+ new("Case 1", () => true, nestedSequence1),
+ new("Case 2", () => true, nestedSequence2)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, nestedSequence1, nestedSequence2);
+ }
+
+ [Fact(DisplayName = "Switch in MatchFirst mode with single match")]
+ public async Task Switch_InMatchFirstMode_WithSingleMatch()
+ {
+ // Arrange
+ var case1Activity = new WriteLine("Case 1");
+ var case2Activity = new WriteLine("Case 2");
+ var case3Activity = new WriteLine("Case 3");
+
+ var switchActivity = new Switch
+ {
+ Mode = new(SwitchMode.MatchFirst),
+ Cases =
+ {
+ new("Case 1", () => false, case1Activity),
+ new("Case 2", () => false, case2Activity),
+ new("Case 3", () => true, case3Activity)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, case3Activity);
+ result.AssertActivitiesNotExecuted(case1Activity, case2Activity);
+ }
+
+ [Fact(DisplayName = "Switch evaluates conditions with expression context")]
+ public async Task Switch_EvaluatesConditionsWithExpressionContext()
+ {
+ // Arrange
+ var conditionValue = true;
+ var case1Activity = new WriteLine("Case 1");
+ var case2Activity = new WriteLine("Case 2");
+ var case3Activity = new WriteLine("Case 3");
+
+ var switchActivity = new Switch
+ {
+ Cases =
+ {
+ new("Case 1", _ => false, case1Activity),
+ new("Case 2", _ => conditionValue, case2Activity),
+ new("Case 3", _ => false, case3Activity)
+ }
+ };
+
+ // Act
+ var result = await _fixture.RunActivityAsync(switchActivity);
+
+ // Assert
+ result.AssertActivitiesCompleted(switchActivity, case2Activity);
+ result.AssertActivitiesNotExecuted(case1Activity, case3Activity);
+ }
+}
\ No newline at end of file