Improves Fork activity and break signal handling (#7104)

* Add unit and integration tests for `Container` activity, covering behavior such as variable scoping, child activity execution, and mixed variable types.

* Refactor `RunWorkflowAndCaptureOutput` method in `ContainerTests` for better code organization

* Move `Sequence` activity tests to a dedicated namespace and add new unit and integration tests for enhanced coverage.

- Deleted outdated `SequenceTests` and related workflows.
- Introduced `SequenceActivity` namespace with improved organization.
- Added comprehensive unit and integration test coverage for sequential execution, nested sequences, conditional breaking, variables, and dynamic activities.

* Relocate `SequenceActivity` tests to `Activities` namespace to improve organization and update references in related test classes.

* Refactor `SequenceTests` to move `DynamicSequenceWorkflow` to its own file for better test organization.

* Extract `TestContainer` to `Elsa.Testing.Shared.Activities` for reuse across test projects.

* Add unit and integration tests for `Break` activity; refactor workflow tests for improved organization

- Introduced `BreakInForkWorkflow` and deprecated `BreakWhileForkWorkflow`.
- Added `BreakTests` unit tests to validate behavior of the `Break` activity, including terminal node implementation and execution completion.
- Enhanced integration tests for `Break` activity, covering multiple looping constructs (`ForEach`, `For`, `While`, `Fork`) and nested workflows.
- Updated `Fork` activity to handle the `BreakSignal` asynchronously.
- Simplified workflow definitions by removing redundant constructors and using concise variable initialization syntax.
- Improved test clarity with better organization, comments, and consistent naming conventions.

* Refactor `Fork` activity tests and workflows for improved organization and coverage

- Relocated `BasicForkWorkflow` and `JoinAnyForkWorkflow` to `Fork/Workflows` namespace.
- Introduced `EmptyForkWorkflow` to test Fork behavior with no branches.
- Enhanced `ForkTests` with scenarios for `Fork` execution with different join modes and branch configurations.
- Refactored `Fork` activity to handle empty branches and simplified `BreakSignal` handling.
- Improved consistency and clarity of test cases, including better assertions and comments.

* Update test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs

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

* Update test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs

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

* Update test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs

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

* Update test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/ForkTests.cs

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

* Update test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/ForkTests.cs

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

* Update GitHub Actions workflow to use .NET 10.x

* Remove outdated GitHub workflows and update configurations to .NET 10.x

* Remove unused result variable assignments in integration tests (#7105)

* Initial plan

* Remove unused result variable assignments in BreakTests and ForkTests

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 <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-11-25 19:29:02 +01:00 committed by GitHub
parent 7c7561b509
commit 3340e14b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 216 additions and 568 deletions

View file

@ -51,7 +51,7 @@ docker/ # Docker configurations
## Build Instructions
### Prerequisites
- **.NET 9.0 SDK** (verified working version: 9.0.305)
- **.NET 10.0 SDK**
- **Build time**: Initial restore ~1-2 minutes, full compile ~5-10 minutes
### Critical Build Information
@ -111,11 +111,10 @@ find test/unit -name "*.csproj" | head -5 | xargs -I {} dotnet build {}
### Expected Build Warnings
- `NU1900`: Unable to load service index for external feeds (safe to ignore)
- `NU1801`: Service index warnings for feedz.io sources (safe to ignore)
- `NU1101`: Missing Elsa.Studio packages (blocks studio app builds)
### Successful Build Indicators
- Core modules (Elsa.Workflows.Core, etc.) compile successfully
- Server applications (Elsa.Server.Web) build without the studio UI
- Server applications (Elsa.Server.Web) build
- Most modules show "succeeded with X warning(s)" (warnings are acceptable)
## Testing
@ -142,7 +141,7 @@ dotnet test --no-build --no-restore [project-path]
### Code Standards
- **Language version**: C# latest
- **Target framework**: .NET 9.0
- **Target framework**: .NET 10.0
- **Nullable reference types**: Enabled
- **Implicit usings**: Enabled
- **EditorConfig**: Configured (4-space indentation, CRLF line endings)
@ -154,23 +153,22 @@ dotnet test --no-build --no-restore [project-path]
- **Async/await**: Extensive use throughout for scalability
### Common Gotchas
1. **External Dependencies**: Studio-related projects require external packages
2. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced
3. **Multiple Target Frameworks**: Some projects conditionally target different frameworks
4. **Build Warnings**: Many NU1900/NU1801 warnings are expected and safe
1. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced
2**Multiple Target Frameworks**: Some projects conditionally target different frameworks
3**Build Warnings**: Many NU1900/NU1801 warnings are expected and safe
## Continuous Integration
### GitHub Actions Workflow
- **Trigger**: Pull requests to `main` branch
- **Runner**: ubuntu-latest
- **.NET Version**: 9.x (latest)
- **.NET Version**: 10.x (latest)
- **Commands**: `./build.cmd Compile Test Pack`
- **File**: `.github/workflows/pr.yml` (auto-generated by NUKE)
### CI Pipeline Steps
1. Checkout code
2. Setup .NET 9.x SDK
2. Setup .NET 10.x SDK
3. Execute: Compile → Test → Pack
4. Expected warnings for external feed access
5. Studio apps may be excluded from CI builds
@ -230,29 +228,6 @@ dotnet run
The server will start on the configured ports (check `appsettings.json` or environment variables).
### Combined Server and Studio
To run both the workflow server and visual studio together:
```bash
cd src/apps/Elsa.ServerAndStudio.Web
dotnet restore --ignore-failed-sources
dotnet run
```
Access the studio at the configured URL (typically `http://localhost:5000` or as configured).
### Using Docker
For quick testing with Docker (see README for full details):
```bash
docker pull elsaworkflows/elsa-server-and-studio-v3:latest
docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e HTTP__BASEURL=http://localhost:13000 -p 13000:8080 elsaworkflows/elsa-server-and-studio-v3:latest
```
Default credentials: username `admin`, password `password`
## Troubleshooting
### Common Issues
@ -296,12 +271,11 @@ Default credentials: username `admin`, password `password`
## Important Notes for Coding Agents
1. **Always use `--ignore-failed-sources`** when restoring packages
2. **Focus on core workflow functionality** rather than studio UI components
3. **Studio apps require external packages** that may not be accessible
4. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings
5. **Test individual modules** rather than solution-wide tests when external deps fail
6. **Use direct dotnet commands** for building specific components when NUKE fails
7. **Check project references** before attempting builds - some projects have conditional references
8. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully
2. **Focus on core workflow functionality**
3. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings
4. **Test individual modules** rather than solution-wide tests when external deps fail
5. **Use direct dotnet commands** for building specific components when NUKE fails
6. **Check project references** before attempting builds - some projects have conditional references
7. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully
Trust these instructions for build and development workflows. Only search for additional information if these instructions are incomplete or found to be incorrect.

View file

@ -13,7 +13,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up .NET 9 SDK
- name: Set up .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
dotnet-version: "10.0.x"

View file

@ -48,7 +48,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Publish TLS smoke test app
run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke

View file

@ -1,61 +0,0 @@
name: Elsa Server + Studio Docker image
on:
workflow_dispatch:
push:
branches:
- develop/3.6.0
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
# list of Docker images to use as base name for tags
images: |
elsaworkflows/elsa-server-and-studio-v3-6-preview
flavor: |
latest=true
# generate Docker tags based on the following events/attributes
tags: |
type=sha
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v3
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/ElsaServerAndStudio.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -37,7 +37,7 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: |
9.x
10.x
- uses: actions/checkout@v4
- name: 'Run: Compile, Test'
run: ./build.cmd Compile Test

View file

@ -94,17 +94,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Core.UnitTes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Tenants", "src\modules\Elsa.Tenants\Elsa.Tenants.csproj", "{29638A67-E79F-44FE-AC05-DA499EBA929E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{B789B743-0FBE-4BBC-9F45-84C20C8D4814}"
ProjectSection(SolutionItems) = preProject
.github\workflows\bounty.yml = .github\workflows\bounty.yml
.github\workflows\elsa-server-and-studio.yml = .github\workflows\elsa-server-and-studio.yml
.github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml
.github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml
.github\workflows\packages.yml = .github\workflows\packages.yml
.github\workflows\pr.yml = .github\workflows\pr.yml
.github\workflows\stale.yml = .github\workflows\stale.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{99F2B1DA-2F69-4D70-A2A3-AC985AD91EC4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C80C8231-D35C-4ACC-9ED6-9F3DB221535E}"
@ -313,6 +302,34 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Scheduling.UnitTests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.IntegrationTests", "test\integration\Elsa.Common.IntegrationTests\Elsa.Common.IntegrationTests.csproj", "{6C451CC5-280E-475E-B95E-23ADCF0CCBFD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{06DC6553-AAEF-4FC8-B16F-039F292E86A3}"
ProjectSection(SolutionItems) = preProject
.github\copilot-instructions.md = .github\copilot-instructions.md
.github\dependabot.yml = .github\dependabot.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{2158FC82-6253-47F2-9216-B266710D50F1}"
ProjectSection(SolutionItems) = preProject
.github\workflows\stale.yml = .github\workflows\stale.yml
.github\workflows\pr.yml = .github\workflows\pr.yml
.github\workflows\packages.yml = .github\workflows\packages.yml
.github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml
.github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml
.github\workflows\docker-ca.yml = .github\workflows\docker-ca.yml
.github\workflows\bounty.yml = .github\workflows\bounty.yml
.github\workflows\copilot-setup-steps.yml = .github\workflows\copilot-setup-steps.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "issue_templates", "issue_templates", "{36556068-64BF-4D4B-827E-EC8CDC23D516}"
ProjectSection(SolutionItems) = preProject
.github\ISSUE_TEMPLATE\bug_report.md = .github\ISSUE_TEMPLATE\bug_report.md
.github\ISSUE_TEMPLATE\feature_request.md = .github\ISSUE_TEMPLATE\feature_request.md
.github\ISSUE_TEMPLATE\improvement.md = .github\ISSUE_TEMPLATE\improvement.md
.github\ISSUE_TEMPLATE\performance.md = .github\ISSUE_TEMPLATE\performance.md
.github\ISSUE_TEMPLATE\task.md = .github\ISSUE_TEMPLATE\task.md
.github\ISSUE_TEMPLATE\documentation.md = .github\ISSUE_TEMPLATE\documentation.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -721,6 +738,8 @@ Global
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92}
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D} = {18453B51-25EB-4317-A4B3-B10518252E92}
{6C451CC5-280E-475E-B95E-23ADCF0CCBFD} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{2158FC82-6253-47F2-9216-B266710D50F1} = {06DC6553-AAEF-4FC8-B16F-039F292E86A3}
{36556068-64BF-4D4B-827E-EC8CDC23D516} = {06DC6553-AAEF-4FC8-B16F-039F292E86A3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -26,9 +26,9 @@ public class For : Activity
/// <inheritdoc />
public For(int start, int end, int step, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line)
{
Start = new Input<int>(start);
End = new Input<int>(end);
Step = new Input<int>(step);
Start = new(start);
End = new(end);
Step = new(step);
}
/// <summary>

View file

@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Elsa.Extensions;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Signals;
using Elsa.Workflows.UIHints;
@ -38,7 +39,17 @@ public class Fork : Activity
public ICollection<IActivity> Branches { get; set; } = new List<IActivity>();
/// <inheritdoc />
protected override ValueTask ExecuteAsync(ActivityExecutionContext context) => context.ScheduleActivities(Branches, CompleteChildAsync);
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// If there are no branches, complete immediately
if (Branches.Count == 0)
{
await context.CompleteActivityAsync();
return;
}
await context.ScheduleActivities(Branches, CompleteChildAsync);
}
private async ValueTask CompleteChildAsync(ActivityCompletedContext context)
{
@ -57,7 +68,7 @@ public class Fork : Activity
// Append activity to the set of completed activities.
var completedActivityIds = targetContext.UpdateProperty<HashSet<string>>("Completed", set =>
{
set ??= new HashSet<string>();
set ??= new();
set.Add(completedChildActivityId);
return set;
});

View file

@ -21,7 +21,7 @@ public class While : Activity
/// </summary>
public static While True(IActivity body) => new(body)
{
Condition = new Input<bool>(true)
Condition = new(true)
};
/// <inheritdoc />

View file

@ -1,64 +1,58 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.IntegrationTests.Activities.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Activities;
public class BreakTests
/// <summary>
/// Integration tests for the <see cref="Workflows.Activities.Break"/> activity.
/// Tests Break behavior across different looping constructs (ForEach, For, While, Fork).
/// </summary>
public class BreakTests(ITestOutputHelper testOutputHelper)
{
private readonly IWorkflowRunner _workflowRunner;
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
public BreakTests(ITestOutputHelper testOutputHelper)
[Fact(DisplayName = "Break exits ForEach loop")]
public async Task Break_ExitsForEachLoop()
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
// Act
await _fixture.RunWorkflowAsync(new BreakForEachWorkflow());
var lines = _fixture.CapturingTextWriter.Lines.ToList();
[Fact(DisplayName = "Break exits out of ForEach")]
public async Task Test1()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<BreakForEachWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "Start", "C#", "End" }, lines);
}
[Fact(DisplayName = "Break exits out of immediate ForEach only")]
public async Task Test2()
[Fact(DisplayName = "Break exits only immediate loop in nested ForEach")]
public async Task Break_ExitsOnlyImmediateLoopInNestedForEach()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<NestedForEachWithBreakWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
// Act
await _fixture.RunWorkflowAsync(new NestedForEachWithBreakWorkflow());
var lines = _fixture.CapturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "C#", "Classes", "Rust", "Classes", "Go", "Classes" }, lines);
}
[Fact(DisplayName = "Break exits out of For")]
public async Task Test3()
[Fact(DisplayName = "Break exits For loop")]
public async Task Break_ExitsForLoop()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<BreakForWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
// Act
await _fixture.RunWorkflowAsync(new BreakForWorkflow());
var lines = _fixture.CapturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "Start", "0", "1", "End" }, lines);
}
[Fact(DisplayName = "Break exits out of While")]
public async Task Test4()
[Fact(DisplayName = "Break exits While loop")]
public async Task Break_ExitsWhileLoop()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<BreakWhileWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
// Act
await _fixture.RunWorkflowAsync(new BreakWhileWorkflow());
var lines = _fixture.CapturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "Start", "1", "2", "End" }, lines);
}
[Fact(DisplayName = "Break removes the right bookmarks")]
public async Task Test5()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<BreakWhileWorkflow>();
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(new[] { "Start", "1", "2", "End" }, lines);
}
}
}

