elsa-core/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs
Sipke Schoorstra d4b69be44b
Refactor inbound connection handling to schedule outbound activity before canceling remaining activities (#7340)
* Refactor inbound connection handling to schedule outbound activity before canceling remaining activities

* Add regression test for WaitAny join schedule-before-cancel ordering (#7341)

* Initial plan

* Add integration test for WaitAny join with blocking branch (schedule-before-cancel regression test)

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
2026-03-02 19:53:10 +01:00

409 lines
14 KiB
C#

using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
using Xunit.Abstractions;
using static Elsa.Activities.IntegrationTests.Flow.FlowchartTestHelpers;
namespace Elsa.Activities.IntegrationTests.Flow;
/// <summary>
/// Integration tests for counter-based flowchart execution strategy.
/// </summary>
[Collection("FlowchartTests")]
public class FlowchartCounterBasedTests
{
private readonly IServiceProvider _services;
private readonly CapturingTextWriter _output;
public FlowchartCounterBasedTests(ITestOutputHelper testOutputHelper)
{
_output = new();
_services = CreateServiceProvider(testOutputHelper, _output);
}
[Fact(DisplayName = "Executes simple linear flowchart")]
public async Task ExecutesSimpleLinearFlowchart()
{
// Arrange
var flowchart = CreateSimpleLinearFlowchart(
new WriteLine("First"),
new WriteLine("Second"),
new WriteLine("Third")
);
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Equal(3, _output.Lines.Count);
Assert.Equal("First", _output.Lines.ElementAt(0));
Assert.Equal("Second", _output.Lines.ElementAt(1));
Assert.Equal("Third", _output.Lines.ElementAt(2));
}
[Fact(DisplayName = "Executes both branches in parallel flowchart")]
public async Task ExecutesBothBranches()
{
// Arrange
var start = new WriteLine("Start");
var branch1 = new WriteLine("Branch1");
var branch2 = new WriteLine("Branch2");
var flowchart = CreateBranchingFlowchart(start, branch1, branch2);
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Equal(3, _output.Lines.Count);
Assert.Contains("Start", _output.Lines);
Assert.Contains("Branch1", _output.Lines);
Assert.Contains("Branch2", _output.Lines);
}
[Fact(DisplayName = "Handles flowchart with no connections")]
public async Task HandlesNoConnections()
{
// Arrange
var activity = new WriteLine("Isolated");
var flowchart = new Flowchart
{
Start = activity,
Activities = { activity }
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Single(_output.Lines);
Assert.Equal("Isolated", _output.Lines.ElementAt(0));
}
[Fact(DisplayName = "Completes when start activity is null")]
public async Task CompletesWhenStartIsNull()
{
// Arrange
var flowchart = new Flowchart
{
Start = null
};
// Act
var result = await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.NotNull(result);
Assert.Empty(_output.Lines);
}
[Fact(DisplayName = "Follows conditional branches with If activity")]
public async Task FollowsConditionalBranches()
{
// Arrange
var ifActivity = new If
{
Condition = new(true),
Then = new WriteLine("Then branch"),
Else = new WriteLine("Else branch")
};
var flowchart = new Flowchart
{
Start = ifActivity,
Activities = { ifActivity }
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Single(_output.Lines);
Assert.Equal("Then branch", _output.Lines.ElementAt(0));
}
[Fact(DisplayName = "Executes join node with WaitAny mode")]
public async Task ExecutesJoinNodeWaitAny()
{
// Arrange
var start = new WriteLine("Start");
var branch1 = new WriteLine("Branch1");
var branch2 = new WriteLine("Branch2");
var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAny) };
var afterJoin = new WriteLine("AfterJoin");
var flowchart = new Flowchart
{
Start = start,
Activities = { start, branch1, branch2, join, afterJoin },
Connections =
{
CreateConnection(start, branch1),
CreateConnection(start, branch2),
CreateConnection(branch1, join),
CreateConnection(branch2, join),
CreateConnection(join, afterJoin)
}
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
Assert.Contains("AfterJoin", _output.Lines);
// At least one branch should execute
Assert.True(_output.Lines.Contains("Branch1") || _output.Lines.Contains("Branch2"));
}
[Fact(DisplayName = "Executes join node with WaitAll mode")]
public async Task ExecutesJoinNodeWaitAll()
{
// Arrange
var start = new WriteLine("Start");
var branch1 = new WriteLine("Branch1");
var branch2 = new WriteLine("Branch2");
var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) };
var afterJoin = new WriteLine("AfterJoin");
var flowchart = new Flowchart
{
Start = start,
Activities = { start, branch1, branch2, join, afterJoin },
Connections =
{
CreateConnection(start, branch1),
CreateConnection(start, branch2),
CreateConnection(branch1, join),
CreateConnection(branch2, join),
CreateConnection(join, afterJoin)
}
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
Assert.Contains("Branch1", _output.Lines);
Assert.Contains("Branch2", _output.Lines);
Assert.Contains("AfterJoin", _output.Lines);
}
[Fact(DisplayName = "Handles multiple sequential joins")]
public async Task HandlesMultipleSequentialJoins()
{
// Arrange
var start = new WriteLine("Start");
var a1 = new WriteLine("A1");
var a2 = new WriteLine("A2");
var join1 = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) };
var b1 = new WriteLine("B1");
var b2 = new WriteLine("B2");
var join2 = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) };
var end = new WriteLine("End");
var flowchart = new Flowchart
{
Start = start,
Activities = { start, a1, a2, join1, b1, b2, join2, end },
Connections =
{
CreateConnection(start, a1),
CreateConnection(start, a2),
CreateConnection(a1, join1),
CreateConnection(a2, join1),
CreateConnection(join1, b1),
CreateConnection(join1, b2),
CreateConnection(b1, join2),
CreateConnection(b2, join2),
CreateConnection(join2, end)
}
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
Assert.Contains("A1", _output.Lines);
Assert.Contains("A2", _output.Lines);
Assert.Contains("B1", _output.Lines);
Assert.Contains("B2", _output.Lines);
Assert.Contains("End", _output.Lines);
}
[Fact(DisplayName = "Handles complex diamond pattern")]
public async Task HandlesComplexDiamondPattern()
{
// Arrange
var start = new WriteLine("Start");
var left1 = new WriteLine("Left1");
var left2 = new WriteLine("Left2");
var right1 = new WriteLine("Right1");
var right2 = new WriteLine("Right2");
var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAll) };
var end = new WriteLine("End");
var flowchart = new Flowchart
{
Start = start,
Activities = { start, left1, left2, right1, right2, join, end },
Connections =
{
CreateConnection(start, left1),
CreateConnection(start, right1),
CreateConnection(left1, left2),
CreateConnection(right1, right2),
CreateConnection(left2, join),
CreateConnection(right2, join),
CreateConnection(join, end)
}
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Contains("Start", _output.Lines);
Assert.Contains("Left1", _output.Lines);
Assert.Contains("Left2", _output.Lines);
Assert.Contains("Right1", _output.Lines);
Assert.Contains("Right2", _output.Lines);
Assert.Contains("End", _output.Lines);
}
[Fact(DisplayName = "Executes activities in correct order for sequential flow")]
public async Task ExecutesInCorrectOrderForSequential()
{
// Arrange
var flowchart = CreateSimpleLinearFlowchart(
new WriteLine("1"),
new WriteLine("2"),
new WriteLine("3"),
new WriteLine("4")
);
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Equal(4, _output.Lines.Count);
Assert.Equal("1", _output.Lines.ElementAt(0));
Assert.Equal("2", _output.Lines.ElementAt(1));
Assert.Equal("3", _output.Lines.ElementAt(2));
Assert.Equal("4", _output.Lines.ElementAt(3));
}
[Fact(DisplayName = "Handles nested flowcharts")]
public async Task HandlesNestedFlowcharts()
{
// Arrange
var innerFlowchart = CreateSimpleLinearFlowchart(
new WriteLine("Inner1"),
new WriteLine("Inner2")
);
var outerFlowchart = CreateSimpleLinearFlowchart(
new WriteLine("Outer1"),
innerFlowchart,
new WriteLine("Outer2")
);
// Act
await RunFlowchartAsync(_services, outerFlowchart);
// Assert
Assert.Contains("Outer1", _output.Lines);
Assert.Contains("Inner1", _output.Lines);
Assert.Contains("Inner2", _output.Lines);
Assert.Contains("Outer2", _output.Lines);
}
[Fact(DisplayName = "Handles unconnected activities in flowchart")]
public async Task HandlesUnconnectedActivities()
{
// Arrange
var connected = new WriteLine("Connected");
var unconnected = new WriteLine("Unconnected");
var flowchart = new Flowchart
{
Start = connected,
Activities = { connected, unconnected }
// No connection to unconnected activity
};
// Act
await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert
Assert.Single(_output.Lines);
Assert.Equal("Connected", _output.Lines.ElementAt(0));
Assert.DoesNotContain("Unconnected", _output.Lines);
}
/// <summary>
/// Regression test for the ordering fix in <see cref="Flowchart.MaybeScheduleWaitAnyActivityAsync"/>:
/// the outbound activity must be scheduled <em>before</em> remaining inbound branches are canceled.
/// Previously, canceling first could trigger <c>CompleteIfNoPendingWorkAsync</c> while the outbound
/// activity was not yet in the scheduler, causing the flowchart to finish prematurely without running
/// the activity downstream of the join.
/// </summary>
[Fact(DisplayName = "WaitAny join schedules outbound before canceling blocked branch, preventing premature completion")]
public async Task WaitAnyJoin_SchedulesOutboundBeforeCancelingBlockedBranch()
{
// Arrange
// Flowchart structure:
// Start
// ├─► Branch1 (fast) ─► Join (WaitAny) ─► AfterJoin
// └─► Branch2 (blocking, creates a bookmark) ─►┘
//
// Because the scheduler is LIFO, Branch2 executes first and suspends with a bookmark.
// Branch1 then completes and triggers the WaitAny join.
// The join must schedule AfterJoin before canceling Branch2; otherwise
// CompleteIfNoPendingWorkAsync fires while AfterJoin is not yet queued,
// completing the flowchart prematurely without executing AfterJoin.
var start = new WriteLine("Start");
var branch1 = new WriteLine("Branch1");
var branch2 = new BlockingActivity { Id = "BlockingBranch" };
var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAny) };
var afterJoin = new WriteLine("AfterJoin");
var flowchart = new Flowchart
{
Start = start,
Activities = { start, branch1, branch2, join, afterJoin },
Connections =
{
CreateConnection(start, branch1),
CreateConnection(start, branch2),
CreateConnection(branch1, join),
CreateConnection(branch2, join),
CreateConnection(join, afterJoin)
}
};
// Act
var result = await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased);
// Assert: the outbound path of the join must have executed
Assert.Contains("AfterJoin", _output.Lines);
// Assert: the workflow must have finished, not suspended waiting for the canceled bookmark
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
// Assert: the blocked branch's bookmark was cleared when it was canceled
Assert.Empty(result.WorkflowState.Bookmarks);
}
/// <summary>
/// A minimal activity that suspends by creating a bookmark, simulating a blocking activity
/// such as a Delay or Timer that would normally be resumed by an external stimulus.
/// </summary>
private sealed class BlockingActivity : Activity
{
protected override void Execute(ActivityExecutionContext context) => context.CreateBookmark();
}
}