From 4a26819ed712d5f7a2e83aa6bbc7545ebe7161a0 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Tue, 7 Oct 2025 15:56:08 +0200 Subject: [PATCH 01/15] (WIP) Initial context helper + provisional unit tests for setvariable --- Elsa.sln | 9 +- .../Elsa.Activities.UnitTests.csproj | 11 + .../Primitives/SetVariableTests.cs | 217 ++++++++++++++++++ test/unit/Elsa.Activities.UnitTests/Usings.cs | 3 + 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj create mode 100644 test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Usings.cs diff --git a/Elsa.sln b/Elsa.sln index 1a2bd9126..3ddca6a56 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -298,6 +298,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.EFCore.Sql EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Alterations.IntegrationTests", "test\integration\Elsa.Alterations.IntegrationTests\Elsa.Alterations.IntegrationTests.csproj", "{51C39AF0-4F41-4FC1-AEBD-D1494407D3F9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.UnitTests", "test\unit\Elsa.Activities.UnitTests\Elsa.Activities.UnitTests.csproj", "{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -602,6 +604,10 @@ Global {51C39AF0-4F41-4FC1-AEBD-D1494407D3F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {51C39AF0-4F41-4FC1-AEBD-D1494407D3F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {51C39AF0-4F41-4FC1-AEBD-D1494407D3F9}.Release|Any CPU.Build.0 = Release|Any CPU + {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -628,7 +634,6 @@ Global {5948B0A5-7873-4DBB-BA03-EB283D6EA91B} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {B08B4E00-C2AB-48F3-8389-449F42AEF179} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {28818676-F6AF-4203-8B65-BD33A50CB9A2} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09} - {DC9CCAD0-7363-4691-B964-FF5B3AEA3F95} = {18453B51-25EB-4317-A4B3-B10518252E92} {29638A67-E79F-44FE-AC05-DA499EBA929E} = {2F3E1026-5054-4E1F-899B-F1A7F70F9912} {A516931E-EDBB-4FC3-BB94-1BB824D5BC61} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {BBCE36D1-6767-4ED1-B3E8-84D2567A962A} = {A516931E-EDBB-4FC3-BB94-1BB824D5BC61} @@ -695,6 +700,8 @@ Global {71D5178D-2490-4681-8621-BF8DED964F33} = {3D0A6C71-4B96-411B-80DB-DDFAFF77C748} {698051E0-7981-43D4-B7BA-F3D8B65004A1} = {3D0A6C71-4B96-411B-80DB-DDFAFF77C748} {51C39AF0-4F41-4FC1-AEBD-D1494407D3F9} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {DC9CCAD0-7363-4691-B964-FF5B3AEA3F95} = {18453B51-25EB-4317-A4B3-B10518252E92} + {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA} = {18453B51-25EB-4317-A4B3-B10518252E92} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj new file mode 100644 index 000000000..3c3dec1fb --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs new file mode 100644 index 000000000..db4b90d02 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -0,0 +1,217 @@ +using Elsa.Common; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Mediator.Contracts; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class SetVariableTests +{ + [Fact] + public async Task Should_Set_Variable() + { + // Arrange + var variable = new Variable("myVar", 0); + var setVariable = new SetVariable(variable, new Input(42)); + var context = CreateMinimalActivityExecutionContext(setVariable); + + // Prepare the activity inputs in the context + //PrepareActivityInputs(setVariable, context); + + // Act + await ExecuteActivityAsync(setVariable, context); + + // Assert + var result = variable.Get(context); + Assert.Equal(42, result); + } + + [Fact] + public async Task Should_Set_Variable_From_Expression() + { + // Arrange + var variable = new Variable("myStringVar", ""); + var setVariable = new SetVariable(variable, new Input("Hello World")); + var context = CreateMinimalActivityExecutionContext(setVariable); + + // Prepare the activity inputs in the context + PrepareActivityInputs(setVariable, context); + + // Act + await ExecuteActivityAsync(setVariable, context); + + // Assert + var result = variable.Get(context); + Assert.Equal("Hello World", result); + } + + /// + /// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities. + /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern + /// to provide proper context for activities like SetVariable. + /// + private static ActivityExecutionContext CreateMinimalActivityExecutionContext(IActivity activity) + { + // Create a minimal service provider with just the required services + var services = new ServiceCollection(); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(); + + // Mock the complex dependencies to avoid deep dependency chains + services.AddSingleton(_ => Substitute.For()); + + // Set up the activity registry lookup service to return proper descriptors for SetVariable activities + var activityRegistryLookup = Substitute.For(); + activityRegistryLookup.FindAsync(Arg.Any(), Arg.Any()).Returns(callInfo => + { + var activityType = callInfo.ArgAt(0); + return Task.FromResult(new ActivityDescriptor + { + TypeName = activityType, + Kind = ActivityKind.Action, + Category = "Primitives", + Description = "Test activity for unit testing", + Version = 1 + }); + }); + services.AddSingleton(_ => activityRegistryLookup); + + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + + var serviceProvider = services.BuildServiceProvider(); + + // Create a minimal workflow + activity.Id = "test-workflow-activity"; // Ensure the activity has an ID + + var workflow = new Workflow + { + Root = activity + }; + + // Create a simple workflow graph manually instead of using the builder + var rootNode = new ActivityNode(activity, "Root"); + var nodes = new List { rootNode }; + var workflowGraph = new WorkflowGraph(workflow, rootNode, nodes); + + // Create workflow execution context using the static factory method + var workflowExecutionContext = WorkflowExecutionContext.CreateAsync( + serviceProvider, + workflowGraph, + "test-instance", + CancellationToken.None + ).GetAwaiter().GetResult(); + + // Create ActivityExecutionContext for the actual activity we want to test + var activityExecutionContext = workflowExecutionContext.CreateActivityExecutionContextAsync(activity) + .GetAwaiter().GetResult(); + + return activityExecutionContext; + } + + /// + /// Helper method to execute an activity using reflection to access the protected ExecuteAsync method. + /// This enables testing activities in isolation without requiring the full workflow engine. + /// + private static async Task ExecuteActivityAsync(IActivity activity, ActivityExecutionContext context) + { + try + { + await context.EvaluateInputPropertiesAsync(); + await activity.ExecuteAsync(context); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + /// + /// Prepares activity inputs by declaring their memory blocks in the execution context. + /// This simulates what the workflow engine does to make input values available to activities. + /// Call this method before executing an activity to ensure its inputs are properly set up. + /// + private static void PrepareActivityInputs(IActivity activity, ActivityExecutionContext context) + { + Console.WriteLine($"Preparing inputs for activity: {activity.GetType().Name}"); + + var properties = activity.GetType().GetProperties(); + + foreach (var property in properties) + { + // Look for Input properties + if (property.PropertyType.IsGenericType && + property.PropertyType.GetGenericTypeDefinition() == typeof(Input<>)) + { + Console.WriteLine($"Found Input property: {property.Name}"); + + var inputValue = property.GetValue(activity); + if (inputValue != null) + { + // Get the MemoryBlockReference from the Input + var memoryBlockRefProperty = inputValue.GetType().GetProperty("MemoryBlockReference"); + if (memoryBlockRefProperty?.GetValue(inputValue) is MemoryBlockReference memoryBlockRef) + { + Console.WriteLine($"MemoryBlockReference ID: {memoryBlockRef.Id}"); + + // Get the expression from the Input + var expressionProperty = inputValue.GetType().GetProperty("Expression"); + var expression = expressionProperty?.GetValue(inputValue); + + if (expression != null) + { + Console.WriteLine($"Expression type: {expression.GetType().Name}"); + + // For Literal expressions, manually create and register the memory block + if (expression.GetType().IsGenericType && + expression.GetType().GetGenericTypeDefinition() == typeof(Literal<>)) + { + var valueProperty = expression.GetType().GetProperty("Value"); + var literalValue = valueProperty?.GetValue(expression); + + Console.WriteLine($"Literal value: {literalValue}"); + + // Create a memory block with the literal value directly + var memoryBlock = new MemoryBlock(literalValue); + + // Register it in the memory system using the memory block reference ID + context.ExpressionExecutionContext.Memory.Blocks[memoryBlockRef.Id] = memoryBlock; + + Console.WriteLine($"Registered memory block with ID: {memoryBlockRef.Id}"); + Console.WriteLine($"Total blocks in memory: {context.ExpressionExecutionContext.Memory.Blocks.Count}"); + + // Also try to register in workflow execution context memory if different + if (context.WorkflowExecutionContext.ExpressionExecutionContext.Memory != context.ExpressionExecutionContext.Memory) + { + context.WorkflowExecutionContext.ExpressionExecutionContext.Memory.Blocks[memoryBlockRef.Id] = memoryBlock; + Console.WriteLine("Also registered in workflow execution context memory"); + } + } + } + } + } + } + } + + // List all memory blocks for debugging + Console.WriteLine("All memory blocks:"); + foreach (var block in context.ExpressionExecutionContext.Memory.Blocks) + { + Console.WriteLine($" {block.Key} -> {block.Value.Value}"); + } + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Usings.cs b/test/unit/Elsa.Activities.UnitTests/Usings.cs new file mode 100644 index 000000000..b45935d97 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Usings.cs @@ -0,0 +1,3 @@ +global using Elsa.Workflows.Activities; +global using Elsa.Workflows.Memory; +global using Elsa.Workflows.Models; \ No newline at end of file From fe4d74189ada5ad96da42201c21187d233577ad8 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 7 Oct 2025 19:59:53 +0200 Subject: [PATCH 02/15] Add coverage enforcement for test projects (#6950) * Add coverage enforcement for test projects * Expand GitHub Actions triggers to include additional branch patterns * Update `coverlet.msbuild` configuration and centralize dependency version management - Removed inline version specification for `coverlet.msbuild` in `test/Directory.Build.props`. - Centralized `coverlet.msbuild` version definition in `Directory.Packages.props` for consistency and maintainability. * Remove `Elsa.Common.Core` unit test project and related test files * Add Directory.Build.props for test project organization and update property configurations - Introduced `Directory.Build.props` files for `test/unit` and `test/integration` to define project-specific properties. - Updated `test/Directory.Build.props` to include new coverage formats and an exclusion for `Elsa.Testing.Shared`. - Adjusted solution file to link new `Directory.Build.props` files. - Configured threshold properties for `unit`, `integration`, and `component` test directories. * Disable coverage collection for performance tests in project file * Expand GitHub Actions workflow triggers and add PR-specific condition for test job --- .github/workflows/packages.yml | 107 ++++++++++++++---- Directory.Packages.props | 1 + Elsa.sln | 6 + test/Directory.Build.props | 13 ++- test/component/Directory.Build.props | 6 +- test/integration/Directory.Build.props | 9 ++ .../Elsa.Workflows.PerformanceTests.csproj | 1 + test/unit/Directory.Build.props | 9 ++ .../Elsa.Common.Core/Elsa.Common.Core.csproj | 5 - .../EnumerableExtensionsTests.cs | 29 ----- 10 files changed, 125 insertions(+), 61 deletions(-) create mode 100644 test/integration/Directory.Build.props create mode 100644 test/unit/Directory.Build.props delete mode 100644 test/unit/Elsa.Common.Core/Elsa.Common.Core.csproj delete mode 100644 test/unit/Elsa.Common.Core/EnumerableExtensionsTests.cs diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 10bb34007..37f97aa5b 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,4 +1,5 @@ name: Packages + on: workflow_dispatch: push: @@ -12,18 +13,87 @@ on: - 'rc/*' - 'develop/*' - 'codex/*' + pull_request: + branches: + - 'main' + - 'bug/*' + - 'perf/*' + - 'patch/*' + - 'feat/*' + - 'enh/*' + - 'rc/*' + - 'develop/*' + - 'codex/*' release: - types: [ prereleased, published ] + types: [prereleased, published] + env: base_version: '3.6.0' feedz_feed_source: 'https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json' nuget_feed_source: 'https://api.nuget.org/v3/index.json' jobs: - build: - name: Build packages + test: + name: Test with coverage runs-on: ubuntu-latest timeout-minutes: 30 + if: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET SDKs + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore solution + run: dotnet restore Elsa.sln + + - name: Build solution + run: dotnet build Elsa.sln --no-restore --configuration Release + + - name: Prepare coverage directory + run: | + rm -rf artifacts/coverage + mkdir -p artifacts/coverage + + - name: Run tests with coverage + run: | + set -euo pipefail + mapfile -t projects < <(find test -type f -name '*.csproj' | sort) + if [ ${#projects[@]} -eq 0 ]; then + echo "No test projects were found in the test directory." >&2 + exit 1 + fi + for project in "${projects[@]}"; do + echo "Running tests with coverage for ${project}" + dotnet test "$project" --configuration Release --no-build --logger "GitHubActions;report-warnings=false" /p:CollectCoverage=true + done + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: artifacts/coverage + + build: + name: Build packages + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + if: ${{ github.event_name != 'pull_request' }} steps: - name: Extract branch name run: | @@ -31,28 +101,31 @@ jobs: BRANCH_NAME=${BRANCH_NAME#refs/heads/} # remove the refs/heads/ prefix # Extract the last part after the last slash of the branch name, if any, e.g., feature/issue-123 -> issue-123 and use it as the version prefix. PACKAGE_PREFIX=$(echo $BRANCH_NAME | rev | cut -d/ -f1 | rev | tr '_' '-') - + # If the branch name is main, use the preview version. Otherwise, use the branch name as the version prefix. if [[ "${BRANCH_NAME}" == "main" || "${BRANCH_NAME}" =~ ^develop/ ]]; then PACKAGE_PREFIX="preview" fi - + echo "Ref: ${{ github.ref }}" echo "Branch name: ${BRANCH_NAME}" echo "Package prefix: ${PACKAGE_PREFIX}" echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV echo "PACKAGE_PREFIX=${PACKAGE_PREFIX}" >> $GITHUB_ENV + - name: Checkout uses: actions/checkout@v4 + - name: Verify commit exists in branch run: | - if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased")]]; then + if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* git branch --remote --contains | grep origin/develop/3.6.0 else git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* git branch --remote --contains | grep origin/${BRANCH_NAME} fi + - name: Set VERSION variable run: | if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then @@ -62,35 +135,21 @@ jobs: else echo "VERSION=${{env.base_version}}-preview.${{github.run_number}}" >> $GITHUB_ENV fi - # - name: Set up JDK 17 - # uses: actions/setup-java@v2 - # with: - # java-version: '17' - # distribution: 'adopt' + - uses: actions/setup-dotnet@v4 with: dotnet-version: 9.x - # - name: Install SonarScanner for .NET - # run: dotnet tool install --global dotnet-sonarscanner - # - name: Install Coverlet for code coverage - # run: dotnet tool install --global coverlet.console - # - name: Begin SonarCloud analysis - # env: - # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # run: dotnet sonarscanner begin /k:"elsa-workflows_elsa-core" /o:"elsa-workflows" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.exclusions=**/obj/**,**/*.dll,build/**,samples/**,src/apps/** /d:"sonar.verbose=true" /d:sonar.cs.opencover.reportsPaths=**/testresults/**/coverage.opencover.xml + - name: Compile+Test+Pack run: ./build.sh Compile+Test+Pack --version ${VERSION} --analyseCode true - # - name: End SonarCloud analysis - # env: - # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + - name: Upload artifact uses: actions/upload-artifact@v4 with: name: elsa-nuget-packages path: packages/*nupkg if: ${{ github.event_name == 'release' || github.event_name == 'push'}} - + publish_preview_feedz: name: Publish to feedz.io needs: build diff --git a/Directory.Packages.props b/Directory.Packages.props index 4768c5cc5..a411ccbda 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + diff --git a/Elsa.sln b/Elsa.sln index 1a2bd9126..f0e4169fb 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -62,8 +62,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{986E54 EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}" + ProjectSection(SolutionItems) = preProject + test\unit\Directory.Build.props = test\unit\Directory.Build.props + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}" + ProjectSection(SolutionItems) = preProject + test\integration\Directory.Build.props = test\integration\Directory.Build.props + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}" ProjectSection(SolutionItems) = preProject diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 03f0023c2..108bef4c9 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -9,6 +9,15 @@ false true false + true + cobertura,lcov,opencover + $(MSBuildThisFileDirectory)../artifacts/coverage/$(MSBuildProjectName)/coverage + 10 + line + total + + [*Elsa.Testing.Shared*]* + @@ -17,7 +26,7 @@ - + @@ -26,4 +35,4 @@ - \ No newline at end of file + diff --git a/test/component/Directory.Build.props b/test/component/Directory.Build.props index 94e6bc5b1..e6ff014e4 100644 --- a/test/component/Directory.Build.props +++ b/test/component/Directory.Build.props @@ -1,7 +1,11 @@ - + + + 40 + + diff --git a/test/integration/Directory.Build.props b/test/integration/Directory.Build.props new file mode 100644 index 000000000..d4fc658e9 --- /dev/null +++ b/test/integration/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + 10 + + + diff --git a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj index ee58b4574..244833891 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj +++ b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj @@ -7,6 +7,7 @@ false Exe + false diff --git a/test/unit/Directory.Build.props b/test/unit/Directory.Build.props new file mode 100644 index 000000000..7e5383630 --- /dev/null +++ b/test/unit/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + 0 + + + diff --git a/test/unit/Elsa.Common.Core/Elsa.Common.Core.csproj b/test/unit/Elsa.Common.Core/Elsa.Common.Core.csproj deleted file mode 100644 index 0d2efc651..000000000 --- a/test/unit/Elsa.Common.Core/Elsa.Common.Core.csproj +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/test/unit/Elsa.Common.Core/EnumerableExtensionsTests.cs b/test/unit/Elsa.Common.Core/EnumerableExtensionsTests.cs deleted file mode 100644 index 3c1598fe8..000000000 --- a/test/unit/Elsa.Common.Core/EnumerableExtensionsTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Elsa.Extensions; -using Xunit; - -namespace Elsa.Common.Core; - -public class EnumerableExtensionsTests -{ - [Fact] - public void ToBatches_ReturnsItemsInListOfPages() - { - var items = new List - { - 1, - 2, - 3, - 4, - 5 - }; - var batchSize = 2; - var batches = items.ToBatches(batchSize).ToList(); - - Assert.Equal(3, batches.Count); - Assert.Equal(2, batches.ElementAt(0).Count()); - Assert.Equal(2, batches.ElementAt(1).Count()); - Assert.Equal(1, batches.ElementAt(2).Count()); - Assert.Equal(3, batches.ElementAt(1).ElementAt(0)); - Assert.Equal(4, batches.ElementAt(1).ElementAt(1)); - } -} \ No newline at end of file From bbbf13c58ce849b7fcbd5f81635876e23d7b46a2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 7 Oct 2025 20:10:38 +0200 Subject: [PATCH 03/15] Allow tests to run on all events Remove condition to run tests only on pull requests. --- .github/workflows/packages.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 37f97aa5b..7e23ef7d4 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -37,7 +37,6 @@ jobs: name: Test with coverage runs-on: ubuntu-latest timeout-minutes: 30 - if: ${{ github.event_name == 'pull_request' }} steps: - name: Checkout uses: actions/checkout@v4 From 5bd037928c426a8739b841337959ba8d882a1df2 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Wed, 8 Oct 2025 14:38:19 +0200 Subject: [PATCH 04/15] Helper for isolated activity testing + few SetVariable tests --- .../Elsa.Activities.UnitTests.csproj | 1 + .../Helpers/ActivityTestHelper.cs | 174 ++++++++++++++++ .../Primitives/SetVariableTests.cs | 196 +----------------- 3 files changed, 183 insertions(+), 188 deletions(-) create mode 100644 test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs diff --git a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj index 3c3dec1fb..3017b8a33 100644 --- a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj +++ b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj @@ -2,6 +2,7 @@ + diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs new file mode 100644 index 000000000..d5cf222fd --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs @@ -0,0 +1,174 @@ +using Elsa.Common; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Services; +using Elsa.Extensions; +using Elsa.Mediator.Contracts; +using Elsa.Workflows; +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.Management.Providers; +using Elsa.Workflows.Management.Services; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Helpers; + +/// +/// Helper class for unit testing activities in isolation. +/// Provides methods to execute activities with minimal setup, abstracting away the complexity +/// of creating WorkflowExecutionContext and ActivityExecutionContext. +/// +public static class ActivityTestHelper +{ + /// + /// Executes an activity in isolation for unit testing purposes and returns the execution context. + /// This method handles all the complexity of setting up the execution context, + /// evaluating inputs, and executing the activity. + /// + /// The activity to execute + /// Task representing the async execution + public static async Task ExecuteActivityAsync(IActivity activity) + { + var context = CreateMinimalActivityExecutionContext(activity, out var serviceProvider); + + // Set up variables and inputs, then execute the activity + await SetupExistingVariablesAsync(activity, context); + await SetupInputValuesInMemoryAsync(activity, context, serviceProvider); + await context.EvaluateInputPropertiesAsync(); + await activity.ExecuteAsync(context); + return context; + } + + /// + /// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities. + /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern + /// to provide proper context for activities. + /// + private static ActivityExecutionContext CreateMinimalActivityExecutionContext(IActivity activity, out IServiceProvider serviceProvider) + { + // Create a minimal service provider with the required services for expression evaluation + var services = new ServiceCollection(); + + // Add core services + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(); + + // Add real expression evaluation services instead of mocks + services.AddScoped(); + + // Add the well-known type registry required by expression handlers + services.AddSingleton(); + + // Add the default expression descriptor provider which includes Literal expressions + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(_ => Substitute.For()); + + // Mock the complex workflow-level dependencies + services.AddSingleton(_ => Substitute.For()); + + // Set up the activity registry lookup service to return proper descriptors + var activityRegistryLookup = Substitute.For(); + activityRegistryLookup.FindAsync(Arg.Any(), Arg.Any()).Returns(callInfo => + { + var activityType = callInfo.ArgAt(0); + return Task.FromResult(new ActivityDescriptor + { + TypeName = activityType, + Kind = ActivityKind.Action, + Category = "Test", + Description = "Test activity for unit testing", + Version = 1 + }); + }); + + services.AddSingleton(_ => activityRegistryLookup); + + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + + serviceProvider = services.BuildServiceProvider(); + + // Create a minimal workflow + activity.Id ??= $"test-activity-{Guid.NewGuid()}"; + + var workflow = new Workflow + { + Root = activity + }; + + // Create a simple workflow graph manually instead of using the builder + var rootNode = new ActivityNode(activity, "Root"); + var nodes = new List { rootNode }; + var workflowGraph = new WorkflowGraph(workflow, rootNode, nodes); + + // Create workflow execution context using the static factory method + var workflowExecutionContext = WorkflowExecutionContext.CreateAsync( + serviceProvider, + workflowGraph, + $"test-instance-{Guid.NewGuid()}", + CancellationToken.None + ).GetAwaiter().GetResult(); + + // Create ActivityExecutionContext for the actual activity we want to test + var activityExecutionContext = workflowExecutionContext.CreateActivityExecutionContextAsync(activity) + .GetAwaiter().GetResult(); + + return activityExecutionContext; + } + + /// + /// Sets up existing variables found on the activity in the execution context. + /// This is necessary because in unit tests, variables need to be initialized. + /// + private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context) + { + var activityType = activity.GetType(); + var variableProperties = activityType.GetProperties() + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.BaseType == typeof(Variable)) + .ToList(); + + foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) + { + variable.Set(context.ExpressionExecutionContext, variable.Value); + } + + return Task.CompletedTask; + } + + /// + /// Sets up input values in memory blocks so that context.Get() can find them during activity execution. + /// This mimics what the workflow engine does when evaluating inputs. + /// + private static async Task SetupInputValuesInMemoryAsync(IActivity activity, ActivityExecutionContext context, IServiceProvider serviceProvider) + { + var activityType = activity.GetType(); + var inputProperties = activityType.GetProperties() + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(Input<>)) + .ToList(); + + foreach (var input in inputProperties.Select(property => property.GetValue(activity) as Input)) + { + if (input?.Expression == null) + { + continue; + } + + // Get the memory block reference for this input + var memoryBlockReference = input.MemoryBlockReference(); + + // Evaluate the input using the expression evaluator + var expressionEvaluator = serviceProvider.GetService(); + var evaluatedValue = await expressionEvaluator!.EvaluateAsync(input, context.ExpressionExecutionContext); + + // Set the value in the memory block + memoryBlockReference.Set(context, evaluatedValue); + } + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index db4b90d02..e5ccb3b13 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,33 +1,19 @@ -using Elsa.Common; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Models; -using Elsa.Extensions; -using Elsa.Mediator.Contracts; +using Elsa.Activities.UnitTests.Helpers; using Elsa.Workflows; -using Elsa.Workflows.Activities; -using Elsa.Workflows.CommitStates; -using Elsa.Workflows.Memory; -using Elsa.Workflows.Models; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; namespace Elsa.Activities.UnitTests.Primitives; public class SetVariableTests { [Fact] - public async Task Should_Set_Variable() + public async Task Should_Set_Variable() { // Arrange - var variable = new Variable("myVar", 0); - var setVariable = new SetVariable(variable, new Input(42)); - var context = CreateMinimalActivityExecutionContext(setVariable); - - // Prepare the activity inputs in the context - //PrepareActivityInputs(setVariable, context); + var variable = new Variable("myVar", 0, "myVar"); + var setVariable = new SetVariable(variable, new Input(42, "inputId")); // Act - await ExecuteActivityAsync(setVariable, context); + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); // Assert var result = variable.Get(context); @@ -38,180 +24,14 @@ public class SetVariableTests public async Task Should_Set_Variable_From_Expression() { // Arrange - var variable = new Variable("myStringVar", ""); - var setVariable = new SetVariable(variable, new Input("Hello World")); - var context = CreateMinimalActivityExecutionContext(setVariable); - - // Prepare the activity inputs in the context - PrepareActivityInputs(setVariable, context); + var variable = new Variable("myStringVar", "", "myStringVar"); + var setVariable = new SetVariable(variable, new Input("Hello World", "inputId")); // Act - await ExecuteActivityAsync(setVariable, context); + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); // Assert var result = variable.Get(context); Assert.Equal("Hello World", result); } - - /// - /// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities. - /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern - /// to provide proper context for activities like SetVariable. - /// - private static ActivityExecutionContext CreateMinimalActivityExecutionContext(IActivity activity) - { - // Create a minimal service provider with just the required services - var services = new ServiceCollection(); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(); - - // Mock the complex dependencies to avoid deep dependency chains - services.AddSingleton(_ => Substitute.For()); - - // Set up the activity registry lookup service to return proper descriptors for SetVariable activities - var activityRegistryLookup = Substitute.For(); - activityRegistryLookup.FindAsync(Arg.Any(), Arg.Any()).Returns(callInfo => - { - var activityType = callInfo.ArgAt(0); - return Task.FromResult(new ActivityDescriptor - { - TypeName = activityType, - Kind = ActivityKind.Action, - Category = "Primitives", - Description = "Test activity for unit testing", - Version = 1 - }); - }); - services.AddSingleton(_ => activityRegistryLookup); - - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - - var serviceProvider = services.BuildServiceProvider(); - - // Create a minimal workflow - activity.Id = "test-workflow-activity"; // Ensure the activity has an ID - - var workflow = new Workflow - { - Root = activity - }; - - // Create a simple workflow graph manually instead of using the builder - var rootNode = new ActivityNode(activity, "Root"); - var nodes = new List { rootNode }; - var workflowGraph = new WorkflowGraph(workflow, rootNode, nodes); - - // Create workflow execution context using the static factory method - var workflowExecutionContext = WorkflowExecutionContext.CreateAsync( - serviceProvider, - workflowGraph, - "test-instance", - CancellationToken.None - ).GetAwaiter().GetResult(); - - // Create ActivityExecutionContext for the actual activity we want to test - var activityExecutionContext = workflowExecutionContext.CreateActivityExecutionContextAsync(activity) - .GetAwaiter().GetResult(); - - return activityExecutionContext; - } - - /// - /// Helper method to execute an activity using reflection to access the protected ExecuteAsync method. - /// This enables testing activities in isolation without requiring the full workflow engine. - /// - private static async Task ExecuteActivityAsync(IActivity activity, ActivityExecutionContext context) - { - try - { - await context.EvaluateInputPropertiesAsync(); - await activity.ExecuteAsync(context); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - - /// - /// Prepares activity inputs by declaring their memory blocks in the execution context. - /// This simulates what the workflow engine does to make input values available to activities. - /// Call this method before executing an activity to ensure its inputs are properly set up. - /// - private static void PrepareActivityInputs(IActivity activity, ActivityExecutionContext context) - { - Console.WriteLine($"Preparing inputs for activity: {activity.GetType().Name}"); - - var properties = activity.GetType().GetProperties(); - - foreach (var property in properties) - { - // Look for Input properties - if (property.PropertyType.IsGenericType && - property.PropertyType.GetGenericTypeDefinition() == typeof(Input<>)) - { - Console.WriteLine($"Found Input property: {property.Name}"); - - var inputValue = property.GetValue(activity); - if (inputValue != null) - { - // Get the MemoryBlockReference from the Input - var memoryBlockRefProperty = inputValue.GetType().GetProperty("MemoryBlockReference"); - if (memoryBlockRefProperty?.GetValue(inputValue) is MemoryBlockReference memoryBlockRef) - { - Console.WriteLine($"MemoryBlockReference ID: {memoryBlockRef.Id}"); - - // Get the expression from the Input - var expressionProperty = inputValue.GetType().GetProperty("Expression"); - var expression = expressionProperty?.GetValue(inputValue); - - if (expression != null) - { - Console.WriteLine($"Expression type: {expression.GetType().Name}"); - - // For Literal expressions, manually create and register the memory block - if (expression.GetType().IsGenericType && - expression.GetType().GetGenericTypeDefinition() == typeof(Literal<>)) - { - var valueProperty = expression.GetType().GetProperty("Value"); - var literalValue = valueProperty?.GetValue(expression); - - Console.WriteLine($"Literal value: {literalValue}"); - - // Create a memory block with the literal value directly - var memoryBlock = new MemoryBlock(literalValue); - - // Register it in the memory system using the memory block reference ID - context.ExpressionExecutionContext.Memory.Blocks[memoryBlockRef.Id] = memoryBlock; - - Console.WriteLine($"Registered memory block with ID: {memoryBlockRef.Id}"); - Console.WriteLine($"Total blocks in memory: {context.ExpressionExecutionContext.Memory.Blocks.Count}"); - - // Also try to register in workflow execution context memory if different - if (context.WorkflowExecutionContext.ExpressionExecutionContext.Memory != context.ExpressionExecutionContext.Memory) - { - context.WorkflowExecutionContext.ExpressionExecutionContext.Memory.Blocks[memoryBlockRef.Id] = memoryBlock; - Console.WriteLine("Also registered in workflow execution context memory"); - } - } - } - } - } - } - } - - // List all memory blocks for debugging - Console.WriteLine("All memory blocks:"); - foreach (var block in context.ExpressionExecutionContext.Memory.Blocks) - { - Console.WriteLine($" {block.Key} -> {block.Value.Value}"); - } - } } \ No newline at end of file From e6df8ea954edb16ce1cbd7dcdad4d95b432d02a7 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Wed, 8 Oct 2025 14:59:59 +0200 Subject: [PATCH 05/15] More unit tests for set variable --- .../Helpers/ActivityTestHelper.cs | 3 +- .../Primitives/SetVariableTests.cs | 422 +++++++++++++++++- 2 files changed, 422 insertions(+), 3 deletions(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs index d5cf222fd..9b01d48f0 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs @@ -25,7 +25,7 @@ public static class ActivityTestHelper /// evaluating inputs, and executing the activity. /// /// The activity to execute - /// Task representing the async execution + /// The ActivityExecutionContext used for execution public static async Task ExecuteActivityAsync(IActivity activity) { var context = CreateMinimalActivityExecutionContext(activity, out var serviceProvider); @@ -35,6 +35,7 @@ public static class ActivityTestHelper await SetupInputValuesInMemoryAsync(activity, context, serviceProvider); await context.EvaluateInputPropertiesAsync(); await activity.ExecuteAsync(context); + return context; } diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index e5ccb3b13..f94ba1812 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,12 +1,14 @@ using Elsa.Activities.UnitTests.Helpers; using Elsa.Workflows; +using Elsa.Expressions.Models; +using System.Text.Json; namespace Elsa.Activities.UnitTests.Primitives; public class SetVariableTests { [Fact] - public async Task Should_Set_Variable() + public async Task Should_Set_Variable_Integer() { // Arrange var variable = new Variable("myVar", 0, "myVar"); @@ -21,7 +23,7 @@ public class SetVariableTests } [Fact] - public async Task Should_Set_Variable_From_Expression() + public async Task Should_Set_Variable_String() { // Arrange var variable = new Variable("myStringVar", "", "myStringVar"); @@ -34,4 +36,420 @@ public class SetVariableTests var result = variable.Get(context); Assert.Equal("Hello World", result); } + + [Fact] + public async Task Should_Overwrite_Existing_Variable() + { + // Arrange + var variable = new Variable("existingVar", 100, "inputId"); + var setVariable = new SetVariable(variable, new Input(200, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert - verify the original value was overwritten + var result = variable.Get(context); + Assert.Equal(200, result); + } + + [Fact] + public async Task Should_Assign_Null_Value() + { + // Arrange + var variable = new Variable("nullVar", "initial", "nullVar"); + var setVariable = new SetVariable(variable, new Input((string)null!, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Null(result); + } + + [Fact] + public async Task Should_Set_Variable_With_Special_Characters() + { + // Arrange + var variable = new Variable("special_var-123", "", "specialVar"); + const string specialValue = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"; + var setVariable = new SetVariable(variable, new Input(specialValue, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal(specialValue, result); + } + + [Fact] + public async Task Should_Serialize_Complex_Object() + { + // Arrange + var complexObject = new ComplexTestObject + { + Id = 123, + Name = "Test Object", + Properties = new Dictionary + { + {"prop1", "value1"}, + {"prop2", 456}, + {"prop3", true} + }, + Items = ["item1", "item2", "item3"] + }; + + var variable = new Variable("complexVar", null!, "complexVar"); + var setVariable = new SetVariable(variable, new Input(complexObject, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.NotNull(result); + Assert.Equal(complexObject.Id, result.Id); + Assert.Equal(complexObject.Name, result.Name); + Assert.Equal(complexObject.Properties.Count, result.Properties.Count); + Assert.Equal(complexObject.Items.Length, result.Items.Length); + } + + [Fact] + public async Task Should_Assign_Large_Payload() + { + // Arrange + var largePayload = new string('A', 1024 * 1024); + var variable = new Variable("largeVar", "", "largeVar"); + var setVariable = new SetVariable(variable, new Input(largePayload, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal(largePayload.Length, result.Length); + Assert.Equal(largePayload, result); + } + + [Fact] + public async Task Should_Handle_Case_Sensitivity_On_Variable_Names() + { + // Arrange + var variable1 = new Variable("CaseSensitive", "value1", "var1"); + var variable2 = new Variable("casesensitive", "value2", "var2"); + + var setVariable1 = new SetVariable(variable1, new Input("updated1", "input1")); + var setVariable2 = new SetVariable(variable2, new Input("updated2", "input2")); + + // Act + var context1 = await ActivityTestHelper.ExecuteActivityAsync(setVariable1); + var context2 = await ActivityTestHelper.ExecuteActivityAsync(setVariable2); + + // Assert - Variables with different casing should be treated as separate + var result1 = variable1.Get(context1); + var result2 = variable2.Get(context2); + + Assert.Equal("updated1", result1); + Assert.Equal("updated2", result2); + } + + [Fact] + public async Task Should_Reassign_Variable_Multiple_Times() + { + // Arrange + var variable = new Variable("multiVar", 0, "multiVar"); + + // Act - Multiple assignments + var setVariable1 = new SetVariable(variable, new Input(10, "input1")); + var context1 = await ActivityTestHelper.ExecuteActivityAsync(setVariable1); + var result1 = variable.Get(context1); + + var setVariable2 = new SetVariable(variable, new Input(20, "input2")); + var context2 = await ActivityTestHelper.ExecuteActivityAsync(setVariable2); + var result2 = variable.Get(context2); + + var setVariable3 = new SetVariable(variable, new Input(30, "input3")); + var context3 = await ActivityTestHelper.ExecuteActivityAsync(setVariable3); + var result3 = variable.Get(context3); + + // Assert - Each execution should update the variable + Assert.Equal(10, result1); + Assert.Equal(20, result2); + Assert.Equal(30, result3); + } + + [Fact] + public async Task Should_Handle_Boolean_Values() + { + // Arrange + var variable = new Variable("boolVar", false, "boolVar"); + var setVariable = new SetVariable(variable, new Input(true, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.True(result); + } + + [Fact] + public async Task Should_Handle_DateTime_Values() + { + // Arrange + var testDate = new DateTime(2025, 10, 8, 14, 30, 0); + var variable = new Variable("dateVar", DateTime.MinValue, "dateVar"); + var setVariable = new SetVariable(variable, new Input(testDate, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal(testDate, result); + } + + [Fact] + public async Task Should_Handle_Decimal_Values() + { + // Arrange + var decimalValue = 123.456789m; + var variable = new Variable("decimalVar", 0m, "decimalVar"); + var setVariable = new SetVariable(variable, new Input(decimalValue, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal(decimalValue, result); + } + + [Fact] + public async Task Should_Handle_Array_Values() + { + // Arrange + var arrayValue = new[] { "item1", "item2", "item3" }; + var variable = new Variable("arrayVar", null!, "arrayVar"); + var setVariable = new SetVariable(variable, new Input(arrayValue, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.NotNull(result); + Assert.Equal(arrayValue.Length, result.Length); + Assert.Equal(arrayValue, result); + } + + [Fact] + public async Task Should_Handle_Dictionary_Values() + { + // Arrange + var dictionaryValue = new Dictionary + { + {"key1", "value1"}, + {"key2", 123}, + {"key3", true} + }; + var variable = new Variable>("dictVar", null!, "dictVar"); + var setVariable = new SetVariable>(variable, new Input>(dictionaryValue, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.NotNull(result); + Assert.Equal(dictionaryValue.Count, result.Count); + Assert.Equal(dictionaryValue["key1"], result["key1"]); + Assert.Equal(dictionaryValue["key2"], result["key2"]); + Assert.Equal(dictionaryValue["key3"], result["key3"]); + } + + [Fact] + public async Task Should_Evaluate_Variable_Name_Via_Expression() + { + // Arrange - Use an expression for the variable name + var dynamicVariableName = "dynamic_var_" + DateTime.Now.Ticks; + var variable = new Variable(dynamicVariableName, "", "dynamicVar"); + var setVariable = new SetVariable(variable, new Input("Dynamic Value", "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal("Dynamic Value", result); + Assert.Equal(dynamicVariableName, variable.Name); + } + + [Fact] + public async Task Should_Evaluate_Value_Via_Expression() + { + // Arrange - Use a computed expression for the value + var computedValue = $"Computed at {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; + var variable = new Variable("expressionVar", "", "expressionVar"); + var setVariable = new SetVariable(variable, new Input(computedValue, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal(computedValue, result); + Assert.Contains("Computed at", result); + } + + [Fact] + public async Task Should_Handle_Expression_That_Returns_Null() + { + // Arrange - Expression that evaluates to null + var variable = new Variable("nullExpressionVar", "initial", "nullExpressionVar"); + var setVariable = new SetVariable(variable, new Input((string)null!, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Null(result); + } + + [Fact] + public async Task Should_Handle_Undefined_Expression_Result() + { + // Arrange - Test handling of undefined/default values + var variable = new Variable("undefinedVar", null, "undefinedVar"); + var setVariable = new SetVariable(variable, new Input((int?)null, "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Null(result); + } + + [Fact] + public async Task Should_Handle_Zero_Length_Variable_Name() + { + // Arrange + var variable = new Variable(string.Empty, "test", "emptyNameVar"); + var setVariable = new SetVariable(variable, new Input("value", "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal("value", result); + } + + [Fact] + public async Task Should_Handle_Unicode_Variable_Names() + { + // Arrange - Test with Unicode characters in variable names + var unicodeVariableName = "变量名_متغیر_переменная_🚀"; + var variable = new Variable(unicodeVariableName, "", "unicodeVar"); + var setVariable = new SetVariable(variable, new Input("Unicode Value", "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal("Unicode Value", result); + Assert.Equal(unicodeVariableName, variable.Name); + } + + [Fact] + public async Task Should_Handle_Extremely_Long_Variable_Name() + { + // Arrange - Test with very long variable name + var longVariableName = new string('a', 1000); + var variable = new Variable(longVariableName, "", "longNameVar"); + var setVariable = new SetVariable(variable, new Input("Long Name Value", "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal("Long Name Value", result); + Assert.Equal(1000, variable.Name.Length); + } + + [Fact] + public async Task Should_Handle_Numeric_Variable_Names() + { + // Arrange - Test with numeric variable names + var numericVariableName = "12345"; + var variable = new Variable(numericVariableName, "", "numericVar"); + var setVariable = new SetVariable(variable, new Input("Numeric Name Value", "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal("Numeric Name Value", result); + Assert.Equal(numericVariableName, variable.Name); + } + + [Fact] + public async Task Should_Handle_Variable_Name_With_Spaces() + { + // Arrange - Test with spaces in variable names + var spacedVariableName = "variable with spaces"; + var variable = new Variable(spacedVariableName, "", "spacedVar"); + var setVariable = new SetVariable(variable, new Input("Spaced Name Value", "inputId")); + + // Act + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + + // Assert + var result = variable.Get(context); + Assert.Equal("Spaced Name Value", result); + Assert.Equal(spacedVariableName, variable.Name); + } + + [Fact] + public async Task Should_Handle_Concurrent_Variable_Updates() + { + // Arrange - Test thread safety with concurrent updates + var variable = new Variable("concurrentVar", 0, "concurrentVar"); + var tasks = new List>(); + + // Act - Create multiple concurrent assignments + for (int i = 1; i <= 10; i++) + { + var value = i; + tasks.Add(Task.Run(async () => + { + var setVariable = new SetVariable(variable, new Input(value, $"input{value}")); + var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + return variable.Get(context); + })); + } + + var results = await Task.WhenAll(tasks); + + // Assert - All operations should complete successfully + Assert.Equal(10, results.Length); + Assert.All(results, result => Assert.True(result >= 1 && result <= 10)); + } +} + +/// +/// Complex test object for serialization testing +/// +public class ComplexTestObject +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public Dictionary Properties { get; set; } = new(); + public string[] Items { get; set; } = []; } \ No newline at end of file From 8eb2b11f6e4a6272bfa8da2eab8d6ca1fb71ab97 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 8 Oct 2025 20:23:25 +0200 Subject: [PATCH 06/15] Refactor `SetVariable` activity: add null safety checks, update variable property to nullable type, and enhance unit tests for edge cases. --- .../Activities/SetVariable.cs | 10 +- .../Services/ActivityRegistry.cs | 1 - .../Helpers/ActivityTestHelper.cs | 136 ++---- .../Primitives/SetVariableTests.cs | 440 +----------------- 4 files changed, 66 insertions(+), 521 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs b/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs index a12b35086..ba99e893d 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs @@ -89,7 +89,7 @@ public class SetVariable : CodeActivity /// The variable to assign the value to. /// [Input(Description = "The variable to assign the value to.")] - public Variable Variable { get; set; } = null!; + public Variable? Variable { get; set; } /// /// The value to assign. @@ -101,9 +101,13 @@ public class SetVariable : CodeActivity protected override void Execute(ActivityExecutionContext context) { // Always refer to the variable by ID to ensure that the variable is resolved from the correct scope. - var variableId = Variable.Id; + var variableId = Variable?.Id; var variable = context.ExpressionExecutionContext.EnumerateVariablesInScope().FirstOrDefault(x => x.Id == variableId); + + if (variable == null) + throw new($"Variable '{variableId}' not found."); + var value = context.Get(Value); - variable?.Set(context, value); + variable.Set(context, value); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index 282d07be3..f5b4482ca 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -57,7 +57,6 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< var activityDescriptor = await activityDescriber.DescribeActivityAsync(activityType, cancellationToken); - Add(activityDescriptor, _activityDescriptors, _manualActivityDescriptors); _manualActivityDescriptors.Add(activityDescriptor); } diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs index 9b01d48f0..1a2adedb4 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs @@ -7,6 +7,7 @@ using Elsa.Workflows; using Elsa.Workflows.CommitStates; using Elsa.Workflows.Management.Providers; using Elsa.Workflows.Management.Services; +using Elsa.Workflows.PortResolvers; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -26,16 +27,15 @@ public static class ActivityTestHelper /// /// The activity to execute /// The ActivityExecutionContext used for execution - public static async Task ExecuteActivityAsync(IActivity activity) + public static async Task ExecuteActivityAsync(IActivity activity, Action? configureServices = null) { - var context = CreateMinimalActivityExecutionContext(activity, out var serviceProvider); - + var context = await CreateMinimalActivityExecutionContext(activity, configureServices); + // Set up variables and inputs, then execute the activity await SetupExistingVariablesAsync(activity, context); - await SetupInputValuesInMemoryAsync(activity, context, serviceProvider); await context.EvaluateInputPropertiesAsync(); await activity.ExecuteAsync(context); - + return context; } @@ -44,82 +44,61 @@ public static class ActivityTestHelper /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern /// to provide proper context for activities. /// - private static ActivityExecutionContext CreateMinimalActivityExecutionContext(IActivity activity, out IServiceProvider serviceProvider) + private static async Task CreateMinimalActivityExecutionContext(IActivity activity, Action? configureServices) { // Create a minimal service provider with the required services for expression evaluation var services = new ServiceCollection(); - + // Add core services + services.AddLogging(); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(); - - // Add real expression evaluation services instead of mocks services.AddScoped(); - - // Add the well-known type registry required by expression handlers services.AddSingleton(); - + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Add the default expression descriptor provider which includes Literal expressions services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton(_ => Substitute.For()); - - // Mock the complex workflow-level dependencies - services.AddSingleton(_ => Substitute.For()); - - // Set up the activity registry lookup service to return proper descriptors - var activityRegistryLookup = Substitute.For(); - activityRegistryLookup.FindAsync(Arg.Any(), Arg.Any()).Returns(callInfo => - { - var activityType = callInfo.ArgAt(0); - return Task.FromResult(new ActivityDescriptor - { - TypeName = activityType, - Kind = ActivityKind.Action, - Category = "Test", - Description = "Test activity for unit testing", - Version = 1 - }); - }); - - services.AddSingleton(_ => activityRegistryLookup); - - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); services.AddSingleton(_ => Substitute.For()); - serviceProvider = services.BuildServiceProvider(); - - // Create a minimal workflow - activity.Id ??= $"test-activity-{Guid.NewGuid()}"; - - var workflow = new Workflow - { - Root = activity - }; - - // Create a simple workflow graph manually instead of using the builder - var rootNode = new ActivityNode(activity, "Root"); - var nodes = new List { rootNode }; - var workflowGraph = new WorkflowGraph(workflow, rootNode, nodes); - + // Call the configure services action if provided. + configureServices?.Invoke(services); + + var serviceProvider = services.BuildServiceProvider(); + var activityRegistry = serviceProvider.GetRequiredService(); + var workflowGraphBuilder = serviceProvider.GetRequiredService(); + 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 = WorkflowExecutionContext.CreateAsync( - serviceProvider, - workflowGraph, - $"test-instance-{Guid.NewGuid()}", + var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync( + serviceProvider, + workflowGraph, + $"test-instance-{Guid.NewGuid()}", CancellationToken.None - ).GetAwaiter().GetResult(); - + ); + // Create ActivityExecutionContext for the actual activity we want to test - var activityExecutionContext = workflowExecutionContext.CreateActivityExecutionContextAsync(activity) - .GetAwaiter().GetResult(); - - return activityExecutionContext; + return await workflowExecutionContext.CreateActivityExecutionContextAsync(activity); } /// @@ -133,7 +112,7 @@ public static class ActivityTestHelper .Where(p => p.PropertyType.IsGenericType && p.PropertyType.BaseType == typeof(Variable)) .ToList(); - + foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) { variable.Set(context.ExpressionExecutionContext, variable.Value); @@ -141,35 +120,4 @@ public static class ActivityTestHelper return Task.CompletedTask; } - - /// - /// Sets up input values in memory blocks so that context.Get() can find them during activity execution. - /// This mimics what the workflow engine does when evaluating inputs. - /// - private static async Task SetupInputValuesInMemoryAsync(IActivity activity, ActivityExecutionContext context, IServiceProvider serviceProvider) - { - var activityType = activity.GetType(); - var inputProperties = activityType.GetProperties() - .Where(p => p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(Input<>)) - .ToList(); - - foreach (var input in inputProperties.Select(property => property.GetValue(activity) as Input)) - { - if (input?.Expression == null) - { - continue; - } - - // Get the memory block reference for this input - var memoryBlockReference = input.MemoryBlockReference(); - - // Evaluate the input using the expression evaluator - var expressionEvaluator = serviceProvider.GetService(); - var evaluatedValue = await expressionEvaluator!.EvaluateAsync(input, context.ExpressionExecutionContext); - - // Set the value in the memory block - memoryBlockReference.Set(context, evaluatedValue); - } - } -} +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index f94ba1812..15d7bf0d8 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,455 +1,49 @@ using Elsa.Activities.UnitTests.Helpers; -using Elsa.Workflows; -using Elsa.Expressions.Models; -using System.Text.Json; namespace Elsa.Activities.UnitTests.Primitives; public class SetVariableTests { [Fact] - public async Task Should_Set_Variable_Integer() + public async Task Should_Set_Variable_Integer() { // Arrange + const int expected = 42; // The answer to life, the universe and everything. var variable = new Variable("myVar", 0, "myVar"); - var setVariable = new SetVariable(variable, new Input(42, "inputId")); - + var setVariable = new SetVariable(variable, new Input(expected)); + // Act var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - + // Assert var result = variable.Get(context); - Assert.Equal(42, result); + Assert.Equal(expected, result); } [Fact] - public async Task Should_Set_Variable_String() + public async Task Should_Not_Throw_When_Variable_Is_Null() { // Arrange - var variable = new Variable("myStringVar", "", "myStringVar"); - var setVariable = new SetVariable(variable, new Input("Hello World", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("Hello World", result); + var setVariable = new SetVariable(null!, new Input("test value")); + + // Act & Assert + var exception = await Record.ExceptionAsync(async () => await ActivityTestHelper.ExecuteActivityAsync(setVariable)); + + Assert.NotNull(exception); } [Fact] - public async Task Should_Overwrite_Existing_Variable() + public async Task Should_Set_Variable_To_Null_Value() { // Arrange - var variable = new Variable("existingVar", 100, "inputId"); - var setVariable = new SetVariable(variable, new Input(200, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - verify the original value was overwritten - var result = variable.Get(context); - Assert.Equal(200, result); - } + var variable = new Variable("myVar", "initial value", "myVar"); + var setVariable = new SetVariable(variable, new Input(default(string))); - [Fact] - public async Task Should_Assign_Null_Value() - { - // Arrange - var variable = new Variable("nullVar", "initial", "nullVar"); - var setVariable = new SetVariable(variable, new Input((string)null!, "inputId")); - // Act var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - + // Assert var result = variable.Get(context); Assert.Null(result); } - - [Fact] - public async Task Should_Set_Variable_With_Special_Characters() - { - // Arrange - var variable = new Variable("special_var-123", "", "specialVar"); - const string specialValue = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"; - var setVariable = new SetVariable(variable, new Input(specialValue, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal(specialValue, result); - } - - [Fact] - public async Task Should_Serialize_Complex_Object() - { - // Arrange - var complexObject = new ComplexTestObject - { - Id = 123, - Name = "Test Object", - Properties = new Dictionary - { - {"prop1", "value1"}, - {"prop2", 456}, - {"prop3", true} - }, - Items = ["item1", "item2", "item3"] - }; - - var variable = new Variable("complexVar", null!, "complexVar"); - var setVariable = new SetVariable(variable, new Input(complexObject, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.NotNull(result); - Assert.Equal(complexObject.Id, result.Id); - Assert.Equal(complexObject.Name, result.Name); - Assert.Equal(complexObject.Properties.Count, result.Properties.Count); - Assert.Equal(complexObject.Items.Length, result.Items.Length); - } - - [Fact] - public async Task Should_Assign_Large_Payload() - { - // Arrange - var largePayload = new string('A', 1024 * 1024); - var variable = new Variable("largeVar", "", "largeVar"); - var setVariable = new SetVariable(variable, new Input(largePayload, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal(largePayload.Length, result.Length); - Assert.Equal(largePayload, result); - } - - [Fact] - public async Task Should_Handle_Case_Sensitivity_On_Variable_Names() - { - // Arrange - var variable1 = new Variable("CaseSensitive", "value1", "var1"); - var variable2 = new Variable("casesensitive", "value2", "var2"); - - var setVariable1 = new SetVariable(variable1, new Input("updated1", "input1")); - var setVariable2 = new SetVariable(variable2, new Input("updated2", "input2")); - - // Act - var context1 = await ActivityTestHelper.ExecuteActivityAsync(setVariable1); - var context2 = await ActivityTestHelper.ExecuteActivityAsync(setVariable2); - - // Assert - Variables with different casing should be treated as separate - var result1 = variable1.Get(context1); - var result2 = variable2.Get(context2); - - Assert.Equal("updated1", result1); - Assert.Equal("updated2", result2); - } - - [Fact] - public async Task Should_Reassign_Variable_Multiple_Times() - { - // Arrange - var variable = new Variable("multiVar", 0, "multiVar"); - - // Act - Multiple assignments - var setVariable1 = new SetVariable(variable, new Input(10, "input1")); - var context1 = await ActivityTestHelper.ExecuteActivityAsync(setVariable1); - var result1 = variable.Get(context1); - - var setVariable2 = new SetVariable(variable, new Input(20, "input2")); - var context2 = await ActivityTestHelper.ExecuteActivityAsync(setVariable2); - var result2 = variable.Get(context2); - - var setVariable3 = new SetVariable(variable, new Input(30, "input3")); - var context3 = await ActivityTestHelper.ExecuteActivityAsync(setVariable3); - var result3 = variable.Get(context3); - - // Assert - Each execution should update the variable - Assert.Equal(10, result1); - Assert.Equal(20, result2); - Assert.Equal(30, result3); - } - - [Fact] - public async Task Should_Handle_Boolean_Values() - { - // Arrange - var variable = new Variable("boolVar", false, "boolVar"); - var setVariable = new SetVariable(variable, new Input(true, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.True(result); - } - - [Fact] - public async Task Should_Handle_DateTime_Values() - { - // Arrange - var testDate = new DateTime(2025, 10, 8, 14, 30, 0); - var variable = new Variable("dateVar", DateTime.MinValue, "dateVar"); - var setVariable = new SetVariable(variable, new Input(testDate, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal(testDate, result); - } - - [Fact] - public async Task Should_Handle_Decimal_Values() - { - // Arrange - var decimalValue = 123.456789m; - var variable = new Variable("decimalVar", 0m, "decimalVar"); - var setVariable = new SetVariable(variable, new Input(decimalValue, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal(decimalValue, result); - } - - [Fact] - public async Task Should_Handle_Array_Values() - { - // Arrange - var arrayValue = new[] { "item1", "item2", "item3" }; - var variable = new Variable("arrayVar", null!, "arrayVar"); - var setVariable = new SetVariable(variable, new Input(arrayValue, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.NotNull(result); - Assert.Equal(arrayValue.Length, result.Length); - Assert.Equal(arrayValue, result); - } - - [Fact] - public async Task Should_Handle_Dictionary_Values() - { - // Arrange - var dictionaryValue = new Dictionary - { - {"key1", "value1"}, - {"key2", 123}, - {"key3", true} - }; - var variable = new Variable>("dictVar", null!, "dictVar"); - var setVariable = new SetVariable>(variable, new Input>(dictionaryValue, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.NotNull(result); - Assert.Equal(dictionaryValue.Count, result.Count); - Assert.Equal(dictionaryValue["key1"], result["key1"]); - Assert.Equal(dictionaryValue["key2"], result["key2"]); - Assert.Equal(dictionaryValue["key3"], result["key3"]); - } - - [Fact] - public async Task Should_Evaluate_Variable_Name_Via_Expression() - { - // Arrange - Use an expression for the variable name - var dynamicVariableName = "dynamic_var_" + DateTime.Now.Ticks; - var variable = new Variable(dynamicVariableName, "", "dynamicVar"); - var setVariable = new SetVariable(variable, new Input("Dynamic Value", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("Dynamic Value", result); - Assert.Equal(dynamicVariableName, variable.Name); - } - - [Fact] - public async Task Should_Evaluate_Value_Via_Expression() - { - // Arrange - Use a computed expression for the value - var computedValue = $"Computed at {DateTime.Now:yyyy-MM-dd HH:mm:ss}"; - var variable = new Variable("expressionVar", "", "expressionVar"); - var setVariable = new SetVariable(variable, new Input(computedValue, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal(computedValue, result); - Assert.Contains("Computed at", result); - } - - [Fact] - public async Task Should_Handle_Expression_That_Returns_Null() - { - // Arrange - Expression that evaluates to null - var variable = new Variable("nullExpressionVar", "initial", "nullExpressionVar"); - var setVariable = new SetVariable(variable, new Input((string)null!, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Null(result); - } - - [Fact] - public async Task Should_Handle_Undefined_Expression_Result() - { - // Arrange - Test handling of undefined/default values - var variable = new Variable("undefinedVar", null, "undefinedVar"); - var setVariable = new SetVariable(variable, new Input((int?)null, "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Null(result); - } - - [Fact] - public async Task Should_Handle_Zero_Length_Variable_Name() - { - // Arrange - var variable = new Variable(string.Empty, "test", "emptyNameVar"); - var setVariable = new SetVariable(variable, new Input("value", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("value", result); - } - - [Fact] - public async Task Should_Handle_Unicode_Variable_Names() - { - // Arrange - Test with Unicode characters in variable names - var unicodeVariableName = "变量名_متغیر_переменная_🚀"; - var variable = new Variable(unicodeVariableName, "", "unicodeVar"); - var setVariable = new SetVariable(variable, new Input("Unicode Value", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("Unicode Value", result); - Assert.Equal(unicodeVariableName, variable.Name); - } - - [Fact] - public async Task Should_Handle_Extremely_Long_Variable_Name() - { - // Arrange - Test with very long variable name - var longVariableName = new string('a', 1000); - var variable = new Variable(longVariableName, "", "longNameVar"); - var setVariable = new SetVariable(variable, new Input("Long Name Value", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("Long Name Value", result); - Assert.Equal(1000, variable.Name.Length); - } - - [Fact] - public async Task Should_Handle_Numeric_Variable_Names() - { - // Arrange - Test with numeric variable names - var numericVariableName = "12345"; - var variable = new Variable(numericVariableName, "", "numericVar"); - var setVariable = new SetVariable(variable, new Input("Numeric Name Value", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("Numeric Name Value", result); - Assert.Equal(numericVariableName, variable.Name); - } - - [Fact] - public async Task Should_Handle_Variable_Name_With_Spaces() - { - // Arrange - Test with spaces in variable names - var spacedVariableName = "variable with spaces"; - var variable = new Variable(spacedVariableName, "", "spacedVar"); - var setVariable = new SetVariable(variable, new Input("Spaced Name Value", "inputId")); - - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - - // Assert - var result = variable.Get(context); - Assert.Equal("Spaced Name Value", result); - Assert.Equal(spacedVariableName, variable.Name); - } - - [Fact] - public async Task Should_Handle_Concurrent_Variable_Updates() - { - // Arrange - Test thread safety with concurrent updates - var variable = new Variable("concurrentVar", 0, "concurrentVar"); - var tasks = new List>(); - - // Act - Create multiple concurrent assignments - for (int i = 1; i <= 10; i++) - { - var value = i; - tasks.Add(Task.Run(async () => - { - var setVariable = new SetVariable(variable, new Input(value, $"input{value}")); - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); - return variable.Get(context); - })); - } - - var results = await Task.WhenAll(tasks); - - // Assert - All operations should complete successfully - Assert.Equal(10, results.Length); - Assert.All(results, result => Assert.True(result >= 1 && result <= 10)); - } -} - -/// -/// Complex test object for serialization testing -/// -public class ComplexTestObject -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public Dictionary Properties { get; set; } = new(); - public string[] Items { get; set; } = []; } \ No newline at end of file From b3c1defd3dab78d7c5f8e4d38b5c8244c8519aaf Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 8 Oct 2025 20:27:18 +0200 Subject: [PATCH 07/15] Fix incorrect test method name to reflect expected throwing behavior when variable is null in `SetVariableTests`. --- .../Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index 15d7bf0d8..0adc16ffa 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -21,7 +21,7 @@ public class SetVariableTests } [Fact] - public async Task Should_Not_Throw_When_Variable_Is_Null() + public async Task Should_Throw_When_Variable_Is_Null() { // Arrange var setVariable = new SetVariable(null!, new Input("test value")); From b5cc5f7ca5583a8f460105e47d3d506c87b12ff3 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 8 Oct 2025 20:36:06 +0200 Subject: [PATCH 08/15] Update `coverlet.msbuild` version to 6.0.4 in `Directory.Packages.props` --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a411ccbda..232f8c561 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + From 2abb937fc11fef4082e1377af813f19f25a9fe87 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 8 Oct 2025 21:10:35 +0200 Subject: [PATCH 09/15] Add integration test for implicit join behavior with merge mode set to None --- .../Elsa.Workflows.IntegrationTests.csproj | 3 +++ .../JoinBehaviors/ForkDecisionJoinTests.cs | 16 ++++++++++++++++ .../Workflows/decision-merge-join-none.json | 1 + 3 files changed, 20 insertions(+) create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj index 79007a43c..f3487fa1f 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj +++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj @@ -32,5 +32,8 @@ Always + + Always + diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs index c3aca5a2a..f3d678175 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs @@ -31,6 +31,22 @@ public class ForkDecisionJoinTests await RunAndAssert("fork-decision-join-waitall.json", ["A", "C", "B", "D"]); } + [Fact(DisplayName = "An implicit join from the True and False branches should execute because the join mode is None and by default, all active branches are joined.")] + public async Task ImplicitJoinFromBranchesShouldExecute() + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + + // Import workflow. + var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json"); + + // Execute. + var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); + + // Assert. + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + } + private async Task RunAndAssert(string workflowFileName, string[] expectedLines) { // Populate registries. diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json new file mode 100644 index 000000000..81820da59 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/decision-merge-join-none.json @@ -0,0 +1 @@ +{"$schema":"https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json","id":"c2601be150ec7739","definitionId":"b8c92c080faf734f","name":"Decision Merge","createdAt":"2025-10-08T19:00:25.918638\u002B00:00","version":5,"toolVersion":"3.6.0.0","variables":[],"inputs":[],"outputs":[],"outcomes":[],"customProperties":{},"isReadonly":false,"isSystem":false,"isLatest":true,"isPublished":true,"options":{"autoUpdateConsumingWorkflows":false},"root":{"id":"990d07168c5223de","nodeId":"Workflow1:990d07168c5223de","name":"Flowchart1","type":"Elsa.Flowchart","version":1,"customProperties":{"notFoundConnections":[],"canStartWorkflow":false,"runAsynchronously":false},"metadata":{},"activities":[{"condition":{"typeName":"Boolean","expression":{"type":"Literal","value":false}},"id":"a38215f28410f472","nodeId":"Workflow1:990d07168c5223de:a38215f28410f472","name":"FlowDecision1","type":"Elsa.FlowDecision","version":1,"customProperties":{"canStartWorkflow":false,"runAsynchronously":false},"metadata":{"designer":{"position":{"x":-237.5,"y":-255.5},"size":{"width":149.84375,"height":67.9765625}}}},{"id":"50e042cfab596891","nodeId":"Workflow1:990d07168c5223de:50e042cfab596891","name":"Start1","type":"Elsa.Start","version":1,"customProperties":{"canStartWorkflow":false,"runAsynchronously":false},"metadata":{"designer":{"position":{"x":-485.5,"y":-255.5},"size":{"width":122.6484375,"height":67.9765625}}}},{"id":"d5b9c2affe09a93b","nodeId":"Workflow1:990d07168c5223de:d5b9c2affe09a93b","name":"End1","type":"Elsa.End","version":1,"customProperties":{"canStartWorkflow":false,"runAsynchronously":false,"mergeMode":"None"},"metadata":{"designer":{"position":{"x":75.5,"y":-255.5},"size":{"width":115.3125,"height":67.9765625}}}}],"variables":[],"connections":[{"source":{"activity":"50e042cfab596891","port":"Done"},"target":{"activity":"a38215f28410f472","port":"In"},"vertices":[]},{"source":{"activity":"a38215f28410f472","port":"True"},"target":{"activity":"d5b9c2affe09a93b","port":"In"},"vertices":[]},{"source":{"activity":"a38215f28410f472","port":"False"},"target":{"activity":"d5b9c2affe09a93b","port":"In"},"vertices":[]}]}} \ No newline at end of file From abfaad85cbc509c62d8141f2bbc2a10797945034 Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Thu, 9 Oct 2025 09:39:07 +0200 Subject: [PATCH 10/15] Update test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs index 1a2adedb4..68d47361e 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs @@ -59,7 +59,6 @@ public static class ActivityTestHelper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); From c02484fd4debff3fd5282d215daec8dbd80694ec Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Thu, 9 Oct 2025 09:40:01 +0200 Subject: [PATCH 11/15] Update src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs b/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs index ba99e893d..689e847e6 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/SetVariable.cs @@ -105,7 +105,7 @@ public class SetVariable : CodeActivity var variable = context.ExpressionExecutionContext.EnumerateVariablesInScope().FirstOrDefault(x => x.Id == variableId); if (variable == null) - throw new($"Variable '{variableId}' not found."); + throw new InvalidOperationException($"Variable '{variableId}' not found."); var value = context.Get(Value); variable.Set(context, value); From c7df5619e58518eb684e8c0c21d8fa601c5d519e Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Thu, 9 Oct 2025 09:40:36 +0200 Subject: [PATCH 12/15] Update test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index 0adc16ffa..7b94781ef 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -37,7 +37,7 @@ public class SetVariableTests { // Arrange var variable = new Variable("myVar", "initial value", "myVar"); - var setVariable = new SetVariable(variable, new Input(default(string))); + var setVariable = new SetVariable(variable, new Input(null)); // Act var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); From 9ea3f49e0fbebd6483f02cb23a7da7d25a319b44 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Thu, 9 Oct 2025 09:41:27 +0200 Subject: [PATCH 13/15] Small improvement for generic Variable type evaluation --- .../Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs index 1a2adedb4..d01f77a29 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs @@ -26,6 +26,7 @@ public static class ActivityTestHelper /// evaluating inputs, and executing the activity. /// /// The activity to execute + /// /// The ActivityExecutionContext used for execution public static async Task ExecuteActivityAsync(IActivity activity, Action? configureServices = null) { @@ -110,7 +111,7 @@ public static class ActivityTestHelper var activityType = activity.GetType(); var variableProperties = activityType.GetProperties() .Where(p => p.PropertyType.IsGenericType && - p.PropertyType.BaseType == typeof(Variable)) + p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>)) .ToList(); foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) From 19b5ab94dd760f97b1c5eaf67408b60c8537d122 Mon Sep 17 00:00:00 2001 From: "lucas.hipolito" Date: Thu, 9 Oct 2025 09:59:04 +0200 Subject: [PATCH 14/15] Workaround for using correct overoad - build fix --- .../Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index 7b94781ef..2f5c7ec5d 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -37,7 +37,7 @@ public class SetVariableTests { // Arrange var variable = new Variable("myVar", "initial value", "myVar"); - var setVariable = new SetVariable(variable, new Input(null)); + var setVariable = new SetVariable(variable, new Input((string?)null)); // Act var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); From 5b014c7566587ae5db61fcaca6472582092a3352 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 9 Oct 2025 13:31:06 +0200 Subject: [PATCH 15/15] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs index a17ab01f9..b8f623ed6 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs @@ -26,7 +26,7 @@ public static class ActivityTestHelper /// evaluating inputs, and executing the activity. /// /// The activity to execute - /// + /// An optional action to configure the service collection for dependency injection during activity execution. /// The ActivityExecutionContext used for execution public static async Task ExecuteActivityAsync(IActivity activity, Action? configureServices = null) {