View file

@ -3,7 +3,7 @@ using Elsa.Workflows.Memory;
namespace Elsa.Workflows.IntegrationTests.Activities.Workflows;
class BreakForEachWorkflow : WorkflowBase
public class BreakForEachWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{

View file

@ -3,7 +3,7 @@ using Elsa.Workflows.Memory;
namespace Elsa.Workflows.IntegrationTests.Activities.Workflows;
class BreakForWorkflow : WorkflowBase
public class BreakForWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{

View file

@ -1,51 +0,0 @@
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Runtime.Activities;
namespace Elsa.Workflows.IntegrationTests.Activities.Workflows;
public class BreakWhileForkWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{
var currentValue = new Variable<int?>("CurrentValue", 0);
workflow.Root = new Sequence
{
Variables = { currentValue },
Activities =
{
While.True(new Fork
{
Branches =
{
new Sequence
{
Activities =
{
new SetVariable
{
Variable = currentValue,
Value = new (context => currentValue.Get(context) + 1)
},
new If(context => currentValue.Get(context) == 3)
{
Then = new Break()
}
}
},
new Sequence
{
Activities =
{
new WriteLine("Waiting for event..."),
new Runtime.Activities.Event("Some event") { Id = "SomeEvent" },
new WriteLine("Resuming"),
}
}
}
}),
}
};
}
}

View file

@ -3,7 +3,7 @@ using Elsa.Workflows.Memory;
namespace Elsa.Workflows.IntegrationTests.Activities;
class NestedForEachWithBreakWorkflow : WorkflowBase
public class NestedForEachWithBreakWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{

View file

@ -1,54 +1,64 @@
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows.IntegrationTests.Activities.Workflows;
using Elsa.Workflows.Options;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Activities;
public class ForkTests
/// <summary>
/// Integration tests for the <see cref="Workflows.Activities.Fork"/> activity.
/// Tests Fork behavior with different join modes and branch configurations.
/// </summary>
public class ForkTests(ITestOutputHelper testOutputHelper)
{
private readonly IWorkflowRunner _workflowRunner;
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IWorkflowBuilderFactory _workflowBuilderFactory;
private readonly IServiceProvider _services;
private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
public ForkTests(ITestOutputHelper testOutputHelper)
[Fact(DisplayName = "Fork executes all branches with WaitAll")]
public async Task Fork_ExecutesAllBranchesWithWaitAll()
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
_workflowBuilderFactory = _services.GetRequiredService<IWorkflowBuilderFactory>();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
// Act
await _fixture.RunWorkflowAsync(new BasicForkWorkflow());
var lines = _fixture.CapturingTextWriter.Lines.ToList();
[Fact(DisplayName = "Each branch executes")]
public async Task Test1()
{
await _services.PopulateRegistriesAsync();
var workflow = await _workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync<BasicForkWorkflow>();
await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "Branch 1", "Branch 2", "Branch 3" }, lines);
}
[Fact(DisplayName = "Wait AnyAsync causes workflow to continue")]
public async Task Test2()
[Fact(DisplayName = "Fork with WaitAny continues after first branch completes")]
public async Task Fork_WaitAnyContinuesAfterFirstBranch()
{
await _services.PopulateRegistriesAsync();
var workflow = await _workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync<JoinAnyForkWorkflow>();
// Arrange & build services
await _fixture.BuildAsync();
var workflowBuilderFactory = _fixture.Services.GetRequiredService<IWorkflowBuilderFactory>();
var workflow = await workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync<JoinAnyForkWorkflow>();
// First run.
var result = await _workflowRunner.RunAsync(workflow);
// Act - First run
var workflowRunner = _fixture.Services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(workflow);
// Collect one of the bookmarks to resume the workflow.
// Collect one of the bookmarks to resume the workflow
var bookmark = result.WorkflowState.Bookmarks.FirstOrDefault(x => x.ActivityId == "Event2");
Assert.NotNull(bookmark);
// Resume the workflow.
// Resume the workflow
var runOptions = new RunWorkflowOptions { BookmarkId = bookmark.Id };
await _workflowRunner.RunAsync(workflow, result.WorkflowState, runOptions);
await workflowRunner.RunAsync(workflow, result.WorkflowState, runOptions);
var lines = _fixture.CapturingTextWriter.Lines.ToList();
// Verify output.
var lines = _capturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "Start", "Branch 2", "End" }, lines);
}
[Fact(DisplayName = "Fork with no branches completes successfully")]
public async Task Fork_WithNoBranchesCompletesSuccessfully()
{
// Act
await _fixture.RunWorkflowAsync(new EmptyForkWorkflow());
var lines = _fixture.CapturingTextWriter.Lines.ToList();
// Assert
Assert.Equal(new[] { "Before fork", "After fork" }, lines);
}
}

