From 3d15baf0f2ef13f37226fa2432f80d01c8e825e7 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 14 Oct 2025 12:45:18 +0200 Subject: [PATCH 1/4] Update doc/qa/test-guidelines.md --- doc/qa/test-guidelines.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index 7064bfbdc..4bab1304e 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -171,7 +171,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu ## Deterministic patterns to avoid flaky tests -1. **For activities unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. +1. **For activity unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. 2. **Resume bookmarks explicitly.** Do not wait for external schedulers — call the engine's resume/trigger APIs in your test to continue execution. 3. **For integration tests, Locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries. From 35704e91662c57fe873d886362fbf6b8c18e3846 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 14 Oct 2025 12:45:23 +0200 Subject: [PATCH 2/4] Update doc/qa/test-guidelines.md --- doc/qa/test-guidelines.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index 4bab1304e..6fbafb73d 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -173,7 +173,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu 1. **For activity unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. 2. **Resume bookmarks explicitly.** Do not wait for external schedulers — call the engine's resume/trigger APIs in your test to continue execution. -3. **For integration tests, Locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries. +3. **For integration tests, locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries. --- From fcd0b5ae9a3e63005c1490eb9dad6401b16fbe3d Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Mon, 20 Oct 2025 20:11:46 +0200 Subject: [PATCH 3/4] Feat/unit test coverage sendhttprequest (#6961) * Unit test coverage for SendHttpRequest + useful extensions * Update src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/unit/Elsa.Activities.UnitTests/HTTP/SendHttpRequestTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/unit/Elsa.Activities.UnitTests/HTTP/SendHttpRequestTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improvements on unit test and documentation for http * Update doc/qa/test-guidelines.md Co-authored-by: Sipke Schoorstra * Improvements on maintainability of sendhttprequest unit tests * Improving tests and scheduled activity evaluation for activity context * Refactor and splitting unnecessary grouped tests * Improvements on SendHttp Unit tests * Improvements on tests * Update test/unit/Elsa.Activities.UnitTests/HTTP/SendHttpRequestTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Small suggestions from copilot * Update test/unit/Elsa.Activities.UnitTests/HTTP/SendHttpRequestTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * small copilot suggestion * Introduce Scheduler Strategy Interfaces and Implementations for Workflow and Activity Execution Contexts (#6984) * Introduce Scheduler Strategy Interfaces and Implementations for Workflow and Activity Execution Contexts - Added `IWorkflowExecutionContextSchedulerStrategy` and `IActivityExecutionContextSchedulerStrategy` interfaces. - Implemented `WorkflowExecutionContextSchedulerStrategy` and `ActivityExecutionContextSchedulerStrategy` for scheduling activities in workflows. - Refactored scheduling logic to utilize the new scheduler strategies. - Updated unit tests and test helpers to reflect refactoring, introducing fake implementations for testing purposes. - Adjusted background execution scheduling and improved extensibility for custom scheduler strategies. * Refactor `SendHttpRequestTests`: simplify scheduling assertions, use shared extensions, and standardize method naming. Streamline helper methods and remove unused test logic. * Refactor: Replace `ActivityTestHelper` with `ActivityTestFixture` in unit tests for streamlined activity testing - Introduced `ActivityTestFixture` with a fluent API for better test setup and execution of activities. - Added extension methods `ActivityTestFixtureExtensions` and `ActivityTestFixtureHttpExtensions` for configuring attributes and HTTP services. - Updated test guidelines and unit tests to use the new fixture and extensions. - Removed `ActivityTestHelper`. * Refactor: Move `ActivityTestFixture` and related extensions to shared project for reuse across test suites - Consolidated `ActivityTestFixture`, `ActivityTestFixtureExtensions`, and `ActivityTestFixtureHttpExtensions` into `Elsa.Testing.Shared`. - Updated namespaces and imports across unit tests to reflect new structure. - Enhanced `AssertActivityAttributes` and added fluent configuration APIs. - Adjusted `Directory.Packages.props` with new dependencies, including `NSubstitute` and `xunit.assert`. * Refactor `SetVariableTests`: inline `ActivityTestFixture` initialization to simplify test setup. * Refactor `ActivityTestFixture`: eliminate redundant field `_services`, add `UsedImplicitly` attributes, and improve service collection management * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add XML documentation for `ActivityExecutionContextExtensions`, detailing methods and parameters. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add XML documentation for scheduler strategies and their methods - Updated `IActivityExecutionContextSchedulerStrategy` and `IWorkflowExecutionContextSchedulerStrategy` interfaces with XML summaries for methods. - Added XML documentation to implementations (`ActivityExecutionContextSchedulerStrategy`, `WorkflowExecutionContextSchedulerStrategy`) and test fakes for clarity. * Refactor `WriteLineTests`: consolidate duplicate test logic, simplify setup with shared helper method, and enhance readability in assertions. * Refactor `WriteLineTests` and `SetVariableTests`: replace `WriteLineAsync` assertions with synchronous `WriteLine`, streamline exception recording in `SetVariableTests`, and remove unused imports. * Add `ParallelTests` for unit and integration testing with various scenarios (#6988) - Added unit tests for `Parallel` activity to ensure proper scheduling of child activities, including empty and mixed activity cases. - Added integration tests to validate execution flow and edge cases for `Parallel` activities (e.g., nested parallelism, fault handling). - Enhanced `ScheduleChildrenAsync` in `Parallel` to handle no activity scenario by completing immediately. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add new unit and integration tests for activity input and expression evaluation (#6990) * Introduce Scheduler Strategy Interfaces and Implementations for Workflow and Activity Execution Contexts - Added `IWorkflowExecutionContextSchedulerStrategy` and `IActivityExecutionContextSchedulerStrategy` interfaces. - Implemented `WorkflowExecutionContextSchedulerStrategy` and `ActivityExecutionContextSchedulerStrategy` for scheduling activities in workflows. - Refactored scheduling logic to utilize the new scheduler strategies. - Updated unit tests and test helpers to reflect refactoring, introducing fake implementations for testing purposes. - Adjusted background execution scheduling and improved extensibility for custom scheduler strategies. * Refactor `SendHttpRequestTests`: simplify scheduling assertions, use shared extensions, and standardize method naming. Streamline helper methods and remove unused test logic. * Refactor: Replace `ActivityTestHelper` with `ActivityTestFixture` in unit tests for streamlined activity testing - Introduced `ActivityTestFixture` with a fluent API for better test setup and execution of activities. - Added extension methods `ActivityTestFixtureExtensions` and `ActivityTestFixtureHttpExtensions` for configuring attributes and HTTP services. - Updated test guidelines and unit tests to use the new fixture and extensions. - Removed `ActivityTestHelper`. * Refactor: Move `ActivityTestFixture` and related extensions to shared project for reuse across test suites - Consolidated `ActivityTestFixture`, `ActivityTestFixtureExtensions`, and `ActivityTestFixtureHttpExtensions` into `Elsa.Testing.Shared`. - Updated namespaces and imports across unit tests to reflect new structure. - Enhanced `AssertActivityAttributes` and added fluent configuration APIs. - Adjusted `Directory.Packages.props` with new dependencies, including `NSubstitute` and `xunit.assert`. * Refactor `SetVariableTests`: inline `ActivityTestFixture` initialization to simplify test setup. * Refactor `ActivityTestFixture`: eliminate redundant field `_services`, add `UsedImplicitly` attributes, and improve service collection management * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add XML documentation for `ActivityExecutionContextExtensions`, detailing methods and parameters. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add XML documentation for scheduler strategies and their methods - Updated `IActivityExecutionContextSchedulerStrategy` and `IWorkflowExecutionContextSchedulerStrategy` interfaces with XML summaries for methods. - Added XML documentation to implementations (`ActivityExecutionContextSchedulerStrategy`, `WorkflowExecutionContextSchedulerStrategy`) and test fakes for clarity. * Refactor `WriteLineTests`: consolidate duplicate test logic, simplify setup with shared helper method, and enhance readability in assertions. * Refactor `WriteLineTests` and `SetVariableTests`: replace `WriteLineAsync` assertions with synchronous `WriteLine`, streamline exception recording in `SetVariableTests`, and remove unused imports. * Add new unit and integration tests for activity input and expression evaluation - Introduced comprehensive test suites covering activity input evaluation, expression handling, and fault scenarios. - Added unit tests for `ExpressionDescriptorRegistry`, `ExpressionEvaluator`, and activity execution context extensions. - Added integration tests: `CustomInputEvaluatorTests`, `InputEvaluationErrorTests`, and `InputPropertyEvaluationTests`. - Enhanced code coverage for edge cases and async evaluation logic. * Remove unused `RunWorkflowAsync` extension method from `RunActivityExtensions`. * Rename `CustomInputEvaluatorTests` to `InputEvaluationTests` for consistency with naming conventions. * Remove unused `Elsa.Workflows.Activities` import from `RunActivityExtensions`. * Remove redundant comments from `InputEvaluationErrorTests` for clarity. * Refactor unit tests to streamline activity and expression evaluations - Refactored `ExecuteActivityAsync` and `ExecuteWriteLineAsync` into shared helpers for consistency and reuse across tests. - Replaced redundant mock setups with helper methods in `ExpressionDescriptorRegistryTests`. - Simplified test setup for expression and activity evaluation by removing unused imports and consolidating configuration logic. - Enhanced readability by reducing duplicate code and leveraging shared utility methods. * Refactor activity input evaluation tests - Extracted `CreateContextAsync` helper into `EvaluationTestHelpers` for reuse across evaluation test suites. - Replaced inline activity context setup with shared helper in `InputPropertyEvaluationTests`, `WrappedInputEvaluationTests`, and related test suites. - Simplified test method names for clarity and consistency. - Updated test annotations to enhance readability and align with naming conventions. * Remove redundant test cases and unused imports - Deleted duplicated and non-essential test cases across evaluation test suites. - Removed unused imports to improve code cleanliness and readability. - Streamlined variable initializations and method calls within test setups. * Remove redundant test case from `InputEvaluationErrorTests` - Deleted the `ContinuesEvaluationForMultipleInputs` test, as it overlaps with existing tests and does not provide additional coverage. * Remove redundant assertion from `InputPropertyEvaluationTests` - Deleted `Assert.True(context.GetHasEvaluatedProperties())`, as it is unnecessary for verifying test outcomes. * Remove redundant test cases from `WrappedInputEvaluationTests` - Deleted `UsesDefaultValueWhenInputIsNull` and `EvaluatesExpression` tests as they are either duplicated or unnecessary for current test coverage. * Add unit test projects for `Elsa.Workflows.Management` and `Elsa.Expressions` - Introduced new test projects to separate and organize unit tests for `Elsa.Workflows.Management` and `Elsa.Expressions`. - Updated `Elsa.sln` to include references to the newly added test projects. - Adjusted namespaces in affected test classes for consistency with the updated project structure. * Refactor `ExpressionEvaluatorTests` for clarity and consistency - Simplified test method names and annotations for improved readability. - Replaced duplicate mock setups with helper functions (`CreateContextAsync`, `CreateContextWithMockHandlerAsync`, and related methods). - Streamlined test setups by removing redundant code and consolidating context creation logic. - Updated test annotations to include descriptive `DisplayName` attributes. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove redundant comment from `ExpressionEvaluatorTests` for clarity * Remove redundant blank lines from unit test classes - Eliminated unnecessary blank lines across `ActivityExecutionContextExtensions` test suites to improve code readability and consistency. * Update src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Expand `test-guidelines.md` with testing best practices, helper references, and example snippets - Added detailed guidance on test project organization, updated helper documentation, and streamlined example code for activity unit testing. - Introduced scheduler strategy information and integration test patterns for deterministic tests. - Clarified usage of shared infrastructure like `ActivityTestFixture` and `AsyncWorkflowRunner`. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: lucas.hipolito Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sipke Schoorstra --- Directory.Packages.props | 339 +++++++++--------- Elsa.sln | 14 + doc/qa/test-guidelines.md | 158 ++++++-- .../ActivityExecutionContextExtensions.cs | 20 ++ .../ActivityTestFixture.cs | 163 ++++++--- .../ActivityTestFixtureExtensions.cs | 43 +++ .../Elsa.Testing.Shared.csproj | 2 + ...tivityExecutionContextSchedulerStrategy.cs | 46 +++ ...rkflowExecutionContextSchedulerStrategy.cs | 26 ++ .../Services/ExpressionEvaluator.cs | 26 +- .../Activities/Parallel.cs | 11 +- .../Contexts/ActivityExecutionContext.cs | 57 +-- ...tivityExecutionContextSchedulerStrategy.cs | 36 ++ ...rkflowExecutionContextSchedulerStrategy.cs | 23 ++ .../ActivityExecutionContextExtensions.cs | 32 +- .../WorkflowExecutionContextExtensions.cs | 26 +- .../Features/WorkflowsFeature.cs | 2 + ...tivityExecutionContextSchedulerStrategy.cs | 71 ++++ ...rkflowExecutionContextSchedulerStrategy.cs | 37 ++ .../ParallelTests.cs | 189 ++++++++++ .../Evaluation/EvaluationTestHelpers.cs | 21 ++ .../Evaluation/InputEvaluationErrorTests.cs | 85 +++++ .../Evaluation/InputEvaluationTests.cs | 73 ++++ .../InputPropertyEvaluationTests.cs | 87 +++++ .../Evaluation/InputStateStorageTests.cs | 152 ++++++++ .../Evaluation/WrappedInputEvaluationTests.cs | 103 ++++++ .../Console/WriteLineTests.cs | 51 +-- .../Elsa.Activities.UnitTests.csproj | 2 + .../ActivityTestFixtureHttpExtensions.cs | 141 ++++++++ .../Helpers/TestHttpMessageHandler.cs | 17 + .../Http/SendHttpRequestTests.cs | 288 +++++++++++++++ .../Parallel/ParallelTests.cs | 97 +++++ .../Primitives/SetVariableTests.cs | 14 +- .../Elsa.Expressions.UnitTests.csproj | 8 + .../Services/ExpressionEvaluatorTests.cs | 169 +++++++++ .../Elsa.Workflows.Core.UnitTests.csproj | 1 + .../FaultHandlingTests.cs | 39 ++ .../HierarchyNavigationTests.cs | 41 +++ .../MetadataTests.cs | 50 +++ .../OutcomeTests.cs | 35 ++ .../PropertyEvaluationTests.cs | 24 ++ .../ResultTests.cs | 39 ++ .../TestHelpers.cs | 22 ++ .../TriggerTests.cs | 23 ++ .../VariableTests.cs | 68 ++++ .../WorkflowInputTests.cs | 72 ++++ ...Elsa.Workflows.Management.UnitTests.csproj | 8 + .../ExpressionDescriptorRegistryTests.cs | 225 ++++++++++++ 48 files changed, 2891 insertions(+), 385 deletions(-) create mode 100644 src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs rename test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs => src/common/Elsa.Testing.Shared/ActivityTestFixture.cs (52%) create mode 100644 src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs create mode 100644 src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs create mode 100644 src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs create mode 100644 src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs create mode 100644 src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs create mode 100644 test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs create mode 100644 test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj create mode 100644 test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs create mode 100644 test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 232f8c561..2c181897c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,174 +9,175 @@ 9.9.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index f92369873..ba08d1d2c 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -311,6 +311,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "qa", "qa", "{0478E6EA-DCB2- doc\qa\test-guidelines.md = doc\qa\test-guidelines.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Management.UnitTests", "test\unit\Elsa.Workflows.Management.UnitTests\Elsa.Workflows.Management.UnitTests.csproj", "{20C75446-CAE0-48D1-85D9-459541C4D8B6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests", "test\unit\Elsa.Expressions.UnitTests\Elsa.Expressions.UnitTests.csproj", "{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -619,6 +623,14 @@ Global {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 + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.Build.0 = Release|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -714,6 +726,8 @@ Global {DC9CCAD0-7363-4691-B964-FF5B3AEA3F95} = {18453B51-25EB-4317-A4B3-B10518252E92} {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA} = {18453B51-25EB-4317-A4B3-B10518252E92} {0478E6EA-DCB2-4667-ADC2-37C62C9C2574} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} + {20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92} + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index 6fbafb73d..de0b29cc1 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -45,7 +45,7 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o ## Quick Start for Contributors -**Before you writ e a test:** +**Before you write a test:** 1. ✅ Understand what you're testing (see [**Which parts of Elsa to test**](#which-parts-of-elsa-to-test-and-which-test-types-to-use)) 2. ✅ Choose the right test layer (unit vs integration vs component) 3. ✅ Use existing helpers (don't reinvent - see [**Test Helpers Reference**](#test-helpers-reference-quick-lookup)) @@ -68,6 +68,24 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o --- +## Test Project Organization + +Tests are organized by module and test type: + +- **`test/unit/Elsa.Activities.UnitTests`** - Activity unit tests +- **`test/unit/Elsa.Expressions.UnitTests`** - Expression evaluation unit tests +- **`test/unit/Elsa.Workflows.Core.UnitTests`** - Core workflow unit tests (ActivityExecutionContext extensions, etc.) +- **`test/unit/Elsa.Workflows.Management.UnitTests`** - Workflow management unit tests +- **`test/integration/Elsa.Workflows.IntegrationTests`** - Workflow integration tests +- **`test/component/Elsa.Workflows.ComponentTests`** - End-to-end component tests + +**Shared test infrastructure:** +- **`src/common/Elsa.Testing.Shared`** - Shared test helpers (`ActivityTestFixture`, fake strategies, extensions) +- **`src/common/Elsa.Testing.Shared.Integration`** - Integration test helpers (`RunActivityExtensions`, `PopulateRegistriesAsync`) +- **`src/common/Elsa.Testing.Shared.Component`** - Component test helpers (event handlers, workflow events) + +--- + ## Which parts of Elsa to test, and which test types to use This section maps Elsa aspects to the exact kinds of tests you should write, with examples and code patterns referencing repository conventions. @@ -76,9 +94,11 @@ This section maps Elsa aspects to the exact kinds of tests you should write, wit #### **Unit tests:** - Test the activity class logic only (no persistence, no scheduler). Cover configuration permutations and boundary inputs. -- Use [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs), `ExecuteActivityAsync` method to run the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions. +- Use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) to configure and execute the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions. +- Use fluent methods: `ConfigureServices()` to add custom services, `ConfigureContext()` to set up context state +- Assert using `context.GetActivityOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)) for outputs, or `context.Get()` for variables. -**Example:** +**Example (does not set output):** ```csharp [Fact] public async Task Should_Set_Variable_Integer() @@ -89,7 +109,8 @@ public async Task Should_Set_Variable_Integer() var setVariable = new SetVariable(variable, new Input(expected)); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + var fixture = new ActivityTestFixture(setVariable); + var context = await fixture.ExecuteAsync(); // Assert var result = context.Get(variable); @@ -97,13 +118,56 @@ public async Task Should_Set_Variable_Integer() } ``` +**Example (sets output using fluent configuration):** +```csharp +[Fact] +public async Task Should_Send_Get_Request_And_Handle_Success_Response() +{ + // Arrange + var expectedUrl = new Uri("https://example.com"); + var sendHttpRequest = new SendHttpRequest + { + Url = new Input(expectedUrl), + Method = new Input("GET") + }; + + // Act + var fixture = new ActivityTestFixture(sendHttpRequest) + .WithHttpServices(responseHandler); // HTTP-specific extension + var context = await fixture.ExecuteAsync(); + + // Assert + var statusCodeOutput = context.GetActivityOutput(_ => sendHttpRequest.StatusCode); + Assert.Equal(200, statusCodeOutput); +} +``` + +**Example (checking scheduled activities):** +```csharp +[Fact] +public async Task Should_Schedule_Child_Activity() +{ + // Arrange + var childActivity = new WriteLine("Hello"); + var parentActivity = new Sequence { Activities = [childActivity] }; + + // Act + var fixture = new ActivityTestFixture(parentActivity); + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(childActivity)); +} +``` + #### **Integration tests:** - Place the activity inside a minimal workflow definition and run via [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs). Assert outputs/variables and that the activity integrates correctly with preceding/following activities. - If activity creates bookmarks or relies on scheduler semantics, integration tests should resume bookmarks via the engine APIs to validate resumption. -Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion. +Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion. Use returned state for deterministic assertions where possible. +--- ### Workflow execution (invoker, middleware, bookmarks) @@ -140,14 +204,38 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status); ## Test Helpers Reference (Quick Lookup) -| Helper | Purpose | Use When | -|-------------------------------------------|--------------------------------------------|-------------------------------------------------------------------------| -| `TestApplicationBuilder` | Build test service provider | All tests as entry point,
except activities unit tests (see below) | -| `ActivityTestHelper.ExecuteActivityAsync` | Run single activity, with isolated context | Unit testing activities | -| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests | -| `PopulateRegistriesAsync` | Register types for JSON deserialization. | Loading JSON workflows.
Integration tests only | -| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) | -| `RunWorkflowUntilEndAsync` | Drive workflow to completion. | Complex resumption scenarios.
Integration tests only | +| Helper | Purpose | Use When | +|-------------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------| +| `TestApplicationBuilder` | Build test service provider | All tests as entry point,
except activities unit tests (see below) | +| `ActivityTestFixture` | Configure and run single activity in isolation | Unit testing activities | +| `ActivityTestFixture.ConfigureServices` | Fluent API to add services to fixture | Activity unit tests requiring custom services | +| `ActivityTestFixture.ConfigureContext` | Fluent API to configure execution context before execution | Setting up test-specific context state | +| `ActivityTestFixture.BuildAsync` | Build context without executing activity | When you need context setup without execution | +| `ActivityTestFixture.ExecuteAsync` | Execute activity and return context | Standard activity unit test execution | +| `context.GetActivityOutput` | Get output from activity using expression selector | Asserting activity outputs in unit tests | +| `context.HasScheduledActivity` | Check if activity is scheduled | Verifying scheduling behavior in unit tests | +| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests | +| `RunActivityExtensions.RunActivityAsync` | Run single activity as workflow | Integration tests for single activities | +| `PopulateRegistriesAsync` | Register types for JSON deserialization | Loading JSON workflows.
Integration tests only | +| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) | +| `AsyncWorkflowRunner.RunAndAwaitWorkflowCompletionAsync` | Drive workflow to completion with activity tracking | Complex async scenarios.
Component tests only | +| `FakeActivityExecutionContextSchedulerStrategy` | Fake scheduler for unit tests | Automatically configured in `ActivityTestFixture` | +| `FakeWorkflowExecutionContextSchedulerStrategy` | Fake workflow scheduler for unit tests | Automatically configured in `ActivityTestFixture` | + +--- + +## Scheduler Strategies for Testing + +When testing activities in isolation, Elsa provides fake scheduler strategy implementations that bypass the production scheduling logic: + +### Available Strategies + +- **`FakeActivityExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately within the activity execution context for deterministic testing +- **`FakeWorkflowExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately at the workflow level for deterministic testing + +These strategies are automatically configured when using [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) and ensure that scheduled activities execute synchronously in tests, making assertions deterministic. + +**Note:** You do not need to manually configure these strategies - `ActivityTestFixture` handles this for you. --- @@ -171,7 +259,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu ## Deterministic patterns to avoid flaky tests -1. **For activity unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. +1. **For activity unit tests, prefer returned state from [`ActivityTestFixture.ExecuteAsync`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. 2. **Resume bookmarks explicitly.** Do not wait for external schedulers — call the engine's resume/trigger APIs in your test to continue execution. 3. **For integration tests, locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries. @@ -183,7 +271,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu --- -## Practical test recipes & snippets (copy/paste-ready) +## Practical test recipes & snippets ### Unit test (activity) — pattern @@ -194,7 +282,8 @@ public async Task MyActivity_Test() var activity = new ActivityToTest(); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(activity); + var fixture = new ActivityTestFixture(activity); + var context = await fixture.ExecuteAsync(); // assert behavior of activity in isolation } @@ -228,10 +317,35 @@ public async Task Workflow_Persists_Instance_And_Journal() .Build(); var runner = sp.GetRequiredService(); -var result = await runner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published)); + var result = await runner.RunAndAwaitWorkflowCompletionAsync( + WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published) + ); result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished); } ``` + +### Component test with async workflow execution + +For testing workflows that complete asynchronously (e.g., with timers, external triggers), use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs): + +```csharp +[Fact] +public async Task Workflow_Completes_Asynchronously() +{ + var sp = new TestApplicationBuilder(testOutput).Build(); + var runner = sp.GetRequiredService(); + + var result = await runner.RunAndAwaitWorkflowCompletionAsync( + WorkflowDefinitionHandle.ByDefinitionId(workflowId, VersionOptions.Published) + ); + + result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished); + result.ActivityExecutionRecords.Should().HaveCount(expectedCount); +} +``` + +`AsyncWorkflowRunner` tracks activity execution records and awaits workflow completion signals, making it ideal for testing asynchronous workflow behavior deterministically. + --- ## FAQ (quick pointers) @@ -244,7 +358,7 @@ Leave the definitions next to the tests that use them. **Q: Which helper should I use to run a workflow?** -A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use `ExecuteActivityAsync` via [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs). +A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs). **Q: How do I check persisted journal entries?** @@ -252,7 +366,7 @@ A: Query [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/ **Q: Do I need a new helper to wait for workflow completion?** -A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios. +A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios. For async workflows in component tests, use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs). --- @@ -260,9 +374,9 @@ A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Cont Search the `test/` tree for examples that follow the above patterns: -- Unit test activity examples: `test/unit/Elsa.Activities.UnitTests` (look for [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) usage). -- Integration workflow examples: `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage). -- Component scenarios exercising persistence: `test/component/Elsa.Workflows.ComponentTests (look for [`AppComponentTest`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs) scaffolds and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions). +- **Unit test activity examples:** `test/unit/Elsa.Activities.UnitTests` (look for [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) usage) +- **Integration workflow examples:** `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage) +- **Component scenarios exercising persistence:** `test/component/Elsa.Workflows.ComponentTests` (look for [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs) and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions) diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs new file mode 100644 index 000000000..fc5f5bff3 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.Workflows; + +namespace Elsa.Testing.Shared; + +/// +/// Provides extension methods for to enhance its functionality. +/// +public static class ActivityExecutionContextExtensions +{ + /// + /// Determines whether the specified activity is scheduled in the workflow execution context. + /// + /// The activity execution context instance used for checking the scheduled activity. + /// The activity to check for scheduling. + /// True if the specified activity is scheduled; otherwise, false. + public static bool HasScheduledActivity(this ActivityExecutionContext activityExecutionContext, IActivity activity) + { + return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null; + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs similarity index 52% rename from test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs rename to src/common/Elsa.Testing.Shared/ActivityTestFixture.cs index b8f623ed6..8412d2189 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs @@ -4,89 +4,102 @@ using Elsa.Expressions.Services; using Elsa.Extensions; using Elsa.Mediator.Contracts; using Elsa.Workflows; +using Elsa.Workflows.Activities; using Elsa.Workflows.CommitStates; using Elsa.Workflows.Management.Providers; using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Memory; using Elsa.Workflows.PortResolvers; +using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using NSubstitute; -namespace Elsa.Activities.UnitTests.Helpers; +namespace Elsa.Testing.Shared; /// -/// 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. +/// A test fixture for unit testing activities in isolation. +/// Provides a fluent API to configure services, variables, and execution context. /// -public static class ActivityTestHelper +public class ActivityTestFixture { + private Action? _configureContextAction; + /// - /// 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. + /// Initializes a new instance of the class. /// - /// 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) + /// The activity to test + public ActivityTestFixture(IActivity activity) { - var context = await CreateMinimalActivityExecutionContext(activity, configureServices); + Activity = activity; + Services = new ServiceCollection(); + AddCoreWorkflowServices(Services); + } + + /// + /// Represents the activity being tested within the context of the activity test fixture. + /// Provides access to the activity for configuration, execution, and validation purposes. + /// + public IActivity Activity { get; } + + /// + /// Gets the service collection for registering additional services. + /// Use this to add services required by the activity under test. + /// + [UsedImplicitly] + public IServiceCollection Services { get; private set; } + + /// + /// Configures the service collection using a fluent action. + /// + /// Action to configure the service collection + /// The fixture instance for method chaining + public ActivityTestFixture ConfigureServices(Action configure) + { + configure(Services); + return this; + } + + /// + /// Configures the activity execution context before execution. + /// Multiple calls to this method will chain the configuration actions together. + /// + /// Action to configure the activity execution context + /// The fixture instance for method chaining + [UsedImplicitly] + public ActivityTestFixture ConfigureContext(Action configure) + { + _configureContextAction += configure; + return this; + } + + /// + /// Executes the activity and returns the execution context. + /// + /// The ActivityExecutionContext after execution + public async Task ExecuteAsync() + { + var context = await BuildAsync(); // Set up variables and inputs, then execute the activity - await SetupExistingVariablesAsync(activity, context); + await SetupExistingVariablesAsync(Activity, context); await context.EvaluateInputPropertiesAsync(); - await activity.ExecuteAsync(context); + 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. + /// Builds the ActivityExecutionContext without executing the activity. /// - private static async Task CreateMinimalActivityExecutionContext(IActivity activity, Action? configureServices) + public async Task BuildAsync() { - // 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(); - services.AddScoped(); - 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()); - - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - - // Call the configure services action if provided. - configureServices?.Invoke(services); - - var serviceProvider = services.BuildServiceProvider(); + var serviceProvider = Services.BuildServiceProvider(); var activityRegistry = serviceProvider.GetRequiredService(); var workflowGraphBuilder = serviceProvider.GetRequiredService(); - await activityRegistry.RegisterAsync(activity.GetType()); - var workflow = Workflow.FromActivity(activity); + + 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 @@ -98,7 +111,12 @@ public static class ActivityTestHelper ); // Create ActivityExecutionContext for the actual activity we want to test - return await workflowExecutionContext.CreateActivityExecutionContextAsync(activity); + var context = await workflowExecutionContext.CreateActivityExecutionContextAsync(Activity); + + // Apply any context configuration action + _configureContextAction?.Invoke(context); + + return context; } /// @@ -120,4 +138,33 @@ public static class ActivityTestHelper return Task.CompletedTask; } + + private static void AddCoreWorkflowServices(IServiceCollection services) + { + services.AddLogging(); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs new file mode 100644 index 000000000..6d473e011 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs @@ -0,0 +1,43 @@ +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Xunit; + +namespace Elsa.Testing.Shared; + +/// +/// General extension methods for ActivityTestFixture. +/// +public static class ActivityTestFixtureExtensions +{ + /// + /// Validates that an activity has the expected attribute configuration. + /// This is useful for ensuring activities are properly decorated with metadata. + /// + /// The test fixture (unused but enables extension method syntax) + /// Expected namespace (e.g., "Elsa") + /// /// Expected activity kind + /// Expected category (e.g., "HTTP") + /// Expected display name + /// Expected description + public static void AssertActivityAttributes( + this ActivityTestFixture fixture, + string expectedNamespace, + ActivityKind expectedKind, + string? expectedCategory = null, + string? expectedDisplayName = null, + string? expectedDescription = null + ) + { + var activityType = fixture.Activity.GetType(); + var activityAttribute = activityType.GetCustomAttributes(typeof(ActivityAttribute), false) + .Cast().FirstOrDefault(); + + Assert.NotNull(activityAttribute); + Assert.Equal(expectedNamespace, activityAttribute.Namespace); + Assert.Equal(expectedKind, activityAttribute.Kind); + + if (expectedCategory != null) Assert.Equal(expectedCategory, activityAttribute.Category); + if (expectedDescription != null) Assert.Equal(expectedDescription, activityAttribute.Description); + if (expectedDisplayName != null) Assert.Equal(expectedDisplayName, activityAttribute.DisplayName); + } +} diff --git a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj index 812045425..89c9b383e 100644 --- a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj +++ b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj @@ -9,7 +9,9 @@ + + diff --git a/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs b/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..c8bfeb541 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs @@ -0,0 +1,46 @@ +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Testing.Shared; + +/// +/// Provides a fake implementation of the interface, +/// designed for testing purposes. This class allows scheduling of activities within an activity execution context +/// and enables control over workflows in a simulated environment. +/// +public class FakeActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy +{ + /// + /// Schedules the provided activity for execution within the given activity execution context. + /// + /// The current activity execution context in which to schedule the activity. + /// The activity to be scheduled. Can be null, in which case no operation is performed. + /// The owner activity execution context, if any. + /// Optional scheduling options to influence how the activity is scheduled. + public Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null) + { + if (activity == null) + return Task.CompletedTask; + + var activityNode = new ActivityNode(activity!, ""); + return ScheduleActivityAsync(context, activityNode, owner, options); + } + + /// + /// Schedules the provided activity for execution within the specified activity execution context using the given scheduling strategy. + /// + /// The activity execution context in which the activity will be scheduled. + /// The activity node to be scheduled. If null, no operation is performed. + /// The owner activity execution context, if any, that triggered the scheduling request. + /// Optional scheduling parameters to customize the scheduling behavior. + public Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) + { + if (activityNode == null) + return Task.CompletedTask; + + context.WorkflowExecutionContext.Schedule(activityNode!, owner ?? context, options); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs b/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..3604678ad --- /dev/null +++ b/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs @@ -0,0 +1,26 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Testing.Shared; + +/// +/// Bypasses validation and schedules the activity immediately. +/// +public class FakeWorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy +{ + /// + /// Schedules an activity within the workflow execution context. + /// + /// The workflow execution context within which the activity will be scheduled. + /// The activity node representing the activity to be scheduled. + /// The execution context owning the activity execution. + /// Optional scheduling options for configuring the behavior of the scheduling process. + /// Returns an representing the scheduled activity work item. + public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null) + { + var workItem = new ActivityWorkItem(activityNode.Activity, owner); + context.Scheduler.Schedule(workItem); + return workItem; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs b/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs index a7c8092e6..4a4f24811 100644 --- a/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs +++ b/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs @@ -4,37 +4,25 @@ using Elsa.Expressions.Models; namespace Elsa.Expressions.Services; /// -public class ExpressionEvaluator : IExpressionEvaluator +public class ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider) : IExpressionEvaluator { - private readonly IExpressionDescriptorRegistry _registry; - private readonly IServiceProvider _serviceProvider; - - /// - /// Initializes a new instance of the class. - /// - public ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider) - { - _registry = registry; - _serviceProvider = serviceProvider; - } - /// - public async ValueTask EvaluateAsync(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + public async ValueTask EvaluateAsync(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null) { return (T?)await EvaluateAsync(expression, typeof(T), context, options); } /// - public async ValueTask EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + public async ValueTask EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null) { var expressionType = expression.Type; - var expressionDescriptor = _registry.Find(expressionType); + var expressionDescriptor = registry.Find(expressionType); if (expressionDescriptor == null) - throw new Exception($"Could not find an descriptor for expression type \"{expressionType}\"."); + throw new($"Could not find a descriptor for expression type \"{expressionType}\"."); - var handler = expressionDescriptor.HandlerFactory(_serviceProvider); - options ??= new ExpressionEvaluatorOptions(); + var handler = expressionDescriptor.HandlerFactory(serviceProvider); + options ??= new(); return await handler.EvaluateAsync(expression, returnType, context, options); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs b/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs index a33bc161a..61de5f867 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs @@ -27,9 +27,16 @@ public class Parallel : Container /// protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) { + // If there are no activities, complete immediately + if (Activities.Count == 0) + { + await context.CompleteActivityAsync(); + return; + } + context.SetProperty(ScheduledChildrenProperty, Activities.Count); - - foreach (var activity in Activities) + + foreach (var activity in Activities) await context.ScheduleActivityAsync(activity, OnChildCompleted); } diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index 2681afe22..0b2f642ad 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -314,10 +314,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The options used to schedule the activity. public async ValueTask ScheduleActivityAsync(IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null) { - var activityNode = activity != null - ? WorkflowExecutionContext.FindNodeByActivity(activity) ?? throw new InvalidOperationException("The specified activity is not part of the workflow.") - : null; - await ScheduleActivityAsync(activityNode, owner, options); + var schedulerStrategy = GetRequiredService(); + await schedulerStrategy.ScheduleActivityAsync(this, activity, owner, options); } /// @@ -326,55 +324,10 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The activity node to schedule. /// The activity execution context that owns the scheduled activity. /// The options used to schedule the activity. - public async ValueTask ScheduleActivityAsync(ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) + public async Task ScheduleActivityAsync(ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) { - if (this.GetIsBackgroundExecution()) - { - // Validate that the specified activity is part of the workflow. - if (activityNode != null && !WorkflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) - throw new InvalidOperationException("The specified activity is not part of the workflow."); - - var scheduledActivity = new ScheduledActivity - { - ActivityNodeId = activityNode?.NodeId, - OwnerActivityInstanceId = owner?.Id, - Options = options != null - ? new ScheduledActivityOptions - { - CompletionCallback = options?.CompletionCallback?.Method.Name, - Tag = options?.Tag, - ExistingActivityInstanceId = options?.ExistingActivityExecutionContext?.Id, - PreventDuplicateScheduling = options?.PreventDuplicateScheduling ?? false, - Variables = options?.Variables?.ToList(), - Input = options?.Input - } - : null - }; - - var scheduledActivities = this.GetBackgroundScheduledActivities().ToList(); - scheduledActivities.Add(scheduledActivity); - this.SetBackgroundScheduledActivities(scheduledActivities); - return; - } - - var completionCallback = options?.CompletionCallback; - owner ??= this; - - if (activityNode == null) - { - if (completionCallback != null) - { - Tag = options?.Tag; - var completedContext = new ActivityCompletedContext(this, this); - await completionCallback(completedContext); - } - else - await owner.CompleteActivityAsync(); - - return; - } - - WorkflowExecutionContext.Schedule(activityNode, owner, options); + var schedulerStrategy = GetRequiredService(); + await schedulerStrategy.ScheduleActivityAsync(this, activityNode, owner, options); } /// diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..959b8a540 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs @@ -0,0 +1,36 @@ +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +/// Defines a strategy for scheduling activities in a workflow execution context. +/// +public interface IActivityExecutionContextSchedulerStrategy +{ + /// + /// Schedules an activity for execution based on the specified context, activity, owner, and scheduling options. + /// + /// The execution context of the activity. + /// The activity to be scheduled. Can be null if no specific activity is targeted. + /// The context of the owning activity, if applicable. Can be null. + /// Optional scheduling options for the activity. + Task ScheduleActivityAsync( + ActivityExecutionContext context, + IActivity? activity, + ActivityExecutionContext? owner, + ScheduleWorkOptions? options = null); + + /// + /// Schedules an activity node for execution within the specified activity execution context. + /// + /// The execution context in which the activity node will be scheduled. + /// The activity node to be scheduled. Can be null if no specific node is targeted. + /// The context of the owning activity execution, if applicable. Can be null. + /// Optional settings for scheduling the activity node. + Task ScheduleActivityAsync( + ActivityExecutionContext context, + ActivityNode? activityNode, + ActivityExecutionContext? owner = null, + ScheduleWorkOptions? options = null); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..e95f82ca9 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs @@ -0,0 +1,23 @@ +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +/// Defines a strategy interface for scheduling activities within the context of a workflow execution. +/// +public interface IWorkflowExecutionContextSchedulerStrategy +{ + /// + /// Schedules an in the context of the provided workflow execution. + /// + /// The execution context of the workflow in which the activity is being scheduled. + /// The node representing the activity to be scheduled. + /// The that owns the scheduled activity. + /// Optional parameters for scheduling the work item. + /// The scheduled . + ActivityWorkItem Schedule(WorkflowExecutionContext context, + ActivityNode activityNode, + ActivityExecutionContext owner, + ScheduleWorkOptions? options = null); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index 5beda310d..501d08122 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using System.Reflection; using System.Text.Json; using Elsa.Common; @@ -458,6 +459,33 @@ public static partial class ActivityExecutionContextExtensions return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null; } - internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; - internal static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true; + /// + /// Gets the output of an activity even when the context is not the same as the activity's context. + /// This is useful when you want to get the output of a previously executed activity. + /// + /// + /// + /// + /// + public static object? GetActivityOutput(this ActivityExecutionContext context, Expression> property) + { + if (property.Body is not MemberExpression memberExpr) + { + return null; + } + + var propertyName = memberExpr.Member.Name; + + var value = context.Get(propertyName); + + if (value != null) + return value; + + var registry = context.WorkflowExecutionContext.GetActivityOutputRegister(); + + return registry.FindOutputByActivityInstanceId(context.Id, propertyName); + } + + public static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; + public static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs index f025ef74d..c89f61bdc 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -109,30 +109,8 @@ public static class WorkflowExecutionContextExtensions ActivityExecutionContext owner, ScheduleWorkOptions? options = null) { - // Validate that the specified activity is part of the workflow. - if (!workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) - throw new InvalidOperationException("The specified activity is not part of the workflow."); - - var scheduler = workflowExecutionContext.Scheduler; - - if (options?.PreventDuplicateScheduling == true) - { - // Check if the activity is already scheduled for the specified owner. - var existingWorkItem = scheduler.Find(x => x.Activity.NodeId == activityNode.NodeId && x.Owner == owner); - - if (existingWorkItem != null) - return existingWorkItem; - } - - var activity = activityNode.Activity; - var tag = options?.Tag; - var workItem = new ActivityWorkItem(activity, owner, tag, options?.Variables, options?.ExistingActivityExecutionContext, options?.Input); - var completionCallback = options?.CompletionCallback; - - workflowExecutionContext.AddCompletionCallback(owner, activityNode, completionCallback, tag); - scheduler.Schedule(workItem); - - return workItem; + var schedulerStrategy = workflowExecutionContext.GetRequiredService(); + return schedulerStrategy.Schedule(workflowExecutionContext, activityNode, owner, options); } /// diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 9a725a410..6fde09cbf 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -170,6 +170,8 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddSingleton() + .AddSingleton() .AddScoped(CommitStateHandler) .AddSingleton() .AddSingleton() diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..25e8d2160 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs @@ -0,0 +1,71 @@ +using Elsa.Extensions; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +public class ActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy +{ + /// + public async Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null) + { + var activityNode = activity != null + ? context.WorkflowExecutionContext.FindNodeByActivity(activity) ?? throw new InvalidOperationException("The specified activity is not part of the workflow.") + : null; + await ScheduleActivityAsync(context, activityNode, owner, options); + } + + /// + public async Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) + { + var workflowExecutionContext = context.WorkflowExecutionContext; + if (context.GetIsBackgroundExecution()) + { + // Validate that the specified activity is part of the workflow. + if (activityNode != null && !workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) + throw new InvalidOperationException("The specified activity is not part of the workflow."); + + var scheduledActivity = new ScheduledActivity + { + ActivityNodeId = activityNode?.NodeId, + OwnerActivityInstanceId = owner?.Id, + Options = options != null + ? new ScheduledActivityOptions + { + CompletionCallback = options?.CompletionCallback?.Method.Name, + Tag = options?.Tag, + ExistingActivityInstanceId = options?.ExistingActivityExecutionContext?.Id, + PreventDuplicateScheduling = options?.PreventDuplicateScheduling ?? false, + Variables = options?.Variables?.ToList(), + Input = options?.Input + } + : null + }; + + var scheduledActivities = context.GetBackgroundScheduledActivities().ToList(); + scheduledActivities.Add(scheduledActivity); + context.SetBackgroundScheduledActivities(scheduledActivities); + return; + } + + var completionCallback = options?.CompletionCallback; + owner ??= context; + + if (activityNode == null) + { + if (completionCallback != null) + { + context.Tag = options?.Tag; + var completedContext = new ActivityCompletedContext(context, context); + await completionCallback(completedContext); + } + else + await owner.CompleteActivityAsync(); + + return; + } + + workflowExecutionContext.Schedule(activityNode, owner, options); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..7142cbcd3 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs @@ -0,0 +1,37 @@ +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +public class WorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy +{ + /// + public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null) + { + // Validate that the specified activity is part of the workflow. + if (!context.NodeActivityLookup.ContainsKey(activityNode.Activity)) + throw new InvalidOperationException("The specified activity is not part of the workflow."); + + var scheduler = context.Scheduler; + + if (options?.PreventDuplicateScheduling == true) + { + // Check if the activity is already scheduled for the specified owner. + var existingWorkItem = scheduler.Find(x => x.Activity.NodeId == activityNode.NodeId && x.Owner == owner); + + if (existingWorkItem != null) + return existingWorkItem; + } + + var activity = activityNode.Activity; + var tag = options?.Tag; + var workItem = new ActivityWorkItem(activity, owner, tag, options?.Variables, options?.ExistingActivityExecutionContext, options?.Input); + var completionCallback = options?.CompletionCallback; + + context.AddCompletionCallback(owner, activityNode, completionCallback, tag); + scheduler.Schedule(workItem); + + return workItem; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs new file mode 100644 index 000000000..bfcec4cac --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs @@ -0,0 +1,189 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Xunit.Abstractions; +using Parallel = Elsa.Workflows.Activities.Parallel; + +namespace Elsa.Activities.IntegrationTests; + +public class ParallelTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _serviceProvider; + + public ParallelTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + } + + [Fact(DisplayName = "Parallel executes all child activities and completes")] + public async Task Parallel_ExecutesAllChildren_AndCompletes() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Equal(3, _capturingTextWriter.Lines.Count); + Assert.Contains("Activity 1", _capturingTextWriter.Lines); + Assert.Contains("Activity 2", _capturingTextWriter.Lines); + Assert.Contains("Activity 3", _capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel completes when empty")] + public async Task Parallel_Completes_WhenEmpty() + { + // Arrange + var parallel = new Parallel(); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Empty(_capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel executes single child activity and completes")] + public async Task Parallel_ExecutesSingleChild_AndCompletes() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Single Activity") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Single(_capturingTextWriter.Lines); + Assert.Equal("Single Activity", _capturingTextWriter.Lines.Single()); + } + + [Fact(DisplayName = "Parallel executes multiple different activity types")] + public async Task Parallel_ExecutesMixedActivityTypes_AndCompletes() + { + // Arrange + var parallel = new Parallel( + new WriteLine("First"), + new SetName("TestName"), + new WriteLine("Second") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Equal(2, _capturingTextWriter.Lines.Count); + Assert.Contains("First", _capturingTextWriter.Lines); + Assert.Contains("Second", _capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel completes only after all children complete")] + public async Task Parallel_CompletesOnlyAfterAllChildrenComplete() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Child 1"), + new WriteLine("Child 2"), + new WriteLine("Child 3"), + new WriteLine("Child 4") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Equal(4, _capturingTextWriter.Lines.Count); + } + + [Fact(DisplayName = "Parallel executes nested Parallel activities")] + public async Task Parallel_ExecutesNestedParallel_AndCompletes() + { + // Arrange + var innerParallel = new Parallel( + new WriteLine("Inner 1"), + new WriteLine("Inner 2") + ); + + var outerParallel = new Parallel( + new WriteLine("Outer 1"), + innerParallel, + new WriteLine("Outer 2") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(outerParallel); + + // Assert + var journal = result.Journal; + var outerParallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == outerParallel); + var innerParallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == innerParallel); + + Assert.NotNull(outerParallelContext); + Assert.NotNull(innerParallelContext); + Assert.Equal(ActivityStatus.Completed, outerParallelContext.Status); + Assert.Equal(ActivityStatus.Completed, innerParallelContext.Status); + Assert.Equal(4, _capturingTextWriter.Lines.Count); + Assert.Contains("Outer 1", _capturingTextWriter.Lines); + Assert.Contains("Outer 2", _capturingTextWriter.Lines); + Assert.Contains("Inner 1", _capturingTextWriter.Lines); + Assert.Contains("Inner 2", _capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel remains in Running state when a child activity faults")] + public async Task Parallel_RemainsRunning_WhenChildFaults() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Before Fault"), + new Fault { Message = new("Test fault") }, + new WriteLine("After Fault") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Running, parallelContext.Status); + Assert.Equal(1, parallelContext.AggregateFaultCount); + // The non-faulted activities should still execute + Assert.Contains("Before Fault", _capturingTextWriter.Lines); + Assert.Contains("After Fault", _capturingTextWriter.Lines); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs new file mode 100644 index 000000000..413e2fbca --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs @@ -0,0 +1,21 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +/// +/// Shared test helpers for input evaluation tests. +/// +public static class EvaluationTestHelpers +{ + /// + /// Creates an ActivityExecutionContext for any activity. + /// + public static async Task CreateContextAsync(TActivity activity) + where TActivity : IActivity + { + var fixture = new ActivityTestFixture(activity); + return await fixture.BuildAsync(); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs new file mode 100644 index 000000000..f44678614 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs @@ -0,0 +1,85 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Exceptions; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputEvaluationErrorTests +{ + [Theory(DisplayName = "Wraps evaluation exceptions in InputEvaluationException")] + [InlineData(typeof(InvalidOperationException), "Expression evaluation failed")] + [InlineData(typeof(ArgumentException), "Detailed inner error message")] + [InlineData(typeof(TimeoutException), "Expression evaluation timed out")] + public async Task WrapsEvaluationExceptions(Type exceptionType, string errorMessage) + { + // Arrange + var exception = (Exception)Activator.CreateInstance(exceptionType, errorMessage)!; + var writeLine = new WriteLine("Test"); + var context = await CreateContextWithMockEvaluatorAsync(writeLine, exception); + + // Act & Assert + var wrappedException = await Assert.ThrowsAsync( + async () => await context.EvaluateInputPropertiesAsync()); + + Assert.Equal("Text", wrappedException.InputName); + Assert.Contains("Failed to evaluate activity input 'Text'", wrappedException.Message); + Assert.IsType(exceptionType, wrappedException.InnerException); + Assert.Equal(errorMessage, wrappedException.InnerException.Message); + Assert.NotNull(wrappedException.InnerException.StackTrace); + } + + [Fact(DisplayName = "Handles empty expression gracefully without throwing")] + public async Task HandlesEmptyExpressionGracefully() + { + // Arrange + var writeLine = new WriteLine(""); + var context = await CreateContextAsync(writeLine); + + // Act + var exception = await Record.ExceptionAsync( + async () => await context.EvaluateInputPropertiesAsync()); + + // Assert + Assert.Null(exception); + } + + private static async Task CreateContextWithMockEvaluatorAsync( + WriteLine writeLine, + Exception thrownException) + { + var mockHandler = Substitute.For(); + mockHandler.EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(thrownException); + + var mockDescriptor = new ExpressionDescriptor + { + Type = "Literal", + HandlerFactory = _ => mockHandler + }; + + var mockProvider = Substitute.For(); + mockProvider.GetDescriptors().Returns([mockDescriptor]); + + var fixture = new ActivityTestFixture(writeLine) + .ConfigureServices(services => + { + services.RemoveWhere(d => d.ServiceType == typeof(IExpressionDescriptorProvider)); + services.AddSingleton(mockProvider); + }); + + return await fixture.BuildAsync(); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs new file mode 100644 index 000000000..54109eccc --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs @@ -0,0 +1,73 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputEvaluationTests +{ + [Theory(DisplayName = "Evaluates literal input values correctly")] + [InlineData("Literal Value")] + [InlineData("")] + [InlineData("Special chars: !@#$%")] + public async Task EvaluatesLiteralInput(string inputValue) + { + // Arrange + var writeLine = new WriteLine(inputValue); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Equal(inputValue, context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Evaluates delegate input expressions")] + public async Task EvaluatesDelegateInput() + { + // Arrange + const string original = "Original"; + var writeLine = new WriteLine(new Input(() => $"TRANSFORMED_{original}")); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Equal("TRANSFORMED_Original", context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Handles null input gracefully")] + public async Task HandlesNullInput() + { + // Arrange + var writeLine = new WriteLine(new Input(default(string)!)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Null(context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Stores evaluated value in activity state")] + public async Task StoresEvaluatedValueInActivityState() + { + // Arrange + const string expectedValue = "Test Value"; + var writeLine = new WriteLine(expectedValue); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.NotNull(writeLine.Text); + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Equal(expectedValue, context.ActivityState["Text"]); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs new file mode 100644 index 000000000..28cd1da89 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs @@ -0,0 +1,87 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputPropertyEvaluationTests +{ + [Fact(DisplayName = "Evaluates all auto-evaluate inputs")] + public async Task EvaluatesAllAutoEvaluateInputs() + { + // Arrange + const string expectedText = "Hello, World!"; + var writeLine = new WriteLine(expectedText); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedText, context.ActivityState.Values); + } + + [Fact(DisplayName = "Evaluates specific input by name")] + public async Task EvaluatesSpecificInputByName() + { + // Arrange + const string expectedValue = "Test Value"; + var writeLine = new WriteLine(expectedValue); + var context = await CreateContextAsync(writeLine); + + // Act + var result = await context.EvaluateInputPropertyAsync("Text"); + + // Assert + Assert.Equal(expectedValue, result); + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Fact(DisplayName = "Evaluates specific input by property expression")] + public async Task EvaluatesSpecificInputByExpression() + { + // Arrange + const int expectedValue = 42; + var variable = new Variable("myVar", expectedValue, "myVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + var result = await context.EvaluateInputPropertyAsync, int>(x => x.Value); + + // Assert + Assert.Equal(expectedValue, result); + } + + [Fact(DisplayName = "Throws when input name not found")] + public async Task ThrowsWhenInputNameNotFound() + { + // Arrange + var writeLine = new WriteLine("Test"); + var context = await CreateContextAsync(writeLine); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await context.EvaluateInputPropertyAsync("NonExistentInput")); + + Assert.Contains("No input with name NonExistentInput could be found", exception.Message); + } + + [Fact(DisplayName = "Sets HasEvaluatedProperties flag after evaluation")] + public async Task SetsHasEvaluatedPropertiesFlag() + { + // Arrange + var writeLine = new WriteLine("Test"); + var context = await CreateContextAsync(writeLine); + + Assert.False(context.GetHasEvaluatedProperties()); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.GetHasEvaluatedProperties()); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs new file mode 100644 index 000000000..67132cbc6 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs @@ -0,0 +1,152 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputStateStorageTests +{ + [Theory(DisplayName = "Stores primitive values in activity state")] + [InlineData(42)] + [InlineData(0)] + [InlineData(-100)] + public async Task StoresPrimitiveIntValues(int expectedValue) + { + // Arrange + var variable = new Variable("intVar", 0, "intVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Theory(DisplayName = "Stores boolean values in activity state")] + [InlineData(true)] + [InlineData(false)] + public async Task StoresBooleanValues(bool expectedValue) + { + // Arrange + var variable = new Variable("boolVar", false, "boolVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Theory(DisplayName = "Stores string values in activity state")] + [InlineData("Test String")] + [InlineData("")] + public async Task StoresStringValues(string expectedValue) + { + // Arrange + var variable = new Variable("stringVar", "", "stringVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Fact(DisplayName = "Stores values using input descriptor name as key")] + public async Task StoresValuesByInputDescriptorName() + { + // Arrange + const string textValue = "Named Input Test"; + var writeLine = new WriteLine(textValue); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Equal(textValue, context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Stores null values correctly")] + public async Task StoresNullValues() + { + // Arrange + var writeLine = new WriteLine(new Input(default(string)!)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Null(context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Overwrites previous values on re-evaluation")] + public async Task OverwritesPreviousValuesOnReEvaluation() + { + // Arrange + const string initialValue = "Initial"; + const string updatedValue = "Updated"; + var writeLine = new WriteLine(initialValue); + var context = await CreateContextAsync(writeLine); + + // Act - First evaluation + await context.EvaluateInputPropertiesAsync(); + var firstValue = context.ActivityState["Text"]; + + // Modify and re-evaluate + writeLine.Text = new(updatedValue); + await context.EvaluateInputPropertyAsync("Text"); + var secondValue = context.ActivityState["Text"]; + + // Assert + Assert.Equal(initialValue, firstValue); + Assert.Equal(updatedValue, secondValue); + } + + [Fact(DisplayName = "Stores multiple inputs separately")] + public async Task StoresMultipleInputsSeparately() + { + // Arrange + var variable = new Variable("multiVar", 100, "multiVar"); + var setVariable = new SetVariable(variable, new Input(200)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.Count >= 1); + } + + [Fact(DisplayName = "Maintains state across multiple evaluations")] + public async Task MaintainsStateAcrossMultipleEvaluations() + { + // Arrange + const string value1 = "First"; + var writeLine1 = new WriteLine(value1); + var context = await CreateContextAsync(writeLine1); + + // Act - First evaluation + await context.EvaluateInputPropertiesAsync(); + var stateCountAfterFirst = context.ActivityState.Count; + + // Second evaluation of same input + await context.EvaluateInputPropertyAsync("Text"); + var stateCountAfterSecond = context.ActivityState.Count; + + // Assert + Assert.Equal(stateCountAfterFirst, stateCountAfterSecond); + Assert.Equal(value1, context.ActivityState["Text"]); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs new file mode 100644 index 000000000..6cddc9807 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs @@ -0,0 +1,103 @@ +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class WrappedInputEvaluationTests +{ + [Fact(DisplayName = "Sets memory block reference with deterministic ID")] + public async Task SetsMemoryBlockReferenceWithDeterministicId() + { + // Arrange + const string expectedValue = "Test Value"; + var writeLine = new WriteLine(new Input(expectedValue)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + var memoryReference = writeLine.Text.MemoryBlockReference(); + Assert.NotNull(memoryReference); + var storedValue = memoryReference.Get(context.ExpressionExecutionContext); + Assert.Equal(expectedValue, storedValue); + } + + [Fact(DisplayName = "Handles missing memory block reference ID")] + public async Task HandlesMissingMemoryBlockReferenceId() + { + // Arrange + const string expectedValue = "Memory Test"; + var writeLine = new WriteLine(new Input(expectedValue)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + var memoryReference = writeLine.Text.MemoryBlockReference(); + Assert.NotNull(memoryReference); + Assert.NotNull(memoryReference.Id); + Assert.False(string.IsNullOrEmpty(memoryReference.Id)); + } + + [Fact(DisplayName = "Stores evaluated value in ExpressionExecutionContext")] + public async Task StoresEvaluatedValueInExpressionExecutionContext() + { + // Arrange + const int expectedValue = 123; + var variable = new Variable("testVar", 0, "testVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + var memoryReference = setVariable.Value.MemoryBlockReference(); + var storedValue = memoryReference.Get(context.ExpressionExecutionContext); + Assert.Equal(expectedValue, storedValue); + } + + [Theory(DisplayName = "Evaluates different expression types correctly")] + [InlineData("Literal", "Literal Test")] + [InlineData("Delegate", "Delegate Result")] + [InlineData("Variable", "Variable Value")] + public async Task EvaluatesExpressionTypes(string expressionType, string expectedValue) + { + // Arrange + WriteLine writeLine; + Variable? variable = null; + + switch (expressionType) + { + case "Literal": + var literal = new Literal(expectedValue); + writeLine = new(new Input(literal)); + break; + case "Delegate": + writeLine = new(new Input(() => expectedValue)); + break; + case "Variable": + variable = new("myVar", expectedValue, "myVar"); + writeLine = new(new Input(variable)); + break; + default: + throw new ArgumentException("Invalid expression type"); + } + + var context = await CreateContextAsync(writeLine); + + variable?.Set(context.ExpressionExecutionContext, expectedValue); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Equal(expectedValue, context.ActivityState["Text"]); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs b/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs index 92c644a9f..ba2e5fa0d 100644 --- a/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs @@ -1,7 +1,8 @@ -using Elsa.Activities.UnitTests.Helpers; +using Elsa.Testing.Shared; using Elsa.Workflows; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +// ReSharper disable MethodHasAsyncOverload namespace Elsa.Activities.UnitTests.Console; @@ -17,18 +18,16 @@ public class WriteLineTests _mockProvider.GetTextWriter().Returns(_mockTextWriter); } - [Fact] - public async Task Should_Write_String_Literal_To_Output() + [Theory] + [InlineData("Hello, World!")] + [InlineData("")] + public async Task Should_Write_Text_To_Output(string expectedText) { // Arrange - const string expectedText = "Hello, World!"; var writeLine = new WriteLine(expectedText); // Act - await ActivityTestHelper.ExecuteActivityAsync(writeLine, services => - { - services.AddSingleton(_mockProvider); - }); + await ExecuteAsync(writeLine); // Assert _mockTextWriter.Received(1).WriteLine(expectedText); @@ -38,35 +37,15 @@ public class WriteLineTests public async Task Should_Write_Null_Value_To_Output() { // Arrange - var writeLine = new WriteLine(new Input((string?)null)); + var writeLine = new WriteLine(new Input(default(string)!)); // Act - await ActivityTestHelper.ExecuteActivityAsync(writeLine, services => - { - services.AddSingleton(_mockProvider); - }); + await ExecuteAsync(writeLine); // Assert _mockTextWriter.Received(1).WriteLine(Arg.Is(s => s == null)); } - [Fact] - public async Task Should_Write_Empty_String_To_Output() - { - // Arrange - const string expectedText = ""; - var writeLine = new WriteLine(expectedText); - - // Act - await ActivityTestHelper.ExecuteActivityAsync(writeLine, services => - { - services.AddSingleton(_mockProvider); - }); - - // Assert - _mockTextWriter.Received(1).WriteLine(expectedText); - } - [Fact] public async Task Should_Use_Default_Provider_When_None_Configured() { @@ -75,9 +54,15 @@ public class WriteLineTests var writeLine = new WriteLine(textToWrite); // Act & Assert - Should not throw exception when no provider is configured - var exception = await Record.ExceptionAsync(async () => - await ActivityTestHelper.ExecuteActivityAsync(writeLine)); - + var exception = await Record.ExceptionAsync(() => ExecuteAsync(writeLine)); + Assert.Null(exception); } + + private async Task ExecuteAsync(IActivity activity) + { + await new ActivityTestFixture(activity) + .ConfigureServices(services => services.AddSingleton(_mockProvider)) + .ExecuteAsync(); + } } diff --git a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj index 3017b8a33..5b8d3f4e6 100644 --- a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj +++ b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj @@ -1,8 +1,10 @@  + + diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs new file mode 100644 index 000000000..ea6b8de4f --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs @@ -0,0 +1,141 @@ +using System.Net; +using System.Text; +using Elsa.Http; +using Elsa.Http.ContentWriters; +using Elsa.Http.Parsers; +using Elsa.Resilience; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Helpers; + +/// +/// Extension methods for configuring HTTP-related services in ActivityTestFixture. +/// +public static class ActivityTestFixtureHttpExtensions +{ + /// + /// Configures services commonly needed for HTTP activity testing. + /// This is a convenience method that combines HTTP services, mock HTTP client factory, and mock resilient invoker. + /// + /// The test fixture to configure + /// Handler for HTTP requests, or null to use a default OK response + /// The fixture instance for method chaining + public static ActivityTestFixture WithHttpServices( + this ActivityTestFixture fixture, + Func>? responseHandler = null) + { + fixture.ConfigureServices(services => + { + var defaultHandler = responseHandler ?? ((_, _) => Task.FromResult(CreateHttpResponse(HttpStatusCode.OK))); + var mockHttpClientFactory = CreateMockHttpClientFactory(defaultHandler); + + services.AddSingleton(mockHttpClientFactory); + services.AddSingleton(CreateMockResilientActivityInvoker()); + AddHttpServices(services); + services.AddLogging(); + }); + + return fixture; + } + + /// + /// Creates a simple HTTP response message with specified status code and optional JSON content. + /// + /// The HTTP status code for the response + /// Optional JSON content for the response body + /// Optional additional headers to add to the response + /// An HttpResponseMessage configured with the specified parameters + public static HttpResponseMessage CreateHttpResponse( + HttpStatusCode statusCode, + string? jsonContent = null, + Dictionary? additionalHeaders = null) + { + var response = new HttpResponseMessage(statusCode); + + if (jsonContent != null) + { + response.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + } + + if (additionalHeaders != null) + { + foreach (var header in additionalHeaders) + { + response.Headers.Add(header.Key, header.Value); + } + } + + return response; + } + + /// + /// Adds all HTTP-related services to the service collection. + /// This includes content factories, parsers, and HTTP client services. + /// + private static void AddHttpServices(IServiceCollection services) + { + // Add all required HTTP services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Add HTTP content parsers + AddHttpContentParsers(services); + + // Add other required services + services.AddHttpClient(); + } + + /// + /// Adds HTTP content parsers to the service collection. + /// These parsers are responsible for parsing different content types in HTTP responses. + /// + private static void AddHttpContentParsers(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Creates a mock IResilientActivityInvoker that directly executes the provided action. + /// Useful for testing activities that depend on resilient execution without the complexity of retry policies. + /// + private static IResilientActivityInvoker CreateMockResilientActivityInvoker() + { + var mock = Substitute.For(); + + // Configure the mock to simply execute the provided action directly + mock.InvokeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(callInfo => + { + var action = callInfo.ArgAt>>(2); + return action.Invoke(); + }); + + return mock; + } + + /// + /// Creates a mock HTTP client factory with a test handler for controlled HTTP responses. + /// + private static IHttpClientFactory CreateMockHttpClientFactory( + Func> responseHandler) + { + var mockHttpClientFactory = Substitute.For(); + var testHandler = new TestHttpMessageHandler(responseHandler); + var httpClient = new HttpClient(testHandler); + mockHttpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + return mockHttpClientFactory; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs new file mode 100644 index 000000000..0b9f1843d --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs @@ -0,0 +1,17 @@ +namespace Elsa.Activities.UnitTests.Helpers; + +/// +/// Custom test HTTP message handler that allows full control over HTTP responses for testing. +/// Use this class to simulate different HTTP scenarios without making actual network calls. +/// +/// Function that defines how to handle HTTP requests and generate responses +public class TestHttpMessageHandler(Func> sendAsyncFunc) : HttpMessageHandler +{ + /// + /// Handles HTTP requests using the provided function. + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return sendAsyncFunc(request, cancellationToken); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs new file mode 100644 index 000000000..71a32a7bb --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs @@ -0,0 +1,288 @@ +using System.Net; +using Elsa.Activities.UnitTests.Helpers; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Http; + +public class SendHttpRequestTests +{ + [Theory] + [InlineData("GET", "https://api.example.com/data", "{\"result\": \"success\"}", 200)] + [InlineData("POST", "https://api.example.com/create", "{\"id\": 123}", 201)] + [InlineData("PUT", "https://api.example.com/update", "{\"updated\": true}", 200)] + public async Task Should_Send_Request_And_Handle_Success_Response(string method, string url, string jsonResponse, int expectedStatusCode) + { + // Arrange + var expectedUrl = new Uri(url); + var expectedMethod = new HttpMethod(method); + var expectedHttpStatusCode = (HttpStatusCode)expectedStatusCode; + var requestCapture = new RequestCapture(); + var responseHandler = CreateResponseHandler(expectedHttpStatusCode, jsonResponse, requestCapture); + var sendHttpRequest = CreateSendHttpRequest(expectedUrl, method); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method); + Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri); + + var statusCodeOutput = context.GetActivityOutput(() => sendHttpRequest.StatusCode); + Assert.Equal(expectedStatusCode, statusCodeOutput); + } + + [Theory] + [InlineData("Bearer token123")] + [InlineData("Basic YWRtaW46cGFzcw==")] + [InlineData("ApiKey abc123")] + public async Task Should_Add_Authorization_Header(string authorizationHeader) + { + // Arrange + var expectedUrl = new Uri("https://api.example.com/secure"); + + var requestCapture = new RequestCapture(); + var responseHandler = CreateResponseHandler(HttpStatusCode.OK, null, requestCapture); + var sendHttpRequest = CreateSendHttpRequest(expectedUrl, authorization: authorizationHeader); + + // Act + await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization); + Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString()); + } + + [Theory] + [InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.NotFound, "mockActivity404")] + [InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.InternalServerError, "mockUnmatchedActivity")] + public async Task Should_Schedule_Activity_According_To_Handlers(int[] statusCodes, string[] activityNames, string handler, HttpStatusCode expectedStatusCode, string expectedScheduledActivityName) + { + // Arrange + var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithStatusHandlers( + expectedStatusCodes: [(statusCodes[0], activityNames[0]), (statusCodes[1], activityNames[1])], + unmatchedHandler: handler + ); + + var responseHandler = CreateResponseHandler(expectedStatusCode); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert that the correct activity was scheduled. + var expectedActivity = childActivities[expectedScheduledActivityName]; + var hasScheduledActivity = context.HasScheduledActivity(expectedActivity); + Assert.True(hasScheduledActivity); + } + + [Fact] + public async Task Should_Schedule_FailedToConnect_Activity_On_HttpRequestException() + { + // Arrange + var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers( + failedToConnect: "mockFailedToConnect" + ); + + var responseHandler = CreateExceptionHandler("Connection failed"); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + var expectedScheduledActivity = childActivities["mockFailedToConnect"]; + var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity); + Assert.True(hasScheduledExpectedActivity); + } + + [Fact] + public async Task Should_Schedule_Timeout_Activity_On_TaskCanceledException() + { + // Arrange + var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers( + timeout: "mockTimeout" + ); + + var responseHandler = CreateExceptionHandler("Request timed out"); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + var expectedScheduledActivity = childActivities["mockTimeout"]; + var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity); + Assert.True(hasScheduledExpectedActivity); + } + + [Fact] + public async Task Should_Schedule_No_Activity_When_No_Status_Code_Cases_Match_And_No_Unmatched_Handler() + { + // Arrange + var (configured, _) = CreateSendHttpRequestWithStatusHandlers([(200, "handler200")], unmatchedHandler: null); + var responseHandler = CreateResponseHandler(HttpStatusCode.InternalServerError); // 500 - no match + + // Act + var context = await ExecuteActivityAsync(configured, responseHandler); + + // Assert + var allScheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(allScheduledActivities); + } + + [Fact] + public async Task Should_Set_Response_Headers_Output() + { + // Arrange + var expectedHeaders = new Dictionary + { + { "Custom-Header", "CustomValue" }, + { "X-Rate-Limit", "100" } + }; + + var responseHandler = CreateResponseHandler(HttpStatusCode.OK, additionalHeaders: expectedHeaders); + var sendHttpRequest = CreateSendHttpRequest(new("https://api.example.com/headers")); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + var responseHeadersObj = context.GetActivityOutput(() => sendHttpRequest.ResponseHeaders); + var responseHeaders = responseHeadersObj as HttpHeaders; + Assert.NotNull(responseHeaders); + Assert.True(responseHeaders.ContainsKey("Custom-Header")); + Assert.True(responseHeaders.ContainsKey("X-Rate-Limit")); + } + + [Fact] + public void Should_Have_Correct_Activity_Attributes() + { + var fixture = new ActivityTestFixture(new SendHttpRequest()); + fixture.AssertActivityAttributes( + expectedNamespace: "Elsa", + expectedCategory: "HTTP", + expectedDisplayName: "HTTP Request", + expectedDescription: "Send an HTTP request.", + expectedKind: ActivityKind.Task + ); + } + + // Private helper methods placed after all public members + private static Func> CreateResponseHandler( + HttpStatusCode statusCode, + string? content = null, + RequestCapture? requestCapture = null, + Dictionary? additionalHeaders = null) + { + return (request, _) => + { + if (requestCapture != null) + requestCapture.CapturedRequest = request; + return Task.FromResult(ActivityTestFixtureHttpExtensions.CreateHttpResponse(statusCode, content, additionalHeaders)); + }; + } + + private sealed class RequestCapture + { + public HttpRequestMessage? CapturedRequest { get; set; } + } + + private static SendHttpRequest CreateSendHttpRequest( + Uri url, + string method = "GET", + object? content = null, + string? contentType = null, + string? authorization = null) + { + return new() + { + Url = new(url), + Method = new(method), + Content = content != null ? new Input(content) : null!, + ContentType = contentType != null ? new Input(contentType) : null!, + Authorization = authorization != null ? new Input(authorization) : null!, + ExpectedStatusCodes = new List() + }; + } + + private static Task ExecuteActivityAsync( + SendHttpRequest sendHttpRequest, + Func> responseHandler) + { + return new ActivityTestFixture(sendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync(); + } + + private static (SendHttpRequest sendHttpRequest, Dictionary childActivities) CreateSendHttpRequestWithStatusHandlers( + (int statusCode, string activityName)[] expectedStatusCodes, + string? unmatchedHandler) + { + var childActivities = new Dictionary(); + + // Create mock activities for expected status codes + var expectedStatusCodeCases = expectedStatusCodes.Select(x => + { + var mockActivity = Substitute.For(); + childActivities[x.activityName] = mockActivity; + return new HttpStatusCodeCase(x.statusCode, mockActivity); + }).ToList(); + + // Create mock activity for unmatched handler + var unmatchedActivity = Substitute.For(); + if (unmatchedHandler is not null) + { + childActivities[unmatchedHandler] = unmatchedActivity; + } + + var sendHttpRequest = new SendHttpRequest + { + Url = new(new Uri("https://api.example.com/test")), + Method = new("GET"), + ExpectedStatusCodes = expectedStatusCodeCases, + UnmatchedStatusCode = unmatchedHandler is not null ? unmatchedActivity : null + }; + + return (sendHttpRequest, childActivities); + } + + private static (SendHttpRequest sendHttpRequest, Dictionary childActivities) CreateSendHttpRequestWithErrorHandlers( + string? failedToConnect = null, + string? timeout = null) + { + var childActivities = new Dictionary(); + + IActivity? failedToConnectActivity = null; + IActivity? timeoutActivity = null; + + if (failedToConnect != null) + { + failedToConnectActivity = Substitute.For(); + childActivities[failedToConnect] = failedToConnectActivity; + } + + if (timeout != null) + { + timeoutActivity = Substitute.For(); + childActivities[timeout] = timeoutActivity; + } + + var sendHttpRequest = new SendHttpRequest + { + Url = new(new Uri("https://api.example.com/error")), + Method = new("GET"), + ExpectedStatusCodes = new List(), + FailedToConnect = failedToConnectActivity, + Timeout = timeoutActivity + }; + + return (sendHttpRequest, childActivities); + } + + private static Func> CreateExceptionHandler(string message) + where TException : Exception + { + return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs b/test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs new file mode 100644 index 000000000..1e0d9d435 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs @@ -0,0 +1,97 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Parallel; + +public class ParallelTests +{ + [Theory(DisplayName = "Parallel should schedule multiple activities")] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public async Task Should_Schedule_Multiple_Activities(int activityCount) + { + // Arrange + var activities = Enumerable.Range(1, activityCount) + .Select(i => new WriteLine($"Activity {i}")) + .Cast() + .ToArray(); + + var parallel = new Workflows.Activities.Parallel(activities); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - All activities should be scheduled + foreach (var activity in activities) + { + Assert.True(context.HasScheduledActivity(activity)); + } + } + + [Fact(DisplayName = "Parallel should not schedule activities when empty")] + public async Task Should_Not_Schedule_Activities_When_Empty() + { + // Arrange + var parallel = new Workflows.Activities.Parallel(); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - No activities should be scheduled (completion is tested in integration tests) + var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(scheduledActivities); + } + + [Fact(DisplayName = "Parallel should schedule a single activity")] + public async Task Should_Execute_Single_Activity() + { + // Arrange + var activity = Substitute.For(); + var parallel = new Workflows.Activities.Parallel(activity); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(activity)); + } + + [Fact(DisplayName = "Parallel should schedule mixed activity types")] + public async Task Should_Schedule_Mixed_Activity_Types() + { + // Arrange + var writeLine = new WriteLine("Test output"); + var testVar = new Variable("testVar", 0, "testVar"); + var setVariable = new SetVariable(testVar, new Input(42)); + var mockActivity = Substitute.For(); + + var parallel = new Workflows.Activities.Parallel(writeLine, setVariable, mockActivity); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - All activities should be scheduled + Assert.True(context.HasScheduledActivity(writeLine)); + Assert.True(context.HasScheduledActivity(setVariable)); + Assert.True(context.HasScheduledActivity(mockActivity)); + } + + [Fact(DisplayName = "Parallel should track scheduled children count internally")] + public async Task Should_Set_Scheduled_Children_Property() + { + // Arrange + var activity1 = new WriteLine("Activity 1"); + var activity2 = new WriteLine("Activity 2"); + var activity3 = new WriteLine("Activity 3"); + var parallel = new Workflows.Activities.Parallel(activity1, activity2, activity3); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - The parallel activity should track that 3 children were scheduled + var scheduledChildrenCount = context.GetProperty("ScheduledChildren"); + Assert.Equal(3, scheduledChildrenCount); + } +} \ 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 2f5c7ec5d..a63f52e56 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,4 +1,5 @@ -using Elsa.Activities.UnitTests.Helpers; +using Elsa.Testing.Shared; +using Elsa.Workflows; namespace Elsa.Activities.UnitTests.Primitives; @@ -13,7 +14,7 @@ public class SetVariableTests var setVariable = new SetVariable(variable, new Input(expected)); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + var context = await ExecuteAsync(setVariable); // Assert var result = variable.Get(context); @@ -27,7 +28,7 @@ public class SetVariableTests var setVariable = new SetVariable(null!, new Input("test value")); // Act & Assert - var exception = await Record.ExceptionAsync(async () => await ActivityTestHelper.ExecuteActivityAsync(setVariable)); + var exception = await Record.ExceptionAsync(() => ExecuteAsync(setVariable)); Assert.NotNull(exception); } @@ -40,10 +41,15 @@ public class SetVariableTests var setVariable = new SetVariable(variable, new Input((string?)null)); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + var context = await ExecuteAsync(setVariable); // Assert var result = variable.Get(context); Assert.Null(result); } + + private static Task ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } } \ No newline at end of file diff --git a/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj new file mode 100644 index 000000000..1d098435a --- /dev/null +++ b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs b/test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs new file mode 100644 index 000000000..3b5b6194a --- /dev/null +++ b/test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs @@ -0,0 +1,169 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Elsa.Expressions.UnitTests.Services; + +public class ExpressionEvaluatorTests +{ + [Fact(DisplayName = "Evaluates expression with generic type parameter")] + public async Task EvaluatesExpressionWithGenericTypeParameter() + { + // Arrange + var context = await CreateContextAsync(); + var evaluator = context.GetRequiredService(); + var expression = new Expression("Literal", "Test Value"); + + // Act + var result = await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext); + + // Assert + Assert.Equal("Test Value", result); + } + + [Fact(DisplayName = "Evaluates expression with type parameter")] + public async Task EvaluatesExpressionWithTypeParameter() + { + // Arrange + var context = await CreateContextAsync(); + var evaluator = context.GetRequiredService(); + var expression = new Expression("Literal", 42); + + // Act + var result = await evaluator.EvaluateAsync(expression, typeof(int), context.ExpressionExecutionContext); + + // Assert + Assert.Equal(42, result); + } + + [Fact(DisplayName = "Throws when expression type not found in registry")] + public async Task ThrowsWhenExpressionTypeNotFound() + { + // Arrange + var context = await CreateContextAsync(); + var evaluator = context.GetRequiredService(); + var expression = new Expression("NonExistentType", "value"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext)); + + Assert.Contains("Could not find a descriptor for expression type", exception.Message); + Assert.Contains("NonExistentType", exception.Message); + } + + [Fact(DisplayName = "Resolves expression handler via descriptor factory")] + public async Task ResolvesExpressionHandlerViaDescriptorFactory() + { + // Arrange + var mockHandler = CreateMockHandler("Mocked Result"); + var context = await CreateContextWithMockHandlerAsync("CustomType", mockHandler); + var evaluator = context.GetRequiredService(); + var expression = new Expression("CustomType", "test"); + + // Act + var result = await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext); + + // Assert + Assert.Equal("Mocked Result", result); + await mockHandler.Received(1).EvaluateAsync( + Arg.Is(e => e.Type == "CustomType" && e.Value as string == "test"), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact(DisplayName = "Passes non-null options as default when not provided")] + public async Task PassesNonNullOptionsAsDefault() + { + // Arrange + var mockHandler = CreateMockHandler("Result"); + var context = await CreateContextWithMockHandlerAsync("TestType", mockHandler); + var evaluator = context.GetRequiredService(); + var expression = new Expression("TestType", "value"); + + // Act + await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext); + + // Assert + await mockHandler.Received(1).EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(o => o != null)); + } + + [Fact(DisplayName = "Wraps handler exceptions in evaluation context")] + public async Task WrapsHandlerExceptions() + { + // Arrange + var mockHandler = CreateMockHandlerThatThrows(new InvalidOperationException("Handler failed")); + var context = await CreateContextWithMockHandlerAsync("FailingType", mockHandler); + var evaluator = context.GetRequiredService(); + var expression = new Expression("FailingType", "bad value"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext)); + + Assert.Equal("Handler failed", exception.Message); + } + + private static Task CreateContextAsync() + { + var activity = new WriteLine("Hello, World!"); + return new ActivityTestFixture(activity).BuildAsync(); + } + + private static async Task CreateContextWithMockHandlerAsync(string expressionType, IExpressionHandler mockHandler) + { + var mockDescriptor = new ExpressionDescriptor + { + Type = expressionType, + HandlerFactory = _ => mockHandler + }; + + var mockProvider = Substitute.For(); + mockProvider.GetDescriptors().Returns([mockDescriptor]); + + var activity = new WriteLine("Hello, World!"); + var fixture = new ActivityTestFixture(activity) + .ConfigureServices(services => + { + services.RemoveWhere(d => d.ServiceType == typeof(IExpressionDescriptorProvider)); + services.AddSingleton(mockProvider); + }); + + return await fixture.BuildAsync(); + } + + private static IExpressionHandler CreateMockHandler(object returnValue) + { + var mockHandler = Substitute.For(); + mockHandler.EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(returnValue); + return mockHandler; + } + + private static IExpressionHandler CreateMockHandlerThatThrows(Exception exception) + { + var mockHandler = Substitute.For(); + mockHandler.EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(exception); + return mockHandler; + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj b/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj index 37c246d69..af5003d06 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj @@ -1,6 +1,7 @@ + diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs new file mode 100644 index 000000000..8db759f1e --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs @@ -0,0 +1,39 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class FaultHandlingTests +{ + [Fact] + public async Task Fault_SetsExceptionAndStatus() + { + // Arrange + var context = await CreateContextAsync(); + var exception = new InvalidOperationException("Test error"); + + // Act + context.Fault(exception); + + // Assert + Assert.Equal(ActivityStatus.Faulted, context.Status); + Assert.Equal(exception, context.Exception); + Assert.Equal(1, context.AggregateFaultCount); + } + + [Fact] + public async Task RecoverFromFault_ResetsFaultCount() + { + // Arrange + var context = await CreateContextAsync(); + var exception = new InvalidOperationException("Test error"); + context.Fault(exception); + + // Act + context.RecoverFromFault(); + + // Assert + Assert.Equal(0, context.AggregateFaultCount); + Assert.Equal(ActivityStatus.Running, context.Status); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs new file mode 100644 index 000000000..fd9b8f60e --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs @@ -0,0 +1,41 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class HierarchyNavigationTests +{ + [Theory] + [InlineData("GetAncestors")] + [InlineData("GetDescendants")] + public async Task HierarchyNavigation_ReturnsEmpty_WhenNoRelatives(string methodName) + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var result = methodName switch + { + "GetAncestors" => context.GetAncestors().ToList(), + "GetDescendants" => context.GetDescendants().ToList(), + _ => throw new ArgumentException($"Unknown method: {methodName}") + }; + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task FindParent_ReturnsNull_WhenNoMatch() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var parent = context.FindParent(x => x.Activity is Sequence); + + // Assert + Assert.Null(parent); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs new file mode 100644 index 000000000..a2fd375c4 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs @@ -0,0 +1,50 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class MetadataTests +{ + [Fact] + public async Task SetExtensionsMetadata_StoresValue() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + context.SetExtensionsMetadata("testKey", "testValue"); + + // Assert + var metadata = context.GetExtensionsMetadata(); + Assert.NotNull(metadata); + Assert.Equal("testValue", metadata["testKey"]); + } + + [Fact] + public async Task GetExtensionsMetadata_ReturnsNull_WhenNotSet() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var metadata = context.GetExtensionsMetadata(); + + // Assert + Assert.Null(metadata); + } + + [Fact] + public async Task SetExtensionsMetadata_UpdatesExistingValue() + { + // Arrange + var context = await CreateContextAsync(); + context.SetExtensionsMetadata("testKey", "value1"); + + // Act + context.SetExtensionsMetadata("testKey", "value2"); + + // Assert + var metadata = context.GetExtensionsMetadata(); + Assert.Equal("value2", metadata!["testKey"]); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs new file mode 100644 index 000000000..dd808bab2 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs @@ -0,0 +1,35 @@ +using Elsa.Extensions; +using Elsa.Workflows.Attributes; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class OutcomeTests +{ + [Theory] + [InlineData(typeof(TestActivityWithPort), "Done")] + [InlineData(typeof(TestActivityWithNamedPort), "CustomOutcome")] + public async Task GetOutcomeName_ReturnsExpectedName(Type activityType, string expectedOutcomeName) + { + // Arrange + var activity = (Activity)Activator.CreateInstance(activityType)!; + var context = await CreateContextAsync(activity); + + // Act + var outcomeName = context.GetOutcomeName("Done"); + + // Assert + Assert.Equal(expectedOutcomeName, outcomeName); + } + + private class TestActivityWithPort : Activity + { + public IActivity? Done { get; set; } + } + + private class TestActivityWithNamedPort : Activity + { + [Port("CustomOutcome")] + public IActivity? Done { get; set; } + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs new file mode 100644 index 000000000..e9326b0c6 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs @@ -0,0 +1,24 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class PropertyEvaluationTests +{ + [Theory] + [InlineData(false)] // Initial state + [InlineData(true)] // After setting flag + public async Task HasEvaluatedProperties_TracksState(bool shouldSetFlag) + { + // Arrange + var context = await CreateContextAsync(); + if (shouldSetFlag) + context.SetHasEvaluatedProperties(); + + // Act + var result = context.GetHasEvaluatedProperties(); + + // Assert + Assert.Equal(shouldSetFlag, result); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs new file mode 100644 index 000000000..f688c2fea --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs @@ -0,0 +1,39 @@ +using Elsa.Extensions; +using Elsa.Workflows.Models; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class ResultTests +{ + [Fact] + public async Task SetResult_SetsResultProperty_WhenActivityImplementsIActivityWithResult() + { + // Arrange + var activity = new ActivityWithResult(); + var context = await CreateContextAsync(activity); + var testValue = "ResultValue"; + + // Act + context.SetResult(testValue); + + // Assert + var result = context.Get(activity.Result); + Assert.Equal(testValue, result); + } + + [Fact] + public async Task SetResult_ThrowsException_WhenActivityDoesNotImplementIActivityWithResult() + { + // Arrange + var context = await CreateContextAsync(); + + // Act & Assert + Assert.Throws(() => context.SetResult("value")); + } + + private class ActivityWithResult : Activity, IActivityWithResult + { + public Output? Result { get; set; } = new Output(); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs new file mode 100644 index 000000000..81d04a34e --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs @@ -0,0 +1,22 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +/// +/// Provides helper methods for ActivityExecutionContextExtensions unit tests. +/// +public static class TestHelpers +{ + /// + /// Creates an ActivityExecutionContext for testing purposes. + /// + /// Optional activity to use. If null, a default WriteLine activity is created. + /// A configured ActivityExecutionContext ready for testing. + public static Task CreateContextAsync(IActivity? activity = null) + { + activity ??= new WriteLine("test"); + var fixture = new ActivityTestFixture(activity); + return fixture.BuildAsync(); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs new file mode 100644 index 000000000..fa6c2b9a9 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs @@ -0,0 +1,23 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class TriggerTests +{ + [Theory] + [InlineData(true, null)] // null means use context.Activity.Id + [InlineData(false, "different-id")] + public async Task IsTriggerOfWorkflow_ReturnsExpectedResult(bool expectedResult, string? triggerActivityId) + { + // Arrange + var context = await CreateContextAsync(); + context.WorkflowExecutionContext.TriggerActivityId = triggerActivityId ?? context.Activity.Id; + + // Act + var result = context.IsTriggerOfWorkflow(); + + // Assert + Assert.Equal(expectedResult, result); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs new file mode 100644 index 000000000..2e2c970bb --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs @@ -0,0 +1,68 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class VariableTests +{ + [Fact] + public async Task CreateVariable_CreatesNewVariable_WithSpecifiedValue() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var variable = context.CreateVariable("testVar", 42); + + // Assert + Assert.NotNull(variable); + Assert.Equal("testVar", variable.Name); + var value = context.GetVariable("testVar"); + Assert.Equal(42, value); + } + + [Fact] + public async Task SetVariable_CreatesOrUpdatesVariable() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + context.SetVariable("testVar", 10); + context.SetVariable("testVar", 20); + + // Assert + var value = context.GetVariable("testVar"); + Assert.Equal(20, value); + } + + [Fact] + public async Task GetVariable_ReturnsNull_WhenVariableDoesNotExist() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var value = context.GetVariable("nonExistent"); + + // Assert + Assert.Null(value); + } + + [Fact] + public async Task GetVariableValues_ReturnsAllVariables_AcrossScopes() + { + // Arrange + var context = await CreateContextAsync(); + context.SetVariable("var1", "value1"); + context.SetVariable("var2", 42); + + // Act + var values = context.GetVariableValues(); + + // Assert + Assert.NotEmpty(values); + Assert.True(values.ContainsKey("var1Variable") || values.ContainsKey("var1")); + Assert.True(values.ContainsKey("var2Variable") || values.ContainsKey("var2")); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs new file mode 100644 index 000000000..f240561e8 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs @@ -0,0 +1,72 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class WorkflowInputTests +{ + [Theory] + [InlineData("TestKey", "TestValue", true, "TestValue", null)] // Key exists, string value + [InlineData("NonExistentKey", null, false, null, null)] // Key doesn't exist + [InlineData("NumberKey", null, true, "42", 42)] // Type conversion from int to string + public async Task TryGetWorkflowInput_HandlesVariousScenarios( + string key, + string? stringValue, + bool expectedResult, + string? expectedValue, + object? inputValue) + { + // Arrange + var context = await CreateContextAsync(); + + if (stringValue != null) + context.WorkflowExecutionContext.Input[key] = stringValue; + else if (inputValue != null) + context.WorkflowExecutionContext.Input[key] = inputValue; + + // Act + var result = context.TryGetWorkflowInput(key, out var value); + + // Assert + Assert.Equal(expectedResult, result); + Assert.Equal(expectedValue, value); + } + + [Fact] + public async Task GetWorkflowInput_ReturnsValue_WhenKeyExists() + { + // Arrange + var context = await CreateContextAsync(); + context.WorkflowExecutionContext.Input["TestKey"] = "TestValue"; + + // Act + var value = context.GetWorkflowInput("TestKey"); + + // Assert + Assert.Equal("TestValue", value); + } + + [Fact] + public async Task GetWorkflowInput_UsesTypeName_WhenKeyNotProvided() + { + // Arrange + var context = await CreateContextAsync(); + context.WorkflowExecutionContext.Input["String"] = "TestValue"; + + // Act + var value = context.GetWorkflowInput(); + + // Assert + Assert.Equal("TestValue", value); + } + + [Fact] + public async Task GetWorkflowInput_ThrowsException_WhenKeyDoesNotExist() + { + // Arrange + var context = await CreateContextAsync(); + + // Act & Assert + Assert.Throws(() => context.GetWorkflowInput("NonExistentKey")); + } +} diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj b/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj new file mode 100644 index 000000000..ac0c7cfde --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs new file mode 100644 index 000000000..ca4e5ecbd --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs @@ -0,0 +1,225 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Workflows.Management.Services; +using NSubstitute; + +namespace Elsa.Workflows.Management.UnitTests.Services; + +public class ExpressionDescriptorRegistryTests +{ + [Fact] + public void Should_Add_Descriptors_Via_Providers() + { + // Arrange + var descriptor1 = CreateDescriptor("Type1"); + var descriptor2 = CreateDescriptor("Type2"); + var provider1 = CreateProvider(descriptor1); + var provider2 = CreateProvider(descriptor2); + + // Act + var registry = CreateRegistry(provider1, provider2); + + // Assert + var allDescriptors = registry.ListAll().ToList(); + Assert.Contains(allDescriptors, d => d.Type == "Type1"); + Assert.Contains(allDescriptors, d => d.Type == "Type2"); + Assert.Equal(2, allDescriptors.Count); + } + + [Fact] + public void Should_Find_Descriptor_By_Type() + { + // Arrange + var descriptor = CreateDescriptor("TestType"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var found = registry.Find("TestType"); + + // Assert + Assert.NotNull(found); + Assert.Equal("TestType", found.Type); + } + + [Fact] + public void Should_Find_Descriptor_By_Predicate() + { + // Arrange + var descriptor1 = CreateDescriptor("Type1"); + var descriptor2 = CreateDescriptor("Type2"); + var registry = CreateRegistry(CreateProvider(descriptor1, descriptor2)); + + // Act + var found = registry.Find(d => d.Type == "Type2"); + + // Assert + Assert.NotNull(found); + Assert.Equal("Type2", found.Type); + } + + [Theory] + [InlineData("NonExistentType")] + [InlineData("")] + [InlineData("WrongType")] + public void Should_Return_Null_When_Descriptor_Not_Found_By_Type(string searchType) + { + // Arrange + var descriptor = CreateDescriptor("ExistingType"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var found = registry.Find(searchType); + + // Assert + Assert.Null(found); + } + + [Fact] + public void Should_Return_Null_When_Descriptor_Not_Found_By_Predicate() + { + // Arrange + var descriptor = CreateDescriptor("Type1"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var found = registry.Find(d => d.Type == "NonExistent"); + + // Assert + Assert.Null(found); + } + + [Fact] + public void Should_List_All_Registered_Descriptors() + { + // Arrange + var descriptor1 = CreateDescriptor("Type1"); + var descriptor2 = CreateDescriptor("Type2"); + var descriptor3 = CreateDescriptor("Type3"); + var registry = CreateRegistry(CreateProvider(descriptor1, descriptor2, descriptor3)); + + // Act + var all = registry.ListAll().ToList(); + + // Assert + Assert.Equal(3, all.Count); + Assert.Contains(all, d => d.Type == "Type1"); + Assert.Contains(all, d => d.Type == "Type2"); + Assert.Contains(all, d => d.Type == "Type3"); + } + + [Fact] + public void Should_Handle_Duplicate_Registrations_Last_Wins() + { + // Arrange - Two providers register the same type + var handler1 = Substitute.For(); + var handler2 = Substitute.For(); + var descriptor1 = CreateDescriptor("DuplicateType", handler1); + var descriptor2 = CreateDescriptor("DuplicateType", handler2); + var provider1 = CreateProvider(descriptor1); + var provider2 = CreateProvider(descriptor2); + + // Act - provider2 is registered after provider1 + var registry = CreateRegistry(provider1, provider2); + + // Assert - Last registration should win + var found = registry.Find("DuplicateType"); + Assert.NotNull(found); + Assert.Same(handler2, found.HandlerFactory(null!)); + } + + [Fact] + public void Should_Add_Single_Descriptor_Via_Add_Method() + { + // Arrange + var registry = CreateRegistry(CreateProvider()); + var newDescriptor = CreateDescriptor("NewType"); + + // Act + registry.Add(newDescriptor); + + // Assert + var found = registry.Find("NewType"); + Assert.NotNull(found); + Assert.Equal("NewType", found.Type); + } + + [Fact] + public void Should_Add_Multiple_Descriptors_Via_AddRange_Method() + { + // Arrange + var registry = CreateRegistry(CreateProvider()); + var descriptors = new[] + { + CreateDescriptor("Range1"), + CreateDescriptor("Range2"), + CreateDescriptor("Range3") + }; + + // Act + registry.AddRange(descriptors); + + // Assert + Assert.NotNull(registry.Find("Range1")); + Assert.NotNull(registry.Find("Range2")); + Assert.NotNull(registry.Find("Range3")); + } + + [Theory] + [InlineData(true)] // Empty provider list + [InlineData(false)] // Provider with empty descriptors + public void Should_Handle_Empty_Scenarios(bool emptyProviderList) + { + // Arrange & Act + var registry = emptyProviderList + ? CreateRegistry() + : CreateRegistry(CreateProvider()); + + // Assert + var all = registry.ListAll().ToList(); + Assert.Empty(all); + } + + [Fact] + public void Should_Enumerate_Descriptors_Multiple_Times() + { + // Arrange + var descriptor = CreateDescriptor("TestType"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var first = registry.ListAll().ToList(); + var second = registry.ListAll().ToList(); + + // Assert + Assert.Equal(first.Count, second.Count); + Assert.Single(first); + Assert.Single(second); + } + + private static ExpressionDescriptor CreateDescriptor(string type) + { + var handler = Substitute.For(); + return CreateDescriptor(type, handler); + } + + private static ExpressionDescriptor CreateDescriptor(string type, IExpressionHandler handler) + { + return new() + { + Type = type, + HandlerFactory = _ => handler + }; + } + + private static IExpressionDescriptorProvider CreateProvider(params ExpressionDescriptor[] descriptors) + { + var provider = Substitute.For(); + provider.GetDescriptors().Returns(descriptors); + return provider; + } + + private static ExpressionDescriptorRegistry CreateRegistry(params IExpressionDescriptorProvider[] providers) + { + return new(providers); + } +} From af84a70ddbffe77046cbafa07607e80a2a56aabc Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 20 Oct 2025 20:13:32 +0200 Subject: [PATCH 4/4] Fix file encoding --- .../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 a63f52e56..361848f8e 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,4 +1,4 @@ -using Elsa.Testing.Shared; +using Elsa.Testing.Shared; using Elsa.Workflows; namespace Elsa.Activities.UnitTests.Primitives;