View file

@ -1,6 +1,6 @@
using Elsa.Workflows.Activities;
namespace Elsa.Workflows.IntegrationTests.Activities;
namespace Elsa.Workflows.IntegrationTests.Activities.Workflows;
public class BasicForkWorkflow : WorkflowBase
{
@ -16,4 +16,4 @@ public class BasicForkWorkflow : WorkflowBase
}
};
}
}
}

View file

@ -0,0 +1,19 @@
using Elsa.Workflows.Activities;
namespace Elsa.Workflows.IntegrationTests.Activities.Workflows;
public class EmptyForkWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{
workflow.Root = new Sequence
{
Activities =
{
new WriteLine("Before fork"),
new Fork(),
new WriteLine("After fork")
}
};
}
}

View file

@ -1,7 +1,7 @@
using Elsa.Workflows.Activities;
using Elsa.Workflows.Runtime.Activities;
namespace Elsa.Workflows.IntegrationTests.Activities;
namespace Elsa.Workflows.IntegrationTests.Activities.Workflows;
public class JoinAnyForkWorkflow : WorkflowBase
{
@ -56,4 +56,4 @@ public class JoinAnyForkWorkflow : WorkflowBase
}
};
}
}
}

View file

@ -36,7 +36,7 @@ public class BreakWhileFromForkWorkflow : WorkflowBase
new Event("Branch 1") { Id = "Branch 1" },
new WriteLine("Branch 1 - Resumed"),
// This should break the while loop, not matter how high up in the tree it is.
// This should break the while loop, no matter how high up in the tree it is.
new Break()
}
},

View file

@ -1,20 +1,18 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Signals;
namespace Elsa.Activities.UnitTests.Flow;
/// <summary>
/// Unit tests for the Fork activity covering various join modes, branching scenarios, and signal handling.
/// Unit tests for the <see cref="Fork"/> activity.
/// </summary>
public class ForkTests
{
[Fact]
public async Task SchedulesAllBranches()
[Fact(DisplayName = "Fork schedules all branches")]
public async Task Fork_SchedulesAllBranches()
{
// Arrange
var branches = CreateBranches(3);
var fork = new Fork
{
JoinMode = ForkJoinMode.WaitAll,
@ -31,36 +29,30 @@ public class ForkTests
}
}
[Fact]
public async Task ExecutesWithNoBranches()
[Fact(DisplayName = "Fork with no branches completes immediately")]
public async Task Fork_WithNoBranchesCompletesImmediately()
{
// Arrange
var fork = new Fork
{
JoinMode = ForkJoinMode.WaitAll
};
var fork = new Fork { JoinMode = ForkJoinMode.WaitAll };
// Act
var context = await ExecuteForkAsync(fork);
// Assert
Assert.NotNull(context);
Assert.Equal(ActivityStatus.Completed, context.Status);
}
[Theory]
[Theory(DisplayName = "Fork respects join mode")]
[InlineData(ForkJoinMode.WaitAll)]
[InlineData(ForkJoinMode.WaitAny)]
public async Task ExecutesWithSpecifiedJoinMode(ForkJoinMode joinMode)
public async Task Fork_RespectsJoinMode(ForkJoinMode joinMode)
{
// Arrange
var branch = new WriteLine("Test Branch");
var fork = new Fork
{
JoinMode = joinMode,
Branches =
{
branch
}
Branches = { branch }
};
// Act
@ -71,305 +63,11 @@ public class ForkTests
Assert.True(context.HasScheduledActivity(branch));
}
[Theory]
[InlineData(ForkJoinMode.WaitAll, 1, 1)] // WaitAll: complete 1 of 2, should track 1
[InlineData(ForkJoinMode.WaitAll, 2, 2)] // WaitAll: complete 2 of 2, should track 2
[InlineData(ForkJoinMode.WaitAny, 1, 1)] // WaitAny: complete 1 of 2, should track 1
public async Task CompletionTrackingWithJoinModes(ForkJoinMode joinMode, int branchesToComplete, int expectedCompleted)
{
// Arrange
var branches = CreateBranches(2);
var fork = new Fork
{
JoinMode = joinMode,
Branches = branches.Cast<IActivity>().ToList()
};
var context = await ExecuteForkAsync(fork);
private static Task<ActivityExecutionContext> ExecuteForkAsync(Fork fork) =>
new ActivityTestFixture(fork).ExecuteAsync();
// Act
HashSet<string> completedSet = null!;
for (var i = 0; i < branchesToComplete; i++)
{
completedSet = await CompleteBranchAsync(fork, context, branches[i]);
}
// Assert
Assert.NotNull(completedSet);
Assert.Equal(expectedCompleted, completedSet.Count);
for (var i = 0; i < branchesToComplete; i++)
{
Assert.Contains(branches[i].Id, completedSet);
}
for (var i = branchesToComplete; i < branches.Length; i++)
{
Assert.DoesNotContain(branches[i].Id, completedSet);
}
}
[Fact]
public async Task HandlesBreakSignal()
{
// Arrange
var fork = new Fork
{
Branches =
{
new WriteLine("Branch")
}
};
var context = await ExecuteForkAsync(fork);
// Act
var breakSignal = new BreakSignal();
var signalContext = new SignalContext(context, context, CancellationToken.None);
await InvokePrivateMethodAsync(fork, "OnBreakSignalReceived", breakSignal, signalContext);
// Assert
Assert.True(context.GetIsBreaking());
}
[Fact]
public async Task CompletesImmediatelyWhenBreaking()
{
// Arrange
var branch = new WriteLine("Branch");
var fork = new Fork
{
Branches =
{
branch
}
};
var context = await ExecuteForkAsync(fork);
context.SetIsBreaking();
// Act
await CompleteBranchAsync(fork, context, branch);
// Assert
Assert.True(context.GetIsBreaking());
}
[Fact]
public async Task SingleBranchCompletesCorrectly()
{
// Arrange
var branch = new WriteLine("Single Branch");
var fork = new Fork
{
JoinMode = ForkJoinMode.WaitAll,
Branches =
{
branch
}
};
var context = await ExecuteForkAsync(fork);
// Act
var completedSet = await CompleteBranchAsync(fork, context, branch);
// Assert
Assert.NotNull(completedSet);
Assert.Single(completedSet);
Assert.Contains(branch.Id, completedSet);
}
[Theory]
[InlineData(ForkJoinMode.WaitAll, 3, 3)] // WaitAll requires all 3 branches to complete
[InlineData(ForkJoinMode.WaitAny, 3, 1)] // WaitAny only needs 1 branch to complete
public async Task HandlesMultipleBranchesWithJoinModes(ForkJoinMode joinMode, int totalBranches, int branchesToComplete)
{
// Arrange
var branches = CreateBranches(totalBranches);
var fork = new Fork
{
JoinMode = joinMode,
Branches = branches.Cast<IActivity>().ToList()
};
var context = await ExecuteForkAsync(fork);
// Act
HashSet<string> completedSet = null!;
for (var i = 0; i < branchesToComplete; i++)
{
completedSet = await CompleteBranchAsync(fork, context, branches[i]);
}
// Assert
Assert.NotNull(completedSet);
Assert.Equal(branchesToComplete, completedSet.Count);
for (var i = 0; i < branchesToComplete; i++)
{
Assert.Contains(branches[i].Id, completedSet);
}
}
[Fact]
public async Task TracksAllActivityCompletions() // Including non-branch activities
{
// Arrange
var branch = new WriteLine("Valid Branch")
{
Id = "branch-activity"
};
var nonBranch = new WriteLine("Non-Branch Activity")
{
Id = "non-branch-activity"
};
var fork = new Fork
{
JoinMode = ForkJoinMode.WaitAll,
Branches =
{
branch
}
};
var context = await ExecuteForkAsync(fork);
// Act - complete a non-branch activity
var completedSet = await CompleteBranchAsync(fork, context, nonBranch);
// Assert - Fork tracks ALL completions, even non-branch activities
Assert.NotNull(completedSet);
Assert.Contains(nonBranch.Id, completedSet);
Assert.Single(completedSet);
}
[Fact]
public async Task JoinLogicOnlyConsidersBranchActivities()
{
// Arrange
var branch = new WriteLine("Valid Branch")
{
Id = "branch-activity"
};
var nonBranch = new WriteLine("Non-Branch Activity")
{
Id = "non-branch-activity"
};
var fork = new Fork
{
JoinMode = ForkJoinMode.WaitAll,
Branches =
{
branch
}
};
var context = await ExecuteForkAsync(fork);
// Act - complete non-branch activity first, then branch activity
await CompleteBranchAsync(fork, context, nonBranch);
var completedSet = await CompleteBranchAsync(fork, context, branch);
// Assert - both activities should be tracked
Assert.NotNull(completedSet);
Assert.Contains(branch.Id, completedSet);
Assert.Contains(nonBranch.Id, completedSet);
Assert.Equal(2, completedSet.Count);
}
[Fact]
public async Task MaintainsSeparateCompletionTracking()
{
// Arrange
var branch1 = new WriteLine("Branch 1")
{
Id = "branch1-unique"
};
var branch2 = new WriteLine("Branch 2")
{
Id = "branch2-unique"
};
var fork1 = new Fork
{
Branches =
{
branch1
}
};
var fork2 = new Fork
{
Branches =
{
branch2
}
};
var context1 = await ExecuteForkAsync(fork1);
var context2 = await ExecuteForkAsync(fork2);
// Act
var completed1 = await CompleteBranchAsync(fork1, context1, branch1);
var completed2 = await CompleteBranchAsync(fork2, context2, branch2);
// Assert - each fork should track its own completions
Assert.NotNull(completed1);
Assert.NotNull(completed2);
Assert.Contains(branch1.Id, completed1);
Assert.DoesNotContain(branch2.Id, completed1);
Assert.Contains(branch2.Id, completed2);
Assert.DoesNotContain(branch1.Id, completed2);
}
private static async Task<ActivityExecutionContext> ExecuteForkAsync(Fork fork)
{
var fixture = new ActivityTestFixture(fork);
return await fixture.ExecuteAsync();
}
private static WriteLine[] CreateBranches(int count, string namePrefix = "Branch")
{
return Enumerable
.Range(1, count)
.Select(i => new WriteLine($"{namePrefix} {i}")
{
Id = $"{namePrefix.ToLower()}-{i}"
})
private static WriteLine[] CreateBranches(int count) =>
Enumerable.Range(1, count)
.Select(i => new WriteLine($"Branch {i}") { Id = $"branch-{i}" })
.ToArray();
}
private static async Task<ActivityExecutionContext> CreateChildContextAsync(IActivity childActivity)
{
var childFixture = new ActivityTestFixture(childActivity);
var childContext = await childFixture.ExecuteAsync();
return childContext;
}
private static async Task<HashSet<string>> CompleteBranchAsync(Fork fork, ActivityExecutionContext parentContext, IActivity branch)
{
var childContext = await CreateChildContextAsync(branch);
var completedContext = new ActivityCompletedContext(parentContext, childContext);
await InvokeCompleteChildAsync(fork, completedContext);
return parentContext.GetProperty<HashSet<string>>("Completed") ?? new HashSet<string>();
}
private static async Task InvokePrivateMethodAsync(object instance, string methodName, params object[] parameters)
{
var method = instance.GetType().GetMethod(methodName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?? throw new InvalidOperationException($"{methodName} method not found on {instance.GetType().Name} class. This may indicate a breaking change in the implementation.");
try
{
var result = method.Invoke(instance, parameters);
switch (result)
{
case ValueTask valueTask:
await valueTask;
break;
case Task task:
await task;
break;
}
}
catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException != null)
{
throw ex.InnerException;
}
}
private static Task InvokeCompleteChildAsync(Fork fork, ActivityCompletedContext completedContext)
{
return InvokePrivateMethodAsync(fork, "CompleteChildAsync", completedContext);
}
}
}

View file

@ -0,0 +1,35 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
namespace Elsa.Activities.UnitTests.Looping;
/// <summary>
/// Unit tests for the <see cref="Break"/> activity.
/// </summary>
public class BreakTests
{
[Fact(DisplayName = "Break implements ITerminalNode interface")]
public void Break_ImplementsITerminalNode()
{
// Arrange
var breakActivity = new Break();
// Assert
Assert.IsAssignableFrom<ITerminalNode>(breakActivity);
}
[Fact(DisplayName = "Break completes execution")]
public async Task Break_CompletesExecution()
{
// Arrange
var breakActivity = new Break();
var fixture = new ActivityTestFixture(breakActivity);
// Act
var context = await fixture.ExecuteAsync();
// Assert
Assert.Equal(ActivityStatus.Completed, context.Status);
}
}